1#[derive(Clone, Debug)]
14pub struct DiversificationCandidate {
15 pub doc_id: u64,
17 pub embedding: Vec<f32>,
19 pub relevance_score: f32,
21}
22
23#[derive(Clone, Debug)]
25pub struct DiversifierConfig {
26 pub lambda: f32,
31}
32
33impl Default for DiversifierConfig {
34 fn default() -> Self {
35 Self { lambda: 0.5 }
36 }
37}
38
39#[derive(Clone, Debug)]
41pub struct DiversifiedResult {
42 pub doc_id: u64,
44 pub relevance_score: f32,
46 pub mmr_score: f32,
48 pub selection_rank: usize,
50}
51
52#[derive(Clone, Debug, Default)]
55pub struct DiversifierStats {
56 pub total_runs: u64,
59 pub total_candidates_processed: u64,
62 pub total_selected: u64,
64 pub avg_lambda: f64,
66}
67
68pub 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
92pub struct SemanticDiversifier {
102 pub config: DiversifierConfig,
104 stats: DiversifierStats,
106}
107
108impl SemanticDiversifier {
109 pub fn new(config: DiversifierConfig) -> Self {
111 Self {
112 config,
113 stats: DiversifierStats::default(),
114 }
115 }
116
117 pub fn select(
130 &mut self,
131 _query: &[f32],
132 candidates: Vec<DiversificationCandidate>,
133 k: usize,
134 ) -> Vec<DiversifiedResult> {
135 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 let mut available: Vec<bool> = vec![true; n];
148 let mut selected: Vec<DiversifiedResult> = Vec::with_capacity(target);
150 let mut selected_embeddings: Vec<&[f32]> = Vec::with_capacity(target);
152
153 for rank in 0..target {
154 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 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 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 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 pub fn stats(&self) -> &DiversifierStats {
209 &self.stats
210 }
211
212 pub fn set_lambda(&mut self, lambda: f32) {
214 self.config.lambda = lambda.clamp(0.0, 1.0);
215 }
216
217 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 fn update_stats_post(&mut self, num_selected: u64, lambda: f32) {
229 self.stats.total_selected += num_selected;
230 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#[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 #[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 #[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 #[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 assert_eq!(result[0].doc_id, 2); assert_eq!(result[1].doc_id, 3); assert_eq!(result[2].doc_id, 1); }
299
300 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
436 fn test_tie_breaking_by_doc_id_ascending() {
437 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 #[test]
452 fn test_mixed_lambda_balanced_selection() {
453 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 make_candidate(1, vec![1.0, 0.0, 0.0], 1.0),
463 make_candidate(2, vec![1.0, 0.0, 0.0], 0.9),
465 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 assert_eq!(
481 result[1].doc_id, 3,
482 "second selection should be doc 3 (diversity)"
483 );
484 }
485
486 #[test]
488 fn test_config_default_lambda() {
489 let config = DiversifierConfig::default();
490 assert!((config.lambda - 0.5).abs() < 1e-6);
491 }
492
493 #[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 #[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 #[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 #[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 d.select(&query, vec![make_candidate(1, vec![1.0, 0.0], 0.9)], 1);
532 d.set_lambda(0.8);
534 d.select(&query, vec![make_candidate(2, vec![0.0, 1.0], 0.7)], 1);
535 let avg = d.stats().avg_lambda;
537 assert!((avg - 0.5).abs() < 1e-6, "avg_lambda={avg}");
538 }
539
540 #[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 #[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 #[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 #[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 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 #[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 #[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 assert!(
616 (result[0].mmr_score - 0.8).abs() < 1e-6,
617 "mmr_score={}",
618 result[0].mmr_score
619 );
620 }
621
622 #[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 #[test]
642 fn test_stats_returns_reference() {
643 let d = default_diversifier();
644 let _s1 = d.stats();
645 let _s2 = d.stats(); }
647
648 #[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 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 #[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); d.select(&q, vec![make_candidate(1, vec![1.0], 0.9)], 3); 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 ); assert_eq!(d.stats().total_candidates_processed, 3);
679 assert_eq!(d.stats().total_runs, 3);
680 }
681}