1#![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#[derive(Debug, Clone)]
51pub struct RecallResult {
52 pub memory_id: Uuid,
54 pub galaxy: Galaxy,
56 pub score: f32,
58 pub bm25_score: f32,
60 pub vector_score: f32,
62 pub importance: f32,
64 pub graph_score: f32,
68 pub trust_factor: f32,
71 pub corroboration: u32,
75 pub in_conformal_set: bool,
78 pub content: String,
80}
81
82#[derive(Debug, Clone)]
86pub struct RecallConfig {
87 pub bm25_weight: f32,
89 pub vector_weight: f32,
91 pub importance_weight: f32,
93 pub graph_weight: f32,
100 pub trust_weight: f32,
108 pub corroboration_weight: f32,
114 pub conformal_alpha: Option<f32>,
120 pub cache_embeddings: bool,
122 pub max_cache_entries: usize,
124 #[allow(dead_code)]
127 pub writer_heap_size: usize,
128 pub promotion_on_read: bool,
133 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 #[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 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 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 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 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 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 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 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 #[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
259pub 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: Mutex<Option<crate::recall_conformal::RecallConformal>>,
275}
276
277#[derive(Debug, Clone, Default, serde::Serialize)]
279pub struct BackfillReport {
280 pub scanned: usize,
282 pub candidates: usize,
284 pub embedded: usize,
286 pub already_embedded: usize,
288 pub skipped_empty: usize,
291 pub errors: usize,
293 pub dry_run: bool,
295}
296
297impl RecallEngine {
298 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 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 #[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 #[must_use]
398 pub fn config(&self) -> &RecallConfig {
399 &self.config
400 }
401
402 #[must_use]
407 pub fn embedder_is_real(&self) -> bool {
408 self.embedder.backend_name() != "stub"
409 }
410
411 #[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 pub fn embedder_probe(&self) -> Result<usize> {
434 self.embedder
435 .embed("wm embedder probe")
436 .map(|vector| vector.len())
437 }
438
439 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 fn embed_content(&self, content: &str) -> Result<Vec<f32>> {
457 let hash = content_hash(content);
458
459 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 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 let embedding = self.embedder.embed(content)?;
487
488 if let Err(error) = self.store.put_embedding_cache(&cache_key, &embedding) {
490 tracing::warn!("embedding cache write failed: {error}");
491 }
492
493 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 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 pub fn store_with_embedding(&self, galaxy: Galaxy, memory: &crate::Memory) -> Result<()> {
520 let embedding = self.embed_content(&memory.content)?;
522
523 self.store.put(galaxy, memory)?;
525
526 self.store.put_embedding(memory.metadata.id, &embedding)?;
528
529 {
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 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 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 const MAX_CHARS_PER_CHUNK: usize = 1500; const MAX_CHARS_PER_ITEM: usize = 1500; 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 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 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 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 {
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 {
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 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 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 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 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 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 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 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 #[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 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 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 let vector_results = if self.vector_half_inert() {
1001 Vec::new()
1002 } else {
1003 let query_vec = match self.embedder.embed_query(query) {
1005 Ok(v) => v,
1006 Err(_) => return (Vec::new(), None),
1007 };
1008
1009 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 let fused = self.fuse_results(&bm25_results, &vector_results, limit);
1026
1027 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 let mut expanded = self.expand_with_graph(fused, limit);
1045
1046 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 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 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 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 #[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 #[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 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 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 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 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 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 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 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 #[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 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 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 #[must_use]
1370 pub fn cache_size(&self) -> usize {
1371 self.embedding_cache.lock().map_or(0, |c| c.len())
1372 }
1373
1374 pub fn clear_cache(&self) {
1376 if let Ok(mut c) = self.embedding_cache.lock() {
1377 c.clear();
1378 }
1379 }
1380
1381 #[must_use]
1383 pub fn vector_count(&self) -> usize {
1384 self.vector_store.lock().map_or(0, |c| c.len())
1385 }
1386}
1387
1388#[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 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 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 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 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 #[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 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#[cfg(test)]
1518mod tests {
1519 use super::*;
1520 use crate::associations::{Association, LinkType};
1521 use crate::embedder::StubEmbedder;
1522
1523 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 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 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 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 #[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 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 let config = RecallConfig::default();
1977 assert!(config.cache_embeddings);
1978 }
1979
1980 #[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 #[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 #[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 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 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 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 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 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 #[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 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 let results = engine.hybrid_search("rust", 10, None);
2332 assert!(!results.is_empty(), "hybrid search should return results");
2333
2334 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 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 let results = engine.hybrid_search("memory", 10, None);
2358 assert!(!results.is_empty(), "should find memories");
2359
2360 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 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 let results = engine.text_search("alpha", 10);
2412 assert!(
2413 !results.is_empty(),
2414 "batch-stored memory should be searchable"
2415 );
2416
2417 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mem_a = Memory::new(Galaxy::Codex, "alpha query topic node".into());
3031 engine.store_with_embedding(Galaxy::Codex, &mem_a).unwrap();
3032
3033 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 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 let results_default = engine.hybrid_search("query topic", 10, None);
3053 assert!(!results_default.is_empty());
3054
3055 engine.config.association_rerank = true;
3057 let results_rerank = engine.hybrid_search("query topic", 10, None);
3058 assert!(!results_rerank.is_empty());
3059
3060 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}