Skip to main content

ipfrs_semantic/
contextual_embedding_search.rs

1//! Contextual Embedding Search — context-aware vector search with query expansion,
2//! negative example suppression, diversity-aware re-ranking, and rich result explanations.
3//!
4//! # Overview
5//!
6//! [`ContextualEmbeddingSearch`] maintains an in-memory flat index of [`SearchDoc`]s and
7//! exposes a single [`ContextualEmbeddingSearch::search`] method that:
8//!
9//! 1. Expands the raw query embedding using recent query history and positive examples.
10//! 2. Optionally suppresses directions associated with negative examples.
11//! 3. Retrieves the top-`rerank_top_n` candidates via brute-force cosine similarity.
12//! 4. Re-ranks those candidates with one of four [`DiversityStrategy`] variants.
13//! 5. Returns up to `top_k` [`ContextualResult`]s with per-feature score explanations.
14
15use std::collections::HashMap;
16
17// ---------------------------------------------------------------------------
18// Pure-Rust vector math helpers
19// ---------------------------------------------------------------------------
20
21/// Cosine similarity in [-1, 1].  Returns 0 if either vector is zero-length.
22pub 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
36/// Euclidean distance between two vectors.
37fn 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
45/// Weighted sum of (slice, weight) pairs.  All slices must share the same length.
46pub 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
60/// Normalize a vector to unit length in-place.  No-op when the vector is near zero.
61fn 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
70// ---------------------------------------------------------------------------
71// Minimal xorshift PRNG (no rand crate)
72// ---------------------------------------------------------------------------
73
74fn 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// ---------------------------------------------------------------------------
88// Public types
89// ---------------------------------------------------------------------------
90
91/// Session context that shapes query expansion and result personalisation.
92#[derive(Debug, Clone)]
93pub struct SearchContext {
94    /// Unique session identifier (opaque string).
95    pub session_id: String,
96    /// Human-readable query texts in chronological order.
97    pub query_history: Vec<String>,
98    /// Positive example embeddings (documents the user liked).
99    pub positive_examples: Vec<Vec<f64>>,
100    /// Negative example embeddings (documents the user disliked).
101    pub negative_examples: Vec<Vec<f64>>,
102    /// How many recent queries (from the tail of `query_history`) affect expansion.
103    pub context_window: usize,
104    /// Embedding counterparts to `query_history` in chronological order.
105    pub(crate) query_embeddings: Vec<Vec<f64>>,
106}
107
108impl SearchContext {
109    /// Create a new, empty context for `session_id`.
110    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/// The expanded query produced by context-aware query expansion.
123///
124/// **Alias**: exported as `CesExpandedQuery` in `lib.rs` to avoid colliding with
125/// the already-public `ExpandedQuery` from `query_expander`.
126#[derive(Debug, Clone)]
127pub struct CesExpandedQuery {
128    /// The raw query embedding before expansion.
129    pub original: Vec<f64>,
130    /// Weighted combination of original + context.
131    pub expanded: Vec<f64>,
132    /// α — weight given to the context component (0 = no expansion).
133    pub expansion_weight: f64,
134    /// Weight given to recent history embeddings.
135    pub history_weight: f64,
136}
137
138/// Strategy for diversity-aware re-ranking.
139#[derive(Debug, Clone)]
140pub enum DiversityStrategy {
141    /// Maximal Marginal Relevance.  λ ∈ \[0,1\] trades off relevance vs. diversity.
142    MaxMarginalRelevance(f64),
143    /// Approximate Determinantal Point Process via greedy volume maximisation.
144    DeterminantalPointProcess,
145    /// Greedy diversity: add next best result only when it is far enough from all selected.
146    GreedyDiversify(f64),
147    /// No diversity re-ranking; sort purely by relevance score.
148    None,
149}
150
151/// A document in the search index.
152#[derive(Debug, Clone)]
153pub struct SearchDoc {
154    /// Unique document identifier.
155    pub id: String,
156    /// Dense embedding vector.
157    pub embedding: Vec<f64>,
158    /// Arbitrary key-value metadata.
159    pub metadata: Vec<(String, String)>,
160}
161
162/// A single result returned by [`ContextualEmbeddingSearch::search`].
163#[derive(Debug, Clone)]
164pub struct ContextualResult {
165    /// Document identifier.
166    pub doc_id: String,
167    /// Raw cosine similarity to the expanded query.
168    pub relevance_score: f64,
169    /// Diversity contribution (higher = more diverse relative to already-selected results).
170    pub diversity_score: f64,
171    /// Final score = (relevance + diversity) / 2.
172    pub final_score: f64,
173    /// 1-based rank among returned results.
174    pub rank: usize,
175    /// Explanation: list of (feature name, contribution value).
176    pub explanation: Vec<(String, f64)>,
177}
178
179/// Configuration for a single search call.
180#[derive(Debug, Clone)]
181pub struct SearchConfig {
182    /// Maximum number of results to return after diversity re-ranking.
183    pub top_k: usize,
184    /// Diversity strategy applied during re-ranking.
185    pub diversity_strategy: DiversityStrategy,
186    /// α ∈ \[0,1\] — how much weight is given to context expansion.  0 = no expansion.
187    pub expansion_alpha: f64,
188    /// Whether to suppress directions of negative examples.
189    pub use_negative_examples: bool,
190    /// Retrieve this many candidates before applying diversity re-ranking.
191    pub rerank_top_n: usize,
192    /// Minimum relevance score; candidates below this threshold are dropped.
193    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/// Running statistics for a [`ContextualEmbeddingSearch`] instance.
210#[derive(Debug, Clone, Default)]
211pub struct SearchStats {
212    /// Total number of search calls completed.
213    pub queries_processed: u64,
214    /// Cumulative average cosine similarity between original and expanded query.
215    pub avg_expansion_similarity: f64,
216    /// Number of times diversity re-ranking changed the result order.
217    pub diversity_gains: u64,
218    /// Number of times a cached result set was returned (future use).
219    pub cache_hits: u64,
220}
221
222/// Errors that can be returned by [`ContextualEmbeddingSearch`].
223#[derive(Debug, Clone, PartialEq)]
224pub enum SearchError {
225    /// The index contains no documents.
226    IndexEmpty,
227    /// Query or document embedding has wrong dimensionality.
228    DimensionMismatch { expected: usize, got: usize },
229    /// Fewer results available than requested.
230    InsufficientResults(usize),
231    /// Bad configuration value.
232    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
252// ---------------------------------------------------------------------------
253// Core struct
254// ---------------------------------------------------------------------------
255
256/// Context-aware embedding search engine with query expansion, negative suppression,
257/// and diversity-aware re-ranking.
258pub struct ContextualEmbeddingSearch {
259    /// Document index: id → SearchDoc.
260    documents: HashMap<String, SearchDoc>,
261    /// Insertion-ordered document IDs for deterministic iteration.
262    doc_order: Vec<String>,
263    /// Expected embedding dimensionality (set on first insertion).
264    dimension: Option<usize>,
265    /// Accumulated statistics.
266    stats: SearchStats,
267}
268
269impl ContextualEmbeddingSearch {
270    /// Create an empty search engine.
271    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    // ------------------------------------------------------------------
281    // Index management
282    // ------------------------------------------------------------------
283
284    /// Add a document to the index.
285    ///
286    /// The first document sets the expected embedding dimension for all subsequent
287    /// insertions and queries.
288    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    /// Remove a document from the index by ID.
313    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    // ------------------------------------------------------------------
324    // Query helpers
325    // ------------------------------------------------------------------
326
327    /// Compute the expanded query embedding.
328    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        // Gather recent query embeddings within the context window.
346        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        // Compute history mean (if any recent embeddings match dimensionality).
358        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        // Incorporate positive examples (with lower weight).
375        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        // Blend: expanded = (1-α)*original + α*context.
393        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    /// Suppress directions corresponding to negative examples.
406    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        // Subtract the projection of the query onto each negative direction.
420        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            // Only suppress if projection is positive (i.e., moving toward the negative).
432            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    // ------------------------------------------------------------------
442    // Diversity re-ranking strategies
443    // ------------------------------------------------------------------
444
445    /// MMR: Maximal Marginal Relevance.
446    fn mmr_rerank(
447        candidates: &[(String, f64, &[f64])], // (id, relevance, embedding)
448        top_k: usize,
449        lambda: f64,
450    ) -> Vec<(String, f64, f64)> {
451        // (id, relevance, diversity_score)
452        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                // First pick: highest relevance.
459                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                // Subsequent picks: maximise MMR score.
471                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    /// Greedy diversify: skip candidates too close to already-selected ones.
500    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 we didn't fill top_k, backfill with closest remaining.
521        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    /// Approximate DPP via greedy volume (kernel matrix determinant) maximisation.
551    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        // Kernel: k(i,j) = relevance_i * cosine(i,j) * relevance_j
561        // Greedy selection: add item maximising marginal log-det contribution.
562        let mut selected: Vec<usize> = Vec::with_capacity(top_k);
563        let mut remaining: Vec<usize> = (0..n).collect();
564
565        // Cholesky-based incremental greedy DPP (simplified).
566        // We maintain L factor implicitly via dot products.
567        let mut l: Vec<Vec<f64>> = vec![vec![0.0; top_k]; n]; // l[i][step]
568
569        while selected.len() < top_k && !remaining.is_empty() {
570            let step = selected.len();
571            let best = if step == 0 {
572                // First: pick highest relevance with small random tie-breaking.
573                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                // Compute marginal gain for each remaining item.
584                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            // Update L factors for the chosen item.
596            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            // Update remaining items' L entries.
602            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    // ------------------------------------------------------------------
633    // Main search
634    // ------------------------------------------------------------------
635
636    /// Search the index.
637    ///
638    /// # Errors
639    ///
640    /// * [`SearchError::IndexEmpty`] — no documents indexed.
641    /// * [`SearchError::DimensionMismatch`] — query dimension doesn't match index.
642    /// * [`SearchError::ConfigurationError`] — `top_k == 0` or `rerank_top_n == 0`.
643    pub fn search(
644        &mut self,
645        query: &[f64],
646        context: &SearchContext,
647        config: &SearchConfig,
648    ) -> Result<Vec<ContextualResult>, SearchError> {
649        // Validate config.
650        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        // Validate index.
660        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        // 1. Query expansion.
672        let expanded_meta = self.expand_query(query, context, config);
673        let mut effective_query = expanded_meta.expanded.clone();
674
675        // 2. Negative example suppression.
676        if config.use_negative_examples {
677            self.suppress_negatives(&mut effective_query, context);
678        }
679
680        // 3. Brute-force initial retrieval.
681        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        // Build candidate slice referencing live embeddings.
703        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        // 4. Diversity re-ranking.
722        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        // Check whether diversity changed the order.
752        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        // 5. Build ContextualResult list.
759        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        // Update stats.
788        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    // ------------------------------------------------------------------
800    // Context update
801    // ------------------------------------------------------------------
802
803    /// Record a new query into `context`.
804    ///
805    /// Adds `query_text` to `query_history` and appends the corresponding embedding.
806    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    // ------------------------------------------------------------------
812    // Batch search
813    // ------------------------------------------------------------------
814
815    /// Execute multiple independent searches sharing the same context and config.
816    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    // ------------------------------------------------------------------
834    // Stats / accessors
835    // ------------------------------------------------------------------
836
837    /// Return accumulated search statistics.
838    pub fn stats(&self) -> SearchStats {
839        self.stats.clone()
840    }
841
842    /// Number of indexed documents.
843    pub fn len(&self) -> usize {
844        self.documents.len()
845    }
846
847    /// `true` if no documents are indexed.
848    pub fn is_empty(&self) -> bool {
849        self.documents.is_empty()
850    }
851
852    /// Current embedding dimensionality, or `None` if the index is empty.
853    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
864// ---------------------------------------------------------------------------
865// Private helpers used by re-ranking methods
866// ---------------------------------------------------------------------------
867
868/// MMR score for candidate `i` given already-selected items.
869fn 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
880/// Maximum cosine similarity between candidate `i` and any selected item.
881fn 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
896/// DPP kernel value: k(i,j) = rel_i * cos(i,j) * rel_j.
897fn kernel_val(candidates: &[(String, f64, &[f64])], i: usize, j: usize) -> f64 {
898    let cos = cosine_similarity(candidates[i].2, candidates[j].2);
899    // Shift cosine to [0,1] to keep kernel positive semi-definite.
900    let cos_shifted = (cos + 1.0) / 2.0;
901    candidates[i].1 * cos_shifted * candidates[j].1
902}
903
904/// Marginal gain of adding candidate `i` to the current DPP selection.
905fn 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// ---------------------------------------------------------------------------
918// Tests
919// ---------------------------------------------------------------------------
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    // -------------------------------------------------------------------
926    // Helpers
927    // -------------------------------------------------------------------
928
929    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            // Spread docs across different directions.
942            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    // -------------------------------------------------------------------
967    // Vector math
968    // -------------------------------------------------------------------
969
970    #[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    // -------------------------------------------------------------------
1028    // Index management
1029    // -------------------------------------------------------------------
1030
1031    #[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    // -------------------------------------------------------------------
1118    // Basic search
1119    // -------------------------------------------------------------------
1120
1121    #[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        // Either returns some high-sim results or InsufficientResults — both OK.
1268        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    // -------------------------------------------------------------------
1280    // Query expansion
1281    // -------------------------------------------------------------------
1282
1283    #[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        // History: queries pointing in (0,1) direction.
1302        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        // Expanded should have a positive Y component now.
1310        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        // Positive example in Y direction should increase Y component.
1328        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    // -------------------------------------------------------------------
1356    // Negative example suppression
1357    // -------------------------------------------------------------------
1358
1359    #[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        // Query is (1,0), negative is (0,1) — orthogonal; suppression should be a no-op.
1380        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]); // push away from -X
1393        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        // Both should succeed; results may differ.
1406        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    // -------------------------------------------------------------------
1415    // DiversityStrategy::None
1416    // -------------------------------------------------------------------
1417
1418    #[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    // -------------------------------------------------------------------
1441    // DiversityStrategy::MaxMarginalRelevance
1442    // -------------------------------------------------------------------
1443
1444    #[test]
1445    fn test_mmr_lambda_1_is_pure_relevance() {
1446        // λ=1 → MMR = relevance, should produce same order as None.
1447        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        // First result should be the same.
1467        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        // First pick in MMR must be the highest-relevance doc.
1509        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    // -------------------------------------------------------------------
1555    // DiversityStrategy::GreedyDiversify
1556    // -------------------------------------------------------------------
1557
1558    #[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        // With a very large min_dist, we still get top_k results (backfill).
1578        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    // -------------------------------------------------------------------
1611    // DiversityStrategy::DeterminantalPointProcess
1612    // -------------------------------------------------------------------
1613
1614    #[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    // -------------------------------------------------------------------
1671    // Final score and explanation
1672    // -------------------------------------------------------------------
1673
1674    #[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    // -------------------------------------------------------------------
1713    // Context update
1714    // -------------------------------------------------------------------
1715
1716    #[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        // Add history pointing to (0,1).
1742        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        // Should succeed without error.
1752        engine
1753            .search(&[1.0, 0.0], &ctx, &cfg)
1754            .expect("test: search with expanded context history should succeed");
1755    }
1756
1757    // -------------------------------------------------------------------
1758    // Batch search
1759    // -------------------------------------------------------------------
1760
1761    #[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        // Batch and single should agree on first result IDs.
1825        assert_eq!(batch[0][0].doc_id, single1[0].doc_id);
1826    }
1827
1828    // -------------------------------------------------------------------
1829    // Stats
1830    // -------------------------------------------------------------------
1831
1832    #[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        // With alpha=0, expansion_sim should be 1.0.
1875        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    // -------------------------------------------------------------------
1897    // Error cases
1898    // -------------------------------------------------------------------
1899
1900    #[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    // -------------------------------------------------------------------
1929    // SearchContext helpers
1930    // -------------------------------------------------------------------
1931
1932    #[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    // -------------------------------------------------------------------
1950    // Determinism / reproducibility
1951    // -------------------------------------------------------------------
1952
1953    #[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    // -------------------------------------------------------------------
1998    // Default impls
1999    // -------------------------------------------------------------------
2000
2001    #[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}