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
277/// Report from a [`RecallEngine::backfill_embeddings`] pass.
278#[derive(Debug, Clone, Default, serde::Serialize)]
279pub struct BackfillReport {
280    /// Memories visited during the (early-stopping) scan.
281    pub scanned: usize,
282    /// Memories found without a stored vector (the batch to embed).
283    pub candidates: usize,
284    /// Vectors embedded + persisted this pass.
285    pub embedded: usize,
286    /// Memories that already had a stored vector.
287    pub already_embedded: usize,
288    /// Memories skipped because their content is empty/whitespace — a 400
289    /// from the embedding server is guaranteed and a vector is meaningless.
290    pub skipped_empty: usize,
291    /// Decode / embed / persist failures (details in logs, never fatal).
292    pub errors: usize,
293    /// True when nothing was written (plan-only pass).
294    pub dry_run: bool,
295}
296
297impl RecallEngine {
298    /// Create a new recall engine.
299    ///
300    /// Returns an error if the Tantivy IndexWriter cannot be created.
301    pub fn new(
302        store: Arc<MemoryStore>,
303        search_engine: Arc<SearchEngine>,
304        vector_store: VectorStore,
305        embedder: Arc<dyn Embedder>,
306        config: RecallConfig,
307    ) -> Result<Self> {
308        let conformal =
309            Mutex::new(config.conformal_alpha.and_then(|alpha| {
310                crate::recall_conformal::RecallConformal::new(alpha, store.clone())
311            }));
312        Ok(Self {
313            store,
314            search_engine,
315            vector_store: Mutex::new(vector_store),
316            embedder,
317            config,
318            embedding_cache: Mutex::new(HashMap::new()),
319            conformal,
320        })
321    }
322
323    /// Record one relevance-feedback sample into the conformal calibrator
324    /// (V8 S8). Errors honestly when the knob is off — there is no
325    /// calibrated set to feed.
326    pub fn record_relevance_feedback(&self, score: f32, relevant: bool) -> Result<usize> {
327        let mut guard = self
328            .conformal
329            .lock()
330            .map_err(|e| CoreError::Memory(format!("recall conformal lock: {e}")))?;
331        match guard.as_mut() {
332            Some(rc) => Ok(rc.record_feedback(score, relevant)),
333            None => Err(CoreError::InvalidArgs(
334                "conformal calibration is not enabled — set WM_RECALL_CONFORMAL_ALPHA in (0,1)"
335                    .into(),
336            )),
337        }
338    }
339
340    /// Conformal disclosure for a completed search: grades the results
341    /// against the calibrated set (marking `in_conformal_set`) and returns
342    /// the set-level info. `Ok(None)` when the knob is off — no claim is
343    /// made at all.
344    // The lock must span the whole grading: membership is read from the
345    // same fitted state that produced the threshold, and a concurrent
346    // record_feedback/fit must not split the disclosure from the marks.
347    #[allow(clippy::significant_drop_tightening)]
348    pub fn conformal_disclosure(
349        &self,
350        results: &mut [RecallResult],
351    ) -> Result<Option<crate::recall_conformal::ConformalSetInfo>> {
352        use crate::recall_conformal::ConformalSetInfo;
353        let guard = self
354            .conformal
355            .lock()
356            .map_err(|e| CoreError::Memory(format!("recall conformal lock: {e}")))?;
357        let Some(rc) = guard.as_ref() else {
358            return Ok(None);
359        };
360        let coverage_target = Some(f64::from(1.0 - rc.alpha()));
361        let info = if rc.is_fitted() {
362            let threshold = rc.threshold();
363            let mut set_size = 0usize;
364            for r in results.iter_mut() {
365                r.in_conformal_set = rc.membership(r.score) == Some(true);
366                if r.in_conformal_set {
367                    set_size += 1;
368                }
369            }
370            ConformalSetInfo {
371                status: "active".into(),
372                alpha: Some(f64::from(rc.alpha())),
373                coverage_target,
374                calibration_samples: Some(rc.sample_count()),
375                threshold,
376                set_size: Some(set_size),
377                hint: None,
378            }
379        } else {
380            ConformalSetInfo {
381                status: "uncalibrated".into(),
382                alpha: Some(f64::from(rc.alpha())),
383                coverage_target,
384                calibration_samples: Some(rc.sample_count()),
385                threshold: None,
386                set_size: None,
387                hint: Some(format!(
388                    "record ≥ {} relevance-feedback samples to calibrate",
389                    crate::recall_conformal::MIN_SAMPLES
390                )),
391            }
392        };
393        Ok(Some(info))
394    }
395
396    /// Get the configuration.
397    #[must_use]
398    pub fn config(&self) -> &RecallConfig {
399        &self.config
400    }
401
402    /// Whether the embedder is a real backend (not a stub).
403    ///
404    /// When false, hybrid search would produce garbage vectors and
405    /// `store_with_embedding` should be avoided in favor of plain BM25.
406    #[must_use]
407    pub fn embedder_is_real(&self) -> bool {
408        self.embedder.backend_name() != "stub"
409    }
410
411    /// Whether the vector half cannot affect the fused ranking under the
412    /// configured weights (F-T0-2 follow-up).
413    ///
414    /// The vector half contributes through two channels: the
415    /// `vector_weight * cosine` term, and candidate-set injection — a
416    /// vector-only hit can still surface when `importance_weight > 0`
417    /// because its fused score then carries the importance term. The half
418    /// is inert only when both channels are zeroed, in which case the
419    /// query embed and the vector-index rehydration are dead weight.
420    #[must_use]
421    fn vector_half_inert(&self) -> bool {
422        self.config.vector_weight <= 0.0 && self.config.importance_weight <= 0.0
423    }
424
425    /// Probe the configured embedder with one tiny input.
426    ///
427    /// Degradation honesty: a configured embedder that cannot answer
428    /// (server down, model missing) must not silently downgrade recall.
429    /// Returns the produced vector length on success.
430    ///
431    /// # Errors
432    /// Propagates the embedder's error (transport, model, dimension).
433    pub fn embedder_probe(&self) -> Result<usize> {
434        self.embedder
435            .embed("wm embedder probe")
436            .map(|vector| vector.len())
437    }
438
439    // ── Write path: auto-embed ─────────────────────────────────────────
440
441    /// Persistent cache key for a text: embedder namespace + content hash.
442    /// Vectors differ across models, so the namespace rides the key.
443    fn embedding_cache_key(&self, embedded_text: &str) -> String {
444        format!(
445            "{}:{}",
446            self.embedder.cache_namespace(),
447            content_hash(embedded_text)
448        )
449    }
450
451    /// Embed content and cache the result.
452    ///
453    /// Returns the embedding vector. Lookup order: in-memory LRU, then the
454    /// persistent content-hash cache (survives restarts — re-runs and
455    /// re-ingest warm-start instead of re-embedding), then the embedder.
456    fn embed_content(&self, content: &str) -> Result<Vec<f32>> {
457        let hash = content_hash(content);
458
459        // Check in-memory cache
460        if self.config.cache_embeddings {
461            let cache = self
462                .embedding_cache
463                .lock()
464                .map_err(|e| CoreError::Memory(format!("embedding cache lock: {e}")))?;
465            if let Some(vec) = cache.get(&hash) {
466                return Ok(vec.clone());
467            }
468        }
469
470        // Check the persistent cache (v26 "Tier 2", finally wired).
471        let cache_key = self.embedding_cache_key(content);
472        match self.store.get_embedding_cache(&cache_key) {
473            Ok(Some(vector)) => {
474                if self.config.cache_embeddings {
475                    if let Ok(mut cache) = self.embedding_cache.lock() {
476                        cache.insert(hash, vector.clone());
477                    }
478                }
479                return Ok(vector);
480            }
481            Ok(None) => {}
482            Err(error) => tracing::warn!("embedding cache read failed: {error}"),
483        }
484
485        // Embed
486        let embedding = self.embedder.embed(content)?;
487
488        // Persist (non-fatal: a cache write failure must not fail the ingest)
489        if let Err(error) = self.store.put_embedding_cache(&cache_key, &embedding) {
490            tracing::warn!("embedding cache write failed: {error}");
491        }
492
493        // Cache in memory
494        if self.config.cache_embeddings {
495            let mut cache = self
496                .embedding_cache
497                .lock()
498                .map_err(|e| CoreError::Memory(format!("embedding cache lock: {e}")))?;
499            if cache.len() >= self.config.max_cache_entries {
500                // Evict ~10% of entries (simple strategy)
501                let to_remove: Vec<String> = cache.keys().take(cache.len() / 10).cloned().collect();
502                for key in to_remove {
503                    cache.remove(&key);
504                }
505            }
506            cache.insert(hash, embedding.clone());
507        }
508
509        Ok(embedding)
510    }
511
512    /// Store a memory with auto-embedding.
513    ///
514    /// 1. Embeds the content using the configured embedder.
515    /// 2. Stores the memory in the given galaxy.
516    /// 3. Stores the embedding in the Embeddings galaxy.
517    /// 4. Adds the embedding to the vector store.
518    /// 5. Indexes the content in Tantivy.
519    pub fn store_with_embedding(&self, galaxy: Galaxy, memory: &crate::Memory) -> Result<()> {
520        // 1. Embed content
521        let embedding = self.embed_content(&memory.content)?;
522
523        // 2. Store memory
524        self.store.put(galaxy, memory)?;
525
526        // 3. Store embedding
527        self.store.put_embedding(memory.metadata.id, &embedding)?;
528
529        // 4. Add to vector store
530        {
531            let mut vs = self
532                .vector_store
533                .lock()
534                .map_err(|e| CoreError::Memory(format!("vector store lock: {e}")))?;
535            vs.add(memory.metadata.id, galaxy, embedding);
536        }
537
538        // 5. Index in Tantivy
539        let timestamp = memory.metadata.created_at.timestamp();
540        let tags: Vec<String> = memory.metadata.tags.clone();
541        {
542            let mut writer = self.search_engine.writer()?;
543            self.search_engine.add_document(
544                &mut writer,
545                &memory.metadata.id.to_string(),
546                galaxy.db_name(),
547                &memory.content,
548                &tags,
549                timestamp,
550            )?;
551            self.search_engine.commit(&mut writer)?;
552        }
553
554        Ok(())
555    }
556
557    /// Batch-store memories with auto-embedding in a single HTTP call + single Tantivy commit.
558    ///
559    /// Like `store_with_embedding` but for multiple memories at once:
560    /// 1. Embeds all content via `embed_batch()` (single HTTP call).
561    /// 2. Stores all memories to LMDB.
562    /// 3. Stores all embeddings.
563    /// 4. Adds all to the vector store.
564    /// 5. Indexes all in Tantivy with a single writer + commit.
565    ///
566    /// Returns the number of memories successfully stored.
567    pub fn store_batch_with_embedding(
568        &self,
569        entries: &[(Galaxy, &crate::Memory)],
570    ) -> Result<usize> {
571        if entries.is_empty() {
572            return Ok(0);
573        }
574
575        // 1. Resolve the persistent cache first (v26 "Tier 2", wired):
576        //    only the misses reach the embedder, chunked as before; hits
577        //    are reassembled in order. Cache keys ride the EMBEDDED text
578        //    (the chunked form), matching the single-item path.
579        const MAX_CHARS_PER_CHUNK: usize = 1500; // ~465 tokens worst case (3.21 chars/token)
580        const MAX_CHARS_PER_ITEM: usize = 1500; // same limit for individual items
581        let contents: Vec<String> = entries
582            .iter()
583            .map(|(_, m)| {
584                if m.content.len() > MAX_CHARS_PER_ITEM {
585                    m.content.chars().take(MAX_CHARS_PER_ITEM).collect()
586                } else {
587                    m.content.clone()
588                }
589            })
590            .collect();
591        let content_refs: Vec<&str> = contents.iter().map(String::as_str).collect();
592        let cache_keys: Vec<String> = content_refs
593            .iter()
594            .map(|c| self.embedding_cache_key(c))
595            .collect();
596        let mut embeddings: Vec<Option<Vec<f32>>> =
597            match self.store.get_embedding_cache_batch(&cache_keys) {
598                Ok(cached) => cached,
599                Err(error) => {
600                    tracing::warn!("embedding cache batch read failed: {error}");
601                    vec![None; cache_keys.len()]
602                }
603            };
604        let misses: Vec<(usize, &str)> = content_refs
605            .iter()
606            .enumerate()
607            .filter(|(i, _)| embeddings[*i].is_none())
608            .map(|(i, content)| (i, *content))
609            .collect();
610
611        // Chunked embedding for the misses only. Two stopping rules: the
612        // char cap (HTTP token limits) and the embedder's preferred batch
613        // size in texts (local engines want big batches so the session
614        // pool fans out efficiently).
615        let max_batch_texts = self.embedder.preferred_max_batch_texts();
616        let mut chunk: Vec<&str> = Vec::new();
617        let mut chunk_positions: Vec<usize> = Vec::new();
618        let mut chunk_chars: usize = 0;
619        let mut embed_chunk = |chunk: &[&str], positions: &[usize], store: &Self| -> Result<()> {
620            let chunk_vecs = store.embedder.embed_batch(chunk)?;
621            if chunk_vecs.len() != chunk.len() {
622                return Err(CoreError::Memory(format!(
623                    "embed_batch returned {} vectors for {} inputs (chunk)",
624                    chunk_vecs.len(),
625                    chunk.len()
626                )));
627            }
628            for (pos, vector) in positions.iter().zip(chunk_vecs) {
629                embeddings[*pos] = Some(vector);
630            }
631            Ok(())
632        };
633        for &(position, content) in &misses {
634            let content_chars = content.len();
635            let flush = !chunk.is_empty()
636                && (chunk_chars + content_chars > MAX_CHARS_PER_CHUNK
637                    || chunk.len() >= max_batch_texts);
638            if flush {
639                embed_chunk(&chunk, &chunk_positions, self)?;
640                chunk.clear();
641                chunk_positions.clear();
642                chunk_chars = 0;
643            }
644            chunk.push(content);
645            chunk_positions.push(position);
646            chunk_chars += content_chars;
647        }
648        if !chunk.is_empty() {
649            embed_chunk(&chunk, &chunk_positions, self)?;
650        }
651
652        // Persist the freshly embedded vectors (one transaction; a cache
653        // write failure must not fail the ingest).
654        let fresh: Vec<(String, Vec<f32>)> = misses
655            .iter()
656            .filter_map(|&(position, _)| {
657                embeddings[position]
658                    .clone()
659                    .map(|vector| (cache_keys[position].clone(), vector))
660            })
661            .collect();
662        if let Err(error) = self.store.put_embedding_cache_batch(&fresh) {
663            tracing::warn!("embedding cache batch write failed: {error}");
664        }
665
666        // 2. Store all memories to LMDB + embeddings
667        for (i, (galaxy, memory)) in entries.iter().enumerate() {
668            let Some(ref embedding) = embeddings[i] else {
669                return Err(CoreError::Memory(format!(
670                    "embedding missing for entry {i} after cache resolution"
671                )));
672            };
673            self.store.put(*galaxy, memory)?;
674            self.store.put_embedding(memory.metadata.id, embedding)?;
675        }
676
677        // 3. Add all to vector store
678        {
679            let mut vs = self
680                .vector_store
681                .lock()
682                .map_err(|e| CoreError::Memory(format!("vector store lock: {e}")))?;
683            for (i, (galaxy, memory)) in entries.iter().enumerate() {
684                if let Some(ref embedding) = embeddings[i] {
685                    vs.add(memory.metadata.id, *galaxy, embedding.clone());
686                }
687            }
688        }
689
690        // 4. Index all in Tantivy with a single commit
691        {
692            let mut writer = self.search_engine.writer()?;
693            for (galaxy, memory) in entries {
694                let timestamp = memory.metadata.created_at.timestamp();
695                let tags: Vec<String> = memory.metadata.tags.clone();
696                self.search_engine.add_document(
697                    &mut writer,
698                    &memory.metadata.id.to_string(),
699                    galaxy.db_name(),
700                    &memory.content,
701                    &tags,
702                    timestamp,
703                )?;
704            }
705            self.search_engine.commit(&mut writer)?;
706        }
707
708        // 5. Fill the in-memory cache for the misses (hits already ride
709        //    the persistent layer; no need to burn LRU slots on them).
710        //    Keyed on the ORIGINAL content hash, matching embed_content.
711        if self.config.cache_embeddings {
712            let mut cache = self
713                .embedding_cache
714                .lock()
715                .map_err(|e| CoreError::Memory(format!("embedding cache lock: {e}")))?;
716            for &(position, _) in &misses {
717                let Some(ref vector) = embeddings[position] else {
718                    continue;
719                };
720                let hash = content_hash(&entries[position].1.content);
721                if cache.len() >= self.config.max_cache_entries {
722                    let to_remove: Vec<String> =
723                        cache.keys().take(cache.len() / 10).cloned().collect();
724                    for key in to_remove {
725                        cache.remove(&key);
726                    }
727                }
728                cache.insert(hash, vector.clone());
729            }
730        }
731
732        Ok(entries.len())
733    }
734
735    // ── Read path: hybrid search ───────────────────────────────────────
736
737    /// Backfill per-memory vectors for memories that have none.
738    ///
739    /// Streams the requested galaxies in LMDB key order and collects up to
740    /// `limit` memories lacking a stored embedding (`limit == 0` = no cap),
741    /// then embeds + persists them, also seeding the in-memory vector index.
742    /// The scan opens its own read transaction and the embed/put writes use
743    /// their own transactions — no nested LMDB read txns, no full-galaxy
744    /// materialization (candidate collection is capped by `limit`).
745    /// `dry_run` reports candidates without writing.
746    ///
747    /// # Errors
748    /// Fails fast when the wired embedder is the stub (backfill would store
749    /// noise), matching the `embedder_is_real` gate used by the write path.
750    pub fn backfill_embeddings(
751        &self,
752        galaxy: Option<Galaxy>,
753        limit: usize,
754        dry_run: bool,
755    ) -> Result<BackfillReport> {
756        if !self.embedder_is_real() {
757            return Err(CoreError::InvalidArgs(
758                "no real embedder configured — memory.reembed requires WM_EMBEDDER_ENDPOINT or the onnx backend".into(),
759            ));
760        }
761        use lmdb::{Cursor as _, Transaction as _};
762        let limit = if limit == 0 { usize::MAX } else { limit };
763        let galaxies: Vec<Galaxy> = match galaxy {
764            Some(g) => vec![g],
765            None => Galaxy::memory_galaxies().to_vec(),
766        };
767        let mut report = BackfillReport {
768            dry_run,
769            ..Default::default()
770        };
771        let mut candidates: Vec<crate::Memory> = Vec::new();
772
773        'galaxy: for g in galaxies {
774            let db = self.store.galaxy_db(g)?;
775            let embeddings_db = self.store.galaxy_db(Galaxy::Embeddings)?;
776            let tx = self
777                .store
778                .env()
779                .begin_ro_txn()
780                .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
781            {
782                let mut cursor = tx
783                    .open_ro_cursor(db)
784                    .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
785                for (key, value) in cursor.iter() {
786                    report.scanned += 1;
787                    let memory = match self.store.decode_record_value(g, key, value) {
788                        Ok(memory) => memory,
789                        Err(error) => {
790                            report.errors += 1;
791                            tracing::warn!("reembed scan skipped undecodable entry: {error}");
792                            continue;
793                        }
794                    };
795                    let has_vector = match tx.get(embeddings_db, memory.metadata.id.as_bytes()) {
796                        Ok(_) => true,
797                        Err(lmdb::Error::NotFound) => false,
798                        Err(e) => {
799                            return Err(CoreError::Memory(format!("LMDB get failed: {e}")));
800                        }
801                    };
802                    if has_vector {
803                        report.already_embedded += 1;
804                        continue;
805                    }
806                    if memory.content.trim().is_empty() {
807                        // A vector for empty text is meaningless and the
808                        // embedding server rejects it (HTTP 400, live-caught
809                        // 2026-09-12) — skip it as a known class, not an error.
810                        report.skipped_empty += 1;
811                        continue;
812                    }
813                    candidates.push(memory);
814                    if candidates.len() >= limit {
815                        break;
816                    }
817                }
818            }
819            tx.commit()
820                .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
821            if candidates.len() >= limit {
822                break 'galaxy;
823            }
824        }
825
826        report.candidates = candidates.len();
827        if dry_run {
828            return Ok(report);
829        }
830
831        // Batch the apply: persistent-cache hits resolve first, misses ride
832        // the embedder's batch API. Chunk size defaults to 32 (sane for the
833        // llama.cpp HTTP server); `WM_REEMBED_BATCH` (1-64) tunes it per unit
834        // — long-transcript stores need small chunks to stay inside the
835        // embedder's request timeout (heritage lesson, 2026-09-12). A batch
836        // failure degrades to per-item embedding so one bad input cannot
837        // sink its chunk.
838        let chunk_size = std::env::var("WM_REEMBED_BATCH")
839            .ok()
840            .and_then(|value| value.parse::<usize>().ok())
841            .map_or(32, |size| size.clamp(1, 64));
842        for chunk in candidates.chunks(chunk_size) {
843            let keys: Vec<String> = chunk
844                .iter()
845                .map(|memory| self.embedding_cache_key(&memory.content))
846                .collect();
847            let mut vectors: Vec<Option<Vec<f32>>> =
848                match self.store.get_embedding_cache_batch(&keys) {
849                    Ok(cached) if cached.len() == chunk.len() => cached,
850                    Ok(_) | Err(_) => vec![None; chunk.len()],
851                };
852
853            let miss_idx: Vec<usize> = vectors
854                .iter()
855                .enumerate()
856                .filter(|(_, vector)| vector.is_none())
857                .map(|(i, _)| i)
858                .collect();
859            if !miss_idx.is_empty() {
860                let texts: Vec<&str> = miss_idx
861                    .iter()
862                    .map(|&i| chunk[i].content.as_str())
863                    .collect();
864                match self.embedder.embed_batch(&texts) {
865                    Ok(embedded) if embedded.len() == texts.len() => {
866                        let cache_entries: Vec<(String, Vec<f32>)> = miss_idx
867                            .iter()
868                            .zip(embedded.iter())
869                            .map(|(&i, vector)| (keys[i].clone(), vector.clone()))
870                            .collect();
871                        if let Err(error) = self.store.put_embedding_cache_batch(&cache_entries) {
872                            tracing::warn!("reembed cache persist failed: {error}");
873                        }
874                        for (&i, vector) in miss_idx.iter().zip(embedded) {
875                            vectors[i] = Some(vector);
876                        }
877                    }
878                    Ok(embedded) => {
879                        tracing::warn!(
880                            expected = texts.len(),
881                            got = embedded.len(),
882                            "reembed batch length mismatch — falling back per item"
883                        );
884                        self.embed_misses_per_item(chunk, &miss_idx, &mut vectors, &mut report);
885                    }
886                    Err(error) => {
887                        tracing::warn!("reembed batch failed ({error}) — falling back per item");
888                        self.embed_misses_per_item(chunk, &miss_idx, &mut vectors, &mut report);
889                    }
890                }
891            }
892
893            for (i, memory) in chunk.iter().enumerate() {
894                let Some(vector) = vectors[i].take() else {
895                    // None here means the per-item fallback already accounted
896                    // this memory as an error — do not double-count.
897                    continue;
898                };
899                if let Err(error) = self.store.put_embedding(memory.metadata.id, &vector) {
900                    report.errors += 1;
901                    tracing::warn!(
902                        memory = %memory.metadata.id,
903                        "reembed persist failed: {error}"
904                    );
905                    continue;
906                }
907                if let Ok(mut vs) = self.vector_store.lock() {
908                    vs.add(memory.metadata.id, memory.metadata.galaxy, vector);
909                }
910                report.embedded += 1;
911            }
912        }
913        Ok(report)
914    }
915
916    /// Per-item embedding fallback for a failed batch: cache-aware (via
917    /// [`RecallEngine::embed_content`]) and error-accounted per memory.
918    fn embed_misses_per_item(
919        &self,
920        chunk: &[crate::Memory],
921        miss_idx: &[usize],
922        vectors: &mut [Option<Vec<f32>>],
923        report: &mut BackfillReport,
924    ) {
925        for &i in miss_idx {
926            match self.embed_content(&chunk[i].content) {
927                Ok(vector) => vectors[i] = Some(vector),
928                Err(error) => {
929                    report.errors += 1;
930                    tracing::warn!(
931                        memory = %chunk[i].metadata.id,
932                        "reembed embed failed: {error}"
933                    );
934                }
935            }
936        }
937    }
938
939    /// Rehydrate the process-local vector index from the Embeddings galaxy.
940    ///
941    /// Vectors persist in LMDB, but `VectorStore` is in-memory: a fresh
942    /// process starts empty and would answer the vector half of hybrid
943    /// search with nothing (the restart gap caught live 2026-09-12 — a
944    /// persisted canary was BM25-invisible and vector-invisible until the
945    /// index was rehydrated). No-op once loaded.
946    fn ensure_vectors_loaded(&self) -> Result<()> {
947        let mut vs = self
948            .vector_store
949            .lock()
950            .map_err(|e| CoreError::Memory(format!("vector store lock: {e}")))?;
951        if vs.is_loaded() {
952            return Ok(());
953        }
954        vs.load(&self.store)
955    }
956
957    /// Hybrid search combining BM25 + vector similarity.
958    ///
959    /// Weights: `bm25_weight * BM25 + vector_weight * cosine + importance_weight * importance`
960    #[must_use]
961    pub fn hybrid_search(
962        &self,
963        query: &str,
964        limit: usize,
965        galaxy_filter: Option<Galaxy>,
966    ) -> Vec<RecallResult> {
967        self.hybrid_search_with_disclosure(query, limit, galaxy_filter)
968            .0
969    }
970
971    /// Hybrid search plus the V8 S8 disclosure: `(results, conformal)`.
972    /// `conformal` is `None` when `WM_RECALL_CONFORMAL_ALPHA` is unset —
973    /// no calibrated claim exists, so none is made.
974    pub fn hybrid_search_with_disclosure(
975        &self,
976        query: &str,
977        limit: usize,
978        galaxy_filter: Option<Galaxy>,
979    ) -> (
980        Vec<RecallResult>,
981        Option<crate::recall_conformal::ConformalSetInfo>,
982    ) {
983        // 2. BM25 search (get more than limit for fusion)
984        let bm25_limit = limit * 3;
985        let bm25_results = self
986            .search_engine
987            .search_in_galaxy(query, galaxy_filter, bm25_limit)
988            .unwrap_or_default();
989
990        // 3. Vector half — skipped entirely when the vector half is inert
991        //    (F-T0-2 follow-up). An inert vector half means the configured
992        //    weights zero both of its contribution channels: the
993        //    `vector_weight * cosine` term and the candidate-set injection
994        //    that can surface vector-only hits through the
995        //    `importance_weight * importance` term. Skipping the query
996        //    embed and the vector-index rehydration then changes nothing
997        //    about the ranking, while a BM25-weighted search no longer
998        //    pays an embedder round-trip (T0: ~100 ms/query) and cannot be
999        //    emptied by an embedder outage it never needed.
1000        let vector_results = if self.vector_half_inert() {
1001            Vec::new()
1002        } else {
1003            // 3a. Embed query
1004            let query_vec = match self.embedder.embed_query(query) {
1005                Ok(v) => v,
1006                Err(_) => return (Vec::new(), None),
1007            };
1008
1009            // 3b. Rehydrate the process-local vector index on first use
1010            //     (vectors persist in LMDB; the index does not). Failure
1011            //     is loud but non-fatal: the BM25 half still answers.
1012            if let Err(error) = self.ensure_vectors_loaded() {
1013                tracing::warn!(
1014                    "vector store rehydration failed ({error}) — hybrid vector half degraded"
1015                );
1016            }
1017
1018            let Ok(vs) = self.vector_store.lock() else {
1019                return (Vec::new(), None);
1020            };
1021            vs.search(&query_vec, bm25_limit, galaxy_filter)
1022        };
1023
1024        // 4. Fuse results (trust weighting applied inside when enabled)
1025        let fused = self.fuse_results(&bm25_results, &vector_results, limit);
1026
1027        // 4b. Validity filter (V8 Slice B) — off unless
1028        // WM_VALIDITY_ENFORCE=1; knob-off this retains everything and the
1029        // surface is byte-identical.
1030        let fused = if crate::memory::validity_enforced() {
1031            fused
1032                .into_iter()
1033                .filter(|r| {
1034                    self.find_memory_anywhere(r.memory_id)
1035                        .is_none_or(|mem| mem.metadata.validity.is_current())
1036                })
1037                .collect()
1038        } else {
1039            fused
1040        };
1041
1042        // 5. Graph expansion (V8 S6 third fusion phase) — off unless
1043        // WM_RECALL_GRAPH_WEIGHT > 0.
1044        let mut expanded = self.expand_with_graph(fused, limit);
1045
1046        // 5b. Corroboration boost (bridging counter) — off unless
1047        // WM_CORROBORATION_WEIGHT > 0. Knob-off counts stay 0 and scores
1048        // are byte-identical; knob-on the distinct-session count feeds the
1049        // saturating boost and is disclosed per-result.
1050        if self.config.corroboration_weight > 0.0 {
1051            for r in &mut expanded {
1052                if let Some(mem) = self.find_memory_anywhere(r.memory_id) {
1053                    let n = mem.metadata.corroborated_by.len();
1054                    r.corroboration = n.min(u32::MAX as usize) as u32;
1055                    r.score = crate::memory::corroboration_boost(
1056                        r.score,
1057                        n,
1058                        self.config.corroboration_weight,
1059                    );
1060                }
1061            }
1062            expanded.sort_by(|a, b| {
1063                b.score
1064                    .partial_cmp(&a.score)
1065                    .unwrap_or(std::cmp::Ordering::Equal)
1066            });
1067        }
1068
1069        // 5d. Association-weighted recall reranking (S10) — off unless
1070        // WM_ASSOCIATION_RERANK=1 or config.association_rerank is true.
1071        // Knob-off scores are byte-identical; knob-on candidates receive a
1072        // bounded connectivity boost based on active cross-galaxy association degree.
1073        if self.config.association_rerank {
1074            if let Ok(assoc_store) = AssociationStore::open(self.store.env()) {
1075                let env = self.store.env();
1076                for r in &mut expanded {
1077                    let outgoing = assoc_store.find_from(env, r.memory_id).unwrap_or_default();
1078                    let incoming = assoc_store.find_to(env, r.memory_id).unwrap_or_default();
1079                    let active_edges = outgoing
1080                        .iter()
1081                        .chain(incoming.iter())
1082                        .filter(|e| e.weight >= 0.2)
1083                        .count();
1084                    if active_edges > 0 {
1085                        let boost = (active_edges as f32 * 0.05).min(0.25);
1086                        r.score *= 1.0 + boost;
1087                    }
1088                }
1089                expanded.sort_by(|a, b| {
1090                    b.score
1091                        .partial_cmp(&a.score)
1092                        .unwrap_or(std::cmp::Ordering::Equal)
1093                });
1094            }
1095        }
1096
1097        // 5c. Promotion-on-read (S5 Hebbian reinforcement on hit path) —
1098        // off unless WM_PROMOTION_ON_READ=1 or config.promotion_on_read is true.
1099        // When enabled, top hits returned to the caller are promoted in LMDB.
1100        if self.config.promotion_on_read {
1101            for r in expanded.iter().take(limit) {
1102                if let Err(e) = self.promote_memory(r.galaxy, r.memory_id) {
1103                    tracing::warn!(
1104                        error = %e,
1105                        memory_id = %r.memory_id,
1106                        galaxy = %r.galaxy.db_name(),
1107                        "promotion on read failed"
1108                    );
1109                }
1110            }
1111        }
1112
1113        // 6. Conformal grading (V8 S8) — off unless
1114        // WM_RECALL_CONFORMAL_ALPHA is set; honest disclosure either way.
1115        match self.conformal_disclosure(&mut expanded) {
1116            Ok(info) => (expanded, info),
1117            Err(e) => {
1118                tracing::warn!(error = %e, "recall conformal disclosure failed");
1119                (expanded, None)
1120            }
1121        }
1122    }
1123
1124    /// Pure vector search (no BM25).
1125    #[must_use]
1126    pub fn vector_search(
1127        &self,
1128        query: &str,
1129        limit: usize,
1130        galaxy_filter: Option<Galaxy>,
1131    ) -> Vec<RecallResult> {
1132        let query_vec = match self.embedder.embed_query(query) {
1133            Ok(v) => v,
1134            Err(_) => return Vec::new(),
1135        };
1136
1137        let vector_results = {
1138            let Ok(vs) = self.vector_store.lock() else {
1139                return Vec::new();
1140            };
1141            vs.search(&query_vec, limit, galaxy_filter)
1142        };
1143
1144        vector_results
1145            .into_iter()
1146            .map(|vr| {
1147                let content = self.get_memory_content(vr.memory_id, vr.galaxy);
1148                RecallResult {
1149                    memory_id: vr.memory_id,
1150                    galaxy: vr.galaxy,
1151                    score: vr.score,
1152                    bm25_score: 0.0,
1153                    vector_score: vr.score,
1154                    importance: 0.0,
1155                    graph_score: 0.0,
1156                    trust_factor: 1.0,
1157                    in_conformal_set: false,
1158                    corroboration: 0,
1159                    content,
1160                }
1161            })
1162            .collect()
1163    }
1164
1165    /// Pure BM25 search (no vector).
1166    #[must_use]
1167    pub fn text_search(&self, query: &str, limit: usize) -> Vec<RecallResult> {
1168        let bm25_results = self.search_engine.search(query, limit).unwrap_or_default();
1169
1170        bm25_results
1171            .into_iter()
1172            .filter_map(|sr| {
1173                let memory_id = Uuid::parse_str(&sr.memory_id).ok()?;
1174                let galaxy = Galaxy::from_db_name(&sr.galaxy)?;
1175                Some(RecallResult {
1176                    memory_id,
1177                    galaxy,
1178                    score: sr.score,
1179                    bm25_score: sr.score,
1180                    vector_score: 0.0,
1181                    importance: 0.0,
1182                    graph_score: 0.0,
1183                    trust_factor: 1.0,
1184                    in_conformal_set: false,
1185                    corroboration: 0,
1186                    content: sr.content,
1187                })
1188            })
1189            .collect()
1190    }
1191
1192    // ── Fusion ─────────────────────────────────────────────────────────
1193
1194    /// Fuse BM25 and vector results into a single ranked list.
1195    fn fuse_results(
1196        &self,
1197        bm25_results: &[SearchResult],
1198        vector_results: &[VectorSearchResult],
1199        limit: usize,
1200    ) -> Vec<RecallResult> {
1201        fuse_results_inner(
1202            bm25_results,
1203            vector_results,
1204            limit,
1205            self.config.bm25_weight,
1206            self.config.vector_weight,
1207            self.config.importance_weight,
1208            self.config.trust_weight,
1209            |id, galaxy| self.get_memory_content(id, galaxy),
1210            |id, galaxy| self.get_memory_importance(id, galaxy),
1211            |id, galaxy| self.get_memory_source_trust(id, galaxy),
1212        )
1213    }
1214
1215    /// Expand fused results one hop through association edges (V8 S6 —
1216    /// the third fusion phase).
1217    ///
1218    /// From the top-3 fused seeds, walk outgoing + incoming edges (weight
1219    /// ≥ 0.2): neighbors already present get a score boost, absent ones
1220    /// are injected (privacy-guarded) with `seed_score * edge_weight *
1221    /// graph_weight` as their contribution, disclosed per-result in
1222    /// `graph_score`. Inert until `WM_RECALL_GRAPH_WEIGHT > 0`; the base
1223    /// fusion is byte-identical when the knob is off.
1224    fn expand_with_graph(&self, mut results: Vec<RecallResult>, limit: usize) -> Vec<RecallResult> {
1225        if self.config.graph_weight <= 0.0 || results.is_empty() {
1226            return results;
1227        }
1228        let Ok(assoc_store) = AssociationStore::open(self.store.env()) else {
1229            return results;
1230        };
1231        let env = self.store.env();
1232        let seeds: Vec<RecallResult> = results.iter().take(3).cloned().collect();
1233        for seed in seeds {
1234            let outgoing = assoc_store
1235                .find_from(env, seed.memory_id)
1236                .unwrap_or_default();
1237            let incoming = assoc_store.find_to(env, seed.memory_id).unwrap_or_default();
1238            for edge in outgoing.into_iter().chain(incoming) {
1239                if edge.weight < 0.2 {
1240                    continue;
1241                }
1242                let neighbor_id = if edge.source == seed.memory_id {
1243                    edge.target
1244                } else {
1245                    edge.source
1246                };
1247                if neighbor_id == seed.memory_id {
1248                    continue;
1249                }
1250                // Validity-aware graph phase (V8 Slice B, knob-gated):
1251                // non-current neighbors contribute nothing while enforced.
1252                // Knob-off this block never runs and fusion is byte-identical.
1253                if crate::memory::validity_enforced()
1254                    && self
1255                        .find_memory_anywhere(neighbor_id)
1256                        .is_some_and(|mem| !mem.metadata.validity.is_current())
1257                {
1258                    continue;
1259                }
1260                let contribution = seed.score * edge.weight * self.config.graph_weight;
1261                if self.config.promotion_on_read {
1262                    let mut activated_edge = edge.clone();
1263                    activated_edge.activate();
1264                    let _ = assoc_store.put(env, &activated_edge);
1265                }
1266                if let Some(existing) = results.iter_mut().find(|r| r.memory_id == neighbor_id) {
1267                    existing.score += contribution;
1268                    existing.graph_score += contribution;
1269                } else if let Some(mem) = self.find_memory_anywhere(neighbor_id) {
1270                    // Injected neighbors honor the privacy flag — the main
1271                    // path must never gain a side door through the graph.
1272                    // Same for validity while enforced (Slice B).
1273                    if mem.metadata.is_private {
1274                        continue;
1275                    }
1276                    if crate::memory::validity_enforced() && !mem.metadata.validity.is_current() {
1277                        continue;
1278                    }
1279                    results.push(RecallResult {
1280                        memory_id: neighbor_id,
1281                        galaxy: mem.metadata.galaxy,
1282                        score: contribution,
1283                        bm25_score: 0.0,
1284                        vector_score: 0.0,
1285                        importance: mem.metadata.importance,
1286                        graph_score: contribution,
1287                        trust_factor: 1.0,
1288                        in_conformal_set: false,
1289                        corroboration: 0,
1290                        content: mem.content.chars().take(400).collect(),
1291                    });
1292                }
1293            }
1294        }
1295        results.sort_by(|a, b| {
1296            b.score
1297                .partial_cmp(&a.score)
1298                .unwrap_or(std::cmp::Ordering::Equal)
1299        });
1300        results.truncate(limit.max(3));
1301        results
1302    }
1303
1304    /// Resolve a memory id across the memory galaxies (S9 cross-galaxy traversal).
1305    fn find_memory_anywhere(&self, id: Uuid) -> Option<crate::memory::Memory> {
1306        self.store
1307            .find_across_galaxies(id)
1308            .ok()
1309            .flatten()
1310            .map(|(_, m)| m)
1311    }
1312
1313    /// Promote a memory on recall hit: calls `Memory::recall()` to apply Hebbian
1314    /// strengthening and updates accessed_at/access_count/recall_count in the store.
1315    pub fn promote_memory(&self, galaxy: Galaxy, id: Uuid) -> Result<bool> {
1316        if let Some(mut mem) = self.store.get(galaxy, id)? {
1317            mem.recall();
1318            self.store.put(galaxy, &mem)?;
1319            Ok(true)
1320        } else {
1321            Ok(false)
1322        }
1323    }
1324
1325    // ── Helpers ────────────────────────────────────────────────────────
1326
1327    /// Get memory content by ID.
1328    fn get_memory_content(&self, id: Uuid, galaxy: Galaxy) -> String {
1329        self.store
1330            .get(galaxy, id)
1331            .ok()
1332            .flatten()
1333            .map(|m| m.content)
1334            .unwrap_or_default()
1335    }
1336
1337    /// Whether a memory is flagged `is_private` (missing memories count as
1338    /// private — they cannot be verified visible).
1339    #[must_use]
1340    pub fn is_private(&self, id: Uuid, galaxy: Galaxy) -> bool {
1341        self.store
1342            .get(galaxy, id)
1343            .ok()
1344            .flatten()
1345            .is_none_or(|m| m.metadata.is_private)
1346    }
1347
1348    /// Get memory importance by ID.
1349    fn get_memory_importance(&self, id: Uuid, galaxy: Galaxy) -> f32 {
1350        self.store
1351            .get(galaxy, id)
1352            .ok()
1353            .flatten()
1354            .map_or(0.0, |m| m.metadata.importance)
1355    }
1356
1357    /// Get memory `source_trust` by ID (V8 S8 trust-into-fusion).
1358    /// Missing memories resolve to 0.7 — the tool-ingested neutral point —
1359    /// so an absent row is trust-neutral rather than trust-maximal.
1360    fn get_memory_source_trust(&self, id: Uuid, galaxy: Galaxy) -> f32 {
1361        self.store
1362            .get(galaxy, id)
1363            .ok()
1364            .flatten()
1365            .map_or(0.7, |m| m.metadata.source_trust)
1366    }
1367
1368    /// Get the number of cached embeddings.
1369    #[must_use]
1370    pub fn cache_size(&self) -> usize {
1371        self.embedding_cache.lock().map_or(0, |c| c.len())
1372    }
1373
1374    /// Clear the embedding cache.
1375    pub fn clear_cache(&self) {
1376        if let Ok(mut c) = self.embedding_cache.lock() {
1377            c.clear();
1378        }
1379    }
1380
1381    /// Get the number of vectors in the vector store.
1382    #[must_use]
1383    pub fn vector_count(&self) -> usize {
1384        self.vector_store.lock().map_or(0, |c| c.len())
1385    }
1386}
1387
1388// ── Fusion implementation ─────────────────────────────────────────────
1389
1390/// Inner fusion logic, extracted for testability without a full engine.
1391#[allow(clippy::too_many_arguments)]
1392fn fuse_results_inner(
1393    bm25_results: &[SearchResult],
1394    vector_results: &[VectorSearchResult],
1395    limit: usize,
1396    bm25_weight: f32,
1397    vector_weight: f32,
1398    importance_weight: f32,
1399    trust_weight: f32,
1400    mut get_content: impl FnMut(Uuid, Galaxy) -> String,
1401    mut get_importance: impl FnMut(Uuid, Galaxy) -> f32,
1402    mut get_source_trust: impl FnMut(Uuid, Galaxy) -> f32,
1403) -> Vec<RecallResult> {
1404    // Normalize BM25 scores
1405    let max_bm25 = bm25_results
1406        .iter()
1407        .map(|r| r.score)
1408        .fold(0.0_f32, f32::max)
1409        .max(0.001);
1410
1411    // Build lookup maps
1412    let mut bm25_map: HashMap<Uuid, (f32, String, Galaxy)> = HashMap::new();
1413    for sr in bm25_results {
1414        if let Ok(id) = Uuid::parse_str(&sr.memory_id) {
1415            match Galaxy::from_db_name(&sr.galaxy) {
1416                Some(galaxy) => {
1417                    let normalized = sr.score / max_bm25;
1418                    bm25_map.insert(id, (normalized, sr.content.clone(), galaxy));
1419                }
1420                None => {
1421                    tracing::warn!(
1422                        "Skipping BM25 result with unknown galaxy '{}' (memory_id={})",
1423                        sr.galaxy,
1424                        sr.memory_id
1425                    );
1426                }
1427            }
1428        }
1429    }
1430
1431    let mut vector_map: HashMap<Uuid, (f32, Galaxy)> = HashMap::new();
1432    for vr in vector_results {
1433        vector_map.insert(vr.memory_id, (vr.score, vr.galaxy));
1434    }
1435
1436    // Collect all unique memory IDs
1437    let mut all_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
1438    all_ids.extend(bm25_map.keys());
1439    all_ids.extend(vector_map.keys());
1440
1441    // Fuse scores
1442    let mut results: Vec<RecallResult> = all_ids
1443        .into_iter()
1444        .map(|id| {
1445            let (bm25_score, content, galaxy_bm25) = bm25_map
1446                .get(&id)
1447                .map_or((0.0, String::new(), Galaxy::Codex), |(s, c, g)| {
1448                    (*s, c.clone(), *g)
1449                });
1450
1451            let (vector_score, galaxy_vec) = vector_map
1452                .get(&id)
1453                .map_or((0.0, Galaxy::Codex), |(s, g)| (*s, *g));
1454
1455            let galaxy = if bm25_score > 0.0 {
1456                galaxy_bm25
1457            } else {
1458                galaxy_vec
1459            };
1460
1461            let content = if content.is_empty() {
1462                get_content(id, galaxy)
1463            } else {
1464                content
1465            };
1466
1467            let importance = get_importance(id, galaxy);
1468
1469            let fused = bm25_weight.mul_add(
1470                bm25_score,
1471                vector_weight.mul_add(vector_score, importance_weight * importance),
1472            );
1473
1474            // Trust weighting (V8 S8): post-fusion multiplier, applied
1475            // here so every consumer of the hybrid path sees the same
1476            // ranking. Factor disclosed per-result; 1.0 when the knob is
1477            // off (byte-identical base fusion). Plain float ops by
1478            // design — mul_add would change rounding and with it the
1479            // ranking (the deterministic-scorer allow class, AGENTS.md).
1480            #[allow(clippy::suboptimal_flops)]
1481            let (score, trust_factor) = if trust_weight > 0.0 {
1482                let source_trust = get_source_trust(id, galaxy);
1483                let factor = (1.0 + trust_weight * (source_trust.clamp(0.0, 1.0) - 0.7)).max(0.0);
1484                (fused * factor, factor)
1485            } else {
1486                (fused, 1.0)
1487            };
1488
1489            RecallResult {
1490                memory_id: id,
1491                galaxy,
1492                score,
1493                bm25_score,
1494                vector_score,
1495                importance,
1496                graph_score: 0.0,
1497                trust_factor,
1498                in_conformal_set: false,
1499                corroboration: 0,
1500                content,
1501            }
1502        })
1503        .collect();
1504
1505    // Sort by fused score descending
1506    results.sort_by(|a, b| {
1507        b.score
1508            .partial_cmp(&a.score)
1509            .unwrap_or(std::cmp::Ordering::Equal)
1510    });
1511    results.truncate(limit);
1512    results
1513}
1514
1515// ── Tests ─────────────────────────────────────────────────────────────
1516
1517#[cfg(test)]
1518mod tests {
1519    use super::*;
1520    use crate::associations::{Association, LinkType};
1521    use crate::embedder::StubEmbedder;
1522
1523    /// S6 acceptance harness: a real store + Tantivy index + engine. Only
1524    /// `indexed` memories are BM25-findable; graph-only neighbors are NOT
1525    /// indexed, so their presence in hybrid results proves traversal.
1526    struct GraphHarness {
1527        _dir: tempfile::TempDir,
1528        store: Arc<MemoryStore>,
1529        engine_with_graph: RecallEngine,
1530        engine_plain: RecallEngine,
1531    }
1532
1533    fn graph_harness() -> GraphHarness {
1534        let dir = tempfile::tempdir().unwrap();
1535        let lmdb = dir.path().join("lmdb");
1536        std::fs::create_dir_all(&lmdb).unwrap();
1537        let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1538        let tantivy = dir.path().join("tantivy");
1539        std::fs::create_dir_all(&tantivy).unwrap();
1540        let search = Arc::new(SearchEngine::open(&tantivy).unwrap());
1541
1542        // Seed: A (indexed, the query hit), B (graph neighbor, NOT
1543        // indexed), C (indexed, unconnected). Edge A --0.8--> B.
1544        let a = Memory::new(Galaxy::Codex, "kumquat governance ratchet".into());
1545        let mut b = Memory::new(Galaxy::Codex, "the follow-up decision".into());
1546        let c = Memory::new(Galaxy::Codex, "kumquat harvest notes".into());
1547        b.metadata.is_private = false;
1548        let (id_a, id_b, id_c) = (a.metadata.id, b.metadata.id, c.metadata.id);
1549        store.put(Galaxy::Codex, &a).unwrap();
1550        store.put(Galaxy::Codex, &b).unwrap();
1551        store.put(Galaxy::Codex, &c).unwrap();
1552
1553        let mut writer = search.writer().unwrap();
1554        for (id, content) in [
1555            (id_a, "kumquat governance ratchet"),
1556            (id_c, "kumquat harvest notes"),
1557        ] {
1558            search
1559                .add_document(
1560                    &mut writer,
1561                    &id.to_string(),
1562                    "codex",
1563                    content,
1564                    &[],
1565                    1_700_000_000,
1566                )
1567                .unwrap();
1568        }
1569        search.commit(&mut writer).unwrap();
1570
1571        let env = store.env();
1572        let assocs = AssociationStore::open(env).unwrap();
1573        assocs
1574            .put(env, &Association::new(id_a, id_b, LinkType::Related, 0.8))
1575            .unwrap();
1576
1577        let store_for_engine = store.clone();
1578        let search_for_engine = search.clone();
1579        let mk_engine = move |graph_weight: f32| {
1580            let config = RecallConfig {
1581                bm25_weight: 1.0,
1582                vector_weight: 0.0,
1583                importance_weight: 0.0,
1584                graph_weight,
1585                ..RecallConfig::default()
1586            };
1587            RecallEngine::new(
1588                store_for_engine.clone(),
1589                search_for_engine.clone(),
1590                VectorStore::new(),
1591                Arc::new(StubEmbedder::default()),
1592                config,
1593            )
1594            .unwrap()
1595        };
1596        GraphHarness {
1597            _dir: dir,
1598            store,
1599            engine_with_graph: mk_engine(0.5),
1600            engine_plain: mk_engine(0.0),
1601        }
1602    }
1603
1604    /// Counts embedder invocations; delegates to the stub. The persistent
1605    /// embedding-cache acceptance is measured in CALLS, not assumptions.
1606    struct CountingEmbedder {
1607        inner: StubEmbedder,
1608        calls: std::sync::atomic::AtomicUsize,
1609    }
1610
1611    impl CountingEmbedder {
1612        fn new() -> Self {
1613            Self {
1614                inner: StubEmbedder::default(),
1615                calls: std::sync::atomic::AtomicUsize::new(0),
1616            }
1617        }
1618
1619        fn call_count(&self) -> usize {
1620            self.calls.load(std::sync::atomic::Ordering::SeqCst)
1621        }
1622    }
1623
1624    impl Embedder for CountingEmbedder {
1625        fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
1626            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1627            self.inner.embed_batch(texts)
1628        }
1629        fn dimension(&self) -> usize {
1630            self.inner.dimension()
1631        }
1632        fn is_available(&self) -> bool {
1633            true
1634        }
1635        fn backend_name(&self) -> &'static str {
1636            "stub-counting"
1637        }
1638    }
1639
1640    fn engine_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
1641        let dir = tempfile::tempdir().unwrap();
1642        let lmdb = dir.path().join("lmdb");
1643        std::fs::create_dir_all(&lmdb).unwrap();
1644        let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1645        let tantivy = dir.path().join("tantivy");
1646        std::fs::create_dir_all(&tantivy).unwrap();
1647        let search = Arc::new(SearchEngine::open(&tantivy).unwrap());
1648        (dir, store, search)
1649    }
1650
1651    fn mk_engine(
1652        store: &Arc<MemoryStore>,
1653        search: &Arc<SearchEngine>,
1654        embedder: Arc<dyn Embedder>,
1655    ) -> RecallEngine {
1656        RecallEngine::new(
1657            store.clone(),
1658            search.clone(),
1659            VectorStore::new(),
1660            embedder,
1661            RecallConfig::default(),
1662        )
1663        .unwrap()
1664    }
1665
1666    #[test]
1667    fn embedding_cache_warm_starts_reingest_across_engine_restart() {
1668        // V8 ship list #2: the content-hash vector cache persists in the
1669        // store, so a fresh engine over the same store re-ingests identical
1670        // content with ZERO embedder calls (v26 Tier-2, wired).
1671        let (_dir, store, search) = engine_fixture();
1672
1673        let contents: Vec<String> = (0..12)
1674            .map(|i| format!("cache warm-start probe number {i} with distinct wording {i}"))
1675            .collect();
1676        let entries: Vec<(Galaxy, crate::Memory)> = contents
1677            .iter()
1678            .map(|c| (Galaxy::Codex, crate::Memory::new(Galaxy::Codex, c.clone())))
1679            .collect();
1680        let refs: Vec<(Galaxy, &crate::Memory)> = entries.iter().map(|(g, m)| (*g, m)).collect();
1681
1682        let first = Arc::new(CountingEmbedder::new());
1683        let engine = mk_engine(&store, &search, first.clone());
1684        assert_eq!(engine.store_batch_with_embedding(&refs).unwrap(), 12);
1685        let first_calls = first.call_count();
1686        assert!(first_calls > 0, "first ingest must embed");
1687        assert_eq!(store.embedding_cache_count().unwrap(), 12);
1688
1689        // Fresh engine over the SAME store (restart semantics: empty
1690        // in-memory cache, persistent layer intact).
1691        let second = Arc::new(CountingEmbedder::new());
1692        let engine2 = mk_engine(&store, &search, second.clone());
1693        let entries2: Vec<(Galaxy, crate::Memory)> = contents
1694            .iter()
1695            .map(|c| (Galaxy::Codex, crate::Memory::new(Galaxy::Codex, c.clone())))
1696            .collect();
1697        let refs2: Vec<(Galaxy, &crate::Memory)> = entries2.iter().map(|(g, m)| (*g, m)).collect();
1698        assert_eq!(engine2.store_batch_with_embedding(&refs2).unwrap(), 12);
1699        assert_eq!(
1700            second.call_count(),
1701            0,
1702            "re-ingest of identical content must serve from the persistent cache"
1703        );
1704        assert_eq!(store.embedding_cache_count().unwrap(), 12);
1705    }
1706
1707    #[test]
1708    fn embedding_cache_scopes_vectors_by_embedder_namespace() {
1709        // Switching models must never serve stale vectors: the cache key
1710        // carries the embedder namespace, so a "different model" is a miss.
1711        let (_dir, store, search) = engine_fixture();
1712
1713        let content = "namespace isolation probe";
1714        let first = Arc::new(CountingEmbedder::new());
1715        let engine = mk_engine(&store, &search, first.clone());
1716        let mem = crate::Memory::new(Galaxy::Codex, content.into());
1717        engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
1718        assert_eq!(first.call_count(), 1);
1719
1720        // A second engine whose embedder reports a DIFFERENT namespace
1721        // must re-embed the same content.
1722        struct OtherNamespaceEmbedder(StubEmbedder);
1723        impl Embedder for OtherNamespaceEmbedder {
1724            fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
1725                self.0.embed_batch(texts)
1726            }
1727            fn dimension(&self) -> usize {
1728                self.0.dimension()
1729            }
1730            fn is_available(&self) -> bool {
1731                true
1732            }
1733            fn backend_name(&self) -> &'static str {
1734                "stub-other"
1735            }
1736        }
1737        let second = Arc::new(OtherNamespaceEmbedder(StubEmbedder::default()));
1738        let engine2 = mk_engine(&store, &search, second);
1739        let mem2 = crate::Memory::new(Galaxy::Codex, content.into());
1740        engine2.store_with_embedding(Galaxy::Codex, &mem2).unwrap();
1741
1742        // Two cache entries: one per namespace.
1743        assert_eq!(store.embedding_cache_count().unwrap(), 2);
1744    }
1745
1746    #[test]
1747    fn graph_expansion_injects_unindexed_neighbors_and_boosts_connected() {
1748        let h = graph_harness();
1749
1750        // Knob off: base fusion only — B is invisible (not indexed).
1751        let plain = h.engine_plain.hybrid_search("kumquat", 10, None);
1752        assert!(plain.iter().all(|r| r.memory_id != {
1753            h.store
1754                .find_by_content_hash(Galaxy::Codex, &content_hash("the follow-up decision"))
1755                .unwrap()
1756                .unwrap()
1757        }));
1758        assert!(plain.iter().all(|r| r.graph_score == 0.0));
1759
1760        // Knob on: B is injected purely via the A→B edge, carrying its
1761        // graph contribution; A keeps the top fused score.
1762        let expanded = h.engine_with_graph.hybrid_search("kumquat", 10, None);
1763        let id_b = h
1764            .store
1765            .find_by_content_hash(Galaxy::Codex, &content_hash("the follow-up decision"))
1766            .unwrap()
1767            .unwrap();
1768        let b = expanded
1769            .iter()
1770            .find(|r| r.memory_id == id_b)
1771            .expect("graph expansion must surface the unindexed neighbor");
1772        assert!(b.graph_score > 0.0, "injected neighbor: {b:?}");
1773        assert_eq!(b.bm25_score, 0.0, "B had no BM25 hit — pure graph entry");
1774        let a_score = expanded
1775            .iter()
1776            .find(|r| r.content.contains("ratchet"))
1777            .unwrap()
1778            .score;
1779        assert!(a_score >= b.score, "seed outranks its 1-hop neighbor");
1780    }
1781
1782    #[test]
1783    fn graph_expansion_honors_the_privacy_flag() {
1784        let h = graph_harness();
1785        let id_b = h
1786            .store
1787            .find_by_content_hash(Galaxy::Codex, &content_hash("the follow-up decision"))
1788            .unwrap()
1789            .unwrap();
1790        // Flip B private → the graph must not open a side door to it.
1791        let mut b = h.store.get(Galaxy::Codex, id_b).unwrap().unwrap();
1792        b.metadata.is_private = true;
1793        h.store.put(Galaxy::Codex, &b).unwrap();
1794        let expanded = h.engine_with_graph.hybrid_search("kumquat", 10, None);
1795        assert!(
1796            expanded.iter().all(|r| r.memory_id != id_b),
1797            "private memory must not be graph-injected"
1798        );
1799    }
1800
1801    #[test]
1802    fn config_default_graph_weight_is_off() {
1803        let config = RecallConfig::default();
1804        assert_eq!(config.graph_weight, 0.0, "evidence-gated: default off");
1805        assert!(config.weights_normalized());
1806    }
1807
1808    // ── RecallConfig tests ─────────────────────────────────────────────
1809
1810    #[test]
1811    fn config_default_weights() {
1812        let config = RecallConfig::default();
1813        assert!(config.weights_normalized());
1814        assert_eq!(config.bm25_weight, 0.5);
1815        assert_eq!(config.vector_weight, 0.3);
1816        assert_eq!(config.importance_weight, 0.2);
1817    }
1818
1819    #[test]
1820    fn config_custom_weights() {
1821        let config = RecallConfig {
1822            bm25_weight: 0.6,
1823            vector_weight: 0.3,
1824            importance_weight: 0.1,
1825            ..Default::default()
1826        };
1827        assert!(config.weights_normalized());
1828    }
1829
1830    #[test]
1831    fn config_unnormalized_weights() {
1832        let config = RecallConfig {
1833            bm25_weight: 0.7,
1834            vector_weight: 0.5,
1835            importance_weight: 0.2,
1836            ..Default::default()
1837        };
1838        assert!(!config.weights_normalized());
1839    }
1840
1841    #[test]
1842    fn config_from_env_uses_defaults() {
1843        // No env vars set — should use defaults
1844        let config = RecallConfig::from_env();
1845        assert_eq!(config.bm25_weight, 0.5);
1846        assert_eq!(config.vector_weight, 0.3);
1847        assert_eq!(config.importance_weight, 0.2);
1848    }
1849
1850    /// Bridging counter: knob-off the fused ranking is byte-identical with
1851    /// or without corroboration stamps; knob-on the corroborated memory is
1852    /// boosted and the count is disclosed per-result.
1853    #[test]
1854    fn corroboration_boost_is_knob_gated_and_disclosed() {
1855        let dir = tempfile::tempdir().unwrap();
1856        let lmdb = dir.path().join("lmdb");
1857        std::fs::create_dir_all(&lmdb).unwrap();
1858        let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1859        let tantivy = dir.path().join("tantivy");
1860        std::fs::create_dir_all(&tantivy).unwrap();
1861        let search = Arc::new(SearchEngine::open(&tantivy).unwrap());
1862
1863        let mut backed = Memory::new(Galaxy::Codex, "zanzibar treaty terms".into());
1864        backed.metadata.corroborated_by = vec![Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()];
1865        let plain = Memory::new(Galaxy::Codex, "zanzibar treaty terms".into());
1866        let (id_backed, id_plain) = (backed.metadata.id, plain.metadata.id);
1867        store.put(Galaxy::Codex, &backed).unwrap();
1868        store.put(Galaxy::Codex, &plain).unwrap();
1869        let mut writer = search.writer().unwrap();
1870        for (id, content) in [
1871            (id_backed, "zanzibar treaty terms"),
1872            (id_plain, "zanzibar treaty terms"),
1873        ] {
1874            search
1875                .add_document(
1876                    &mut writer,
1877                    &id.to_string(),
1878                    "codex",
1879                    content,
1880                    &[],
1881                    1_700_000_000,
1882                )
1883                .unwrap();
1884        }
1885        search.commit(&mut writer).unwrap();
1886
1887        let mk = |weight: f32| {
1888            RecallEngine::new(
1889                store.clone(),
1890                search.clone(),
1891                VectorStore::new(),
1892                Arc::new(StubEmbedder::default()),
1893                RecallConfig {
1894                    bm25_weight: 1.0,
1895                    vector_weight: 0.0,
1896                    importance_weight: 0.0,
1897                    corroboration_weight: weight,
1898                    ..RecallConfig::default()
1899                },
1900            )
1901            .unwrap()
1902        };
1903        // Knob off: identical scores, zero disclosure.
1904        let off = mk(0.0).hybrid_search("zanzibar treaty", 10, None);
1905        let (b_off, p_off) = (
1906            off.iter().find(|r| r.memory_id == id_backed).unwrap(),
1907            off.iter().find(|r| r.memory_id == id_plain).unwrap(),
1908        );
1909        assert!((b_off.score - p_off.score).abs() < 1e-6);
1910        assert_eq!(b_off.corroboration, 0);
1911        // Knob on: 3-session backing boosts (factor 1 + 3/5) + disclosed.
1912        let on = mk(1.0).hybrid_search("zanzibar treaty", 10, None);
1913        let (b_on, p_on) = (
1914            on.iter().find(|r| r.memory_id == id_backed).unwrap(),
1915            on.iter().find(|r| r.memory_id == id_plain).unwrap(),
1916        );
1917        assert_eq!(b_on.corroboration, 3);
1918        assert_eq!(p_on.corroboration, 0);
1919        let expected = b_off.score * 1.6;
1920        assert!((b_on.score - expected).abs() < 1e-4, "{b_on:?}");
1921        assert!((p_on.score - p_off.score).abs() < 1e-6);
1922        assert!(on[0].memory_id == id_backed, "boosted memory ranks first");
1923    }
1924
1925    // ── RecallResult tests ─────────────────────────────────────────────
1926    #[test]
1927    fn recall_result_fields() {
1928        let result = RecallResult {
1929            memory_id: Uuid::new_v4(),
1930            galaxy: Galaxy::Codex,
1931            score: 0.85,
1932            bm25_score: 0.7,
1933            vector_score: 0.9,
1934            importance: 0.5,
1935            graph_score: 0.0,
1936            trust_factor: 1.0,
1937            in_conformal_set: false,
1938            corroboration: 0,
1939            content: "test content".into(),
1940        };
1941        assert_eq!(result.score, 0.85);
1942        assert_eq!(result.bm25_score, 0.7);
1943        assert_eq!(result.vector_score, 0.9);
1944    }
1945
1946    // ── RecallEngine unit tests (with stub embedder) ───────────────────
1947
1948    fn fuse(
1949        bm25: &[SearchResult],
1950        vector: &[VectorSearchResult],
1951        limit: usize,
1952    ) -> Vec<RecallResult> {
1953        fuse_results_inner(
1954            bm25,
1955            vector,
1956            limit,
1957            0.5,
1958            0.3,
1959            0.2,
1960            0.0,
1961            |_, _| String::new(),
1962            |_, _| 0.0,
1963            |_, _| 0.7,
1964        )
1965    }
1966
1967    #[test]
1968    fn engine_config_default() {
1969        let config = RecallConfig::default();
1970        assert_eq!(config.bm25_weight, 0.5);
1971    }
1972
1973    #[test]
1974    fn engine_cache_concept() {
1975        // Cache is tested via embed_content_caches_result below
1976        let config = RecallConfig::default();
1977        assert!(config.cache_embeddings);
1978    }
1979
1980    // ── Fusion logic tests ─────────────────────────────────────────────
1981
1982    #[test]
1983    fn fuse_results_empty() {
1984        let results = fuse(&[], &[], 10);
1985        assert!(results.is_empty());
1986    }
1987
1988    #[test]
1989    fn fuse_results_bm25_only() {
1990        let id = Uuid::new_v4();
1991        let bm25 = vec![SearchResult {
1992            memory_id: id.to_string(),
1993            galaxy: Galaxy::Codex.db_name().to_string(),
1994            score: 5.0,
1995            normalized_score: 0.0,
1996            content: "test".into(),
1997        }];
1998        let results = fuse(&bm25, &[], 10);
1999        assert_eq!(results.len(), 1);
2000        assert!(results[0].bm25_score > 0.0);
2001        assert_eq!(results[0].vector_score, 0.0);
2002    }
2003
2004    #[test]
2005    fn fuse_results_vector_only() {
2006        let id = Uuid::new_v4();
2007        let vector = vec![VectorSearchResult {
2008            memory_id: id,
2009            galaxy: Galaxy::Codex,
2010            score: 0.85,
2011        }];
2012        let results = fuse(&[], &vector, 10);
2013        assert_eq!(results.len(), 1);
2014        assert_eq!(results[0].bm25_score, 0.0);
2015        assert!(results[0].vector_score > 0.0);
2016    }
2017
2018    #[test]
2019    fn fuse_results_both_sources() {
2020        let id = Uuid::new_v4();
2021        let bm25 = vec![SearchResult {
2022            memory_id: id.to_string(),
2023            galaxy: Galaxy::Codex.db_name().to_string(),
2024            score: 5.0,
2025            normalized_score: 0.0,
2026            content: "test content".into(),
2027        }];
2028        let vector = vec![VectorSearchResult {
2029            memory_id: id,
2030            galaxy: Galaxy::Codex,
2031            score: 0.85,
2032        }];
2033        let results = fuse(&bm25, &vector, 10);
2034        assert_eq!(results.len(), 1);
2035        assert!(results[0].bm25_score > 0.0);
2036        assert!(results[0].vector_score > 0.0);
2037        assert!(results[0].score > results[0].bm25_score * 0.5);
2038    }
2039
2040    #[test]
2041    fn fuse_results_sorted_by_score() {
2042        let id1 = Uuid::new_v4();
2043        let id2 = Uuid::new_v4();
2044        let bm25 = vec![
2045            SearchResult {
2046                memory_id: id1.to_string(),
2047                galaxy: Galaxy::Codex.db_name().to_string(),
2048                score: 3.0,
2049                normalized_score: 0.0,
2050                content: "lower".into(),
2051            },
2052            SearchResult {
2053                memory_id: id2.to_string(),
2054                galaxy: Galaxy::Codex.db_name().to_string(),
2055                score: 8.0,
2056                normalized_score: 0.0,
2057                content: "higher".into(),
2058            },
2059        ];
2060        let results = fuse(&bm25, &[], 10);
2061        assert_eq!(results.len(), 2);
2062        assert!(results[0].score >= results[1].score);
2063    }
2064
2065    #[test]
2066    fn fuse_results_truncated_to_limit() {
2067        let bm25: Vec<SearchResult> = (0..20)
2068            .map(|i| SearchResult {
2069                memory_id: Uuid::new_v4().to_string(),
2070                galaxy: Galaxy::Codex.db_name().to_string(),
2071                score: 1.0 + i as f32,
2072                normalized_score: 0.0,
2073                content: format!("content {i}"),
2074            })
2075            .collect();
2076        let results = fuse(&bm25, &[], 5);
2077        assert_eq!(results.len(), 5);
2078    }
2079
2080    #[test]
2081    fn fuse_results_normalizes_bm25() {
2082        let id = Uuid::new_v4();
2083        let bm25 = vec![SearchResult {
2084            memory_id: id.to_string(),
2085            galaxy: Galaxy::Codex.db_name().to_string(),
2086            score: 100.0,
2087            normalized_score: 0.0,
2088            content: "test".into(),
2089        }];
2090        let results = fuse(&bm25, &[], 10);
2091        assert!((results[0].bm25_score - 1.0).abs() < 0.01);
2092    }
2093
2094    // ── Embedding cache tests ──────────────────────────────────────────
2095
2096    #[test]
2097    fn embed_content_caches_result() {
2098        let embedder = StubEmbedder::new(384);
2099        let content = "test content for caching";
2100        let vec1 = embedder.embed(content).unwrap();
2101        let vec2 = embedder.embed(content).unwrap();
2102        assert_eq!(vec1, vec2);
2103    }
2104
2105    #[test]
2106    fn embed_content_different_content_different_result() {
2107        let embedder = StubEmbedder::new(384);
2108        let vec1 = embedder.embed("content one").unwrap();
2109        let vec2 = embedder.embed("content two").unwrap();
2110        assert_ne!(vec1, vec2);
2111    }
2112
2113    // ── Weight configuration tests ─────────────────────────────────────
2114
2115    #[test]
2116    fn fuse_with_zero_bm25_weight() {
2117        let id = Uuid::new_v4();
2118        let bm25 = vec![SearchResult {
2119            memory_id: id.to_string(),
2120            galaxy: Galaxy::Codex.db_name().to_string(),
2121            score: 5.0,
2122            normalized_score: 0.0,
2123            content: "test".into(),
2124        }];
2125        let results = fuse_results_inner(
2126            &bm25,
2127            &[],
2128            10,
2129            0.5,
2130            0.3,
2131            0.2,
2132            0.0,
2133            |_, _| String::new(),
2134            |_, _| 0.0,
2135            |_, _| 0.7,
2136        );
2137        assert!((results[0].score - 0.5).abs() < 0.01);
2138    }
2139
2140    #[test]
2141    fn fuse_with_zero_vector_weight() {
2142        let id = Uuid::new_v4();
2143        let vector = vec![VectorSearchResult {
2144            memory_id: id,
2145            galaxy: Galaxy::Codex,
2146            score: 0.9,
2147        }];
2148        let results = fuse_results_inner(
2149            &[],
2150            &vector,
2151            10,
2152            0.5,
2153            0.3,
2154            0.2,
2155            0.0,
2156            |_, _| String::new(),
2157            |_, _| 0.0,
2158            |_, _| 0.7,
2159        );
2160        assert!((results[0].score - 0.27).abs() < 0.01);
2161    }
2162
2163    #[test]
2164    fn trust_weight_zero_is_byte_identical_to_no_weight() {
2165        let id = Uuid::new_v4();
2166        let bm25 = vec![SearchResult {
2167            memory_id: id.to_string(),
2168            galaxy: Galaxy::Codex.db_name().to_string(),
2169            score: 5.0,
2170            normalized_score: 0.0,
2171            content: "test".into(),
2172        }];
2173        // Knob off: the low-trust getter is never consulted, the score is
2174        // the plain fused value, and the disclosure never lies.
2175        let results = fuse_results_inner(
2176            &bm25,
2177            &[],
2178            10,
2179            0.5,
2180            0.3,
2181            0.2,
2182            0.0,
2183            |_, _| String::new(),
2184            |_, _| 0.0,
2185            |_, _| 0.4,
2186        );
2187        assert!((results[0].score - 0.5).abs() < 0.01);
2188        assert!((results[0].trust_factor - 1.0).abs() < f32::EPSILON);
2189        assert!(!results[0].in_conformal_set);
2190    }
2191
2192    #[test]
2193    fn trust_weight_orders_high_trust_above_low() {
2194        // Two candidates with identical fused scores; only source_trust
2195        // differs. With weight 0.5: factor = 1 + 0.5*(trust − 0.7).
2196        let high = Uuid::new_v4();
2197        let low = Uuid::new_v4();
2198        let mk = |id: &Uuid| SearchResult {
2199            memory_id: id.to_string(),
2200            galaxy: Galaxy::Codex.db_name().to_string(),
2201            score: 5.0,
2202            normalized_score: 0.0,
2203            content: "test".into(),
2204        };
2205        let bm25 = vec![mk(&high), mk(&low)];
2206        let mut trust_calls = 0;
2207        let results = fuse_results_inner(
2208            &bm25,
2209            &[],
2210            10,
2211            0.5,
2212            0.3,
2213            0.2,
2214            0.5,
2215            |_, _| String::new(),
2216            |_, _| 0.0,
2217            |id, _| {
2218                trust_calls += 1;
2219                if id == high { 1.0 } else { 0.4 }
2220            },
2221        );
2222        let hi = results.iter().find(|r| r.memory_id == high).unwrap();
2223        let lo = results.iter().find(|r| r.memory_id == low).unwrap();
2224        assert!(
2225            hi.score > lo.score,
2226            "user-confirmed (1.0) must outrank low-trust (0.4) at equal fused score"
2227        );
2228        // Factors disclosed: 1 + 0.5*(1.0−0.7) = 1.15; 1 + 0.5*(0.4−0.7) = 0.85.
2229        assert!((hi.trust_factor - 1.15).abs() < 0.001);
2230        assert!((lo.trust_factor - 0.85).abs() < 0.001);
2231        assert!(trust_calls >= 2, "getter consulted per candidate");
2232        // Neutral 0.7 stays exactly neutral even with the knob on.
2233        let neutral = Uuid::new_v4();
2234        let bm25_neutral = vec![SearchResult {
2235            memory_id: neutral.to_string(),
2236            galaxy: Galaxy::Codex.db_name().to_string(),
2237            score: 5.0,
2238            normalized_score: 0.0,
2239            content: "test".into(),
2240        }];
2241        let res_n = fuse_results_inner(
2242            &bm25_neutral,
2243            &[],
2244            10,
2245            0.5,
2246            0.3,
2247            0.2,
2248            0.5,
2249            |_, _| String::new(),
2250            |_, _| 0.0,
2251            |_, _| 0.7,
2252        );
2253        assert!((res_n[0].trust_factor - 1.0).abs() < 0.001);
2254    }
2255
2256    #[test]
2257    fn config_defaults_keep_both_s8_knobs_off() {
2258        // Evidence-gated defaults: trust weighting and conformal sets ship
2259        // OFF — the base fusion must be untouched unless the operator opts
2260        // in. (Env parsing for the knobs follows the same guard pattern as
2261        // WM_RECALL_GRAPH_WEIGHT: finite, clamped to range, else default.)
2262        let cfg = RecallConfig::default();
2263        assert_eq!(cfg.trust_weight, 0.0);
2264        assert_eq!(cfg.conformal_alpha, None);
2265        let env_cfg = RecallConfig::from_env();
2266        assert_eq!(env_cfg.trust_weight, 0.0, "unset env stays off");
2267        assert_eq!(env_cfg.conformal_alpha, None, "unset env stays off");
2268    }
2269
2270    // ── GalaxyExt tests ────────────────────────────────────────────────
2271
2272    #[test]
2273    fn galaxy_from_db_name_valid() {
2274        assert_eq!(Galaxy::from_db_name("codex"), Some(Galaxy::Codex));
2275    }
2276
2277    #[test]
2278    fn galaxy_from_db_name_invalid() {
2279        assert_eq!(Galaxy::from_db_name("nonexistent"), None);
2280    }
2281
2282    // ── Integration tests (end-to-end with temp-dir LMDB + Tantivy) ────
2283
2284    use crate::Memory;
2285    use tempfile::tempdir;
2286
2287    fn setup_engine() -> (tempfile::TempDir, RecallEngine) {
2288        let tmp = tempdir().unwrap();
2289        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
2290        let tantivy_path = tmp.path().join("tantivy");
2291        std::fs::create_dir_all(&tantivy_path).unwrap();
2292        let search = Arc::new(SearchEngine::open(&tantivy_path).unwrap());
2293        let vector_store = VectorStore::new();
2294        let embedder: Arc<dyn Embedder> = Arc::new(StubEmbedder::new(384));
2295        let engine = RecallEngine::new(
2296            store,
2297            search,
2298            vector_store,
2299            embedder,
2300            RecallConfig::default(),
2301        )
2302        .unwrap();
2303        (tmp, engine)
2304    }
2305
2306    #[test]
2307    fn integration_store_and_hybrid_search_roundtrip() {
2308        let (_tmp, engine) = setup_engine();
2309
2310        let mem1 = Memory::new(
2311            Galaxy::Codex,
2312            "Rust programming language is fast and safe".into(),
2313        )
2314        .with_importance(0.8)
2315        .with_tags(vec!["rust".into(), "programming".into()]);
2316        let mem2 = Memory::new(Galaxy::Codex, "Python is great for data science".into())
2317            .with_importance(0.5)
2318            .with_tags(vec!["python".into(), "data".into()]);
2319        let mem3 = Memory::new(
2320            Galaxy::Codex,
2321            "The Rust ownership model prevents memory leaks".into(),
2322        )
2323        .with_importance(0.9)
2324        .with_tags(vec!["rust".into(), "memory".into()]);
2325
2326        engine.store_with_embedding(Galaxy::Codex, &mem1).unwrap();
2327        engine.store_with_embedding(Galaxy::Codex, &mem2).unwrap();
2328        engine.store_with_embedding(Galaxy::Codex, &mem3).unwrap();
2329
2330        // Search for "rust" — should find mem1 and mem3 (both contain "rust")
2331        let results = engine.hybrid_search("rust", 10, None);
2332        assert!(!results.is_empty(), "hybrid search should return results");
2333
2334        // All results should contain "rust" in content or be vector-similar
2335        let top_contents: Vec<&str> = results.iter().map(|r| r.content.as_str()).collect();
2336        assert!(
2337            top_contents.iter().any(|c| c.contains("Rust")),
2338            "top results should include Rust content, got: {top_contents:?}"
2339        );
2340    }
2341
2342    #[test]
2343    fn integration_bm25_and_vector_both_contribute() {
2344        let (_tmp, engine) = setup_engine();
2345
2346        // Store memories with distinct content
2347        for i in 0..5 {
2348            let mem = Memory::new(
2349                Galaxy::Codex,
2350                format!("memory about topic {i} with unique content"),
2351            )
2352            .with_importance(0.5);
2353            engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2354        }
2355
2356        // Search for a term that exists in all memories
2357        let results = engine.hybrid_search("memory", 10, None);
2358        assert!(!results.is_empty(), "should find memories");
2359
2360        // BM25 should have contributed (all contain "memory")
2361        let has_bm25 = results.iter().any(|r| r.bm25_score > 0.0);
2362        assert!(has_bm25, "BM25 should contribute to fused results");
2363    }
2364
2365    #[test]
2366    fn integration_vector_search_only() {
2367        let (_tmp, engine) = setup_engine();
2368
2369        let content = "unique searchable content for vector test";
2370        let mem = Memory::new(Galaxy::Codex, content.into()).with_importance(0.7);
2371        engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2372
2373        // StubEmbedder is hash-based — same text produces same vector
2374        let results = engine.vector_search(content, 10, None);
2375        assert_eq!(results.len(), 1);
2376        assert_eq!(results[0].memory_id, mem.metadata.id);
2377        assert!(results[0].vector_score > 0.0);
2378    }
2379
2380    #[test]
2381    fn integration_text_search_only() {
2382        let (_tmp, engine) = setup_engine();
2383
2384        let mem = Memory::new(Galaxy::Codex, "specific text about rust ownership".into())
2385            .with_tags(vec!["rust".into()]);
2386        engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2387
2388        let results = engine.text_search("rust", 10);
2389        assert!(!results.is_empty(), "text search should find results");
2390        assert!(results.iter().any(|r| r.bm25_score > 0.0));
2391    }
2392
2393    #[test]
2394    fn integration_batch_store_with_embedding() {
2395        let (_tmp, engine) = setup_engine();
2396
2397        let mem1 = Memory::new(Galaxy::Codex, "alpha beta gamma".into());
2398        let mem2 = Memory::new(Galaxy::Codex, "delta epsilon zeta".into());
2399        let mem3 = Memory::new(Galaxy::Codex, "eta theta iota".into());
2400
2401        let entries = vec![
2402            (Galaxy::Codex, &mem1),
2403            (Galaxy::Codex, &mem2),
2404            (Galaxy::Codex, &mem3),
2405        ];
2406
2407        let count = engine.store_batch_with_embedding(&entries).unwrap();
2408        assert_eq!(count, 3);
2409
2410        // All three should be searchable via BM25
2411        let results = engine.text_search("alpha", 10);
2412        assert!(
2413            !results.is_empty(),
2414            "batch-stored memory should be searchable"
2415        );
2416
2417        // All three should be in the vector store
2418        let vresults = engine.vector_search("alpha beta gamma", 10, None);
2419        assert_eq!(
2420            vresults.len(),
2421            1,
2422            "vector search should find the exact match"
2423        );
2424        assert_eq!(vresults[0].memory_id, mem1.metadata.id);
2425    }
2426
2427    #[test]
2428    fn integration_batch_store_empty() {
2429        let (_tmp, engine) = setup_engine();
2430        let entries: Vec<(Galaxy, &Memory)> = vec![];
2431        let count = engine.store_batch_with_embedding(&entries).unwrap();
2432        assert_eq!(count, 0);
2433    }
2434
2435    #[test]
2436    fn integration_galaxy_filter() {
2437        let (_tmp, engine) = setup_engine();
2438
2439        let mem_codex = Memory::new(Galaxy::Codex, "codex memory about rust".into());
2440        let mem_research = Memory::new(Galaxy::Research, "research memory about rust".into());
2441
2442        engine
2443            .store_with_embedding(Galaxy::Codex, &mem_codex)
2444            .unwrap();
2445        engine
2446            .store_with_embedding(Galaxy::Research, &mem_research)
2447            .unwrap();
2448
2449        let results = engine.hybrid_search("rust", 10, Some(Galaxy::Codex));
2450        assert!(!results.is_empty());
2451        assert!(
2452            results.iter().all(|r| r.galaxy == Galaxy::Codex),
2453            "all results should be from Codex galaxy"
2454        );
2455    }
2456
2457    #[test]
2458    fn integration_empty_search() {
2459        let (_tmp, engine) = setup_engine();
2460        let results = engine.hybrid_search("nonexistent", 10, None);
2461        assert!(results.is_empty());
2462    }
2463
2464    #[test]
2465    fn integration_cache_populated_after_store() {
2466        let (_tmp, engine) = setup_engine();
2467
2468        let mem = Memory::new(Galaxy::Codex, "content to be cached".into());
2469        engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2470
2471        // The embedding cache should have one entry
2472        assert_eq!(engine.cache_size(), 1);
2473    }
2474
2475    #[test]
2476    fn integration_vector_count_tracks_stores() {
2477        let (_tmp, engine) = setup_engine();
2478
2479        assert_eq!(engine.vector_count(), 0);
2480
2481        for i in 0..3 {
2482            let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
2483            engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2484        }
2485
2486        assert_eq!(engine.vector_count(), 3);
2487    }
2488
2489    #[test]
2490    fn integration_importance_affects_ranking() {
2491        let (_tmp, engine) = setup_engine();
2492
2493        // Two memories with same content keyword but different importance
2494        let mem_low =
2495            Memory::new(Galaxy::Codex, "rust programming basics".into()).with_importance(0.1);
2496        let mem_high =
2497            Memory::new(Galaxy::Codex, "rust programming advanced".into()).with_importance(0.9);
2498
2499        engine
2500            .store_with_embedding(Galaxy::Codex, &mem_low)
2501            .unwrap();
2502        engine
2503            .store_with_embedding(Galaxy::Codex, &mem_high)
2504            .unwrap();
2505
2506        let results = engine.hybrid_search("rust", 10, None);
2507        assert_eq!(results.len(), 2);
2508
2509        // The higher-importance memory should generally rank higher
2510        // (both have similar BM25 and vector scores, importance breaks the tie)
2511        let high_idx = results
2512            .iter()
2513            .position(|r| r.memory_id == mem_high.metadata.id)
2514            .unwrap();
2515        let low_idx = results
2516            .iter()
2517            .position(|r| r.memory_id == mem_low.metadata.id)
2518            .unwrap();
2519        assert!(
2520            high_idx < low_idx,
2521            "higher importance memory should rank higher"
2522        );
2523    }
2524
2525    #[test]
2526    fn config_from_env_rejects_nan_weights() {
2527        // Test the validation logic directly rather than via env vars
2528        // (wm-memory has forbid(unsafe_code), can't use set_var)
2529        let mut config = RecallConfig::default();
2530        let w: f32 = "NaN".parse().unwrap();
2531        if w.is_finite() && w >= 0.0 {
2532            config.bm25_weight = w.min(1.0);
2533        }
2534        assert_eq!(
2535            config.bm25_weight, 0.5,
2536            "NaN should be rejected, default kept"
2537        );
2538    }
2539
2540    #[test]
2541    fn config_from_env_rejects_negative_weights() {
2542        let mut config = RecallConfig::default();
2543        let w: f32 = "-0.5".parse().unwrap();
2544        if w.is_finite() && w >= 0.0 {
2545            config.vector_weight = w.min(1.0);
2546        }
2547        assert_eq!(
2548            config.vector_weight, 0.3,
2549            "Negative should be rejected, default kept"
2550        );
2551    }
2552
2553    #[test]
2554    fn config_from_env_clamps_weights_to_1() {
2555        let mut config = RecallConfig::default();
2556        let w: f32 = "5.0".parse().unwrap();
2557        if w.is_finite() && w >= 0.0 {
2558            config.importance_weight = w.min(1.0);
2559        }
2560        assert_eq!(
2561            config.importance_weight, 1.0,
2562            "Weight should be clamped to 1.0"
2563        );
2564    }
2565
2566    #[test]
2567    fn config_from_env_normalizes_weights() {
2568        let mut config = RecallConfig {
2569            bm25_weight: 0.8,
2570            vector_weight: 0.8,
2571            importance_weight: 0.8,
2572            ..Default::default()
2573        };
2574        let sum = config.bm25_weight + config.vector_weight + config.importance_weight;
2575        if sum > 0.0 && (sum - 1.0).abs() > 0.01 {
2576            config.bm25_weight /= sum;
2577            config.vector_weight /= sum;
2578            config.importance_weight /= sum;
2579        }
2580        assert!(
2581            config.weights_normalized(),
2582            "Weights should be normalized to sum to 1.0"
2583        );
2584    }
2585
2586    #[test]
2587    fn config_from_env_rejects_infinity() {
2588        let mut config = RecallConfig::default();
2589        let w: f32 = "inf".parse().unwrap();
2590        if w.is_finite() && w >= 0.0 {
2591            config.bm25_weight = w.min(1.0);
2592        }
2593        assert_eq!(
2594            config.bm25_weight, 0.5,
2595            "Infinity should be rejected, default kept"
2596        );
2597    }
2598
2599    #[test]
2600    fn test_promotion_on_read_config_default() {
2601        let default_config = RecallConfig::default();
2602        assert!(!default_config.promotion_on_read);
2603
2604        let custom_config = RecallConfig {
2605            promotion_on_read: true,
2606            ..Default::default()
2607        };
2608        assert!(custom_config.promotion_on_read);
2609    }
2610
2611    #[test]
2612    fn test_promote_memory_updates_hebbian_score_and_counts() {
2613        let tmp = tempfile::tempdir().unwrap();
2614        let store_dir = tmp.path().join("store");
2615        std::fs::create_dir_all(&store_dir).unwrap();
2616        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2617        let index_dir = tmp.path().join("index");
2618        std::fs::create_dir_all(&index_dir).unwrap();
2619        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2620        let vector_store = VectorStore::new();
2621        let embedder = Arc::new(crate::embedder::StubEmbedder::default());
2622        let config = RecallConfig {
2623            promotion_on_read: true,
2624            ..Default::default()
2625        };
2626        let engine =
2627            RecallEngine::new(store.clone(), search_engine, vector_store, embedder, config)
2628                .unwrap();
2629
2630        let mut mem = crate::Memory::new(Galaxy::Codex, "promotion on read test".to_string());
2631        mem.metadata.neuro_score = 0.5;
2632        mem.metadata.novelty_score = 1.0;
2633        let mem_id = mem.metadata.id;
2634        store.put(Galaxy::Codex, &mem).unwrap();
2635
2636        // Promote memory
2637        let promoted = engine.promote_memory(Galaxy::Codex, mem_id).unwrap();
2638        assert!(promoted);
2639
2640        let reloaded = store.get(Galaxy::Codex, mem_id).unwrap().unwrap();
2641        assert_eq!(reloaded.metadata.recall_count, 1);
2642        assert_eq!(reloaded.metadata.access_count, 1);
2643        assert!(
2644            reloaded.metadata.neuro_score > 0.5,
2645            "neuro_score should increase via Hebbian boost"
2646        );
2647        assert!(
2648            reloaded.metadata.novelty_score < 1.0,
2649            "novelty_score should decay on recall"
2650        );
2651    }
2652
2653    #[test]
2654    fn test_hybrid_search_triggers_promotion_on_read() {
2655        let tmp = tempfile::tempdir().unwrap();
2656        let store_dir = tmp.path().join("store");
2657        std::fs::create_dir_all(&store_dir).unwrap();
2658        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2659        let index_dir = tmp.path().join("index");
2660        std::fs::create_dir_all(&index_dir).unwrap();
2661        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2662        let vector_store = VectorStore::new();
2663        let embedder = Arc::new(crate::embedder::StubEmbedder::default());
2664        let config = RecallConfig {
2665            promotion_on_read: true,
2666            ..Default::default()
2667        };
2668        let engine = RecallEngine::new(
2669            store.clone(),
2670            search_engine.clone(),
2671            vector_store,
2672            embedder,
2673            config,
2674        )
2675        .unwrap();
2676
2677        let mut mem = crate::Memory::new(Galaxy::Codex, "tokio army swarm tactics".to_string());
2678        mem.metadata.neuro_score = 0.5;
2679        mem.metadata.novelty_score = 1.0;
2680        let mem_id = mem.metadata.id;
2681        store.put(Galaxy::Codex, &mem).unwrap();
2682
2683        let mut writer = search_engine.writer().unwrap();
2684        search_engine
2685            .add_document(
2686                &mut writer,
2687                &mem_id.to_string(),
2688                "codex",
2689                "tokio army swarm tactics",
2690                &[],
2691                1_700_000_000,
2692            )
2693            .unwrap();
2694        search_engine.commit(&mut writer).unwrap();
2695
2696        // Perform search with promotion_on_read active
2697        let (results, _) =
2698            engine.hybrid_search_with_disclosure("tokio army", 5, Some(Galaxy::Codex));
2699        assert!(!results.is_empty());
2700        assert_eq!(results[0].memory_id, mem_id);
2701
2702        let reloaded = store.get(Galaxy::Codex, mem_id).unwrap().unwrap();
2703        assert_eq!(reloaded.metadata.recall_count, 1);
2704        assert!(reloaded.metadata.neuro_score > 0.5);
2705    }
2706
2707    #[test]
2708    fn hybrid_search_rehydrates_vectors_across_restart() {
2709        let tmp = tempfile::tempdir().unwrap();
2710        let store_dir = tmp.path().join("store");
2711        std::fs::create_dir_all(&store_dir).unwrap();
2712        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2713        let index_dir = tmp.path().join("index");
2714        std::fs::create_dir_all(&index_dir).unwrap();
2715        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2716        let embedder = Arc::new(crate::embedder::StubEmbedder::default());
2717        let dim = embedder.dimension();
2718
2719        // An earlier process persisted the memory + its embedding in LMDB.
2720        let mem = crate::Memory::new(Galaxy::Codex, "persisted vector canary".to_string());
2721        let mem_id = mem.metadata.id;
2722        store.put(Galaxy::Codex, &mem).unwrap();
2723        store.put_embedding(mem_id, &vec![0.5_f32; dim]).unwrap();
2724
2725        // Fresh process: new engine, empty in-memory vector index.
2726        let engine = RecallEngine::new(
2727            store,
2728            search_engine,
2729            VectorStore::new(),
2730            embedder,
2731            RecallConfig::default(),
2732        )
2733        .unwrap();
2734        assert!(!engine.vector_store.lock().unwrap().is_loaded());
2735
2736        // The first hybrid query must rehydrate the index from LMDB —
2737        // before the fix, the vector half answered from an empty index.
2738        let _ = engine.hybrid_search_with_disclosure("rehydration probe", 5, None);
2739
2740        let vs = engine.vector_store.lock().unwrap();
2741        assert!(
2742            vs.is_loaded(),
2743            "vector store should be loaded after the first hybrid search"
2744        );
2745        assert_eq!(vs.len(), 1, "persisted embedding should be indexed");
2746    }
2747
2748    #[test]
2749    fn bm25_weighted_search_skips_the_query_embed() {
2750        // F-T0-2 follow-up: with the vector and importance weights zeroed
2751        // the vector half cannot change the ranking, so the query embed
2752        // and the vector-index rehydration must be skipped. The embedder
2753        // here fails every call — before the fast path the search returned
2754        // nothing because the embed error won (T0 bm25-baseline paid the
2755        // embed despite ranking by BM25 alone).
2756        struct FailingEmbedder;
2757        impl crate::embedder::Embedder for FailingEmbedder {
2758            fn embed_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>> {
2759                Err(CoreError::Memory("embedder offline".into()))
2760            }
2761            fn dimension(&self) -> usize {
2762                16
2763            }
2764            fn is_available(&self) -> bool {
2765                false
2766            }
2767            fn backend_name(&self) -> &'static str {
2768                "failing-test"
2769            }
2770        }
2771
2772        let tmp = tempfile::tempdir().unwrap();
2773        let store_dir = tmp.path().join("store");
2774        std::fs::create_dir_all(&store_dir).unwrap();
2775        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2776        let index_dir = tmp.path().join("index");
2777        std::fs::create_dir_all(&index_dir).unwrap();
2778        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2779
2780        let config = RecallConfig {
2781            bm25_weight: 1.0,
2782            vector_weight: 0.0,
2783            importance_weight: 0.0,
2784            ..RecallConfig::default()
2785        };
2786        let engine = RecallEngine::new(
2787            store.clone(),
2788            search_engine.clone(),
2789            VectorStore::new(),
2790            Arc::new(FailingEmbedder),
2791            config,
2792        )
2793        .unwrap();
2794
2795        let mem = crate::Memory::new(
2796            Galaxy::Codex,
2797            "kotlin coroutine budget meeting notes".to_string(),
2798        );
2799        let mem_id = mem.metadata.id;
2800        store.put(Galaxy::Codex, &mem).unwrap();
2801        let mut writer = search_engine.writer().unwrap();
2802        search_engine
2803            .add_document(
2804                &mut writer,
2805                &mem_id.to_string(),
2806                "codex",
2807                "kotlin coroutine budget meeting notes",
2808                &[],
2809                1_700_000_000,
2810            )
2811            .unwrap();
2812        search_engine.commit(&mut writer).unwrap();
2813
2814        let (results, _) =
2815            engine.hybrid_search_with_disclosure("kotlin coroutine budget", 5, Some(Galaxy::Codex));
2816        assert_eq!(
2817            results.len(),
2818            1,
2819            "the BM25 half must answer without the embedder"
2820        );
2821        assert_eq!(results[0].memory_id, mem_id);
2822        assert!(
2823            !engine.vector_store.lock().unwrap().is_loaded(),
2824            "an inert vector half must not rehydrate the vector index"
2825        );
2826    }
2827
2828    #[test]
2829    fn backfill_embeddings_dry_run_then_apply() {
2830        struct TestEmbedder;
2831        impl crate::embedder::Embedder for TestEmbedder {
2832            fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
2833                Ok(texts.iter().map(|_| vec![0.25_f32; 8]).collect())
2834            }
2835            fn dimension(&self) -> usize {
2836                8
2837            }
2838            fn is_available(&self) -> bool {
2839                true
2840            }
2841            fn backend_name(&self) -> &'static str {
2842                "test"
2843            }
2844        }
2845
2846        let tmp = tempfile::tempdir().unwrap();
2847        let store_dir = tmp.path().join("store");
2848        std::fs::create_dir_all(&store_dir).unwrap();
2849        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2850        let index_dir = tmp.path().join("index");
2851        std::fs::create_dir_all(&index_dir).unwrap();
2852        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2853
2854        let mem_a = crate::Memory::new(Galaxy::Codex, "alpha unique content".to_string());
2855        let mem_b = crate::Memory::new(Galaxy::Codex, "beta unique content".to_string());
2856        let (id_a, id_b) = (mem_a.metadata.id, mem_b.metadata.id);
2857        store.put(Galaxy::Codex, &mem_a).unwrap();
2858        store.put(Galaxy::Codex, &mem_b).unwrap();
2859
2860        let engine = RecallEngine::new(
2861            store.clone(),
2862            search_engine,
2863            VectorStore::new(),
2864            Arc::new(TestEmbedder),
2865            RecallConfig::default(),
2866        )
2867        .unwrap();
2868
2869        // Dry run: candidates found, nothing written.
2870        let plan = engine
2871            .backfill_embeddings(Some(Galaxy::Codex), 0, true)
2872            .unwrap();
2873        assert!(plan.dry_run);
2874        assert_eq!(plan.scanned, 2);
2875        assert_eq!(plan.candidates, 2);
2876        assert_eq!(plan.embedded, 0);
2877        assert!(store.get_embedding(id_a).unwrap().is_none());
2878
2879        // Apply: both vectors persisted and indexed.
2880        let applied = engine
2881            .backfill_embeddings(Some(Galaxy::Codex), 0, false)
2882            .unwrap();
2883        assert_eq!(applied.embedded, 2);
2884        assert!(store.get_embedding(id_a).unwrap().is_some());
2885        assert!(store.get_embedding(id_b).unwrap().is_some());
2886        assert_eq!(engine.vector_store.lock().unwrap().len(), 2);
2887
2888        // Re-run: nothing left to do.
2889        let again = engine
2890            .backfill_embeddings(Some(Galaxy::Codex), 0, false)
2891            .unwrap();
2892        assert_eq!(again.candidates, 0);
2893        assert_eq!(again.already_embedded, 2);
2894    }
2895
2896    #[test]
2897    fn backfill_chunks_large_batches() {
2898        struct TestEmbedder;
2899        impl crate::embedder::Embedder for TestEmbedder {
2900            fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
2901                Ok(texts.iter().map(|_| vec![0.1_f32; 4]).collect())
2902            }
2903            fn dimension(&self) -> usize {
2904                4
2905            }
2906            fn is_available(&self) -> bool {
2907                true
2908            }
2909            fn backend_name(&self) -> &'static str {
2910                "test"
2911            }
2912        }
2913
2914        let tmp = tempfile::tempdir().unwrap();
2915        let store_dir = tmp.path().join("store");
2916        std::fs::create_dir_all(&store_dir).unwrap();
2917        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2918        let index_dir = tmp.path().join("index");
2919        std::fs::create_dir_all(&index_dir).unwrap();
2920        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2921        for i in 0..40 {
2922            let mem = crate::Memory::new(Galaxy::Codex, format!("chunked memory {i}"));
2923            store.put(Galaxy::Codex, &mem).unwrap();
2924        }
2925        let engine = RecallEngine::new(
2926            store,
2927            search_engine,
2928            VectorStore::new(),
2929            Arc::new(TestEmbedder),
2930            RecallConfig::default(),
2931        )
2932        .unwrap();
2933
2934        // 40 candidates > 32-text chunk → exercises the multi-chunk apply.
2935        let report = engine
2936            .backfill_embeddings(Some(Galaxy::Codex), 0, false)
2937            .unwrap();
2938        assert_eq!(report.embedded, 40);
2939        assert_eq!(report.errors, 0);
2940        assert_eq!(engine.vector_store.lock().unwrap().len(), 40);
2941    }
2942
2943    #[test]
2944    fn backfill_skips_empty_content_and_counts_failures_once() {
2945        struct FailEmbedder;
2946        impl crate::embedder::Embedder for FailEmbedder {
2947            fn embed_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>> {
2948                Err(CoreError::Memory("simulated embedder failure".into()))
2949            }
2950            fn dimension(&self) -> usize {
2951                4
2952            }
2953            fn is_available(&self) -> bool {
2954                true
2955            }
2956            fn backend_name(&self) -> &'static str {
2957                "test-fail"
2958            }
2959        }
2960
2961        let tmp = tempfile::tempdir().unwrap();
2962        let store_dir = tmp.path().join("store");
2963        std::fs::create_dir_all(&store_dir).unwrap();
2964        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2965        let index_dir = tmp.path().join("index");
2966        std::fs::create_dir_all(&index_dir).unwrap();
2967        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2968
2969        let empty = crate::Memory::new(Galaxy::Codex, "   ".to_string());
2970        let real = crate::Memory::new(Galaxy::Codex, "real content".to_string());
2971        store.put(Galaxy::Codex, &empty).unwrap();
2972        store.put(Galaxy::Codex, &real).unwrap();
2973
2974        let engine = RecallEngine::new(
2975            store,
2976            search_engine,
2977            VectorStore::new(),
2978            Arc::new(FailEmbedder),
2979            RecallConfig::default(),
2980        )
2981        .unwrap();
2982        let report = engine
2983            .backfill_embeddings(Some(Galaxy::Codex), 0, false)
2984            .unwrap();
2985        assert_eq!(report.skipped_empty, 1, "whitespace-only memory is skipped");
2986        assert_eq!(report.candidates, 1);
2987        assert_eq!(
2988            report.errors, 1,
2989            "a failed memory must be counted once (batch fallback), not twice"
2990        );
2991    }
2992
2993    #[test]
2994    fn backfill_refuses_stub_embedder() {
2995        let tmp = tempfile::tempdir().unwrap();
2996        let store_dir = tmp.path().join("store");
2997        std::fs::create_dir_all(&store_dir).unwrap();
2998        let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2999        let index_dir = tmp.path().join("index");
3000        std::fs::create_dir_all(&index_dir).unwrap();
3001        let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
3002        let engine = RecallEngine::new(
3003            store,
3004            search_engine,
3005            VectorStore::new(),
3006            Arc::new(crate::embedder::StubEmbedder::default()),
3007            RecallConfig::default(),
3008        )
3009        .unwrap();
3010        let err = engine
3011            .backfill_embeddings(Some(Galaxy::Codex), 10, true)
3012            .unwrap_err();
3013        assert!(err.to_string().contains("no real embedder"));
3014    }
3015
3016    #[test]
3017    fn embedder_probe_returns_vector_len() {
3018        let (_tmp, engine) = setup_engine();
3019        let dim = engine.embedder_probe().unwrap();
3020        assert!(dim > 0, "probe must return the embedder dimension");
3021    }
3022
3023    #[test]
3024    fn test_s10_association_rerank() {
3025        let (_tmp, mut engine) = setup_engine();
3026        let env = engine.store.env();
3027        let assoc_store = AssociationStore::open(env).unwrap();
3028
3029        // Memory A: solo node
3030        let mem_a = Memory::new(Galaxy::Codex, "alpha query topic node".into());
3031        engine.store_with_embedding(Galaxy::Codex, &mem_a).unwrap();
3032
3033        // Memory B: connected to C
3034        let mem_b = Memory::new(Galaxy::Codex, "beta query topic node".into());
3035        let id_b = mem_b.metadata.id;
3036        engine.store_with_embedding(Galaxy::Codex, &mem_b).unwrap();
3037
3038        // Target memory C connected to B
3039        let mem_c = Memory::new(Galaxy::Research, "gamma target node".into());
3040        let id_c = mem_c.metadata.id;
3041        engine.store.put(Galaxy::Research, &mem_c).unwrap();
3042
3043        let edge = crate::associations::Association::new(
3044            id_b,
3045            id_c,
3046            crate::associations::LinkType::Related,
3047            0.8,
3048        );
3049        assoc_store.put(env, &edge).unwrap();
3050
3051        // Search with association_rerank = false (default)
3052        let results_default = engine.hybrid_search("query topic", 10, None);
3053        assert!(!results_default.is_empty());
3054
3055        // Search with association_rerank = true
3056        engine.config.association_rerank = true;
3057        let results_rerank = engine.hybrid_search("query topic", 10, None);
3058        assert!(!results_rerank.is_empty());
3059
3060        // Memory B should receive the association boost
3061        let score_b_default = results_default
3062            .iter()
3063            .find(|r| r.memory_id == id_b)
3064            .unwrap()
3065            .score;
3066        let score_b_rerank = results_rerank
3067            .iter()
3068            .find(|r| r.memory_id == id_b)
3069            .unwrap()
3070            .score;
3071        assert!(score_b_rerank > score_b_default);
3072    }
3073}