1use std::collections::HashMap;
16
17pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
23 if a.len() != b.len() || a.is_empty() {
24 return 0.0;
25 }
26 let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
27 let na = a.iter().map(|x| x * x).sum::<f64>().sqrt();
28 let nb = b.iter().map(|x| x * x).sum::<f64>().sqrt();
29 if na < 1e-10 || nb < 1e-10 {
30 0.0
31 } else {
32 (dot / (na * nb)).clamp(-1.0, 1.0)
33 }
34}
35
36fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
38 a.iter()
39 .zip(b.iter())
40 .map(|(x, y)| (x - y) * (x - y))
41 .sum::<f64>()
42 .sqrt()
43}
44
45pub fn weighted_sum(vecs: &[(&[f64], f64)]) -> Vec<f64> {
47 if vecs.is_empty() {
48 return vec![];
49 }
50 let dim = vecs[0].0.len();
51 let mut result = vec![0.0f64; dim];
52 for (v, w) in vecs {
53 for (r, x) in result.iter_mut().zip(v.iter()) {
54 *r += x * w;
55 }
56 }
57 result
58}
59
60fn normalize_in_place(v: &mut [f64]) {
62 let norm = v.iter().map(|x| x * x).sum::<f64>().sqrt();
63 if norm > 1e-10 {
64 for x in v.iter_mut() {
65 *x /= norm;
66 }
67 }
68}
69
70fn xorshift64(state: &mut u64) -> u64 {
75 let mut x = *state;
76 x ^= x << 13;
77 x ^= x >> 7;
78 x ^= x << 17;
79 *state = x;
80 x
81}
82
83fn xorshift_f64(state: &mut u64) -> f64 {
84 (xorshift64(state) >> 11) as f64 / (1u64 << 53) as f64
85}
86
87#[derive(Debug, Clone)]
93pub struct SearchContext {
94 pub session_id: String,
96 pub query_history: Vec<String>,
98 pub positive_examples: Vec<Vec<f64>>,
100 pub negative_examples: Vec<Vec<f64>>,
102 pub context_window: usize,
104 pub(crate) query_embeddings: Vec<Vec<f64>>,
106}
107
108impl SearchContext {
109 pub fn new(session_id: impl Into<String>, context_window: usize) -> Self {
111 Self {
112 session_id: session_id.into(),
113 query_history: Vec::new(),
114 positive_examples: Vec::new(),
115 negative_examples: Vec::new(),
116 context_window,
117 query_embeddings: Vec::new(),
118 }
119 }
120}
121
122#[derive(Debug, Clone)]
127pub struct CesExpandedQuery {
128 pub original: Vec<f64>,
130 pub expanded: Vec<f64>,
132 pub expansion_weight: f64,
134 pub history_weight: f64,
136}
137
138#[derive(Debug, Clone)]
140pub enum DiversityStrategy {
141 MaxMarginalRelevance(f64),
143 DeterminantalPointProcess,
145 GreedyDiversify(f64),
147 None,
149}
150
151#[derive(Debug, Clone)]
153pub struct SearchDoc {
154 pub id: String,
156 pub embedding: Vec<f64>,
158 pub metadata: Vec<(String, String)>,
160}
161
162#[derive(Debug, Clone)]
164pub struct ContextualResult {
165 pub doc_id: String,
167 pub relevance_score: f64,
169 pub diversity_score: f64,
171 pub final_score: f64,
173 pub rank: usize,
175 pub explanation: Vec<(String, f64)>,
177}
178
179#[derive(Debug, Clone)]
181pub struct SearchConfig {
182 pub top_k: usize,
184 pub diversity_strategy: DiversityStrategy,
186 pub expansion_alpha: f64,
188 pub use_negative_examples: bool,
190 pub rerank_top_n: usize,
192 pub min_relevance: f64,
194}
195
196impl Default for SearchConfig {
197 fn default() -> Self {
198 Self {
199 top_k: 10,
200 diversity_strategy: DiversityStrategy::MaxMarginalRelevance(0.5),
201 expansion_alpha: 0.3,
202 use_negative_examples: true,
203 rerank_top_n: 50,
204 min_relevance: 0.0,
205 }
206 }
207}
208
209#[derive(Debug, Clone, Default)]
211pub struct SearchStats {
212 pub queries_processed: u64,
214 pub avg_expansion_similarity: f64,
216 pub diversity_gains: u64,
218 pub cache_hits: u64,
220}
221
222#[derive(Debug, Clone, PartialEq)]
224pub enum SearchError {
225 IndexEmpty,
227 DimensionMismatch { expected: usize, got: usize },
229 InsufficientResults(usize),
231 ConfigurationError(String),
233}
234
235impl std::fmt::Display for SearchError {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 match self {
238 Self::IndexEmpty => write!(f, "index is empty"),
239 Self::DimensionMismatch { expected, got } => {
240 write!(f, "dimension mismatch: expected {expected}, got {got}")
241 }
242 Self::InsufficientResults(n) => {
243 write!(f, "only {n} results available")
244 }
245 Self::ConfigurationError(msg) => write!(f, "configuration error: {msg}"),
246 }
247 }
248}
249
250impl std::error::Error for SearchError {}
251
252pub struct ContextualEmbeddingSearch {
259 documents: HashMap<String, SearchDoc>,
261 doc_order: Vec<String>,
263 dimension: Option<usize>,
265 stats: SearchStats,
267}
268
269impl ContextualEmbeddingSearch {
270 pub fn new() -> Self {
272 Self {
273 documents: HashMap::new(),
274 doc_order: Vec::new(),
275 dimension: None,
276 stats: SearchStats::default(),
277 }
278 }
279
280 pub fn add_document(&mut self, doc: SearchDoc) -> Result<(), SearchError> {
289 if doc.embedding.is_empty() {
290 return Err(SearchError::ConfigurationError(
291 "embedding must not be empty".into(),
292 ));
293 }
294 match self.dimension {
295 None => self.dimension = Some(doc.embedding.len()),
296 Some(expected) if expected != doc.embedding.len() => {
297 return Err(SearchError::DimensionMismatch {
298 expected,
299 got: doc.embedding.len(),
300 })
301 }
302 _ => {}
303 }
304 let id = doc.id.clone();
305 if !self.documents.contains_key(&id) {
306 self.doc_order.push(id.clone());
307 }
308 self.documents.insert(id, doc);
309 Ok(())
310 }
311
312 pub fn remove_document(&mut self, id: &str) -> Result<(), SearchError> {
314 if self.documents.remove(id).is_none() {
315 return Err(SearchError::ConfigurationError(format!(
316 "document '{id}' not found"
317 )));
318 }
319 self.doc_order.retain(|x| x != id);
320 Ok(())
321 }
322
323 fn expand_query(
329 &self,
330 query: &[f64],
331 context: &SearchContext,
332 config: &SearchConfig,
333 ) -> CesExpandedQuery {
334 let alpha = config.expansion_alpha.clamp(0.0, 1.0);
335
336 if alpha <= 1e-10 {
337 return CesExpandedQuery {
338 original: query.to_vec(),
339 expanded: query.to_vec(),
340 expansion_weight: 0.0,
341 history_weight: 0.0,
342 };
343 }
344
345 let window = context.context_window.max(1);
347 let recent: Vec<&Vec<f64>> = context
348 .query_embeddings
349 .iter()
350 .rev()
351 .take(window)
352 .collect::<Vec<_>>()
353 .into_iter()
354 .rev()
355 .collect();
356
357 let dim = query.len();
359 let valid_recent: Vec<&[f64]> = recent
360 .iter()
361 .filter(|v| v.len() == dim)
362 .map(|v| v.as_slice())
363 .collect();
364
365 let history_weight = if valid_recent.is_empty() { 0.0 } else { 1.0 };
366 let mut context_vec = if valid_recent.is_empty() {
367 query.to_vec()
368 } else {
369 let w = 1.0 / valid_recent.len() as f64;
370 let pairs: Vec<(&[f64], f64)> = valid_recent.iter().map(|v| (*v, w)).collect();
371 weighted_sum(&pairs)
372 };
373
374 let valid_pos: Vec<&[f64]> = context
376 .positive_examples
377 .iter()
378 .filter(|v| v.len() == dim)
379 .map(|v| v.as_slice())
380 .collect();
381
382 if !valid_pos.is_empty() {
383 let pos_w = 0.5 / valid_pos.len() as f64;
384 for (c, p) in context_vec.iter_mut().zip(
385 weighted_sum(&valid_pos.iter().map(|v| (*v, pos_w)).collect::<Vec<_>>()).iter(),
386 ) {
387 *c += p;
388 }
389 }
390 normalize_in_place(&mut context_vec);
391
392 let pairs: Vec<(&[f64], f64)> = vec![(query, 1.0 - alpha), (&context_vec, alpha)];
394 let mut expanded = weighted_sum(&pairs);
395 normalize_in_place(&mut expanded);
396
397 CesExpandedQuery {
398 original: query.to_vec(),
399 expanded,
400 expansion_weight: alpha,
401 history_weight,
402 }
403 }
404
405 fn suppress_negatives(&self, query: &mut [f64], context: &SearchContext) {
407 let dim = query.len();
408 let valid_neg: Vec<&[f64]> = context
409 .negative_examples
410 .iter()
411 .filter(|v| v.len() == dim)
412 .map(|v| v.as_slice())
413 .collect();
414
415 if valid_neg.is_empty() {
416 return;
417 }
418
419 for neg in &valid_neg {
421 let neg_norm_sq: f64 = neg.iter().map(|x| x * x).sum();
422 if neg_norm_sq < 1e-10 {
423 continue;
424 }
425 let proj: f64 = query
426 .iter()
427 .zip(neg.iter())
428 .map(|(q, n)| q * n)
429 .sum::<f64>()
430 / neg_norm_sq;
431 if proj > 0.0 {
433 for (q, n) in query.iter_mut().zip(neg.iter()) {
434 *q -= proj * n;
435 }
436 }
437 }
438 normalize_in_place(query);
439 }
440
441 fn mmr_rerank(
447 candidates: &[(String, f64, &[f64])], top_k: usize,
449 lambda: f64,
450 ) -> Vec<(String, f64, f64)> {
451 let lambda = lambda.clamp(0.0, 1.0);
453 let mut selected: Vec<usize> = Vec::with_capacity(top_k);
454 let mut remaining: Vec<usize> = (0..candidates.len()).collect();
455
456 while selected.len() < top_k && !remaining.is_empty() {
457 let best_idx = if selected.is_empty() {
458 remaining
460 .iter()
461 .copied()
462 .max_by(|&a, &b| {
463 candidates[a]
464 .1
465 .partial_cmp(&candidates[b].1)
466 .unwrap_or(std::cmp::Ordering::Equal)
467 })
468 .unwrap_or(remaining[0])
469 } else {
470 remaining
472 .iter()
473 .copied()
474 .max_by(|&a, &b| {
475 let mmr_a = mmr_score(candidates, a, &selected, lambda);
476 let mmr_b = mmr_score(candidates, b, &selected, lambda);
477 mmr_a
478 .partial_cmp(&mmr_b)
479 .unwrap_or(std::cmp::Ordering::Equal)
480 })
481 .unwrap_or(remaining[0])
482 };
483
484 let pos = remaining.iter().position(|&x| x == best_idx).unwrap_or(0);
485 remaining.remove(pos);
486 selected.push(best_idx);
487 }
488
489 selected
490 .iter()
491 .map(|&i| {
492 let max_sim = max_similarity_to_selected(candidates, i, &selected);
493 let div = 1.0 - max_sim.max(0.0);
494 (candidates[i].0.clone(), candidates[i].1, div)
495 })
496 .collect()
497 }
498
499 fn greedy_diversify_rerank(
501 candidates: &[(String, f64, &[f64])],
502 top_k: usize,
503 min_dist: f64,
504 ) -> Vec<(String, f64, f64)> {
505 let mut selected: Vec<usize> = Vec::with_capacity(top_k);
506
507 for (i, _) in candidates.iter().enumerate() {
508 if selected.len() >= top_k {
509 break;
510 }
511 let too_close = selected.iter().any(|&s| {
512 let dist = euclidean_distance(candidates[i].2, candidates[s].2);
513 dist < min_dist
514 });
515 if !too_close || selected.is_empty() {
516 selected.push(i);
517 }
518 }
519
520 if selected.len() < top_k {
522 for i in 0..candidates.len() {
523 if selected.len() >= top_k {
524 break;
525 }
526 if !selected.contains(&i) {
527 selected.push(i);
528 }
529 }
530 }
531
532 selected
533 .iter()
534 .map(|&i| {
535 let max_sim = if selected.len() > 1 {
536 selected
537 .iter()
538 .filter(|&&j| j != i)
539 .map(|&j| cosine_similarity(candidates[i].2, candidates[j].2))
540 .fold(f64::NEG_INFINITY, f64::max)
541 } else {
542 0.0
543 };
544 let div = 1.0 - max_sim.clamp(0.0, 1.0);
545 (candidates[i].0.clone(), candidates[i].1, div)
546 })
547 .collect()
548 }
549
550 fn dpp_rerank(
552 candidates: &[(String, f64, &[f64])],
553 top_k: usize,
554 rng: &mut u64,
555 ) -> Vec<(String, f64, f64)> {
556 if candidates.is_empty() {
557 return vec![];
558 }
559 let n = candidates.len();
560 let mut selected: Vec<usize> = Vec::with_capacity(top_k);
563 let mut remaining: Vec<usize> = (0..n).collect();
564
565 let mut l: Vec<Vec<f64>> = vec![vec![0.0; top_k]; n]; while selected.len() < top_k && !remaining.is_empty() {
570 let step = selected.len();
571 let best = if step == 0 {
572 remaining
574 .iter()
575 .copied()
576 .max_by(|&a, &b| {
577 let va = candidates[a].1 + xorshift_f64(rng) * 1e-9;
578 let vb = candidates[b].1 + xorshift_f64(rng) * 1e-9;
579 va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
580 })
581 .unwrap_or(remaining[0])
582 } else {
583 remaining
585 .iter()
586 .copied()
587 .max_by(|&a, &b| {
588 let ga = dpp_marginal(candidates, a, &selected, &l, step);
589 let gb = dpp_marginal(candidates, b, &selected, &l, step);
590 ga.partial_cmp(&gb).unwrap_or(std::cmp::Ordering::Equal)
591 })
592 .unwrap_or(remaining[0])
593 };
594
595 let k_best_best = kernel_val(candidates, best, best);
597 let l_sq: f64 = (0..step).map(|t| l[best][t] * l[best][t]).sum();
598 let diag = (k_best_best - l_sq).max(1e-10).sqrt();
599 l[best][step] = diag;
600
601 for &r in &remaining {
603 if r == best {
604 continue;
605 }
606 let k_r_best = kernel_val(candidates, r, best);
607 let cross: f64 = (0..step).map(|t| l[r][t] * l[best][t]).sum();
608 if diag > 1e-10 {
609 l[r][step] = (k_r_best - cross) / diag;
610 }
611 }
612
613 let pos = remaining.iter().position(|&x| x == best).unwrap_or(0);
614 remaining.remove(pos);
615 selected.push(best);
616 }
617
618 selected
619 .iter()
620 .map(|&i| {
621 let max_sim = selected
622 .iter()
623 .filter(|&&j| j != i)
624 .map(|&j| cosine_similarity(candidates[i].2, candidates[j].2))
625 .fold(0.0_f64, f64::max);
626 let div = 1.0 - max_sim.clamp(0.0, 1.0);
627 (candidates[i].0.clone(), candidates[i].1, div)
628 })
629 .collect()
630 }
631
632 pub fn search(
644 &mut self,
645 query: &[f64],
646 context: &SearchContext,
647 config: &SearchConfig,
648 ) -> Result<Vec<ContextualResult>, SearchError> {
649 if config.top_k == 0 {
651 return Err(SearchError::ConfigurationError("top_k must be > 0".into()));
652 }
653 if config.rerank_top_n == 0 {
654 return Err(SearchError::ConfigurationError(
655 "rerank_top_n must be > 0".into(),
656 ));
657 }
658
659 if self.documents.is_empty() {
661 return Err(SearchError::IndexEmpty);
662 }
663 let expected_dim = self.dimension.unwrap_or(query.len());
664 if query.len() != expected_dim {
665 return Err(SearchError::DimensionMismatch {
666 expected: expected_dim,
667 got: query.len(),
668 });
669 }
670
671 let expanded_meta = self.expand_query(query, context, config);
673 let mut effective_query = expanded_meta.expanded.clone();
674
675 if config.use_negative_examples {
677 self.suppress_negatives(&mut effective_query, context);
678 }
679
680 let rerank_n = config.rerank_top_n.min(self.documents.len());
682 let mut scored: Vec<(String, f64)> = self
683 .doc_order
684 .iter()
685 .filter_map(|id| {
686 let doc = self.documents.get(id)?;
687 let sim = cosine_similarity(&effective_query, &doc.embedding);
688 if sim >= config.min_relevance {
689 Some((id.clone(), sim))
690 } else {
691 None
692 }
693 })
694 .collect();
695 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
696 scored.truncate(rerank_n);
697
698 if scored.is_empty() {
699 return Err(SearchError::InsufficientResults(0));
700 }
701
702 let candidates_owned: Vec<(String, f64, Vec<f64>)> = scored
704 .iter()
705 .map(|(id, rel)| {
706 let emb = self
707 .documents
708 .get(id)
709 .map(|d| d.embedding.clone())
710 .unwrap_or_default();
711 (id.clone(), *rel, emb)
712 })
713 .collect();
714 let candidates: Vec<(String, f64, &[f64])> = candidates_owned
715 .iter()
716 .map(|(id, rel, emb)| (id.as_str().to_owned(), *rel, emb.as_slice()))
717 .collect();
718
719 let top_k = config.top_k.min(candidates.len());
720
721 let relevance_before: Vec<f64> = candidates.iter().take(top_k).map(|c| c.1).collect();
723
724 let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_1337u64;
725 let reranked: Vec<(String, f64, f64)> = match &config.diversity_strategy {
726 DiversityStrategy::MaxMarginalRelevance(lambda) => {
727 Self::mmr_rerank(&candidates, top_k, *lambda)
728 }
729 DiversityStrategy::GreedyDiversify(min_dist) => {
730 Self::greedy_diversify_rerank(&candidates, top_k, *min_dist)
731 }
732 DiversityStrategy::DeterminantalPointProcess => {
733 Self::dpp_rerank(&candidates, top_k, &mut rng_state)
734 }
735 DiversityStrategy::None => candidates
736 .iter()
737 .take(top_k)
738 .map(|(id, rel, emb)| {
739 let max_sim = candidates
740 .iter()
741 .filter(|(oid, _, _)| oid != id)
742 .take(top_k)
743 .map(|(_, _, oem)| cosine_similarity(emb, oem))
744 .fold(0.0_f64, f64::max);
745 let div = 1.0 - max_sim.clamp(0.0, 1.0);
746 (id.clone(), *rel, div)
747 })
748 .collect(),
749 };
750
751 let reranked_relevances: Vec<f64> = reranked.iter().map(|r| r.1).collect();
753 let order_changed = relevance_before
754 .iter()
755 .zip(reranked_relevances.iter())
756 .any(|(a, b)| (a - b).abs() > 1e-9);
757
758 let expansion_sim = cosine_similarity(query, &expanded_meta.expanded);
760
761 let results: Vec<ContextualResult> = reranked
762 .into_iter()
763 .enumerate()
764 .map(|(idx, (doc_id, relevance_score, diversity_score))| {
765 let final_score = (relevance_score + diversity_score) / 2.0;
766 let explanation = vec![
767 ("relevance".to_string(), relevance_score),
768 ("diversity".to_string(), diversity_score),
769 (
770 "expansion_alpha".to_string(),
771 expanded_meta.expansion_weight,
772 ),
773 ("expansion_sim".to_string(), expansion_sim),
774 ("history_weight".to_string(), expanded_meta.history_weight),
775 ];
776 ContextualResult {
777 doc_id,
778 relevance_score,
779 diversity_score,
780 final_score,
781 rank: idx + 1,
782 explanation,
783 }
784 })
785 .collect();
786
787 self.stats.queries_processed += 1;
789 let n = self.stats.queries_processed as f64;
790 self.stats.avg_expansion_similarity =
791 ((n - 1.0) * self.stats.avg_expansion_similarity + expansion_sim) / n;
792 if order_changed {
793 self.stats.diversity_gains += 1;
794 }
795
796 Ok(results)
797 }
798
799 pub fn update_context(&self, context: &mut SearchContext, query: &[f64], query_text: String) {
807 context.query_history.push(query_text);
808 context.query_embeddings.push(query.to_vec());
809 }
810
811 pub fn batch_search(
817 &mut self,
818 queries: &[Vec<f64>],
819 context: &SearchContext,
820 config: &SearchConfig,
821 ) -> Result<Vec<Vec<ContextualResult>>, SearchError> {
822 if queries.is_empty() {
823 return Ok(vec![]);
824 }
825 let mut all_results = Vec::with_capacity(queries.len());
826 for query in queries {
827 let results = self.search(query, context, config)?;
828 all_results.push(results);
829 }
830 Ok(all_results)
831 }
832
833 pub fn stats(&self) -> SearchStats {
839 self.stats.clone()
840 }
841
842 pub fn len(&self) -> usize {
844 self.documents.len()
845 }
846
847 pub fn is_empty(&self) -> bool {
849 self.documents.is_empty()
850 }
851
852 pub fn dimension(&self) -> Option<usize> {
854 self.dimension
855 }
856}
857
858impl Default for ContextualEmbeddingSearch {
859 fn default() -> Self {
860 Self::new()
861 }
862}
863
864fn mmr_score(
870 candidates: &[(String, f64, &[f64])],
871 i: usize,
872 selected: &[usize],
873 lambda: f64,
874) -> f64 {
875 let rel = candidates[i].1;
876 let max_sim = max_similarity_to_selected(candidates, i, selected);
877 lambda * rel - (1.0 - lambda) * max_sim
878}
879
880fn max_similarity_to_selected(
882 candidates: &[(String, f64, &[f64])],
883 i: usize,
884 selected: &[usize],
885) -> f64 {
886 if selected.is_empty() {
887 return 0.0;
888 }
889 selected
890 .iter()
891 .map(|&s| cosine_similarity(candidates[i].2, candidates[s].2))
892 .fold(f64::NEG_INFINITY, f64::max)
893 .max(0.0)
894}
895
896fn kernel_val(candidates: &[(String, f64, &[f64])], i: usize, j: usize) -> f64 {
898 let cos = cosine_similarity(candidates[i].2, candidates[j].2);
899 let cos_shifted = (cos + 1.0) / 2.0;
901 candidates[i].1 * cos_shifted * candidates[j].1
902}
903
904fn dpp_marginal(
906 candidates: &[(String, f64, &[f64])],
907 i: usize,
908 _selected: &[usize],
909 l: &[Vec<f64>],
910 step: usize,
911) -> f64 {
912 let k_ii = kernel_val(candidates, i, i);
913 let l_sq: f64 = (0..step).map(|t| l[i][t] * l[i][t]).sum();
914 (k_ii - l_sq).max(0.0)
915}
916
917#[cfg(test)]
922mod tests {
923 use super::*;
924
925 fn make_doc(id: &str, embedding: Vec<f64>) -> SearchDoc {
930 SearchDoc {
931 id: id.to_string(),
932 embedding,
933 metadata: vec![("key".to_string(), "val".to_string())],
934 }
935 }
936
937 fn uniform_index(n: usize, dim: usize) -> ContextualEmbeddingSearch {
938 let mut engine = ContextualEmbeddingSearch::new();
939 for i in 0..n {
940 let mut emb = vec![0.0f64; dim];
941 let angle = std::f64::consts::PI * 2.0 * (i as f64) / (n as f64);
943 emb[0] = angle.cos();
944 if dim > 1 {
945 emb[1] = angle.sin();
946 }
947 engine
948 .add_document(make_doc(&format!("doc{i}"), emb))
949 .expect("test: add_document should succeed for uniform index doc");
950 }
951 engine
952 }
953
954 fn default_context() -> SearchContext {
955 SearchContext::new("test-session", 5)
956 }
957
958 fn default_config() -> SearchConfig {
959 SearchConfig {
960 top_k: 5,
961 rerank_top_n: 20,
962 ..Default::default()
963 }
964 }
965
966 #[test]
971 fn test_cosine_similarity_identical() {
972 let v = vec![1.0, 2.0, 3.0];
973 let sim = cosine_similarity(&v, &v);
974 assert!((sim - 1.0).abs() < 1e-9, "identical vectors: {sim}");
975 }
976
977 #[test]
978 fn test_cosine_similarity_orthogonal() {
979 let a = vec![1.0, 0.0];
980 let b = vec![0.0, 1.0];
981 assert!((cosine_similarity(&a, &b)).abs() < 1e-9);
982 }
983
984 #[test]
985 fn test_cosine_similarity_opposite() {
986 let a = vec![1.0, 0.0];
987 let b = vec![-1.0, 0.0];
988 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-9);
989 }
990
991 #[test]
992 fn test_cosine_similarity_zero_vector() {
993 let a = vec![0.0, 0.0];
994 let b = vec![1.0, 2.0];
995 assert_eq!(cosine_similarity(&a, &b), 0.0);
996 }
997
998 #[test]
999 fn test_cosine_similarity_length_mismatch() {
1000 let a = vec![1.0, 2.0];
1001 let b = vec![1.0];
1002 assert_eq!(cosine_similarity(&a, &b), 0.0);
1003 }
1004
1005 #[test]
1006 fn test_weighted_sum_single() {
1007 let v = vec![1.0, 2.0, 3.0];
1008 let result = weighted_sum(&[(&v, 2.0)]);
1009 assert_eq!(result, vec![2.0, 4.0, 6.0]);
1010 }
1011
1012 #[test]
1013 fn test_weighted_sum_two() {
1014 let a = vec![1.0, 0.0];
1015 let b = vec![0.0, 1.0];
1016 let result = weighted_sum(&[(&a, 0.5), (&b, 0.5)]);
1017 assert!((result[0] - 0.5).abs() < 1e-9);
1018 assert!((result[1] - 0.5).abs() < 1e-9);
1019 }
1020
1021 #[test]
1022 fn test_weighted_sum_empty() {
1023 let result = weighted_sum(&[] as &[(&[f64], f64)]);
1024 assert!(result.is_empty());
1025 }
1026
1027 #[test]
1032 fn test_add_document_sets_dimension() {
1033 let mut engine = ContextualEmbeddingSearch::new();
1034 engine
1035 .add_document(make_doc("a", vec![1.0, 2.0]))
1036 .expect("test: add_document should succeed for first doc");
1037 assert_eq!(engine.dimension(), Some(2));
1038 }
1039
1040 #[test]
1041 fn test_add_document_dimension_mismatch() {
1042 let mut engine = ContextualEmbeddingSearch::new();
1043 engine
1044 .add_document(make_doc("a", vec![1.0, 2.0]))
1045 .expect("test: add_document should succeed for initial doc");
1046 let err = engine
1047 .add_document(make_doc("b", vec![1.0]))
1048 .expect_err("test: dimension mismatch should produce an error");
1049 assert_eq!(
1050 err,
1051 SearchError::DimensionMismatch {
1052 expected: 2,
1053 got: 1
1054 }
1055 );
1056 }
1057
1058 #[test]
1059 fn test_add_duplicate_overwrites() {
1060 let mut engine = ContextualEmbeddingSearch::new();
1061 engine
1062 .add_document(make_doc("a", vec![1.0, 0.0]))
1063 .expect("test: add_document should succeed for first insert");
1064 engine
1065 .add_document(make_doc("a", vec![0.0, 1.0]))
1066 .expect("test: add_document should succeed for duplicate overwrite");
1067 assert_eq!(engine.len(), 1);
1068 }
1069
1070 #[test]
1071 fn test_remove_document() {
1072 let mut engine = ContextualEmbeddingSearch::new();
1073 engine
1074 .add_document(make_doc("a", vec![1.0, 0.0]))
1075 .expect("test: add_document should succeed before remove");
1076 engine
1077 .remove_document("a")
1078 .expect("test: remove_document should succeed for existing doc");
1079 assert_eq!(engine.len(), 0);
1080 }
1081
1082 #[test]
1083 fn test_remove_nonexistent() {
1084 let mut engine = ContextualEmbeddingSearch::new();
1085 let err = engine
1086 .remove_document("ghost")
1087 .expect_err("test: removing nonexistent doc should fail");
1088 matches!(err, SearchError::ConfigurationError(_));
1089 }
1090
1091 #[test]
1092 fn test_add_empty_embedding() {
1093 let mut engine = ContextualEmbeddingSearch::new();
1094 let err = engine
1095 .add_document(make_doc("empty", vec![]))
1096 .expect_err("test: empty embedding should produce an error");
1097 matches!(err, SearchError::ConfigurationError(_));
1098 }
1099
1100 #[test]
1101 fn test_is_empty_initially() {
1102 let engine = ContextualEmbeddingSearch::new();
1103 assert!(engine.is_empty());
1104 }
1105
1106 #[test]
1107 fn test_len_after_adds() {
1108 let mut engine = ContextualEmbeddingSearch::new();
1109 for i in 0..5 {
1110 engine
1111 .add_document(make_doc(&format!("d{i}"), vec![i as f64, 0.0]))
1112 .expect("test: add_document should succeed for each doc in loop");
1113 }
1114 assert_eq!(engine.len(), 5);
1115 }
1116
1117 #[test]
1122 fn test_search_empty_index() {
1123 let mut engine = ContextualEmbeddingSearch::new();
1124 let ctx = default_context();
1125 let cfg = default_config();
1126 let err = engine
1127 .search(&[1.0, 0.0], &ctx, &cfg)
1128 .expect_err("test: search on empty index should fail");
1129 assert_eq!(err, SearchError::IndexEmpty);
1130 }
1131
1132 #[test]
1133 fn test_search_returns_top_k() {
1134 let mut engine = uniform_index(10, 2);
1135 let ctx = default_context();
1136 let cfg = SearchConfig {
1137 top_k: 3,
1138 rerank_top_n: 10,
1139 expansion_alpha: 0.0,
1140 diversity_strategy: DiversityStrategy::None,
1141 ..Default::default()
1142 };
1143 let results = engine
1144 .search(&[1.0, 0.0], &ctx, &cfg)
1145 .expect("test: search should succeed and return results");
1146 assert_eq!(results.len(), 3);
1147 }
1148
1149 #[test]
1150 fn test_search_ranks_are_sequential() {
1151 let mut engine = uniform_index(5, 2);
1152 let ctx = default_context();
1153 let cfg = SearchConfig {
1154 top_k: 5,
1155 rerank_top_n: 5,
1156 expansion_alpha: 0.0,
1157 diversity_strategy: DiversityStrategy::None,
1158 ..Default::default()
1159 };
1160 let results = engine
1161 .search(&[1.0, 0.0], &ctx, &cfg)
1162 .expect("test: search should succeed returning ranked results");
1163 for (i, r) in results.iter().enumerate() {
1164 assert_eq!(r.rank, i + 1);
1165 }
1166 }
1167
1168 #[test]
1169 fn test_search_query_dimension_mismatch() {
1170 let mut engine = uniform_index(3, 3);
1171 let ctx = default_context();
1172 let cfg = default_config();
1173 let err = engine
1174 .search(&[1.0, 0.0], &ctx, &cfg)
1175 .expect_err("test: mismatched query dimension should fail");
1176 assert_eq!(
1177 err,
1178 SearchError::DimensionMismatch {
1179 expected: 3,
1180 got: 2
1181 }
1182 );
1183 }
1184
1185 #[test]
1186 fn test_search_config_top_k_zero() {
1187 let mut engine = uniform_index(3, 2);
1188 let ctx = default_context();
1189 let cfg = SearchConfig {
1190 top_k: 0,
1191 ..Default::default()
1192 };
1193 let err = engine
1194 .search(&[1.0, 0.0], &ctx, &cfg)
1195 .expect_err("test: top_k=0 config should fail");
1196 matches!(err, SearchError::ConfigurationError(_));
1197 }
1198
1199 #[test]
1200 fn test_search_config_rerank_top_n_zero() {
1201 let mut engine = uniform_index(3, 2);
1202 let ctx = default_context();
1203 let cfg = SearchConfig {
1204 rerank_top_n: 0,
1205 ..Default::default()
1206 };
1207 let err = engine
1208 .search(&[1.0, 0.0], &ctx, &cfg)
1209 .expect_err("test: rerank_top_n=0 config should fail");
1210 matches!(err, SearchError::ConfigurationError(_));
1211 }
1212
1213 #[test]
1214 fn test_search_top_k_capped_at_index_size() {
1215 let mut engine = uniform_index(3, 2);
1216 let ctx = default_context();
1217 let cfg = SearchConfig {
1218 top_k: 100,
1219 rerank_top_n: 100,
1220 expansion_alpha: 0.0,
1221 diversity_strategy: DiversityStrategy::None,
1222 ..Default::default()
1223 };
1224 let results = engine
1225 .search(&[1.0, 0.0], &ctx, &cfg)
1226 .expect("test: search should succeed with top_k capped at index size");
1227 assert!(results.len() <= 3);
1228 }
1229
1230 #[test]
1231 fn test_search_best_result_is_most_similar() {
1232 let mut engine = ContextualEmbeddingSearch::new();
1233 engine
1234 .add_document(make_doc("close", vec![1.0, 0.0]))
1235 .expect("test: add_document should succeed for close doc");
1236 engine
1237 .add_document(make_doc("far", vec![-1.0, 0.0]))
1238 .expect("test: add_document should succeed for far doc");
1239 let ctx = default_context();
1240 let cfg = SearchConfig {
1241 top_k: 2,
1242 rerank_top_n: 2,
1243 expansion_alpha: 0.0,
1244 diversity_strategy: DiversityStrategy::None,
1245 min_relevance: -1.0,
1246 ..Default::default()
1247 };
1248 let results = engine
1249 .search(&[1.0, 0.0], &ctx, &cfg)
1250 .expect("test: search should succeed returning best result");
1251 assert_eq!(results[0].doc_id, "close");
1252 }
1253
1254 #[test]
1255 fn test_search_min_relevance_filters() {
1256 let mut engine = uniform_index(8, 2);
1257 let ctx = default_context();
1258 let cfg = SearchConfig {
1259 top_k: 10,
1260 rerank_top_n: 10,
1261 expansion_alpha: 0.0,
1262 diversity_strategy: DiversityStrategy::None,
1263 min_relevance: 0.9,
1264 ..Default::default()
1265 };
1266 let results = engine.search(&[1.0, 0.0], &ctx, &cfg);
1267 match results {
1269 Ok(r) => {
1270 for res in &r {
1271 assert!(res.relevance_score >= 0.9 - 1e-6);
1272 }
1273 }
1274 Err(SearchError::InsufficientResults(_)) => {}
1275 Err(e) => panic!("unexpected error: {e}"),
1276 }
1277 }
1278
1279 #[test]
1284 fn test_expansion_no_alpha() {
1285 let engine = ContextualEmbeddingSearch::new();
1286 let ctx = default_context();
1287 let cfg = SearchConfig {
1288 expansion_alpha: 0.0,
1289 ..Default::default()
1290 };
1291 let eq = engine.expand_query(&[1.0, 0.0], &ctx, &cfg);
1292 assert_eq!(eq.original, vec![1.0, 0.0]);
1293 assert_eq!(eq.expanded, vec![1.0, 0.0]);
1294 assert!((eq.expansion_weight).abs() < 1e-9);
1295 }
1296
1297 #[test]
1298 fn test_expansion_shifts_query_toward_history() {
1299 let mut ctx = SearchContext::new("s", 5);
1300 let engine = ContextualEmbeddingSearch::new();
1301 ctx.query_embeddings.push(vec![0.0, 1.0]);
1303 ctx.query_embeddings.push(vec![0.0, 1.0]);
1304 let cfg = SearchConfig {
1305 expansion_alpha: 0.5,
1306 ..Default::default()
1307 };
1308 let eq = engine.expand_query(&[1.0, 0.0], &ctx, &cfg);
1309 assert!(
1311 eq.expanded[1] > 0.01,
1312 "expected y > 0, got {:?}",
1313 eq.expanded
1314 );
1315 }
1316
1317 #[test]
1318 fn test_expansion_with_positive_examples() {
1319 let mut ctx = SearchContext::new("s", 5);
1320 let engine = ContextualEmbeddingSearch::new();
1321 ctx.positive_examples.push(vec![0.0, 1.0]);
1322 let cfg = SearchConfig {
1323 expansion_alpha: 0.5,
1324 ..Default::default()
1325 };
1326 let eq = engine.expand_query(&[1.0, 0.0], &ctx, &cfg);
1327 assert!(eq.expanded[1] > 0.0);
1329 }
1330
1331 #[test]
1332 fn test_expansion_weight_stored() {
1333 let engine = ContextualEmbeddingSearch::new();
1334 let ctx = default_context();
1335 let cfg = SearchConfig {
1336 expansion_alpha: 0.4,
1337 ..Default::default()
1338 };
1339 let eq = engine.expand_query(&[1.0, 0.0], &ctx, &cfg);
1340 assert!((eq.expansion_weight - 0.4).abs() < 1e-9);
1341 }
1342
1343 #[test]
1344 fn test_expansion_history_weight_zero_when_no_history() {
1345 let engine = ContextualEmbeddingSearch::new();
1346 let ctx = default_context();
1347 let cfg = SearchConfig {
1348 expansion_alpha: 0.5,
1349 ..Default::default()
1350 };
1351 let eq = engine.expand_query(&[1.0, 0.0], &ctx, &cfg);
1352 assert!((eq.history_weight).abs() < 1e-9);
1353 }
1354
1355 #[test]
1360 fn test_negative_suppression_reduces_projection() {
1361 let engine = ContextualEmbeddingSearch::new();
1362 let mut ctx = SearchContext::new("s", 5);
1363 ctx.negative_examples.push(vec![0.0, 1.0]);
1364
1365 let mut query = vec![0.5, 0.5];
1366 normalize_in_place(&mut query);
1367 let original_y = query[1];
1368 engine.suppress_negatives(&mut query, &ctx);
1369 assert!(
1370 query[1] < original_y,
1371 "Y component should decrease after suppression"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_negative_suppression_no_effect_orthogonal() {
1377 let engine = ContextualEmbeddingSearch::new();
1378 let mut ctx = SearchContext::new("s", 5);
1379 ctx.negative_examples.push(vec![0.0, 1.0]);
1381
1382 let mut query = vec![1.0, 0.0];
1383 engine.suppress_negatives(&mut query, &ctx);
1384 assert!((query[0] - 1.0).abs() < 1e-6);
1385 assert!(query[1].abs() < 1e-6);
1386 }
1387
1388 #[test]
1389 fn test_negative_suppression_uses_config() {
1390 let mut engine = uniform_index(5, 2);
1391 let mut ctx = SearchContext::new("s", 5);
1392 ctx.negative_examples.push(vec![-1.0, 0.0]); let cfg_with = SearchConfig {
1394 use_negative_examples: true,
1395 expansion_alpha: 0.0,
1396 top_k: 5,
1397 rerank_top_n: 5,
1398 diversity_strategy: DiversityStrategy::None,
1399 min_relevance: -1.0,
1400 };
1401 let cfg_without = SearchConfig {
1402 use_negative_examples: false,
1403 ..cfg_with.clone()
1404 };
1405 engine
1407 .search(&[1.0, 0.0], &ctx, &cfg_with)
1408 .expect("test: search with negative examples enabled should succeed");
1409 engine
1410 .search(&[1.0, 0.0], &ctx, &cfg_without)
1411 .expect("test: search with negative examples disabled should succeed");
1412 }
1413
1414 #[test]
1419 fn test_diversity_none_sorted_by_relevance() {
1420 let mut engine = uniform_index(6, 2);
1421 let ctx = default_context();
1422 let cfg = SearchConfig {
1423 top_k: 4,
1424 rerank_top_n: 6,
1425 expansion_alpha: 0.0,
1426 diversity_strategy: DiversityStrategy::None,
1427 ..Default::default()
1428 };
1429 let results = engine
1430 .search(&[1.0, 0.0], &ctx, &cfg)
1431 .expect("test: search with None strategy should succeed");
1432 for w in results.windows(2) {
1433 assert!(
1434 w[0].relevance_score >= w[1].relevance_score - 1e-9,
1435 "not sorted by relevance"
1436 );
1437 }
1438 }
1439
1440 #[test]
1445 fn test_mmr_lambda_1_is_pure_relevance() {
1446 let mut engine = uniform_index(8, 2);
1448 let ctx = default_context();
1449 let mk = |strategy| SearchConfig {
1450 top_k: 4,
1451 rerank_top_n: 8,
1452 expansion_alpha: 0.0,
1453 diversity_strategy: strategy,
1454 ..Default::default()
1455 };
1456 let r_none = engine
1457 .search(&[1.0, 0.0], &ctx, &mk(DiversityStrategy::None))
1458 .expect("test: search with None diversity should succeed");
1459 let r_mmr = engine
1460 .search(
1461 &[1.0, 0.0],
1462 &ctx,
1463 &mk(DiversityStrategy::MaxMarginalRelevance(1.0)),
1464 )
1465 .expect("test: search with MMR lambda=1 should succeed");
1466 assert_eq!(r_none[0].doc_id, r_mmr[0].doc_id);
1468 }
1469
1470 #[test]
1471 fn test_mmr_lambda_0_maximises_diversity() {
1472 let mut engine = uniform_index(10, 2);
1473 let ctx = default_context();
1474 let cfg = SearchConfig {
1475 top_k: 5,
1476 rerank_top_n: 10,
1477 expansion_alpha: 0.0,
1478 diversity_strategy: DiversityStrategy::MaxMarginalRelevance(0.0),
1479 ..Default::default()
1480 };
1481 let results = engine
1482 .search(&[1.0, 0.0], &ctx, &cfg)
1483 .expect("test: MMR lambda=0 search should succeed");
1484 assert_eq!(results.len(), 5);
1485 }
1486
1487 #[test]
1488 fn test_mmr_diversity_scores_present() {
1489 let mut engine = uniform_index(10, 2);
1490 let ctx = default_context();
1491 let cfg = SearchConfig {
1492 top_k: 5,
1493 rerank_top_n: 10,
1494 expansion_alpha: 0.0,
1495 diversity_strategy: DiversityStrategy::MaxMarginalRelevance(0.5),
1496 ..Default::default()
1497 };
1498 let results = engine
1499 .search(&[1.0, 0.0], &ctx, &cfg)
1500 .expect("test: MMR search should succeed returning diversity scores");
1501 for r in &results {
1502 assert!(r.diversity_score >= 0.0 && r.diversity_score <= 1.0 + 1e-6);
1503 }
1504 }
1505
1506 #[test]
1507 fn test_mmr_correct_first_pick() {
1508 let mut engine = ContextualEmbeddingSearch::new();
1510 engine
1511 .add_document(make_doc("best", vec![1.0, 0.0]))
1512 .expect("test: add_document should succeed for best doc");
1513 engine
1514 .add_document(make_doc("second", vec![0.7, 0.7]))
1515 .expect("test: add_document should succeed for second doc");
1516 engine
1517 .add_document(make_doc("third", vec![-1.0, 0.0]))
1518 .expect("test: add_document should succeed for third doc");
1519 let ctx = default_context();
1520 let cfg = SearchConfig {
1521 top_k: 3,
1522 rerank_top_n: 3,
1523 expansion_alpha: 0.0,
1524 diversity_strategy: DiversityStrategy::MaxMarginalRelevance(0.5),
1525 min_relevance: -1.0,
1526 ..Default::default()
1527 };
1528 let results = engine
1529 .search(&[1.0, 0.0], &ctx, &cfg)
1530 .expect("test: MMR search should succeed identifying best first pick");
1531 assert_eq!(results[0].doc_id, "best");
1532 }
1533
1534 #[test]
1535 fn test_mmr_single_doc() {
1536 let mut engine = ContextualEmbeddingSearch::new();
1537 engine
1538 .add_document(make_doc("only", vec![1.0, 0.0]))
1539 .expect("test: add_document should succeed for single doc");
1540 let ctx = default_context();
1541 let cfg = SearchConfig {
1542 top_k: 1,
1543 rerank_top_n: 1,
1544 expansion_alpha: 0.0,
1545 diversity_strategy: DiversityStrategy::MaxMarginalRelevance(0.5),
1546 ..Default::default()
1547 };
1548 let results = engine
1549 .search(&[1.0, 0.0], &ctx, &cfg)
1550 .expect("test: MMR search with single doc should succeed");
1551 assert_eq!(results.len(), 1);
1552 }
1553
1554 #[test]
1559 fn test_greedy_diversify_basic() {
1560 let mut engine = uniform_index(10, 2);
1561 let ctx = default_context();
1562 let cfg = SearchConfig {
1563 top_k: 5,
1564 rerank_top_n: 10,
1565 expansion_alpha: 0.0,
1566 diversity_strategy: DiversityStrategy::GreedyDiversify(0.1),
1567 ..Default::default()
1568 };
1569 let results = engine
1570 .search(&[1.0, 0.0], &ctx, &cfg)
1571 .expect("test: greedy diversify search should succeed");
1572 assert!(!results.is_empty());
1573 }
1574
1575 #[test]
1576 fn test_greedy_diversify_strict_threshold_backfills() {
1577 let mut engine = uniform_index(10, 2);
1579 let ctx = default_context();
1580 let cfg = SearchConfig {
1581 top_k: 5,
1582 rerank_top_n: 10,
1583 expansion_alpha: 0.0,
1584 diversity_strategy: DiversityStrategy::GreedyDiversify(999.0),
1585 ..Default::default()
1586 };
1587 let results = engine
1588 .search(&[1.0, 0.0], &ctx, &cfg)
1589 .expect("test: greedy diversify with strict threshold should succeed");
1590 assert_eq!(results.len(), 5);
1591 }
1592
1593 #[test]
1594 fn test_greedy_diversify_zero_threshold_like_none() {
1595 let mut engine = uniform_index(8, 2);
1596 let ctx = default_context();
1597 let cfg = SearchConfig {
1598 top_k: 4,
1599 rerank_top_n: 8,
1600 expansion_alpha: 0.0,
1601 diversity_strategy: DiversityStrategy::GreedyDiversify(0.0),
1602 ..Default::default()
1603 };
1604 let results = engine
1605 .search(&[1.0, 0.0], &ctx, &cfg)
1606 .expect("test: greedy diversify with zero threshold should succeed");
1607 assert_eq!(results.len(), 4);
1608 }
1609
1610 #[test]
1615 fn test_dpp_basic() {
1616 let mut engine = uniform_index(10, 2);
1617 let ctx = default_context();
1618 let cfg = SearchConfig {
1619 top_k: 5,
1620 rerank_top_n: 10,
1621 expansion_alpha: 0.0,
1622 diversity_strategy: DiversityStrategy::DeterminantalPointProcess,
1623 ..Default::default()
1624 };
1625 let results = engine
1626 .search(&[1.0, 0.0], &ctx, &cfg)
1627 .expect("test: DPP search should succeed");
1628 assert_eq!(results.len(), 5);
1629 }
1630
1631 #[test]
1632 fn test_dpp_scores_in_range() {
1633 let mut engine = uniform_index(10, 2);
1634 let ctx = default_context();
1635 let cfg = SearchConfig {
1636 top_k: 5,
1637 rerank_top_n: 10,
1638 expansion_alpha: 0.0,
1639 diversity_strategy: DiversityStrategy::DeterminantalPointProcess,
1640 ..Default::default()
1641 };
1642 let results = engine
1643 .search(&[1.0, 0.0], &ctx, &cfg)
1644 .expect("test: DPP search should succeed returning scored results");
1645 for r in &results {
1646 assert!((0.0..=1.0 + 1e-6).contains(&r.diversity_score));
1647 }
1648 }
1649
1650 #[test]
1651 fn test_dpp_single_doc() {
1652 let mut engine = ContextualEmbeddingSearch::new();
1653 engine
1654 .add_document(make_doc("a", vec![1.0, 0.0]))
1655 .expect("test: add_document should succeed for single DPP doc");
1656 let ctx = default_context();
1657 let cfg = SearchConfig {
1658 top_k: 1,
1659 rerank_top_n: 1,
1660 expansion_alpha: 0.0,
1661 diversity_strategy: DiversityStrategy::DeterminantalPointProcess,
1662 ..Default::default()
1663 };
1664 let results = engine
1665 .search(&[1.0, 0.0], &ctx, &cfg)
1666 .expect("test: DPP search with single doc should succeed");
1667 assert_eq!(results.len(), 1);
1668 }
1669
1670 #[test]
1675 fn test_final_score_is_average() {
1676 let mut engine = uniform_index(5, 2);
1677 let ctx = default_context();
1678 let cfg = SearchConfig {
1679 top_k: 3,
1680 rerank_top_n: 5,
1681 expansion_alpha: 0.0,
1682 diversity_strategy: DiversityStrategy::None,
1683 ..Default::default()
1684 };
1685 let results = engine
1686 .search(&[1.0, 0.0], &ctx, &cfg)
1687 .expect("test: search should succeed for final score verification");
1688 for r in &results {
1689 let expected = (r.relevance_score + r.diversity_score) / 2.0;
1690 assert!((r.final_score - expected).abs() < 1e-9);
1691 }
1692 }
1693
1694 #[test]
1695 fn test_explanation_contains_features() {
1696 let mut engine = uniform_index(5, 2);
1697 let ctx = default_context();
1698 let cfg = default_config();
1699 let results = engine
1700 .search(&[1.0, 0.0], &ctx, &cfg)
1701 .expect("test: search should succeed to check explanation features");
1702 let keys: Vec<&str> = results[0]
1703 .explanation
1704 .iter()
1705 .map(|(k, _)| k.as_str())
1706 .collect();
1707 assert!(keys.contains(&"relevance"));
1708 assert!(keys.contains(&"diversity"));
1709 assert!(keys.contains(&"expansion_alpha"));
1710 }
1711
1712 #[test]
1717 fn test_update_context_adds_history() {
1718 let engine = ContextualEmbeddingSearch::new();
1719 let mut ctx = default_context();
1720 engine.update_context(&mut ctx, &[1.0, 0.0], "first query".to_string());
1721 assert_eq!(ctx.query_history.len(), 1);
1722 assert_eq!(ctx.query_embeddings.len(), 1);
1723 }
1724
1725 #[test]
1726 fn test_update_context_multiple_queries() {
1727 let engine = ContextualEmbeddingSearch::new();
1728 let mut ctx = default_context();
1729 for i in 0..5 {
1730 engine.update_context(&mut ctx, &[i as f64, 0.0], format!("query {i}"));
1731 }
1732 assert_eq!(ctx.query_history.len(), 5);
1733 assert_eq!(ctx.query_embeddings.len(), 5);
1734 }
1735
1736 #[test]
1737 fn test_update_context_then_search_uses_history() {
1738 let mut engine = uniform_index(10, 2);
1739 let mut ctx = SearchContext::new("s", 5);
1740 let helper = ContextualEmbeddingSearch::new();
1741 helper.update_context(&mut ctx, &[0.0, 1.0], "q1".to_string());
1743 helper.update_context(&mut ctx, &[0.0, 1.0], "q2".to_string());
1744 let cfg = SearchConfig {
1745 top_k: 3,
1746 rerank_top_n: 10,
1747 expansion_alpha: 0.5,
1748 diversity_strategy: DiversityStrategy::None,
1749 ..Default::default()
1750 };
1751 engine
1753 .search(&[1.0, 0.0], &ctx, &cfg)
1754 .expect("test: search with expanded context history should succeed");
1755 }
1756
1757 #[test]
1762 fn test_batch_search_empty_queries() {
1763 let mut engine = uniform_index(5, 2);
1764 let ctx = default_context();
1765 let cfg = default_config();
1766 let results = engine
1767 .batch_search(&[], &ctx, &cfg)
1768 .expect("test: batch_search with empty queries should succeed");
1769 assert!(results.is_empty());
1770 }
1771
1772 #[test]
1773 fn test_batch_search_multiple_queries() {
1774 let mut engine = uniform_index(10, 2);
1775 let ctx = default_context();
1776 let cfg = SearchConfig {
1777 top_k: 3,
1778 rerank_top_n: 10,
1779 expansion_alpha: 0.0,
1780 diversity_strategy: DiversityStrategy::None,
1781 ..Default::default()
1782 };
1783 let queries = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![-1.0, 0.0]];
1784 let results = engine
1785 .batch_search(&queries, &ctx, &cfg)
1786 .expect("test: batch_search with multiple queries should succeed");
1787 assert_eq!(results.len(), 3);
1788 for r in &results {
1789 assert_eq!(r.len(), 3);
1790 }
1791 }
1792
1793 #[test]
1794 fn test_batch_search_propagates_error() {
1795 let mut engine = ContextualEmbeddingSearch::new();
1796 let ctx = default_context();
1797 let cfg = default_config();
1798 let queries = vec![vec![1.0, 0.0]];
1799 let err = engine
1800 .batch_search(&queries, &ctx, &cfg)
1801 .expect_err("test: batch_search on empty index should fail");
1802 assert_eq!(err, SearchError::IndexEmpty);
1803 }
1804
1805 #[test]
1806 fn test_batch_search_independent_results() {
1807 let mut engine = uniform_index(10, 2);
1808 let ctx = default_context();
1809 let cfg = SearchConfig {
1810 top_k: 3,
1811 rerank_top_n: 10,
1812 expansion_alpha: 0.0,
1813 diversity_strategy: DiversityStrategy::None,
1814 ..Default::default()
1815 };
1816 let q1 = vec![1.0, 0.0];
1817 let q2 = vec![-1.0, 0.0];
1818 let batch = engine
1819 .batch_search(&[q1.clone(), q2.clone()], &ctx, &cfg)
1820 .expect("test: batch_search should succeed for independent results");
1821 let single1 = engine
1822 .search(&q1, &ctx, &cfg)
1823 .expect("test: single search should succeed for comparison");
1824 assert_eq!(batch[0][0].doc_id, single1[0].doc_id);
1826 }
1827
1828 #[test]
1833 fn test_stats_initial_zero() {
1834 let engine = ContextualEmbeddingSearch::new();
1835 let s = engine.stats();
1836 assert_eq!(s.queries_processed, 0);
1837 assert_eq!(s.cache_hits, 0);
1838 }
1839
1840 #[test]
1841 fn test_stats_queries_processed_increments() {
1842 let mut engine = uniform_index(5, 2);
1843 let ctx = default_context();
1844 let cfg = SearchConfig {
1845 top_k: 3,
1846 rerank_top_n: 5,
1847 expansion_alpha: 0.0,
1848 diversity_strategy: DiversityStrategy::None,
1849 ..Default::default()
1850 };
1851 engine
1852 .search(&[1.0, 0.0], &ctx, &cfg)
1853 .expect("test: first search should succeed for stats tracking");
1854 engine
1855 .search(&[0.0, 1.0], &ctx, &cfg)
1856 .expect("test: second search should succeed for stats tracking");
1857 assert_eq!(engine.stats().queries_processed, 2);
1858 }
1859
1860 #[test]
1861 fn test_stats_avg_expansion_similarity_updates() {
1862 let mut engine = uniform_index(5, 2);
1863 let ctx = default_context();
1864 let cfg = SearchConfig {
1865 top_k: 3,
1866 rerank_top_n: 5,
1867 expansion_alpha: 0.0,
1868 diversity_strategy: DiversityStrategy::None,
1869 ..Default::default()
1870 };
1871 engine
1872 .search(&[1.0, 0.0], &ctx, &cfg)
1873 .expect("test: search should succeed for avg expansion similarity check");
1874 assert!((engine.stats().avg_expansion_similarity - 1.0).abs() < 1e-6);
1876 }
1877
1878 #[test]
1879 fn test_stats_batch_updates_correctly() {
1880 let mut engine = uniform_index(5, 2);
1881 let ctx = default_context();
1882 let cfg = SearchConfig {
1883 top_k: 3,
1884 rerank_top_n: 5,
1885 expansion_alpha: 0.0,
1886 diversity_strategy: DiversityStrategy::None,
1887 ..Default::default()
1888 };
1889 let queries: Vec<Vec<f64>> = vec![vec![1.0, 0.0]; 4];
1890 engine
1891 .batch_search(&queries, &ctx, &cfg)
1892 .expect("test: batch_search should succeed for stats update check");
1893 assert_eq!(engine.stats().queries_processed, 4);
1894 }
1895
1896 #[test]
1901 fn test_error_display_index_empty() {
1902 let e = SearchError::IndexEmpty;
1903 assert!(!e.to_string().is_empty());
1904 }
1905
1906 #[test]
1907 fn test_error_display_dimension_mismatch() {
1908 let e = SearchError::DimensionMismatch {
1909 expected: 3,
1910 got: 2,
1911 };
1912 assert!(e.to_string().contains('3'));
1913 assert!(e.to_string().contains('2'));
1914 }
1915
1916 #[test]
1917 fn test_error_display_insufficient_results() {
1918 let e = SearchError::InsufficientResults(5);
1919 assert!(e.to_string().contains('5'));
1920 }
1921
1922 #[test]
1923 fn test_error_display_configuration() {
1924 let e = SearchError::ConfigurationError("bad value".to_string());
1925 assert!(e.to_string().contains("bad value"));
1926 }
1927
1928 #[test]
1933 fn test_search_context_new() {
1934 let ctx = SearchContext::new("my-session", 10);
1935 assert_eq!(ctx.session_id, "my-session");
1936 assert_eq!(ctx.context_window, 10);
1937 assert!(ctx.query_history.is_empty());
1938 }
1939
1940 #[test]
1941 fn test_search_context_positive_negative() {
1942 let mut ctx = SearchContext::new("s", 5);
1943 ctx.positive_examples.push(vec![1.0, 0.0]);
1944 ctx.negative_examples.push(vec![-1.0, 0.0]);
1945 assert_eq!(ctx.positive_examples.len(), 1);
1946 assert_eq!(ctx.negative_examples.len(), 1);
1947 }
1948
1949 #[test]
1954 fn test_search_deterministic() {
1955 let mut engine = uniform_index(10, 2);
1956 let ctx = default_context();
1957 let cfg = SearchConfig {
1958 top_k: 5,
1959 rerank_top_n: 10,
1960 expansion_alpha: 0.0,
1961 diversity_strategy: DiversityStrategy::MaxMarginalRelevance(0.5),
1962 ..Default::default()
1963 };
1964 let r1 = engine
1965 .search(&[1.0, 0.0], &ctx, &cfg)
1966 .expect("test: first deterministic search should succeed");
1967 let r2 = engine
1968 .search(&[1.0, 0.0], &ctx, &cfg)
1969 .expect("test: second deterministic search should succeed");
1970 for (a, b) in r1.iter().zip(r2.iter()) {
1971 assert_eq!(a.doc_id, b.doc_id);
1972 }
1973 }
1974
1975 #[test]
1976 fn test_dpp_deterministic() {
1977 let mut engine = uniform_index(10, 2);
1978 let ctx = default_context();
1979 let cfg = SearchConfig {
1980 top_k: 5,
1981 rerank_top_n: 10,
1982 expansion_alpha: 0.0,
1983 diversity_strategy: DiversityStrategy::DeterminantalPointProcess,
1984 ..Default::default()
1985 };
1986 let r1 = engine
1987 .search(&[1.0, 0.0], &ctx, &cfg)
1988 .expect("test: first DPP deterministic search should succeed");
1989 let r2 = engine
1990 .search(&[1.0, 0.0], &ctx, &cfg)
1991 .expect("test: second DPP deterministic search should succeed");
1992 for (a, b) in r1.iter().zip(r2.iter()) {
1993 assert_eq!(a.doc_id, b.doc_id);
1994 }
1995 }
1996
1997 #[test]
2002 fn test_default_search_config() {
2003 let cfg = SearchConfig::default();
2004 assert_eq!(cfg.top_k, 10);
2005 assert!(cfg.use_negative_examples);
2006 }
2007
2008 #[test]
2009 fn test_default_contextual_embedding_search() {
2010 let engine = ContextualEmbeddingSearch::default();
2011 assert!(engine.is_empty());
2012 }
2013}