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
277impl RecallEngine {
278 pub fn new(
282 store: Arc<MemoryStore>,
283 search_engine: Arc<SearchEngine>,
284 vector_store: VectorStore,
285 embedder: Arc<dyn Embedder>,
286 config: RecallConfig,
287 ) -> Result<Self> {
288 let conformal =
289 Mutex::new(config.conformal_alpha.and_then(|alpha| {
290 crate::recall_conformal::RecallConformal::new(alpha, store.clone())
291 }));
292 Ok(Self {
293 store,
294 search_engine,
295 vector_store: Mutex::new(vector_store),
296 embedder,
297 config,
298 embedding_cache: Mutex::new(HashMap::new()),
299 conformal,
300 })
301 }
302
303 pub fn record_relevance_feedback(&self, score: f32, relevant: bool) -> Result<usize> {
307 let mut guard = self
308 .conformal
309 .lock()
310 .map_err(|e| CoreError::Memory(format!("recall conformal lock: {e}")))?;
311 match guard.as_mut() {
312 Some(rc) => Ok(rc.record_feedback(score, relevant)),
313 None => Err(CoreError::InvalidArgs(
314 "conformal calibration is not enabled — set WM_RECALL_CONFORMAL_ALPHA in (0,1)"
315 .into(),
316 )),
317 }
318 }
319
320 #[allow(clippy::significant_drop_tightening)]
328 pub fn conformal_disclosure(
329 &self,
330 results: &mut [RecallResult],
331 ) -> Result<Option<crate::recall_conformal::ConformalSetInfo>> {
332 use crate::recall_conformal::ConformalSetInfo;
333 let guard = self
334 .conformal
335 .lock()
336 .map_err(|e| CoreError::Memory(format!("recall conformal lock: {e}")))?;
337 let Some(rc) = guard.as_ref() else {
338 return Ok(None);
339 };
340 let coverage_target = Some(f64::from(1.0 - rc.alpha()));
341 let info = if rc.is_fitted() {
342 let threshold = rc.threshold();
343 let mut set_size = 0usize;
344 for r in results.iter_mut() {
345 r.in_conformal_set = rc.membership(r.score) == Some(true);
346 if r.in_conformal_set {
347 set_size += 1;
348 }
349 }
350 ConformalSetInfo {
351 status: "active".into(),
352 alpha: Some(f64::from(rc.alpha())),
353 coverage_target,
354 calibration_samples: Some(rc.sample_count()),
355 threshold,
356 set_size: Some(set_size),
357 hint: None,
358 }
359 } else {
360 ConformalSetInfo {
361 status: "uncalibrated".into(),
362 alpha: Some(f64::from(rc.alpha())),
363 coverage_target,
364 calibration_samples: Some(rc.sample_count()),
365 threshold: None,
366 set_size: None,
367 hint: Some(format!(
368 "record ≥ {} relevance-feedback samples to calibrate",
369 crate::recall_conformal::MIN_SAMPLES
370 )),
371 }
372 };
373 Ok(Some(info))
374 }
375
376 #[must_use]
378 pub fn config(&self) -> &RecallConfig {
379 &self.config
380 }
381
382 #[must_use]
387 pub fn embedder_is_real(&self) -> bool {
388 self.embedder.backend_name() != "stub"
389 }
390
391 fn embedding_cache_key(&self, embedded_text: &str) -> String {
396 format!(
397 "{}:{}",
398 self.embedder.cache_namespace(),
399 content_hash(embedded_text)
400 )
401 }
402
403 fn embed_content(&self, content: &str) -> Result<Vec<f32>> {
409 let hash = content_hash(content);
410
411 if self.config.cache_embeddings {
413 let cache = self
414 .embedding_cache
415 .lock()
416 .map_err(|e| CoreError::Memory(format!("embedding cache lock: {e}")))?;
417 if let Some(vec) = cache.get(&hash) {
418 return Ok(vec.clone());
419 }
420 }
421
422 let cache_key = self.embedding_cache_key(content);
424 match self.store.get_embedding_cache(&cache_key) {
425 Ok(Some(vector)) => {
426 if self.config.cache_embeddings {
427 if let Ok(mut cache) = self.embedding_cache.lock() {
428 cache.insert(hash, vector.clone());
429 }
430 }
431 return Ok(vector);
432 }
433 Ok(None) => {}
434 Err(error) => tracing::warn!("embedding cache read failed: {error}"),
435 }
436
437 let embedding = self.embedder.embed(content)?;
439
440 if let Err(error) = self.store.put_embedding_cache(&cache_key, &embedding) {
442 tracing::warn!("embedding cache write failed: {error}");
443 }
444
445 if self.config.cache_embeddings {
447 let mut cache = self
448 .embedding_cache
449 .lock()
450 .map_err(|e| CoreError::Memory(format!("embedding cache lock: {e}")))?;
451 if cache.len() >= self.config.max_cache_entries {
452 let to_remove: Vec<String> = cache.keys().take(cache.len() / 10).cloned().collect();
454 for key in to_remove {
455 cache.remove(&key);
456 }
457 }
458 cache.insert(hash, embedding.clone());
459 }
460
461 Ok(embedding)
462 }
463
464 pub fn store_with_embedding(&self, galaxy: Galaxy, memory: &crate::Memory) -> Result<()> {
472 let embedding = self.embed_content(&memory.content)?;
474
475 self.store.put(galaxy, memory)?;
477
478 self.store.put_embedding(memory.metadata.id, &embedding)?;
480
481 {
483 let mut vs = self
484 .vector_store
485 .lock()
486 .map_err(|e| CoreError::Memory(format!("vector store lock: {e}")))?;
487 vs.add(memory.metadata.id, galaxy, embedding);
488 }
489
490 let timestamp = memory.metadata.created_at.timestamp();
492 let tags: Vec<String> = memory.metadata.tags.clone();
493 {
494 let mut writer = self.search_engine.writer()?;
495 self.search_engine.add_document(
496 &mut writer,
497 &memory.metadata.id.to_string(),
498 galaxy.db_name(),
499 &memory.content,
500 &tags,
501 timestamp,
502 )?;
503 self.search_engine.commit(&mut writer)?;
504 }
505
506 Ok(())
507 }
508
509 pub fn store_batch_with_embedding(
520 &self,
521 entries: &[(Galaxy, &crate::Memory)],
522 ) -> Result<usize> {
523 if entries.is_empty() {
524 return Ok(0);
525 }
526
527 const MAX_CHARS_PER_CHUNK: usize = 1500; const MAX_CHARS_PER_ITEM: usize = 1500; let contents: Vec<String> = entries
534 .iter()
535 .map(|(_, m)| {
536 if m.content.len() > MAX_CHARS_PER_ITEM {
537 m.content.chars().take(MAX_CHARS_PER_ITEM).collect()
538 } else {
539 m.content.clone()
540 }
541 })
542 .collect();
543 let content_refs: Vec<&str> = contents.iter().map(String::as_str).collect();
544 let cache_keys: Vec<String> = content_refs
545 .iter()
546 .map(|c| self.embedding_cache_key(c))
547 .collect();
548 let mut embeddings: Vec<Option<Vec<f32>>> =
549 match self.store.get_embedding_cache_batch(&cache_keys) {
550 Ok(cached) => cached,
551 Err(error) => {
552 tracing::warn!("embedding cache batch read failed: {error}");
553 vec![None; cache_keys.len()]
554 }
555 };
556 let misses: Vec<(usize, &str)> = content_refs
557 .iter()
558 .enumerate()
559 .filter(|(i, _)| embeddings[*i].is_none())
560 .map(|(i, content)| (i, *content))
561 .collect();
562
563 let max_batch_texts = self.embedder.preferred_max_batch_texts();
568 let mut chunk: Vec<&str> = Vec::new();
569 let mut chunk_positions: Vec<usize> = Vec::new();
570 let mut chunk_chars: usize = 0;
571 let mut embed_chunk = |chunk: &[&str], positions: &[usize], store: &Self| -> Result<()> {
572 let chunk_vecs = store.embedder.embed_batch(chunk)?;
573 if chunk_vecs.len() != chunk.len() {
574 return Err(CoreError::Memory(format!(
575 "embed_batch returned {} vectors for {} inputs (chunk)",
576 chunk_vecs.len(),
577 chunk.len()
578 )));
579 }
580 for (pos, vector) in positions.iter().zip(chunk_vecs) {
581 embeddings[*pos] = Some(vector);
582 }
583 Ok(())
584 };
585 for &(position, content) in &misses {
586 let content_chars = content.len();
587 let flush = !chunk.is_empty()
588 && (chunk_chars + content_chars > MAX_CHARS_PER_CHUNK
589 || chunk.len() >= max_batch_texts);
590 if flush {
591 embed_chunk(&chunk, &chunk_positions, self)?;
592 chunk.clear();
593 chunk_positions.clear();
594 chunk_chars = 0;
595 }
596 chunk.push(content);
597 chunk_positions.push(position);
598 chunk_chars += content_chars;
599 }
600 if !chunk.is_empty() {
601 embed_chunk(&chunk, &chunk_positions, self)?;
602 }
603
604 let fresh: Vec<(String, Vec<f32>)> = misses
607 .iter()
608 .filter_map(|&(position, _)| {
609 embeddings[position]
610 .clone()
611 .map(|vector| (cache_keys[position].clone(), vector))
612 })
613 .collect();
614 if let Err(error) = self.store.put_embedding_cache_batch(&fresh) {
615 tracing::warn!("embedding cache batch write failed: {error}");
616 }
617
618 for (i, (galaxy, memory)) in entries.iter().enumerate() {
620 let Some(ref embedding) = embeddings[i] else {
621 return Err(CoreError::Memory(format!(
622 "embedding missing for entry {i} after cache resolution"
623 )));
624 };
625 self.store.put(*galaxy, memory)?;
626 self.store.put_embedding(memory.metadata.id, embedding)?;
627 }
628
629 {
631 let mut vs = self
632 .vector_store
633 .lock()
634 .map_err(|e| CoreError::Memory(format!("vector store lock: {e}")))?;
635 for (i, (galaxy, memory)) in entries.iter().enumerate() {
636 if let Some(ref embedding) = embeddings[i] {
637 vs.add(memory.metadata.id, *galaxy, embedding.clone());
638 }
639 }
640 }
641
642 {
644 let mut writer = self.search_engine.writer()?;
645 for (galaxy, memory) in entries {
646 let timestamp = memory.metadata.created_at.timestamp();
647 let tags: Vec<String> = memory.metadata.tags.clone();
648 self.search_engine.add_document(
649 &mut writer,
650 &memory.metadata.id.to_string(),
651 galaxy.db_name(),
652 &memory.content,
653 &tags,
654 timestamp,
655 )?;
656 }
657 self.search_engine.commit(&mut writer)?;
658 }
659
660 if self.config.cache_embeddings {
664 let mut cache = self
665 .embedding_cache
666 .lock()
667 .map_err(|e| CoreError::Memory(format!("embedding cache lock: {e}")))?;
668 for &(position, _) in &misses {
669 let Some(ref vector) = embeddings[position] else {
670 continue;
671 };
672 let hash = content_hash(&entries[position].1.content);
673 if cache.len() >= self.config.max_cache_entries {
674 let to_remove: Vec<String> =
675 cache.keys().take(cache.len() / 10).cloned().collect();
676 for key in to_remove {
677 cache.remove(&key);
678 }
679 }
680 cache.insert(hash, vector.clone());
681 }
682 }
683
684 Ok(entries.len())
685 }
686
687 #[must_use]
693 pub fn hybrid_search(
694 &self,
695 query: &str,
696 limit: usize,
697 galaxy_filter: Option<Galaxy>,
698 ) -> Vec<RecallResult> {
699 self.hybrid_search_with_disclosure(query, limit, galaxy_filter)
700 .0
701 }
702
703 pub fn hybrid_search_with_disclosure(
707 &self,
708 query: &str,
709 limit: usize,
710 galaxy_filter: Option<Galaxy>,
711 ) -> (
712 Vec<RecallResult>,
713 Option<crate::recall_conformal::ConformalSetInfo>,
714 ) {
715 let query_vec = match self.embedder.embed_query(query) {
717 Ok(v) => v,
718 Err(_) => return (Vec::new(), None),
719 };
720
721 let bm25_limit = limit * 3;
723 let bm25_results = self
724 .search_engine
725 .search_in_galaxy(query, galaxy_filter, bm25_limit)
726 .unwrap_or_default();
727
728 let vector_results = {
730 let Ok(vs) = self.vector_store.lock() else {
731 return (Vec::new(), None);
732 };
733 vs.search(&query_vec, bm25_limit, galaxy_filter)
734 };
735
736 let fused = self.fuse_results(&bm25_results, &vector_results, limit);
738
739 let fused = if crate::memory::validity_enforced() {
743 fused
744 .into_iter()
745 .filter(|r| {
746 self.find_memory_anywhere(r.memory_id)
747 .is_none_or(|mem| mem.metadata.validity.is_current())
748 })
749 .collect()
750 } else {
751 fused
752 };
753
754 let mut expanded = self.expand_with_graph(fused, limit);
757
758 if self.config.corroboration_weight > 0.0 {
763 for r in &mut expanded {
764 if let Some(mem) = self.find_memory_anywhere(r.memory_id) {
765 let n = mem.metadata.corroborated_by.len();
766 r.corroboration = n.min(u32::MAX as usize) as u32;
767 r.score = crate::memory::corroboration_boost(
768 r.score,
769 n,
770 self.config.corroboration_weight,
771 );
772 }
773 }
774 expanded.sort_by(|a, b| {
775 b.score
776 .partial_cmp(&a.score)
777 .unwrap_or(std::cmp::Ordering::Equal)
778 });
779 }
780
781 if self.config.association_rerank {
786 if let Ok(assoc_store) = AssociationStore::open(self.store.env()) {
787 let env = self.store.env();
788 for r in &mut expanded {
789 let outgoing = assoc_store.find_from(env, r.memory_id).unwrap_or_default();
790 let incoming = assoc_store.find_to(env, r.memory_id).unwrap_or_default();
791 let active_edges = outgoing
792 .iter()
793 .chain(incoming.iter())
794 .filter(|e| e.weight >= 0.2)
795 .count();
796 if active_edges > 0 {
797 let boost = (active_edges as f32 * 0.05).min(0.25);
798 r.score *= 1.0 + boost;
799 }
800 }
801 expanded.sort_by(|a, b| {
802 b.score
803 .partial_cmp(&a.score)
804 .unwrap_or(std::cmp::Ordering::Equal)
805 });
806 }
807 }
808
809 if self.config.promotion_on_read {
813 for r in expanded.iter().take(limit) {
814 if let Err(e) = self.promote_memory(r.galaxy, r.memory_id) {
815 tracing::warn!(
816 error = %e,
817 memory_id = %r.memory_id,
818 galaxy = %r.galaxy.db_name(),
819 "promotion on read failed"
820 );
821 }
822 }
823 }
824
825 match self.conformal_disclosure(&mut expanded) {
828 Ok(info) => (expanded, info),
829 Err(e) => {
830 tracing::warn!(error = %e, "recall conformal disclosure failed");
831 (expanded, None)
832 }
833 }
834 }
835
836 #[must_use]
838 pub fn vector_search(
839 &self,
840 query: &str,
841 limit: usize,
842 galaxy_filter: Option<Galaxy>,
843 ) -> Vec<RecallResult> {
844 let query_vec = match self.embedder.embed_query(query) {
845 Ok(v) => v,
846 Err(_) => return Vec::new(),
847 };
848
849 let vector_results = {
850 let Ok(vs) = self.vector_store.lock() else {
851 return Vec::new();
852 };
853 vs.search(&query_vec, limit, galaxy_filter)
854 };
855
856 vector_results
857 .into_iter()
858 .map(|vr| {
859 let content = self.get_memory_content(vr.memory_id, vr.galaxy);
860 RecallResult {
861 memory_id: vr.memory_id,
862 galaxy: vr.galaxy,
863 score: vr.score,
864 bm25_score: 0.0,
865 vector_score: vr.score,
866 importance: 0.0,
867 graph_score: 0.0,
868 trust_factor: 1.0,
869 in_conformal_set: false,
870 corroboration: 0,
871 content,
872 }
873 })
874 .collect()
875 }
876
877 #[must_use]
879 pub fn text_search(&self, query: &str, limit: usize) -> Vec<RecallResult> {
880 let bm25_results = self.search_engine.search(query, limit).unwrap_or_default();
881
882 bm25_results
883 .into_iter()
884 .filter_map(|sr| {
885 let memory_id = Uuid::parse_str(&sr.memory_id).ok()?;
886 let galaxy = Galaxy::from_db_name(&sr.galaxy)?;
887 Some(RecallResult {
888 memory_id,
889 galaxy,
890 score: sr.score,
891 bm25_score: sr.score,
892 vector_score: 0.0,
893 importance: 0.0,
894 graph_score: 0.0,
895 trust_factor: 1.0,
896 in_conformal_set: false,
897 corroboration: 0,
898 content: sr.content,
899 })
900 })
901 .collect()
902 }
903
904 fn fuse_results(
908 &self,
909 bm25_results: &[SearchResult],
910 vector_results: &[VectorSearchResult],
911 limit: usize,
912 ) -> Vec<RecallResult> {
913 fuse_results_inner(
914 bm25_results,
915 vector_results,
916 limit,
917 self.config.bm25_weight,
918 self.config.vector_weight,
919 self.config.importance_weight,
920 self.config.trust_weight,
921 |id, galaxy| self.get_memory_content(id, galaxy),
922 |id, galaxy| self.get_memory_importance(id, galaxy),
923 |id, galaxy| self.get_memory_source_trust(id, galaxy),
924 )
925 }
926
927 fn expand_with_graph(&self, mut results: Vec<RecallResult>, limit: usize) -> Vec<RecallResult> {
937 if self.config.graph_weight <= 0.0 || results.is_empty() {
938 return results;
939 }
940 let Ok(assoc_store) = AssociationStore::open(self.store.env()) else {
941 return results;
942 };
943 let env = self.store.env();
944 let seeds: Vec<RecallResult> = results.iter().take(3).cloned().collect();
945 for seed in seeds {
946 let outgoing = assoc_store
947 .find_from(env, seed.memory_id)
948 .unwrap_or_default();
949 let incoming = assoc_store.find_to(env, seed.memory_id).unwrap_or_default();
950 for edge in outgoing.into_iter().chain(incoming) {
951 if edge.weight < 0.2 {
952 continue;
953 }
954 let neighbor_id = if edge.source == seed.memory_id {
955 edge.target
956 } else {
957 edge.source
958 };
959 if neighbor_id == seed.memory_id {
960 continue;
961 }
962 if crate::memory::validity_enforced()
966 && self
967 .find_memory_anywhere(neighbor_id)
968 .is_some_and(|mem| !mem.metadata.validity.is_current())
969 {
970 continue;
971 }
972 let contribution = seed.score * edge.weight * self.config.graph_weight;
973 if self.config.promotion_on_read {
974 let mut activated_edge = edge.clone();
975 activated_edge.activate();
976 let _ = assoc_store.put(env, &activated_edge);
977 }
978 if let Some(existing) = results.iter_mut().find(|r| r.memory_id == neighbor_id) {
979 existing.score += contribution;
980 existing.graph_score += contribution;
981 } else if let Some(mem) = self.find_memory_anywhere(neighbor_id) {
982 if mem.metadata.is_private {
986 continue;
987 }
988 if crate::memory::validity_enforced() && !mem.metadata.validity.is_current() {
989 continue;
990 }
991 results.push(RecallResult {
992 memory_id: neighbor_id,
993 galaxy: mem.metadata.galaxy,
994 score: contribution,
995 bm25_score: 0.0,
996 vector_score: 0.0,
997 importance: mem.metadata.importance,
998 graph_score: contribution,
999 trust_factor: 1.0,
1000 in_conformal_set: false,
1001 corroboration: 0,
1002 content: mem.content.chars().take(400).collect(),
1003 });
1004 }
1005 }
1006 }
1007 results.sort_by(|a, b| {
1008 b.score
1009 .partial_cmp(&a.score)
1010 .unwrap_or(std::cmp::Ordering::Equal)
1011 });
1012 results.truncate(limit.max(3));
1013 results
1014 }
1015
1016 fn find_memory_anywhere(&self, id: Uuid) -> Option<crate::memory::Memory> {
1018 self.store
1019 .find_across_galaxies(id)
1020 .ok()
1021 .flatten()
1022 .map(|(_, m)| m)
1023 }
1024
1025 pub fn promote_memory(&self, galaxy: Galaxy, id: Uuid) -> Result<bool> {
1028 if let Some(mut mem) = self.store.get(galaxy, id)? {
1029 mem.recall();
1030 self.store.put(galaxy, &mem)?;
1031 Ok(true)
1032 } else {
1033 Ok(false)
1034 }
1035 }
1036
1037 fn get_memory_content(&self, id: Uuid, galaxy: Galaxy) -> String {
1041 self.store
1042 .get(galaxy, id)
1043 .ok()
1044 .flatten()
1045 .map(|m| m.content)
1046 .unwrap_or_default()
1047 }
1048
1049 #[must_use]
1052 pub fn is_private(&self, id: Uuid, galaxy: Galaxy) -> bool {
1053 self.store
1054 .get(galaxy, id)
1055 .ok()
1056 .flatten()
1057 .is_none_or(|m| m.metadata.is_private)
1058 }
1059
1060 fn get_memory_importance(&self, id: Uuid, galaxy: Galaxy) -> f32 {
1062 self.store
1063 .get(galaxy, id)
1064 .ok()
1065 .flatten()
1066 .map_or(0.0, |m| m.metadata.importance)
1067 }
1068
1069 fn get_memory_source_trust(&self, id: Uuid, galaxy: Galaxy) -> f32 {
1073 self.store
1074 .get(galaxy, id)
1075 .ok()
1076 .flatten()
1077 .map_or(0.7, |m| m.metadata.source_trust)
1078 }
1079
1080 #[must_use]
1082 pub fn cache_size(&self) -> usize {
1083 self.embedding_cache.lock().map_or(0, |c| c.len())
1084 }
1085
1086 pub fn clear_cache(&self) {
1088 if let Ok(mut c) = self.embedding_cache.lock() {
1089 c.clear();
1090 }
1091 }
1092
1093 #[must_use]
1095 pub fn vector_count(&self) -> usize {
1096 self.vector_store.lock().map_or(0, |c| c.len())
1097 }
1098}
1099
1100#[allow(clippy::too_many_arguments)]
1104fn fuse_results_inner(
1105 bm25_results: &[SearchResult],
1106 vector_results: &[VectorSearchResult],
1107 limit: usize,
1108 bm25_weight: f32,
1109 vector_weight: f32,
1110 importance_weight: f32,
1111 trust_weight: f32,
1112 mut get_content: impl FnMut(Uuid, Galaxy) -> String,
1113 mut get_importance: impl FnMut(Uuid, Galaxy) -> f32,
1114 mut get_source_trust: impl FnMut(Uuid, Galaxy) -> f32,
1115) -> Vec<RecallResult> {
1116 let max_bm25 = bm25_results
1118 .iter()
1119 .map(|r| r.score)
1120 .fold(0.0_f32, f32::max)
1121 .max(0.001);
1122
1123 let mut bm25_map: HashMap<Uuid, (f32, String, Galaxy)> = HashMap::new();
1125 for sr in bm25_results {
1126 if let Ok(id) = Uuid::parse_str(&sr.memory_id) {
1127 match Galaxy::from_db_name(&sr.galaxy) {
1128 Some(galaxy) => {
1129 let normalized = sr.score / max_bm25;
1130 bm25_map.insert(id, (normalized, sr.content.clone(), galaxy));
1131 }
1132 None => {
1133 tracing::warn!(
1134 "Skipping BM25 result with unknown galaxy '{}' (memory_id={})",
1135 sr.galaxy,
1136 sr.memory_id
1137 );
1138 }
1139 }
1140 }
1141 }
1142
1143 let mut vector_map: HashMap<Uuid, (f32, Galaxy)> = HashMap::new();
1144 for vr in vector_results {
1145 vector_map.insert(vr.memory_id, (vr.score, vr.galaxy));
1146 }
1147
1148 let mut all_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
1150 all_ids.extend(bm25_map.keys());
1151 all_ids.extend(vector_map.keys());
1152
1153 let mut results: Vec<RecallResult> = all_ids
1155 .into_iter()
1156 .map(|id| {
1157 let (bm25_score, content, galaxy_bm25) = bm25_map
1158 .get(&id)
1159 .map_or((0.0, String::new(), Galaxy::Codex), |(s, c, g)| {
1160 (*s, c.clone(), *g)
1161 });
1162
1163 let (vector_score, galaxy_vec) = vector_map
1164 .get(&id)
1165 .map_or((0.0, Galaxy::Codex), |(s, g)| (*s, *g));
1166
1167 let galaxy = if bm25_score > 0.0 {
1168 galaxy_bm25
1169 } else {
1170 galaxy_vec
1171 };
1172
1173 let content = if content.is_empty() {
1174 get_content(id, galaxy)
1175 } else {
1176 content
1177 };
1178
1179 let importance = get_importance(id, galaxy);
1180
1181 let fused = bm25_weight.mul_add(
1182 bm25_score,
1183 vector_weight.mul_add(vector_score, importance_weight * importance),
1184 );
1185
1186 #[allow(clippy::suboptimal_flops)]
1193 let (score, trust_factor) = if trust_weight > 0.0 {
1194 let source_trust = get_source_trust(id, galaxy);
1195 let factor = (1.0 + trust_weight * (source_trust.clamp(0.0, 1.0) - 0.7)).max(0.0);
1196 (fused * factor, factor)
1197 } else {
1198 (fused, 1.0)
1199 };
1200
1201 RecallResult {
1202 memory_id: id,
1203 galaxy,
1204 score,
1205 bm25_score,
1206 vector_score,
1207 importance,
1208 graph_score: 0.0,
1209 trust_factor,
1210 in_conformal_set: false,
1211 corroboration: 0,
1212 content,
1213 }
1214 })
1215 .collect();
1216
1217 results.sort_by(|a, b| {
1219 b.score
1220 .partial_cmp(&a.score)
1221 .unwrap_or(std::cmp::Ordering::Equal)
1222 });
1223 results.truncate(limit);
1224 results
1225}
1226
1227#[cfg(test)]
1230mod tests {
1231 use super::*;
1232 use crate::associations::{Association, LinkType};
1233 use crate::embedder::StubEmbedder;
1234
1235 struct GraphHarness {
1239 _dir: tempfile::TempDir,
1240 store: Arc<MemoryStore>,
1241 engine_with_graph: RecallEngine,
1242 engine_plain: RecallEngine,
1243 }
1244
1245 fn graph_harness() -> GraphHarness {
1246 let dir = tempfile::tempdir().unwrap();
1247 let lmdb = dir.path().join("lmdb");
1248 std::fs::create_dir_all(&lmdb).unwrap();
1249 let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1250 let tantivy = dir.path().join("tantivy");
1251 std::fs::create_dir_all(&tantivy).unwrap();
1252 let search = Arc::new(SearchEngine::open(&tantivy).unwrap());
1253
1254 let a = Memory::new(Galaxy::Codex, "kumquat governance ratchet".into());
1257 let mut b = Memory::new(Galaxy::Codex, "the follow-up decision".into());
1258 let c = Memory::new(Galaxy::Codex, "kumquat harvest notes".into());
1259 b.metadata.is_private = false;
1260 let (id_a, id_b, id_c) = (a.metadata.id, b.metadata.id, c.metadata.id);
1261 store.put(Galaxy::Codex, &a).unwrap();
1262 store.put(Galaxy::Codex, &b).unwrap();
1263 store.put(Galaxy::Codex, &c).unwrap();
1264
1265 let mut writer = search.writer().unwrap();
1266 for (id, content) in [
1267 (id_a, "kumquat governance ratchet"),
1268 (id_c, "kumquat harvest notes"),
1269 ] {
1270 search
1271 .add_document(
1272 &mut writer,
1273 &id.to_string(),
1274 "codex",
1275 content,
1276 &[],
1277 1_700_000_000,
1278 )
1279 .unwrap();
1280 }
1281 search.commit(&mut writer).unwrap();
1282
1283 let env = store.env();
1284 let assocs = AssociationStore::open(env).unwrap();
1285 assocs
1286 .put(env, &Association::new(id_a, id_b, LinkType::Related, 0.8))
1287 .unwrap();
1288
1289 let store_for_engine = store.clone();
1290 let search_for_engine = search.clone();
1291 let mk_engine = move |graph_weight: f32| {
1292 let config = RecallConfig {
1293 bm25_weight: 1.0,
1294 vector_weight: 0.0,
1295 importance_weight: 0.0,
1296 graph_weight,
1297 ..RecallConfig::default()
1298 };
1299 RecallEngine::new(
1300 store_for_engine.clone(),
1301 search_for_engine.clone(),
1302 VectorStore::new(),
1303 Arc::new(StubEmbedder::default()),
1304 config,
1305 )
1306 .unwrap()
1307 };
1308 GraphHarness {
1309 _dir: dir,
1310 store,
1311 engine_with_graph: mk_engine(0.5),
1312 engine_plain: mk_engine(0.0),
1313 }
1314 }
1315
1316 struct CountingEmbedder {
1319 inner: StubEmbedder,
1320 calls: std::sync::atomic::AtomicUsize,
1321 }
1322
1323 impl CountingEmbedder {
1324 fn new() -> Self {
1325 Self {
1326 inner: StubEmbedder::default(),
1327 calls: std::sync::atomic::AtomicUsize::new(0),
1328 }
1329 }
1330
1331 fn call_count(&self) -> usize {
1332 self.calls.load(std::sync::atomic::Ordering::SeqCst)
1333 }
1334 }
1335
1336 impl Embedder for CountingEmbedder {
1337 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
1338 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1339 self.inner.embed_batch(texts)
1340 }
1341 fn dimension(&self) -> usize {
1342 self.inner.dimension()
1343 }
1344 fn is_available(&self) -> bool {
1345 true
1346 }
1347 fn backend_name(&self) -> &'static str {
1348 "stub-counting"
1349 }
1350 }
1351
1352 fn engine_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
1353 let dir = tempfile::tempdir().unwrap();
1354 let lmdb = dir.path().join("lmdb");
1355 std::fs::create_dir_all(&lmdb).unwrap();
1356 let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1357 let tantivy = dir.path().join("tantivy");
1358 std::fs::create_dir_all(&tantivy).unwrap();
1359 let search = Arc::new(SearchEngine::open(&tantivy).unwrap());
1360 (dir, store, search)
1361 }
1362
1363 fn mk_engine(
1364 store: &Arc<MemoryStore>,
1365 search: &Arc<SearchEngine>,
1366 embedder: Arc<dyn Embedder>,
1367 ) -> RecallEngine {
1368 RecallEngine::new(
1369 store.clone(),
1370 search.clone(),
1371 VectorStore::new(),
1372 embedder,
1373 RecallConfig::default(),
1374 )
1375 .unwrap()
1376 }
1377
1378 #[test]
1379 fn embedding_cache_warm_starts_reingest_across_engine_restart() {
1380 let (_dir, store, search) = engine_fixture();
1384
1385 let contents: Vec<String> = (0..12)
1386 .map(|i| format!("cache warm-start probe number {i} with distinct wording {i}"))
1387 .collect();
1388 let entries: Vec<(Galaxy, crate::Memory)> = contents
1389 .iter()
1390 .map(|c| (Galaxy::Codex, crate::Memory::new(Galaxy::Codex, c.clone())))
1391 .collect();
1392 let refs: Vec<(Galaxy, &crate::Memory)> = entries.iter().map(|(g, m)| (*g, m)).collect();
1393
1394 let first = Arc::new(CountingEmbedder::new());
1395 let engine = mk_engine(&store, &search, first.clone());
1396 assert_eq!(engine.store_batch_with_embedding(&refs).unwrap(), 12);
1397 let first_calls = first.call_count();
1398 assert!(first_calls > 0, "first ingest must embed");
1399 assert_eq!(store.embedding_cache_count().unwrap(), 12);
1400
1401 let second = Arc::new(CountingEmbedder::new());
1404 let engine2 = mk_engine(&store, &search, second.clone());
1405 let entries2: Vec<(Galaxy, crate::Memory)> = contents
1406 .iter()
1407 .map(|c| (Galaxy::Codex, crate::Memory::new(Galaxy::Codex, c.clone())))
1408 .collect();
1409 let refs2: Vec<(Galaxy, &crate::Memory)> = entries2.iter().map(|(g, m)| (*g, m)).collect();
1410 assert_eq!(engine2.store_batch_with_embedding(&refs2).unwrap(), 12);
1411 assert_eq!(
1412 second.call_count(),
1413 0,
1414 "re-ingest of identical content must serve from the persistent cache"
1415 );
1416 assert_eq!(store.embedding_cache_count().unwrap(), 12);
1417 }
1418
1419 #[test]
1420 fn embedding_cache_scopes_vectors_by_embedder_namespace() {
1421 let (_dir, store, search) = engine_fixture();
1424
1425 let content = "namespace isolation probe";
1426 let first = Arc::new(CountingEmbedder::new());
1427 let engine = mk_engine(&store, &search, first.clone());
1428 let mem = crate::Memory::new(Galaxy::Codex, content.into());
1429 engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
1430 assert_eq!(first.call_count(), 1);
1431
1432 struct OtherNamespaceEmbedder(StubEmbedder);
1435 impl Embedder for OtherNamespaceEmbedder {
1436 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
1437 self.0.embed_batch(texts)
1438 }
1439 fn dimension(&self) -> usize {
1440 self.0.dimension()
1441 }
1442 fn is_available(&self) -> bool {
1443 true
1444 }
1445 fn backend_name(&self) -> &'static str {
1446 "stub-other"
1447 }
1448 }
1449 let second = Arc::new(OtherNamespaceEmbedder(StubEmbedder::default()));
1450 let engine2 = mk_engine(&store, &search, second);
1451 let mem2 = crate::Memory::new(Galaxy::Codex, content.into());
1452 engine2.store_with_embedding(Galaxy::Codex, &mem2).unwrap();
1453
1454 assert_eq!(store.embedding_cache_count().unwrap(), 2);
1456 }
1457
1458 #[test]
1459 fn graph_expansion_injects_unindexed_neighbors_and_boosts_connected() {
1460 let h = graph_harness();
1461
1462 let plain = h.engine_plain.hybrid_search("kumquat", 10, None);
1464 assert!(plain.iter().all(|r| r.memory_id != {
1465 h.store
1466 .find_by_content_hash(Galaxy::Codex, &content_hash("the follow-up decision"))
1467 .unwrap()
1468 .unwrap()
1469 }));
1470 assert!(plain.iter().all(|r| r.graph_score == 0.0));
1471
1472 let expanded = h.engine_with_graph.hybrid_search("kumquat", 10, None);
1475 let id_b = h
1476 .store
1477 .find_by_content_hash(Galaxy::Codex, &content_hash("the follow-up decision"))
1478 .unwrap()
1479 .unwrap();
1480 let b = expanded
1481 .iter()
1482 .find(|r| r.memory_id == id_b)
1483 .expect("graph expansion must surface the unindexed neighbor");
1484 assert!(b.graph_score > 0.0, "injected neighbor: {b:?}");
1485 assert_eq!(b.bm25_score, 0.0, "B had no BM25 hit — pure graph entry");
1486 let a_score = expanded
1487 .iter()
1488 .find(|r| r.content.contains("ratchet"))
1489 .unwrap()
1490 .score;
1491 assert!(a_score >= b.score, "seed outranks its 1-hop neighbor");
1492 }
1493
1494 #[test]
1495 fn graph_expansion_honors_the_privacy_flag() {
1496 let h = graph_harness();
1497 let id_b = h
1498 .store
1499 .find_by_content_hash(Galaxy::Codex, &content_hash("the follow-up decision"))
1500 .unwrap()
1501 .unwrap();
1502 let mut b = h.store.get(Galaxy::Codex, id_b).unwrap().unwrap();
1504 b.metadata.is_private = true;
1505 h.store.put(Galaxy::Codex, &b).unwrap();
1506 let expanded = h.engine_with_graph.hybrid_search("kumquat", 10, None);
1507 assert!(
1508 expanded.iter().all(|r| r.memory_id != id_b),
1509 "private memory must not be graph-injected"
1510 );
1511 }
1512
1513 #[test]
1514 fn config_default_graph_weight_is_off() {
1515 let config = RecallConfig::default();
1516 assert_eq!(config.graph_weight, 0.0, "evidence-gated: default off");
1517 assert!(config.weights_normalized());
1518 }
1519
1520 #[test]
1523 fn config_default_weights() {
1524 let config = RecallConfig::default();
1525 assert!(config.weights_normalized());
1526 assert_eq!(config.bm25_weight, 0.5);
1527 assert_eq!(config.vector_weight, 0.3);
1528 assert_eq!(config.importance_weight, 0.2);
1529 }
1530
1531 #[test]
1532 fn config_custom_weights() {
1533 let config = RecallConfig {
1534 bm25_weight: 0.6,
1535 vector_weight: 0.3,
1536 importance_weight: 0.1,
1537 ..Default::default()
1538 };
1539 assert!(config.weights_normalized());
1540 }
1541
1542 #[test]
1543 fn config_unnormalized_weights() {
1544 let config = RecallConfig {
1545 bm25_weight: 0.7,
1546 vector_weight: 0.5,
1547 importance_weight: 0.2,
1548 ..Default::default()
1549 };
1550 assert!(!config.weights_normalized());
1551 }
1552
1553 #[test]
1554 fn config_from_env_uses_defaults() {
1555 let config = RecallConfig::from_env();
1557 assert_eq!(config.bm25_weight, 0.5);
1558 assert_eq!(config.vector_weight, 0.3);
1559 assert_eq!(config.importance_weight, 0.2);
1560 }
1561
1562 #[test]
1566 fn corroboration_boost_is_knob_gated_and_disclosed() {
1567 let dir = tempfile::tempdir().unwrap();
1568 let lmdb = dir.path().join("lmdb");
1569 std::fs::create_dir_all(&lmdb).unwrap();
1570 let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1571 let tantivy = dir.path().join("tantivy");
1572 std::fs::create_dir_all(&tantivy).unwrap();
1573 let search = Arc::new(SearchEngine::open(&tantivy).unwrap());
1574
1575 let mut backed = Memory::new(Galaxy::Codex, "zanzibar treaty terms".into());
1576 backed.metadata.corroborated_by = vec![Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()];
1577 let plain = Memory::new(Galaxy::Codex, "zanzibar treaty terms".into());
1578 let (id_backed, id_plain) = (backed.metadata.id, plain.metadata.id);
1579 store.put(Galaxy::Codex, &backed).unwrap();
1580 store.put(Galaxy::Codex, &plain).unwrap();
1581 let mut writer = search.writer().unwrap();
1582 for (id, content) in [
1583 (id_backed, "zanzibar treaty terms"),
1584 (id_plain, "zanzibar treaty terms"),
1585 ] {
1586 search
1587 .add_document(
1588 &mut writer,
1589 &id.to_string(),
1590 "codex",
1591 content,
1592 &[],
1593 1_700_000_000,
1594 )
1595 .unwrap();
1596 }
1597 search.commit(&mut writer).unwrap();
1598
1599 let mk = |weight: f32| {
1600 RecallEngine::new(
1601 store.clone(),
1602 search.clone(),
1603 VectorStore::new(),
1604 Arc::new(StubEmbedder::default()),
1605 RecallConfig {
1606 bm25_weight: 1.0,
1607 vector_weight: 0.0,
1608 importance_weight: 0.0,
1609 corroboration_weight: weight,
1610 ..RecallConfig::default()
1611 },
1612 )
1613 .unwrap()
1614 };
1615 let off = mk(0.0).hybrid_search("zanzibar treaty", 10, None);
1617 let (b_off, p_off) = (
1618 off.iter().find(|r| r.memory_id == id_backed).unwrap(),
1619 off.iter().find(|r| r.memory_id == id_plain).unwrap(),
1620 );
1621 assert!((b_off.score - p_off.score).abs() < 1e-6);
1622 assert_eq!(b_off.corroboration, 0);
1623 let on = mk(1.0).hybrid_search("zanzibar treaty", 10, None);
1625 let (b_on, p_on) = (
1626 on.iter().find(|r| r.memory_id == id_backed).unwrap(),
1627 on.iter().find(|r| r.memory_id == id_plain).unwrap(),
1628 );
1629 assert_eq!(b_on.corroboration, 3);
1630 assert_eq!(p_on.corroboration, 0);
1631 let expected = b_off.score * 1.6;
1632 assert!((b_on.score - expected).abs() < 1e-4, "{b_on:?}");
1633 assert!((p_on.score - p_off.score).abs() < 1e-6);
1634 assert!(on[0].memory_id == id_backed, "boosted memory ranks first");
1635 }
1636
1637 #[test]
1639 fn recall_result_fields() {
1640 let result = RecallResult {
1641 memory_id: Uuid::new_v4(),
1642 galaxy: Galaxy::Codex,
1643 score: 0.85,
1644 bm25_score: 0.7,
1645 vector_score: 0.9,
1646 importance: 0.5,
1647 graph_score: 0.0,
1648 trust_factor: 1.0,
1649 in_conformal_set: false,
1650 corroboration: 0,
1651 content: "test content".into(),
1652 };
1653 assert_eq!(result.score, 0.85);
1654 assert_eq!(result.bm25_score, 0.7);
1655 assert_eq!(result.vector_score, 0.9);
1656 }
1657
1658 fn fuse(
1661 bm25: &[SearchResult],
1662 vector: &[VectorSearchResult],
1663 limit: usize,
1664 ) -> Vec<RecallResult> {
1665 fuse_results_inner(
1666 bm25,
1667 vector,
1668 limit,
1669 0.5,
1670 0.3,
1671 0.2,
1672 0.0,
1673 |_, _| String::new(),
1674 |_, _| 0.0,
1675 |_, _| 0.7,
1676 )
1677 }
1678
1679 #[test]
1680 fn engine_config_default() {
1681 let config = RecallConfig::default();
1682 assert_eq!(config.bm25_weight, 0.5);
1683 }
1684
1685 #[test]
1686 fn engine_cache_concept() {
1687 let config = RecallConfig::default();
1689 assert!(config.cache_embeddings);
1690 }
1691
1692 #[test]
1695 fn fuse_results_empty() {
1696 let results = fuse(&[], &[], 10);
1697 assert!(results.is_empty());
1698 }
1699
1700 #[test]
1701 fn fuse_results_bm25_only() {
1702 let id = Uuid::new_v4();
1703 let bm25 = vec![SearchResult {
1704 memory_id: id.to_string(),
1705 galaxy: Galaxy::Codex.db_name().to_string(),
1706 score: 5.0,
1707 normalized_score: 0.0,
1708 content: "test".into(),
1709 }];
1710 let results = fuse(&bm25, &[], 10);
1711 assert_eq!(results.len(), 1);
1712 assert!(results[0].bm25_score > 0.0);
1713 assert_eq!(results[0].vector_score, 0.0);
1714 }
1715
1716 #[test]
1717 fn fuse_results_vector_only() {
1718 let id = Uuid::new_v4();
1719 let vector = vec![VectorSearchResult {
1720 memory_id: id,
1721 galaxy: Galaxy::Codex,
1722 score: 0.85,
1723 }];
1724 let results = fuse(&[], &vector, 10);
1725 assert_eq!(results.len(), 1);
1726 assert_eq!(results[0].bm25_score, 0.0);
1727 assert!(results[0].vector_score > 0.0);
1728 }
1729
1730 #[test]
1731 fn fuse_results_both_sources() {
1732 let id = Uuid::new_v4();
1733 let bm25 = vec![SearchResult {
1734 memory_id: id.to_string(),
1735 galaxy: Galaxy::Codex.db_name().to_string(),
1736 score: 5.0,
1737 normalized_score: 0.0,
1738 content: "test content".into(),
1739 }];
1740 let vector = vec![VectorSearchResult {
1741 memory_id: id,
1742 galaxy: Galaxy::Codex,
1743 score: 0.85,
1744 }];
1745 let results = fuse(&bm25, &vector, 10);
1746 assert_eq!(results.len(), 1);
1747 assert!(results[0].bm25_score > 0.0);
1748 assert!(results[0].vector_score > 0.0);
1749 assert!(results[0].score > results[0].bm25_score * 0.5);
1750 }
1751
1752 #[test]
1753 fn fuse_results_sorted_by_score() {
1754 let id1 = Uuid::new_v4();
1755 let id2 = Uuid::new_v4();
1756 let bm25 = vec![
1757 SearchResult {
1758 memory_id: id1.to_string(),
1759 galaxy: Galaxy::Codex.db_name().to_string(),
1760 score: 3.0,
1761 normalized_score: 0.0,
1762 content: "lower".into(),
1763 },
1764 SearchResult {
1765 memory_id: id2.to_string(),
1766 galaxy: Galaxy::Codex.db_name().to_string(),
1767 score: 8.0,
1768 normalized_score: 0.0,
1769 content: "higher".into(),
1770 },
1771 ];
1772 let results = fuse(&bm25, &[], 10);
1773 assert_eq!(results.len(), 2);
1774 assert!(results[0].score >= results[1].score);
1775 }
1776
1777 #[test]
1778 fn fuse_results_truncated_to_limit() {
1779 let bm25: Vec<SearchResult> = (0..20)
1780 .map(|i| SearchResult {
1781 memory_id: Uuid::new_v4().to_string(),
1782 galaxy: Galaxy::Codex.db_name().to_string(),
1783 score: 1.0 + i as f32,
1784 normalized_score: 0.0,
1785 content: format!("content {i}"),
1786 })
1787 .collect();
1788 let results = fuse(&bm25, &[], 5);
1789 assert_eq!(results.len(), 5);
1790 }
1791
1792 #[test]
1793 fn fuse_results_normalizes_bm25() {
1794 let id = Uuid::new_v4();
1795 let bm25 = vec![SearchResult {
1796 memory_id: id.to_string(),
1797 galaxy: Galaxy::Codex.db_name().to_string(),
1798 score: 100.0,
1799 normalized_score: 0.0,
1800 content: "test".into(),
1801 }];
1802 let results = fuse(&bm25, &[], 10);
1803 assert!((results[0].bm25_score - 1.0).abs() < 0.01);
1804 }
1805
1806 #[test]
1809 fn embed_content_caches_result() {
1810 let embedder = StubEmbedder::new(384);
1811 let content = "test content for caching";
1812 let vec1 = embedder.embed(content).unwrap();
1813 let vec2 = embedder.embed(content).unwrap();
1814 assert_eq!(vec1, vec2);
1815 }
1816
1817 #[test]
1818 fn embed_content_different_content_different_result() {
1819 let embedder = StubEmbedder::new(384);
1820 let vec1 = embedder.embed("content one").unwrap();
1821 let vec2 = embedder.embed("content two").unwrap();
1822 assert_ne!(vec1, vec2);
1823 }
1824
1825 #[test]
1828 fn fuse_with_zero_bm25_weight() {
1829 let id = Uuid::new_v4();
1830 let bm25 = vec![SearchResult {
1831 memory_id: id.to_string(),
1832 galaxy: Galaxy::Codex.db_name().to_string(),
1833 score: 5.0,
1834 normalized_score: 0.0,
1835 content: "test".into(),
1836 }];
1837 let results = fuse_results_inner(
1838 &bm25,
1839 &[],
1840 10,
1841 0.5,
1842 0.3,
1843 0.2,
1844 0.0,
1845 |_, _| String::new(),
1846 |_, _| 0.0,
1847 |_, _| 0.7,
1848 );
1849 assert!((results[0].score - 0.5).abs() < 0.01);
1850 }
1851
1852 #[test]
1853 fn fuse_with_zero_vector_weight() {
1854 let id = Uuid::new_v4();
1855 let vector = vec![VectorSearchResult {
1856 memory_id: id,
1857 galaxy: Galaxy::Codex,
1858 score: 0.9,
1859 }];
1860 let results = fuse_results_inner(
1861 &[],
1862 &vector,
1863 10,
1864 0.5,
1865 0.3,
1866 0.2,
1867 0.0,
1868 |_, _| String::new(),
1869 |_, _| 0.0,
1870 |_, _| 0.7,
1871 );
1872 assert!((results[0].score - 0.27).abs() < 0.01);
1873 }
1874
1875 #[test]
1876 fn trust_weight_zero_is_byte_identical_to_no_weight() {
1877 let id = Uuid::new_v4();
1878 let bm25 = vec![SearchResult {
1879 memory_id: id.to_string(),
1880 galaxy: Galaxy::Codex.db_name().to_string(),
1881 score: 5.0,
1882 normalized_score: 0.0,
1883 content: "test".into(),
1884 }];
1885 let results = fuse_results_inner(
1888 &bm25,
1889 &[],
1890 10,
1891 0.5,
1892 0.3,
1893 0.2,
1894 0.0,
1895 |_, _| String::new(),
1896 |_, _| 0.0,
1897 |_, _| 0.4,
1898 );
1899 assert!((results[0].score - 0.5).abs() < 0.01);
1900 assert!((results[0].trust_factor - 1.0).abs() < f32::EPSILON);
1901 assert!(!results[0].in_conformal_set);
1902 }
1903
1904 #[test]
1905 fn trust_weight_orders_high_trust_above_low() {
1906 let high = Uuid::new_v4();
1909 let low = Uuid::new_v4();
1910 let mk = |id: &Uuid| SearchResult {
1911 memory_id: id.to_string(),
1912 galaxy: Galaxy::Codex.db_name().to_string(),
1913 score: 5.0,
1914 normalized_score: 0.0,
1915 content: "test".into(),
1916 };
1917 let bm25 = vec![mk(&high), mk(&low)];
1918 let mut trust_calls = 0;
1919 let results = fuse_results_inner(
1920 &bm25,
1921 &[],
1922 10,
1923 0.5,
1924 0.3,
1925 0.2,
1926 0.5,
1927 |_, _| String::new(),
1928 |_, _| 0.0,
1929 |id, _| {
1930 trust_calls += 1;
1931 if id == high { 1.0 } else { 0.4 }
1932 },
1933 );
1934 let hi = results.iter().find(|r| r.memory_id == high).unwrap();
1935 let lo = results.iter().find(|r| r.memory_id == low).unwrap();
1936 assert!(
1937 hi.score > lo.score,
1938 "user-confirmed (1.0) must outrank low-trust (0.4) at equal fused score"
1939 );
1940 assert!((hi.trust_factor - 1.15).abs() < 0.001);
1942 assert!((lo.trust_factor - 0.85).abs() < 0.001);
1943 assert!(trust_calls >= 2, "getter consulted per candidate");
1944 let neutral = Uuid::new_v4();
1946 let bm25_neutral = vec![SearchResult {
1947 memory_id: neutral.to_string(),
1948 galaxy: Galaxy::Codex.db_name().to_string(),
1949 score: 5.0,
1950 normalized_score: 0.0,
1951 content: "test".into(),
1952 }];
1953 let res_n = fuse_results_inner(
1954 &bm25_neutral,
1955 &[],
1956 10,
1957 0.5,
1958 0.3,
1959 0.2,
1960 0.5,
1961 |_, _| String::new(),
1962 |_, _| 0.0,
1963 |_, _| 0.7,
1964 );
1965 assert!((res_n[0].trust_factor - 1.0).abs() < 0.001);
1966 }
1967
1968 #[test]
1969 fn config_defaults_keep_both_s8_knobs_off() {
1970 let cfg = RecallConfig::default();
1975 assert_eq!(cfg.trust_weight, 0.0);
1976 assert_eq!(cfg.conformal_alpha, None);
1977 let env_cfg = RecallConfig::from_env();
1978 assert_eq!(env_cfg.trust_weight, 0.0, "unset env stays off");
1979 assert_eq!(env_cfg.conformal_alpha, None, "unset env stays off");
1980 }
1981
1982 #[test]
1985 fn galaxy_from_db_name_valid() {
1986 assert_eq!(Galaxy::from_db_name("codex"), Some(Galaxy::Codex));
1987 }
1988
1989 #[test]
1990 fn galaxy_from_db_name_invalid() {
1991 assert_eq!(Galaxy::from_db_name("nonexistent"), None);
1992 }
1993
1994 use crate::Memory;
1997 use tempfile::tempdir;
1998
1999 fn setup_engine() -> (tempfile::TempDir, RecallEngine) {
2000 let tmp = tempdir().unwrap();
2001 let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
2002 let tantivy_path = tmp.path().join("tantivy");
2003 std::fs::create_dir_all(&tantivy_path).unwrap();
2004 let search = Arc::new(SearchEngine::open(&tantivy_path).unwrap());
2005 let vector_store = VectorStore::new();
2006 let embedder: Arc<dyn Embedder> = Arc::new(StubEmbedder::new(384));
2007 let engine = RecallEngine::new(
2008 store,
2009 search,
2010 vector_store,
2011 embedder,
2012 RecallConfig::default(),
2013 )
2014 .unwrap();
2015 (tmp, engine)
2016 }
2017
2018 #[test]
2019 fn integration_store_and_hybrid_search_roundtrip() {
2020 let (_tmp, engine) = setup_engine();
2021
2022 let mem1 = Memory::new(
2023 Galaxy::Codex,
2024 "Rust programming language is fast and safe".into(),
2025 )
2026 .with_importance(0.8)
2027 .with_tags(vec!["rust".into(), "programming".into()]);
2028 let mem2 = Memory::new(Galaxy::Codex, "Python is great for data science".into())
2029 .with_importance(0.5)
2030 .with_tags(vec!["python".into(), "data".into()]);
2031 let mem3 = Memory::new(
2032 Galaxy::Codex,
2033 "The Rust ownership model prevents memory leaks".into(),
2034 )
2035 .with_importance(0.9)
2036 .with_tags(vec!["rust".into(), "memory".into()]);
2037
2038 engine.store_with_embedding(Galaxy::Codex, &mem1).unwrap();
2039 engine.store_with_embedding(Galaxy::Codex, &mem2).unwrap();
2040 engine.store_with_embedding(Galaxy::Codex, &mem3).unwrap();
2041
2042 let results = engine.hybrid_search("rust", 10, None);
2044 assert!(!results.is_empty(), "hybrid search should return results");
2045
2046 let top_contents: Vec<&str> = results.iter().map(|r| r.content.as_str()).collect();
2048 assert!(
2049 top_contents.iter().any(|c| c.contains("Rust")),
2050 "top results should include Rust content, got: {top_contents:?}"
2051 );
2052 }
2053
2054 #[test]
2055 fn integration_bm25_and_vector_both_contribute() {
2056 let (_tmp, engine) = setup_engine();
2057
2058 for i in 0..5 {
2060 let mem = Memory::new(
2061 Galaxy::Codex,
2062 format!("memory about topic {i} with unique content"),
2063 )
2064 .with_importance(0.5);
2065 engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2066 }
2067
2068 let results = engine.hybrid_search("memory", 10, None);
2070 assert!(!results.is_empty(), "should find memories");
2071
2072 let has_bm25 = results.iter().any(|r| r.bm25_score > 0.0);
2074 assert!(has_bm25, "BM25 should contribute to fused results");
2075 }
2076
2077 #[test]
2078 fn integration_vector_search_only() {
2079 let (_tmp, engine) = setup_engine();
2080
2081 let content = "unique searchable content for vector test";
2082 let mem = Memory::new(Galaxy::Codex, content.into()).with_importance(0.7);
2083 engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2084
2085 let results = engine.vector_search(content, 10, None);
2087 assert_eq!(results.len(), 1);
2088 assert_eq!(results[0].memory_id, mem.metadata.id);
2089 assert!(results[0].vector_score > 0.0);
2090 }
2091
2092 #[test]
2093 fn integration_text_search_only() {
2094 let (_tmp, engine) = setup_engine();
2095
2096 let mem = Memory::new(Galaxy::Codex, "specific text about rust ownership".into())
2097 .with_tags(vec!["rust".into()]);
2098 engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2099
2100 let results = engine.text_search("rust", 10);
2101 assert!(!results.is_empty(), "text search should find results");
2102 assert!(results.iter().any(|r| r.bm25_score > 0.0));
2103 }
2104
2105 #[test]
2106 fn integration_batch_store_with_embedding() {
2107 let (_tmp, engine) = setup_engine();
2108
2109 let mem1 = Memory::new(Galaxy::Codex, "alpha beta gamma".into());
2110 let mem2 = Memory::new(Galaxy::Codex, "delta epsilon zeta".into());
2111 let mem3 = Memory::new(Galaxy::Codex, "eta theta iota".into());
2112
2113 let entries = vec![
2114 (Galaxy::Codex, &mem1),
2115 (Galaxy::Codex, &mem2),
2116 (Galaxy::Codex, &mem3),
2117 ];
2118
2119 let count = engine.store_batch_with_embedding(&entries).unwrap();
2120 assert_eq!(count, 3);
2121
2122 let results = engine.text_search("alpha", 10);
2124 assert!(
2125 !results.is_empty(),
2126 "batch-stored memory should be searchable"
2127 );
2128
2129 let vresults = engine.vector_search("alpha beta gamma", 10, None);
2131 assert_eq!(
2132 vresults.len(),
2133 1,
2134 "vector search should find the exact match"
2135 );
2136 assert_eq!(vresults[0].memory_id, mem1.metadata.id);
2137 }
2138
2139 #[test]
2140 fn integration_batch_store_empty() {
2141 let (_tmp, engine) = setup_engine();
2142 let entries: Vec<(Galaxy, &Memory)> = vec![];
2143 let count = engine.store_batch_with_embedding(&entries).unwrap();
2144 assert_eq!(count, 0);
2145 }
2146
2147 #[test]
2148 fn integration_galaxy_filter() {
2149 let (_tmp, engine) = setup_engine();
2150
2151 let mem_codex = Memory::new(Galaxy::Codex, "codex memory about rust".into());
2152 let mem_research = Memory::new(Galaxy::Research, "research memory about rust".into());
2153
2154 engine
2155 .store_with_embedding(Galaxy::Codex, &mem_codex)
2156 .unwrap();
2157 engine
2158 .store_with_embedding(Galaxy::Research, &mem_research)
2159 .unwrap();
2160
2161 let results = engine.hybrid_search("rust", 10, Some(Galaxy::Codex));
2162 assert!(!results.is_empty());
2163 assert!(
2164 results.iter().all(|r| r.galaxy == Galaxy::Codex),
2165 "all results should be from Codex galaxy"
2166 );
2167 }
2168
2169 #[test]
2170 fn integration_empty_search() {
2171 let (_tmp, engine) = setup_engine();
2172 let results = engine.hybrid_search("nonexistent", 10, None);
2173 assert!(results.is_empty());
2174 }
2175
2176 #[test]
2177 fn integration_cache_populated_after_store() {
2178 let (_tmp, engine) = setup_engine();
2179
2180 let mem = Memory::new(Galaxy::Codex, "content to be cached".into());
2181 engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2182
2183 assert_eq!(engine.cache_size(), 1);
2185 }
2186
2187 #[test]
2188 fn integration_vector_count_tracks_stores() {
2189 let (_tmp, engine) = setup_engine();
2190
2191 assert_eq!(engine.vector_count(), 0);
2192
2193 for i in 0..3 {
2194 let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
2195 engine.store_with_embedding(Galaxy::Codex, &mem).unwrap();
2196 }
2197
2198 assert_eq!(engine.vector_count(), 3);
2199 }
2200
2201 #[test]
2202 fn integration_importance_affects_ranking() {
2203 let (_tmp, engine) = setup_engine();
2204
2205 let mem_low =
2207 Memory::new(Galaxy::Codex, "rust programming basics".into()).with_importance(0.1);
2208 let mem_high =
2209 Memory::new(Galaxy::Codex, "rust programming advanced".into()).with_importance(0.9);
2210
2211 engine
2212 .store_with_embedding(Galaxy::Codex, &mem_low)
2213 .unwrap();
2214 engine
2215 .store_with_embedding(Galaxy::Codex, &mem_high)
2216 .unwrap();
2217
2218 let results = engine.hybrid_search("rust", 10, None);
2219 assert_eq!(results.len(), 2);
2220
2221 let high_idx = results
2224 .iter()
2225 .position(|r| r.memory_id == mem_high.metadata.id)
2226 .unwrap();
2227 let low_idx = results
2228 .iter()
2229 .position(|r| r.memory_id == mem_low.metadata.id)
2230 .unwrap();
2231 assert!(
2232 high_idx < low_idx,
2233 "higher importance memory should rank higher"
2234 );
2235 }
2236
2237 #[test]
2238 fn config_from_env_rejects_nan_weights() {
2239 let mut config = RecallConfig::default();
2242 let w: f32 = "NaN".parse().unwrap();
2243 if w.is_finite() && w >= 0.0 {
2244 config.bm25_weight = w.min(1.0);
2245 }
2246 assert_eq!(
2247 config.bm25_weight, 0.5,
2248 "NaN should be rejected, default kept"
2249 );
2250 }
2251
2252 #[test]
2253 fn config_from_env_rejects_negative_weights() {
2254 let mut config = RecallConfig::default();
2255 let w: f32 = "-0.5".parse().unwrap();
2256 if w.is_finite() && w >= 0.0 {
2257 config.vector_weight = w.min(1.0);
2258 }
2259 assert_eq!(
2260 config.vector_weight, 0.3,
2261 "Negative should be rejected, default kept"
2262 );
2263 }
2264
2265 #[test]
2266 fn config_from_env_clamps_weights_to_1() {
2267 let mut config = RecallConfig::default();
2268 let w: f32 = "5.0".parse().unwrap();
2269 if w.is_finite() && w >= 0.0 {
2270 config.importance_weight = w.min(1.0);
2271 }
2272 assert_eq!(
2273 config.importance_weight, 1.0,
2274 "Weight should be clamped to 1.0"
2275 );
2276 }
2277
2278 #[test]
2279 fn config_from_env_normalizes_weights() {
2280 let mut config = RecallConfig {
2281 bm25_weight: 0.8,
2282 vector_weight: 0.8,
2283 importance_weight: 0.8,
2284 ..Default::default()
2285 };
2286 let sum = config.bm25_weight + config.vector_weight + config.importance_weight;
2287 if sum > 0.0 && (sum - 1.0).abs() > 0.01 {
2288 config.bm25_weight /= sum;
2289 config.vector_weight /= sum;
2290 config.importance_weight /= sum;
2291 }
2292 assert!(
2293 config.weights_normalized(),
2294 "Weights should be normalized to sum to 1.0"
2295 );
2296 }
2297
2298 #[test]
2299 fn config_from_env_rejects_infinity() {
2300 let mut config = RecallConfig::default();
2301 let w: f32 = "inf".parse().unwrap();
2302 if w.is_finite() && w >= 0.0 {
2303 config.bm25_weight = w.min(1.0);
2304 }
2305 assert_eq!(
2306 config.bm25_weight, 0.5,
2307 "Infinity should be rejected, default kept"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_promotion_on_read_config_default() {
2313 let default_config = RecallConfig::default();
2314 assert!(!default_config.promotion_on_read);
2315
2316 let custom_config = RecallConfig {
2317 promotion_on_read: true,
2318 ..Default::default()
2319 };
2320 assert!(custom_config.promotion_on_read);
2321 }
2322
2323 #[test]
2324 fn test_promote_memory_updates_hebbian_score_and_counts() {
2325 let tmp = tempfile::tempdir().unwrap();
2326 let store_dir = tmp.path().join("store");
2327 std::fs::create_dir_all(&store_dir).unwrap();
2328 let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2329 let index_dir = tmp.path().join("index");
2330 std::fs::create_dir_all(&index_dir).unwrap();
2331 let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2332 let vector_store = VectorStore::new();
2333 let embedder = Arc::new(crate::embedder::StubEmbedder::default());
2334 let config = RecallConfig {
2335 promotion_on_read: true,
2336 ..Default::default()
2337 };
2338 let engine =
2339 RecallEngine::new(store.clone(), search_engine, vector_store, embedder, config)
2340 .unwrap();
2341
2342 let mut mem = crate::Memory::new(Galaxy::Codex, "promotion on read test".to_string());
2343 mem.metadata.neuro_score = 0.5;
2344 mem.metadata.novelty_score = 1.0;
2345 let mem_id = mem.metadata.id;
2346 store.put(Galaxy::Codex, &mem).unwrap();
2347
2348 let promoted = engine.promote_memory(Galaxy::Codex, mem_id).unwrap();
2350 assert!(promoted);
2351
2352 let reloaded = store.get(Galaxy::Codex, mem_id).unwrap().unwrap();
2353 assert_eq!(reloaded.metadata.recall_count, 1);
2354 assert_eq!(reloaded.metadata.access_count, 1);
2355 assert!(
2356 reloaded.metadata.neuro_score > 0.5,
2357 "neuro_score should increase via Hebbian boost"
2358 );
2359 assert!(
2360 reloaded.metadata.novelty_score < 1.0,
2361 "novelty_score should decay on recall"
2362 );
2363 }
2364
2365 #[test]
2366 fn test_hybrid_search_triggers_promotion_on_read() {
2367 let tmp = tempfile::tempdir().unwrap();
2368 let store_dir = tmp.path().join("store");
2369 std::fs::create_dir_all(&store_dir).unwrap();
2370 let store = Arc::new(MemoryStore::open_default(&store_dir).unwrap());
2371 let index_dir = tmp.path().join("index");
2372 std::fs::create_dir_all(&index_dir).unwrap();
2373 let search_engine = Arc::new(SearchEngine::open(&index_dir).unwrap());
2374 let vector_store = VectorStore::new();
2375 let embedder = Arc::new(crate::embedder::StubEmbedder::default());
2376 let config = RecallConfig {
2377 promotion_on_read: true,
2378 ..Default::default()
2379 };
2380 let engine = RecallEngine::new(
2381 store.clone(),
2382 search_engine.clone(),
2383 vector_store,
2384 embedder,
2385 config,
2386 )
2387 .unwrap();
2388
2389 let mut mem = crate::Memory::new(Galaxy::Codex, "tokio army swarm tactics".to_string());
2390 mem.metadata.neuro_score = 0.5;
2391 mem.metadata.novelty_score = 1.0;
2392 let mem_id = mem.metadata.id;
2393 store.put(Galaxy::Codex, &mem).unwrap();
2394
2395 let mut writer = search_engine.writer().unwrap();
2396 search_engine
2397 .add_document(
2398 &mut writer,
2399 &mem_id.to_string(),
2400 "codex",
2401 "tokio army swarm tactics",
2402 &[],
2403 1_700_000_000,
2404 )
2405 .unwrap();
2406 search_engine.commit(&mut writer).unwrap();
2407
2408 let (results, _) =
2410 engine.hybrid_search_with_disclosure("tokio army", 5, Some(Galaxy::Codex));
2411 assert!(!results.is_empty());
2412 assert_eq!(results[0].memory_id, mem_id);
2413
2414 let reloaded = store.get(Galaxy::Codex, mem_id).unwrap().unwrap();
2415 assert_eq!(reloaded.metadata.recall_count, 1);
2416 assert!(reloaded.metadata.neuro_score > 0.5);
2417 }
2418
2419 #[test]
2420 fn test_s10_association_rerank() {
2421 let (_tmp, mut engine) = setup_engine();
2422 let env = engine.store.env();
2423 let assoc_store = AssociationStore::open(env).unwrap();
2424
2425 let mem_a = Memory::new(Galaxy::Codex, "alpha query topic node".into());
2427 engine.store_with_embedding(Galaxy::Codex, &mem_a).unwrap();
2428
2429 let mem_b = Memory::new(Galaxy::Codex, "beta query topic node".into());
2431 let id_b = mem_b.metadata.id;
2432 engine.store_with_embedding(Galaxy::Codex, &mem_b).unwrap();
2433
2434 let mem_c = Memory::new(Galaxy::Research, "gamma target node".into());
2436 let id_c = mem_c.metadata.id;
2437 engine.store.put(Galaxy::Research, &mem_c).unwrap();
2438
2439 let edge = crate::associations::Association::new(
2440 id_b,
2441 id_c,
2442 crate::associations::LinkType::Related,
2443 0.8,
2444 );
2445 assoc_store.put(env, &edge).unwrap();
2446
2447 let results_default = engine.hybrid_search("query topic", 10, None);
2449 assert!(!results_default.is_empty());
2450
2451 engine.config.association_rerank = true;
2453 let results_rerank = engine.hybrid_search("query topic", 10, None);
2454 assert!(!results_rerank.is_empty());
2455
2456 let score_b_default = results_default
2458 .iter()
2459 .find(|r| r.memory_id == id_b)
2460 .unwrap()
2461 .score;
2462 let score_b_rerank = results_rerank
2463 .iter()
2464 .find(|r| r.memory_id == id_b)
2465 .unwrap()
2466 .score;
2467 assert!(score_b_rerank > score_b_default);
2468 }
2469}