Skip to main content

ipfrs_semantic/
diversifier.rs

1//! Maximal Marginal Relevance (MMR) diversification for semantic search results.
2//!
3//! This module implements the MMR algorithm which selects top-k results that
4//! balance relevance to the query with diversity among the selected items.
5//! The trade-off is controlled by a `lambda` parameter: 1.0 = pure relevance,
6//! 0.0 = pure diversity.
7
8// ---------------------------------------------------------------------------
9// Public types
10// ---------------------------------------------------------------------------
11
12/// A single candidate for diversified selection.
13#[derive(Clone, Debug)]
14pub struct DiversificationCandidate {
15    /// Opaque document identifier.
16    pub doc_id: u64,
17    /// Dense embedding vector for this document.
18    pub embedding: Vec<f32>,
19    /// Similarity score to the query (higher = more relevant).
20    pub relevance_score: f32,
21}
22
23/// Configuration for the MMR diversification algorithm.
24#[derive(Clone, Debug)]
25pub struct DiversifierConfig {
26    /// Trade-off between relevance and diversity.
27    /// - `1.0` = pure relevance (select by relevance_score only)
28    /// - `0.0` = pure diversity (select most different items)
29    /// - `0.5` = balanced (default)
30    pub lambda: f32,
31}
32
33impl Default for DiversifierConfig {
34    fn default() -> Self {
35        Self { lambda: 0.5 }
36    }
37}
38
39/// A single result from the diversified selection process.
40#[derive(Clone, Debug)]
41pub struct DiversifiedResult {
42    /// Opaque document identifier.
43    pub doc_id: u64,
44    /// Similarity score to the query (inherited from the candidate).
45    pub relevance_score: f32,
46    /// MMR score used for selection in this round.
47    pub mmr_score: f32,
48    /// 0-indexed order in which this document was selected.
49    pub selection_rank: usize,
50}
51
52/// Aggregate statistics across all `select` calls made on a
53/// [`SemanticDiversifier`] instance.
54#[derive(Clone, Debug, Default)]
55pub struct DiversifierStats {
56    /// Number of times [`SemanticDiversifier::select`] was called (runs that
57    /// actually produced at least one result count, as do empty-result calls).
58    pub total_runs: u64,
59    /// Total number of candidates processed across all runs (sum of the input
60    /// `candidates` lengths).
61    pub total_candidates_processed: u64,
62    /// Total number of results selected across all runs.
63    pub total_selected: u64,
64    /// Running mean of the `lambda` value used across all runs.
65    pub avg_lambda: f64,
66}
67
68// ---------------------------------------------------------------------------
69// Mathematical helpers
70// ---------------------------------------------------------------------------
71
72/// Cosine similarity between two vectors.
73///
74/// Returns `0.0` when:
75/// - Either slice is empty.
76/// - The slices have different lengths.
77/// - Either vector has zero magnitude.
78pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
79    if a.is_empty() || b.is_empty() || a.len() != b.len() {
80        return 0.0;
81    }
82    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
83    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
84    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
85    if norm_a == 0.0 || norm_b == 0.0 {
86        0.0
87    } else {
88        dot / (norm_a * norm_b)
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Core engine
94// ---------------------------------------------------------------------------
95
96/// Diversifies a ranked list of candidates using Maximal Marginal Relevance.
97///
98/// Each call to [`select`](SemanticDiversifier::select) greedily picks items
99/// that simultaneously maximise relevance to the query and minimise similarity
100/// to previously selected items.
101pub struct SemanticDiversifier {
102    /// MMR configuration (lambda, etc.).
103    pub config: DiversifierConfig,
104    /// Aggregate statistics accumulated across all `select` calls.
105    stats: DiversifierStats,
106}
107
108impl SemanticDiversifier {
109    /// Create a new diversifier with the given configuration.
110    pub fn new(config: DiversifierConfig) -> Self {
111        Self {
112            config,
113            stats: DiversifierStats::default(),
114        }
115    }
116
117    /// Select up to `k` diversified results from `candidates` using MMR.
118    ///
119    /// The algorithm greedily builds a selected set `S`, starting from empty.
120    /// Each iteration scores every remaining candidate with:
121    ///
122    /// ```text
123    /// mmr = lambda * relevance_score - (1 - lambda) * max_sim_to_selected
124    /// ```
125    ///
126    /// Ties are broken by `doc_id` ascending.
127    ///
128    /// Returns an empty `Vec` when `candidates` is empty or `k == 0`.
129    pub fn select(
130        &mut self,
131        _query: &[f32],
132        candidates: Vec<DiversificationCandidate>,
133        k: usize,
134    ) -> Vec<DiversifiedResult> {
135        // Record the run regardless of whether results are produced.
136        let n = candidates.len();
137        self.update_stats_pre(n as u64);
138
139        if candidates.is_empty() || k == 0 {
140            return Vec::new();
141        }
142
143        let target = k.min(n);
144        let lambda = self.config.lambda;
145
146        // Track which candidates are still available using a boolean mask.
147        let mut available: Vec<bool> = vec![true; n];
148        // Selected results in order of selection.
149        let mut selected: Vec<DiversifiedResult> = Vec::with_capacity(target);
150        // Embeddings of the already-selected documents (for similarity lookups).
151        let mut selected_embeddings: Vec<&[f32]> = Vec::with_capacity(target);
152
153        for rank in 0..target {
154            // --- Score every remaining candidate ---
155            let mut best_idx: Option<usize> = None;
156            let mut best_mmr = f32::NEG_INFINITY;
157            let mut best_doc_id: u64 = u64::MAX;
158
159            for (idx, candidate) in candidates.iter().enumerate() {
160                if !available[idx] {
161                    continue;
162                }
163
164                // Maximum similarity to any already-selected document.
165                let max_sim_to_selected: f32 = if selected_embeddings.is_empty() {
166                    0.0
167                } else {
168                    selected_embeddings
169                        .iter()
170                        .map(|s| cosine_similarity(&candidate.embedding, s))
171                        .fold(f32::NEG_INFINITY, f32::max)
172                };
173
174                let mmr = lambda * candidate.relevance_score - (1.0 - lambda) * max_sim_to_selected;
175
176                // Tie-breaking: prefer lower doc_id.
177                let is_better =
178                    mmr > best_mmr || (mmr == best_mmr && candidate.doc_id < best_doc_id);
179
180                if is_better {
181                    best_mmr = mmr;
182                    best_idx = Some(idx);
183                    best_doc_id = candidate.doc_id;
184                }
185            }
186
187            // `best_idx` is always `Some` here because there is at least one
188            // available candidate (we loop `target` times which is ≤ available count).
189            if let Some(idx) = best_idx {
190                available[idx] = false;
191                selected_embeddings.push(&candidates[idx].embedding);
192                selected.push(DiversifiedResult {
193                    doc_id: candidates[idx].doc_id,
194                    relevance_score: candidates[idx].relevance_score,
195                    mmr_score: best_mmr,
196                    selection_rank: rank,
197                });
198            }
199        }
200
201        let num_selected = selected.len() as u64;
202        self.update_stats_post(num_selected, lambda);
203
204        selected
205    }
206
207    /// Return a reference to the accumulated statistics.
208    pub fn stats(&self) -> &DiversifierStats {
209        &self.stats
210    }
211
212    /// Override the lambda trade-off parameter, clamped to `[0.0, 1.0]`.
213    pub fn set_lambda(&mut self, lambda: f32) {
214        self.config.lambda = lambda.clamp(0.0, 1.0);
215    }
216
217    // -----------------------------------------------------------------------
218    // Private helpers
219    // -----------------------------------------------------------------------
220
221    /// Record the start of a new run (candidates count).
222    fn update_stats_pre(&mut self, candidate_count: u64) {
223        self.stats.total_runs += 1;
224        self.stats.total_candidates_processed += candidate_count;
225    }
226
227    /// Record the end of a run (selected count and lambda contribution).
228    fn update_stats_post(&mut self, num_selected: u64, lambda: f32) {
229        self.stats.total_selected += num_selected;
230        // Maintain running mean of lambda.
231        let n = self.stats.total_runs as f64;
232        self.stats.avg_lambda = self.stats.avg_lambda + (lambda as f64 - self.stats.avg_lambda) / n;
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Tests
238// ---------------------------------------------------------------------------
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn make_candidate(
245        doc_id: u64,
246        embedding: Vec<f32>,
247        relevance_score: f32,
248    ) -> DiversificationCandidate {
249        DiversificationCandidate {
250            doc_id,
251            embedding,
252            relevance_score,
253        }
254    }
255
256    fn default_diversifier() -> SemanticDiversifier {
257        SemanticDiversifier::new(DiversifierConfig::default())
258    }
259
260    // 1. select returns empty for empty candidates
261    #[test]
262    fn test_select_empty_candidates() {
263        let mut d = default_diversifier();
264        let query = vec![1.0f32, 0.0];
265        let result = d.select(&query, vec![], 5);
266        assert!(result.is_empty());
267    }
268
269    // 2. select returns empty for k == 0
270    #[test]
271    fn test_select_k_zero() {
272        let mut d = default_diversifier();
273        let query = vec![1.0f32, 0.0];
274        let candidates = vec![
275            make_candidate(1, vec![1.0, 0.0], 0.9),
276            make_candidate(2, vec![0.0, 1.0], 0.8),
277        ];
278        let result = d.select(&query, candidates, 0);
279        assert!(result.is_empty());
280    }
281
282    // 3. lambda=1.0 (pure relevance): selects by relevance_score order
283    #[test]
284    fn test_lambda_one_pure_relevance() {
285        let mut d = SemanticDiversifier::new(DiversifierConfig { lambda: 1.0 });
286        let query = vec![1.0f32, 0.0];
287        let candidates = vec![
288            make_candidate(1, vec![1.0, 0.0], 0.5),
289            make_candidate(2, vec![1.0, 0.0], 0.9),
290            make_candidate(3, vec![1.0, 0.0], 0.7),
291        ];
292        let result = d.select(&query, candidates, 3);
293        assert_eq!(result.len(), 3);
294        // Should be ordered by relevance: 0.9, 0.7, 0.5
295        assert_eq!(result[0].doc_id, 2); // relevance 0.9
296        assert_eq!(result[1].doc_id, 3); // relevance 0.7
297        assert_eq!(result[2].doc_id, 1); // relevance 0.5
298    }
299
300    // 4. lambda=0.0 (pure diversity): selects most different items
301    #[test]
302    fn test_lambda_zero_pure_diversity() {
303        let mut d = SemanticDiversifier::new(DiversifierConfig { lambda: 0.0 });
304        let query = vec![1.0f32, 0.0, 0.0];
305        // Three orthogonal unit vectors — max diversity selects all with equal mmr=0
306        // but first selection (S empty) always picks the first by doc_id tie-break
307        let candidates = vec![
308            make_candidate(10, vec![1.0, 0.0, 0.0], 0.9),
309            make_candidate(20, vec![0.0, 1.0, 0.0], 0.1),
310            make_candidate(30, vec![0.0, 0.0, 1.0], 0.5),
311        ];
312        let result = d.select(&query, candidates, 3);
313        assert_eq!(result.len(), 3);
314        // All selected; just verify all doc_ids are present
315        let doc_ids: Vec<u64> = result.iter().map(|r| r.doc_id).collect();
316        assert!(doc_ids.contains(&10));
317        assert!(doc_ids.contains(&20));
318        assert!(doc_ids.contains(&30));
319    }
320
321    // 5. selection_rank assigned correctly (0, 1, 2, ...)
322    #[test]
323    fn test_selection_rank_assigned_correctly() {
324        let mut d = default_diversifier();
325        let query = vec![1.0f32, 0.0];
326        let candidates = vec![
327            make_candidate(1, vec![1.0, 0.0], 0.9),
328            make_candidate(2, vec![0.0, 1.0], 0.8),
329            make_candidate(3, vec![0.5, 0.5], 0.7),
330        ];
331        let result = d.select(&query, candidates, 3);
332        for (i, r) in result.iter().enumerate() {
333            assert_eq!(r.selection_rank, i, "rank mismatch at position {i}");
334        }
335    }
336
337    // 6. k larger than candidates: returns all candidates
338    #[test]
339    fn test_k_larger_than_candidates() {
340        let mut d = default_diversifier();
341        let query = vec![1.0f32, 0.0];
342        let candidates = vec![
343            make_candidate(1, vec![1.0, 0.0], 0.9),
344            make_candidate(2, vec![0.0, 1.0], 0.5),
345        ];
346        let result = d.select(&query, candidates, 100);
347        assert_eq!(result.len(), 2);
348    }
349
350    // 7a. cosine_similarity: orthogonal vectors → 0.0
351    #[test]
352    fn test_cosine_similarity_orthogonal() {
353        let a = vec![1.0f32, 0.0];
354        let b = vec![0.0f32, 1.0];
355        let sim = cosine_similarity(&a, &b);
356        assert!(sim.abs() < 1e-6, "Expected ~0.0, got {sim}");
357    }
358
359    // 7b. cosine_similarity: identical vectors → 1.0
360    #[test]
361    fn test_cosine_similarity_identical() {
362        let a = vec![0.3f32, 0.4, 0.5];
363        let sim = cosine_similarity(&a, &a);
364        assert!((sim - 1.0).abs() < 1e-6, "Expected ~1.0, got {sim}");
365    }
366
367    // 7c. cosine_similarity: zero vector → 0.0
368    #[test]
369    fn test_cosine_similarity_zero_vector() {
370        let a = vec![0.0f32, 0.0, 0.0];
371        let b = vec![1.0f32, 2.0, 3.0];
372        let sim = cosine_similarity(&a, &b);
373        assert!(sim.abs() < 1e-6, "Expected 0.0, got {sim}");
374    }
375
376    // 7d. cosine_similarity: dimension mismatch → 0.0
377    #[test]
378    fn test_cosine_similarity_dimension_mismatch() {
379        let a = vec![1.0f32, 0.0];
380        let b = vec![1.0f32, 0.0, 0.0];
381        let sim = cosine_similarity(&a, &b);
382        assert!(sim.abs() < 1e-6, "Expected 0.0, got {sim}");
383    }
384
385    // 7e. cosine_similarity: empty slices → 0.0
386    #[test]
387    fn test_cosine_similarity_empty() {
388        let sim = cosine_similarity(&[], &[]);
389        assert!(sim.abs() < 1e-6, "Expected 0.0, got {sim}");
390    }
391
392    // 8. stats accumulate correctly across multiple runs
393    #[test]
394    fn test_stats_accumulate() {
395        let mut d = default_diversifier();
396        let query = vec![1.0f32, 0.0];
397        let c1 = vec![
398            make_candidate(1, vec![1.0, 0.0], 0.9),
399            make_candidate(2, vec![0.0, 1.0], 0.5),
400        ];
401        let c2 = vec![make_candidate(3, vec![0.5, 0.5], 0.7)];
402        d.select(&query, c1, 2);
403        d.select(&query, c2, 1);
404        let stats = d.stats();
405        assert_eq!(stats.total_runs, 2);
406        assert_eq!(stats.total_candidates_processed, 3);
407        assert_eq!(stats.total_selected, 3);
408    }
409
410    // 9. set_lambda clamps to [0.0, 1.0] — above 1.0
411    #[test]
412    fn test_set_lambda_clamps_above_one() {
413        let mut d = default_diversifier();
414        d.set_lambda(2.5);
415        assert!((d.config.lambda - 1.0).abs() < 1e-6);
416    }
417
418    // 10. set_lambda clamps to [0.0, 1.0] — below 0.0
419    #[test]
420    fn test_set_lambda_clamps_below_zero() {
421        let mut d = default_diversifier();
422        d.set_lambda(-0.3);
423        assert!(d.config.lambda.abs() < 1e-6);
424    }
425
426    // 11. set_lambda within [0.0, 1.0] is stored unchanged
427    #[test]
428    fn test_set_lambda_valid_value() {
429        let mut d = default_diversifier();
430        d.set_lambda(0.7);
431        assert!((d.config.lambda - 0.7).abs() < 1e-6);
432    }
433
434    // 12. Tie-breaking by doc_id ascending
435    #[test]
436    fn test_tie_breaking_by_doc_id_ascending() {
437        // lambda=1.0 and identical relevance scores → tie → lower doc_id wins
438        let mut d = SemanticDiversifier::new(DiversifierConfig { lambda: 1.0 });
439        let query = vec![1.0f32, 0.0];
440        let candidates = vec![
441            make_candidate(30, vec![1.0, 0.0], 0.8),
442            make_candidate(10, vec![1.0, 0.0], 0.8),
443            make_candidate(20, vec![1.0, 0.0], 0.8),
444        ];
445        let result = d.select(&query, candidates, 1);
446        assert_eq!(result.len(), 1);
447        assert_eq!(result[0].doc_id, 10, "lowest doc_id should win tie");
448    }
449
450    // 13. Mixed lambda=0.5 produces balanced selection
451    #[test]
452    fn test_mixed_lambda_balanced_selection() {
453        // Two candidates: one very relevant but redundant with the first selection,
454        // one less relevant but diverse. With lambda=0.5 and the diverse candidate
455        // having zero similarity to the first selected, it should be preferred over
456        // a highly similar but more relevant candidate.
457        let mut d = SemanticDiversifier::new(DiversifierConfig { lambda: 0.5 });
458        let query = vec![1.0f32, 0.0, 0.0];
459
460        let candidates = vec![
461            // First selection will be this one (highest relevance_score).
462            make_candidate(1, vec![1.0, 0.0, 0.0], 1.0),
463            // Same direction as doc 1 — high similarity penalty when selected 2nd.
464            make_candidate(2, vec![1.0, 0.0, 0.0], 0.9),
465            // Orthogonal to doc 1 — no similarity penalty.
466            make_candidate(3, vec![0.0, 1.0, 0.0], 0.5),
467        ];
468
469        let result = d.select(&query, candidates, 2);
470        assert_eq!(result.len(), 2);
471        assert_eq!(
472            result[0].doc_id, 1,
473            "first selection should be doc 1 (highest relevance)"
474        );
475
476        // For second selection:
477        // doc 2: mmr = 0.5*0.9 - 0.5*1.0 = 0.45 - 0.5 = -0.05
478        // doc 3: mmr = 0.5*0.5 - 0.5*0.0 = 0.25 - 0.0 = 0.25
479        // doc 3 wins despite lower relevance due to diversity bonus.
480        assert_eq!(
481            result[1].doc_id, 3,
482            "second selection should be doc 3 (diversity)"
483        );
484    }
485
486    // 14. DiversifierConfig default lambda is 0.5
487    #[test]
488    fn test_config_default_lambda() {
489        let config = DiversifierConfig::default();
490        assert!((config.lambda - 0.5).abs() < 1e-6);
491    }
492
493    // 15. stats starts at zero
494    #[test]
495    fn test_stats_initial_zero() {
496        let d = default_diversifier();
497        let s = d.stats();
498        assert_eq!(s.total_runs, 0);
499        assert_eq!(s.total_candidates_processed, 0);
500        assert_eq!(s.total_selected, 0);
501        assert!(s.avg_lambda.abs() < 1e-9);
502    }
503
504    // 16. Empty candidates still increments total_runs
505    #[test]
506    fn test_empty_candidates_increments_runs() {
507        let mut d = default_diversifier();
508        d.select(&[1.0f32], vec![], 5);
509        assert_eq!(d.stats().total_runs, 1);
510        assert_eq!(d.stats().total_candidates_processed, 0);
511        assert_eq!(d.stats().total_selected, 0);
512    }
513
514    // 17. k=0 still increments total_runs (because update_stats_pre is called first)
515    #[test]
516    fn test_k_zero_increments_runs() {
517        let mut d = default_diversifier();
518        let candidates = vec![make_candidate(1, vec![1.0f32], 0.9)];
519        d.select(&[1.0f32], candidates, 0);
520        assert_eq!(d.stats().total_runs, 1);
521        assert_eq!(d.stats().total_candidates_processed, 1);
522        assert_eq!(d.stats().total_selected, 0);
523    }
524
525    // 18. avg_lambda computed correctly over two runs with different lambdas
526    #[test]
527    fn test_avg_lambda_two_runs() {
528        let mut d = SemanticDiversifier::new(DiversifierConfig { lambda: 0.2 });
529        let query = vec![1.0f32, 0.0];
530        // Run 1: lambda = 0.2
531        d.select(&query, vec![make_candidate(1, vec![1.0, 0.0], 0.9)], 1);
532        // Run 2: lambda = 0.8
533        d.set_lambda(0.8);
534        d.select(&query, vec![make_candidate(2, vec![0.0, 1.0], 0.7)], 1);
535        // mean of 0.2 and 0.8 = 0.5
536        let avg = d.stats().avg_lambda;
537        assert!((avg - 0.5).abs() < 1e-6, "avg_lambda={avg}");
538    }
539
540    // 19. Single candidate is always selected when k >= 1
541    #[test]
542    fn test_single_candidate_selected() {
543        let mut d = default_diversifier();
544        let query = vec![0.5f32, 0.5];
545        let result = d.select(&query, vec![make_candidate(42, vec![0.5, 0.5], 0.8)], 1);
546        assert_eq!(result.len(), 1);
547        assert_eq!(result[0].doc_id, 42);
548        assert_eq!(result[0].selection_rank, 0);
549    }
550
551    // 20. cosine_similarity: parallel (same direction) vectors → 1.0
552    #[test]
553    fn test_cosine_similarity_parallel() {
554        let a = vec![2.0f32, 0.0];
555        let b = vec![5.0f32, 0.0];
556        let sim = cosine_similarity(&a, &b);
557        assert!((sim - 1.0).abs() < 1e-6, "Expected ~1.0, got {sim}");
558    }
559
560    // 21. cosine_similarity: anti-parallel vectors → -1.0
561    #[test]
562    fn test_cosine_similarity_anti_parallel() {
563        let a = vec![1.0f32, 0.0];
564        let b = vec![-1.0f32, 0.0];
565        let sim = cosine_similarity(&a, &b);
566        assert!((sim + 1.0).abs() < 1e-6, "Expected ~-1.0, got {sim}");
567    }
568
569    // 22. Selection order matches selection_rank field
570    #[test]
571    fn test_selection_order_matches_rank() {
572        let mut d = SemanticDiversifier::new(DiversifierConfig { lambda: 1.0 });
573        let query = vec![1.0f32, 0.0];
574        let candidates = vec![
575            make_candidate(1, vec![1.0, 0.0], 0.3),
576            make_candidate(2, vec![1.0, 0.0], 0.8),
577            make_candidate(3, vec![1.0, 0.0], 0.6),
578            make_candidate(4, vec![1.0, 0.0], 0.1),
579        ];
580        let result = d.select(&query, candidates, 4);
581        assert_eq!(result.len(), 4);
582        for (pos, r) in result.iter().enumerate() {
583            assert_eq!(r.selection_rank, pos);
584        }
585        // Order should be doc 2, 3, 1, 4 (decreasing relevance)
586        assert_eq!(result[0].doc_id, 2);
587        assert_eq!(result[1].doc_id, 3);
588        assert_eq!(result[2].doc_id, 1);
589        assert_eq!(result[3].doc_id, 4);
590    }
591
592    // 23. Total selected never exceeds total candidates
593    #[test]
594    fn test_total_selected_never_exceeds_candidates() {
595        let mut d = default_diversifier();
596        let query = vec![1.0f32, 0.0];
597        let c = vec![
598            make_candidate(1, vec![1.0, 0.0], 0.9),
599            make_candidate(2, vec![0.0, 1.0], 0.8),
600        ];
601        let result = d.select(&query, c, 10);
602        assert!(result.len() <= 2);
603        assert_eq!(result.len(), 2);
604    }
605
606    // 24. MMR score field is populated on DiversifiedResult
607    #[test]
608    fn test_mmr_score_populated() {
609        let mut d = SemanticDiversifier::new(DiversifierConfig { lambda: 1.0 });
610        let query = vec![1.0f32, 0.0];
611        let candidates = vec![make_candidate(1, vec![1.0, 0.0], 0.8)];
612        let result = d.select(&query, candidates, 1);
613        assert_eq!(result.len(), 1);
614        // With empty S and lambda=1.0: mmr = 1.0 * 0.8 - 0.0 * 0.0 = 0.8
615        assert!(
616            (result[0].mmr_score - 0.8).abs() < 1e-6,
617            "mmr_score={}",
618            result[0].mmr_score
619        );
620    }
621
622    // 25. DiversifiedResult.relevance_score matches candidate.relevance_score
623    #[test]
624    fn test_relevance_score_preserved() {
625        let mut d = default_diversifier();
626        let query = vec![1.0f32, 0.0];
627        let candidates = vec![
628            make_candidate(1, vec![1.0, 0.0], 0.777),
629            make_candidate(2, vec![0.0, 1.0], 0.333),
630        ];
631        let result = d.select(&query, candidates, 2);
632        let by_doc: std::collections::HashMap<u64, f32> = result
633            .iter()
634            .map(|r| (r.doc_id, r.relevance_score))
635            .collect();
636        assert!((by_doc[&1] - 0.777).abs() < 1e-5);
637        assert!((by_doc[&2] - 0.333).abs() < 1e-5);
638    }
639
640    // 26. stats() returns a reference (not consuming self)
641    #[test]
642    fn test_stats_returns_reference() {
643        let d = default_diversifier();
644        let _s1 = d.stats();
645        let _s2 = d.stats(); // can call multiple times
646    }
647
648    // 27. Pure diversity with two identical embeddings: tie broken by doc_id
649    #[test]
650    fn test_pure_diversity_identical_embeddings_tie_break() {
651        let mut d = SemanticDiversifier::new(DiversifierConfig { lambda: 0.0 });
652        let query = vec![1.0f32, 0.0];
653        // Both have identical embeddings and same relevance → first round tie at mmr=0
654        let candidates = vec![
655            make_candidate(5, vec![1.0, 0.0], 0.5),
656            make_candidate(3, vec![1.0, 0.0], 0.5),
657        ];
658        let result = d.select(&query, candidates, 1);
659        assert_eq!(result.len(), 1);
660        assert_eq!(result[0].doc_id, 3, "lower doc_id should win tie");
661    }
662
663    // 28. total_candidates_processed accumulates correctly across empty and non-empty runs
664    #[test]
665    fn test_total_candidates_processed_cumulative() {
666        let mut d = default_diversifier();
667        let q = vec![1.0f32];
668        d.select(&q, vec![], 5); // 0 candidates
669        d.select(&q, vec![make_candidate(1, vec![1.0], 0.9)], 3); // 1 candidate
670        d.select(
671            &q,
672            vec![
673                make_candidate(2, vec![0.5], 0.8),
674                make_candidate(3, vec![0.1], 0.7),
675            ],
676            2,
677        ); // 2 candidates
678        assert_eq!(d.stats().total_candidates_processed, 3);
679        assert_eq!(d.stats().total_runs, 3);
680    }
681}