1use std::collections::HashSet;
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::sync::Arc;
45
46use thiserror::Error;
47
48#[derive(Debug, Error, PartialEq, Eq, Clone)]
54pub enum EvalError {
55 #[error("query-id mismatch: ground truth is '{ground_truth}', results are '{results}'")]
57 QueryIdMismatch {
58 ground_truth: String,
60 results: String,
62 },
63
64 #[error("result set is empty")]
66 EmptyResults,
67
68 #[error("ground truth contains no relevant documents")]
70 EmptyGroundTruth,
71}
72
73#[derive(Debug, Clone)]
83pub struct GroundTruth {
84 pub query_id: String,
86 pub relevant_ids: Vec<String>,
88 pub top_k: usize,
90}
91
92#[derive(Debug, Clone)]
94pub struct SearchResultSet {
95 pub query_id: String,
97 pub result_ids: Vec<String>,
99}
100
101#[derive(Debug, Clone, PartialEq)]
103pub struct QualityMetrics {
104 pub recall_at_k: f64,
106 pub precision_at_k: f64,
108 pub ndcg_at_k: f64,
110 pub average_precision: f64,
112 pub reciprocal_rank: f64,
114}
115
116impl QualityMetrics {
117 fn zero() -> Self {
120 Self {
121 recall_at_k: 0.0,
122 precision_at_k: 0.0,
123 ndcg_at_k: 0.0,
124 average_precision: 0.0,
125 reciprocal_rank: 0.0,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct EvaluatorStatsSnapshot {
137 pub total_evaluated: u64,
139 pub total_batches: u64,
141}
142
143#[derive(Debug)]
145pub struct EvaluatorStats {
146 pub total_evaluated: AtomicU64,
148 pub total_batches: AtomicU64,
150}
151
152impl EvaluatorStats {
153 fn new() -> Self {
154 Self {
155 total_evaluated: AtomicU64::new(0),
156 total_batches: AtomicU64::new(0),
157 }
158 }
159
160 pub fn snapshot(&self) -> EvaluatorStatsSnapshot {
162 EvaluatorStatsSnapshot {
163 total_evaluated: self.total_evaluated.load(Ordering::Relaxed),
164 total_batches: self.total_batches.load(Ordering::Relaxed),
165 }
166 }
167}
168
169#[derive(Debug, Clone)]
178pub struct SearchQualityEvaluator {
179 pub stats: Arc<EvaluatorStats>,
181}
182
183impl Default for SearchQualityEvaluator {
184 fn default() -> Self {
185 Self::new()
186 }
187}
188
189impl SearchQualityEvaluator {
190 pub fn new() -> Self {
192 Self {
193 stats: Arc::new(EvaluatorStats::new()),
194 }
195 }
196
197 pub fn evaluate(
208 &self,
209 ground_truth: &GroundTruth,
210 results: &SearchResultSet,
211 ) -> Result<QualityMetrics, EvalError> {
212 if ground_truth.query_id != results.query_id {
214 return Err(EvalError::QueryIdMismatch {
215 ground_truth: ground_truth.query_id.clone(),
216 results: results.query_id.clone(),
217 });
218 }
219
220 if results.result_ids.is_empty() {
221 return Err(EvalError::EmptyResults);
222 }
223
224 if ground_truth.relevant_ids.is_empty() {
225 return Err(EvalError::EmptyGroundTruth);
226 }
227
228 let k = ground_truth.top_k;
229 let relevant = &ground_truth.relevant_ids;
230 let retrieved = &results.result_ids;
231
232 let recall = Self::recall_at_k(relevant, retrieved, k);
233 let precision = Self::precision_at_k(relevant, retrieved, k);
234 let ndcg = Self::ndcg_at_k(relevant, retrieved, k);
235 let ap = Self::average_precision_at_k(relevant, retrieved, k);
236 let rr = Self::reciprocal_rank(relevant, retrieved);
237
238 self.stats.total_evaluated.fetch_add(1, Ordering::Relaxed);
239
240 Ok(QualityMetrics {
241 recall_at_k: recall,
242 precision_at_k: precision,
243 ndcg_at_k: ndcg,
244 average_precision: ap,
245 reciprocal_rank: rr,
246 })
247 }
248
249 pub fn batch_evaluate(
254 &self,
255 pairs: &[(GroundTruth, SearchResultSet)],
256 ) -> Vec<Result<QualityMetrics, EvalError>> {
257 self.stats.total_batches.fetch_add(1, Ordering::Relaxed);
258 pairs.iter().map(|(gt, rs)| self.evaluate(gt, rs)).collect()
259 }
260
261 pub fn mean_metrics(&self, metrics: &[QualityMetrics]) -> QualityMetrics {
265 if metrics.is_empty() {
266 return QualityMetrics::zero();
267 }
268
269 let n = metrics.len() as f64;
270 let sum = metrics
271 .iter()
272 .fold(QualityMetrics::zero(), |acc, m| QualityMetrics {
273 recall_at_k: acc.recall_at_k + m.recall_at_k,
274 precision_at_k: acc.precision_at_k + m.precision_at_k,
275 ndcg_at_k: acc.ndcg_at_k + m.ndcg_at_k,
276 average_precision: acc.average_precision + m.average_precision,
277 reciprocal_rank: acc.reciprocal_rank + m.reciprocal_rank,
278 });
279
280 QualityMetrics {
281 recall_at_k: sum.recall_at_k / n,
282 precision_at_k: sum.precision_at_k / n,
283 ndcg_at_k: sum.ndcg_at_k / n,
284 average_precision: sum.average_precision / n,
285 reciprocal_rank: sum.reciprocal_rank / n,
286 }
287 }
288
289 pub fn recall_at_k(relevant: &[String], results: &[String], k: usize) -> f64 {
295 if relevant.is_empty() || k == 0 {
296 return 0.0;
297 }
298
299 let relevant_set: HashSet<&str> = relevant.iter().map(String::as_str).collect();
300 let top_k = &results[..k.min(results.len())];
301 let hits = top_k
302 .iter()
303 .filter(|id| relevant_set.contains(id.as_str()))
304 .count();
305
306 let denominator = relevant.len().min(k);
307 hits as f64 / denominator as f64
308 }
309
310 pub fn precision_at_k(relevant: &[String], results: &[String], k: usize) -> f64 {
314 if k == 0 || results.is_empty() {
315 return 0.0;
316 }
317
318 let relevant_set: HashSet<&str> = relevant.iter().map(String::as_str).collect();
319 let top_k = &results[..k.min(results.len())];
320 let hits = top_k
321 .iter()
322 .filter(|id| relevant_set.contains(id.as_str()))
323 .count();
324
325 hits as f64 / k as f64
326 }
327
328 pub fn ndcg_at_k(relevant: &[String], results: &[String], k: usize) -> f64 {
334 if relevant.is_empty() || k == 0 {
335 return 0.0;
336 }
337
338 let relevant_set: HashSet<&str> = relevant.iter().map(String::as_str).collect();
339 let top_k = &results[..k.min(results.len())];
340
341 let dcg: f64 = top_k
342 .iter()
343 .enumerate()
344 .map(|(i, id)| {
345 let rel = if relevant_set.contains(id.as_str()) {
346 1.0_f64
347 } else {
348 0.0_f64
349 };
350 rel / (i as f64 + 2.0_f64).log2()
351 })
352 .sum();
353
354 let ideal_hits = relevant.len().min(k);
355 let idcg: f64 = (0..ideal_hits)
356 .map(|i| 1.0_f64 / (i as f64 + 2.0_f64).log2())
357 .sum();
358
359 if idcg < f64::EPSILON {
360 return 0.0;
361 }
362
363 dcg / idcg
364 }
365
366 fn average_precision_at_k(relevant: &[String], results: &[String], k: usize) -> f64 {
370 if relevant.is_empty() || k == 0 || results.is_empty() {
371 return 0.0;
372 }
373
374 let relevant_set: HashSet<&str> = relevant.iter().map(String::as_str).collect();
375 let top_k = &results[..k.min(results.len())];
376
377 let mut hits = 0usize;
378 let mut sum = 0.0_f64;
379
380 for (i, id) in top_k.iter().enumerate() {
381 if relevant_set.contains(id.as_str()) {
382 hits += 1;
383 sum += hits as f64 / (i + 1) as f64;
385 }
386 }
387
388 if relevant.is_empty() {
389 return 0.0;
390 }
391
392 sum / relevant.len() as f64
393 }
394
395 fn reciprocal_rank(relevant: &[String], results: &[String]) -> f64 {
400 if relevant.is_empty() || results.is_empty() {
401 return 0.0;
402 }
403
404 let relevant_set: HashSet<&str> = relevant.iter().map(String::as_str).collect();
405 for (i, id) in results.iter().enumerate() {
406 if relevant_set.contains(id.as_str()) {
407 return 1.0 / (i + 1) as f64;
408 }
409 }
410 0.0
411 }
412}
413
414#[cfg(test)]
419mod tests {
420 use super::*;
421
422 fn sv(ids: &[&str]) -> Vec<String> {
425 ids.iter().map(|s| s.to_string()).collect()
426 }
427
428 fn gt(query_id: &str, relevant: &[&str], k: usize) -> GroundTruth {
429 GroundTruth {
430 query_id: query_id.to_string(),
431 relevant_ids: sv(relevant),
432 top_k: k,
433 }
434 }
435
436 fn rs(query_id: &str, results: &[&str]) -> SearchResultSet {
437 SearchResultSet {
438 query_id: query_id.to_string(),
439 result_ids: sv(results),
440 }
441 }
442
443 #[test]
446 fn test_perfect_recall() {
447 let recall =
448 SearchQualityEvaluator::recall_at_k(&sv(&["a", "b", "c"]), &sv(&["a", "b", "c"]), 3);
449 assert!(
450 (recall - 1.0).abs() < 1e-9,
451 "perfect recall should be 1.0, got {recall}"
452 );
453 }
454
455 #[test]
458 fn test_zero_recall() {
459 let recall =
460 SearchQualityEvaluator::recall_at_k(&sv(&["a", "b", "c"]), &sv(&["x", "y", "z"]), 3);
461 assert!(
462 (recall - 0.0).abs() < 1e-9,
463 "zero recall should be 0.0, got {recall}"
464 );
465 }
466
467 #[test]
470 fn test_partial_recall() {
471 let recall =
473 SearchQualityEvaluator::recall_at_k(&sv(&["a", "b", "c"]), &sv(&["a", "b", "x"]), 3);
474 let expected = 2.0 / 3.0;
475 assert!(
476 (recall - expected).abs() < 1e-9,
477 "partial recall {recall} ≠ {expected}"
478 );
479 }
480
481 #[test]
484 fn test_precision_at_k() {
485 let prec = SearchQualityEvaluator::precision_at_k(
487 &sv(&["a", "b", "c"]),
488 &sv(&["a", "x", "b", "y"]),
489 4,
490 );
491 let expected = 2.0 / 4.0;
492 assert!((prec - expected).abs() < 1e-9, "P@4 {prec} ≠ {expected}");
493 }
494
495 #[test]
496 fn test_precision_at_k_perfect() {
497 let prec = SearchQualityEvaluator::precision_at_k(
498 &sv(&["a", "b", "c"]),
499 &sv(&["a", "b", "c", "d"]),
500 3,
501 );
502 assert!(
503 (prec - 1.0).abs() < 1e-9,
504 "perfect P@3 should be 1.0, got {prec}"
505 );
506 }
507
508 #[test]
511 fn test_ndcg_perfect_ranking() {
512 let ndcg =
514 SearchQualityEvaluator::ndcg_at_k(&sv(&["a", "b", "c"]), &sv(&["a", "b", "c"]), 3);
515 assert!(
516 (ndcg - 1.0).abs() < 1e-9,
517 "perfect NDCG should be 1.0, got {ndcg}"
518 );
519 }
520
521 #[test]
524 fn test_ndcg_worst_ranking() {
525 let ndcg =
527 SearchQualityEvaluator::ndcg_at_k(&sv(&["a", "b", "c"]), &sv(&["x", "y", "z"]), 3);
528 assert!(
529 (ndcg - 0.0).abs() < 1e-9,
530 "worst NDCG should be 0.0, got {ndcg}"
531 );
532 }
533
534 #[test]
535 fn test_ndcg_partial_ranking() {
536 let ndcg = SearchQualityEvaluator::ndcg_at_k(&sv(&["a", "b"]), &sv(&["a", "x", "y"]), 3);
541 let idcg = 1.0_f64 / 2.0_f64.log2() + 1.0_f64 / 3.0_f64.log2();
542 let expected = (1.0_f64 / 2.0_f64.log2()) / idcg;
543 assert!(
544 (ndcg - expected).abs() < 1e-9,
545 "partial NDCG {ndcg} ≠ {expected}"
546 );
547 }
548
549 #[test]
552 fn test_average_precision_perfect() {
553 let evaluator = SearchQualityEvaluator::new();
554 let m = evaluator
555 .evaluate(>("q", &["a", "b", "c"], 3), &rs("q", &["a", "b", "c"]))
556 .expect("test: evaluate should succeed for valid perfect-match inputs");
557 assert!(
559 (m.average_precision - 1.0).abs() < 1e-9,
560 "AP={}",
561 m.average_precision
562 );
563 }
564
565 #[test]
566 fn test_average_precision_interleaved() {
567 let evaluator = SearchQualityEvaluator::new();
571 let m = evaluator
572 .evaluate(
573 >("q", &["a", "b", "c"], 5),
574 &rs("q", &["a", "x", "b", "y", "c"]),
575 )
576 .expect("test: evaluate should succeed for valid interleaved inputs");
577 let expected = (1.0_f64 + 2.0 / 3.0 + 3.0 / 5.0) / 3.0;
578 assert!(
579 (m.average_precision - expected).abs() < 1e-9,
580 "AP={} ≠ {expected}",
581 m.average_precision
582 );
583 }
584
585 #[test]
588 fn test_reciprocal_rank_first_hit_rank1() {
589 let evaluator = SearchQualityEvaluator::new();
590 let m = evaluator
591 .evaluate(>("q", &["a"], 3), &rs("q", &["a", "b", "c"]))
592 .expect("test: evaluate should succeed when first result is the only relevant doc");
593 assert!(
594 (m.reciprocal_rank - 1.0).abs() < 1e-9,
595 "RR={}",
596 m.reciprocal_rank
597 );
598 }
599
600 #[test]
601 fn test_reciprocal_rank_first_hit_rank3() {
602 let evaluator = SearchQualityEvaluator::new();
603 let m = evaluator
604 .evaluate(>("q", &["c"], 3), &rs("q", &["a", "b", "c"]))
605 .expect("test: evaluate should succeed when relevant doc appears at rank 3");
606 assert!(
607 (m.reciprocal_rank - 1.0 / 3.0).abs() < 1e-9,
608 "RR={}",
609 m.reciprocal_rank
610 );
611 }
612
613 #[test]
614 fn test_reciprocal_rank_no_hit() {
615 let evaluator = SearchQualityEvaluator::new();
616 let m = evaluator
617 .evaluate(>("q", &["z"], 3), &rs("q", &["a", "b", "c"]))
618 .expect("test: evaluate should succeed even when no relevant doc appears in results");
619 assert!(
620 (m.reciprocal_rank - 0.0).abs() < 1e-9,
621 "RR={}",
622 m.reciprocal_rank
623 );
624 }
625
626 #[test]
629 fn test_batch_evaluate_returns_all() {
630 let evaluator = SearchQualityEvaluator::new();
631
632 let pairs = vec![
633 (gt("q1", &["a", "b"], 2), rs("q1", &["a", "b"])),
634 (gt("q2", &["x"], 2), rs("q2", &["x", "y"])),
635 (gt("q3", &["p"], 2), rs("q3", &["z", "w"])),
636 ];
637
638 let results = evaluator.batch_evaluate(&pairs);
639 assert_eq!(results.len(), 3, "batch should return one result per pair");
640
641 for r in &results {
643 assert!(r.is_ok(), "unexpected error: {r:?}");
644 }
645
646 let snap = evaluator.stats.snapshot();
647 assert_eq!(snap.total_batches, 1);
648 assert_eq!(snap.total_evaluated, 3);
649 }
650
651 #[test]
654 fn test_mean_metrics_averages_correctly() {
655 let evaluator = SearchQualityEvaluator::new();
656
657 let m1 = QualityMetrics {
658 recall_at_k: 1.0,
659 precision_at_k: 1.0,
660 ndcg_at_k: 1.0,
661 average_precision: 1.0,
662 reciprocal_rank: 1.0,
663 };
664 let m2 = QualityMetrics {
665 recall_at_k: 0.0,
666 precision_at_k: 0.0,
667 ndcg_at_k: 0.0,
668 average_precision: 0.0,
669 reciprocal_rank: 0.0,
670 };
671
672 let mean = evaluator.mean_metrics(&[m1, m2]);
673 assert!((mean.recall_at_k - 0.5).abs() < 1e-9);
674 assert!((mean.precision_at_k - 0.5).abs() < 1e-9);
675 assert!((mean.ndcg_at_k - 0.5).abs() < 1e-9);
676 assert!((mean.average_precision - 0.5).abs() < 1e-9);
677 assert!((mean.reciprocal_rank - 0.5).abs() < 1e-9);
678 }
679
680 #[test]
681 fn test_mean_metrics_empty_slice() {
682 let evaluator = SearchQualityEvaluator::new();
683 let mean = evaluator.mean_metrics(&[]);
684 assert_eq!(mean, QualityMetrics::zero());
685 }
686
687 #[test]
690 fn test_query_id_mismatch_error() {
691 let evaluator = SearchQualityEvaluator::new();
692 let err = evaluator
693 .evaluate(>("q1", &["a"], 3), &rs("q2", &["a"]))
694 .unwrap_err();
695
696 assert_eq!(
697 err,
698 EvalError::QueryIdMismatch {
699 ground_truth: "q1".to_string(),
700 results: "q2".to_string(),
701 }
702 );
703 }
704
705 #[test]
708 fn test_empty_results_error() {
709 let evaluator = SearchQualityEvaluator::new();
710 let err = evaluator
711 .evaluate(>("q", &["a"], 3), &rs("q", &[]))
712 .unwrap_err();
713
714 assert_eq!(err, EvalError::EmptyResults);
715 }
716
717 #[test]
720 fn test_empty_ground_truth_error() {
721 let evaluator = SearchQualityEvaluator::new();
722 let err = evaluator
723 .evaluate(>("q", &[], 3), &rs("q", &["a"]))
724 .unwrap_err();
725
726 assert_eq!(err, EvalError::EmptyGroundTruth);
727 }
728
729 #[test]
732 fn test_stats_increment_on_evaluate() {
733 let evaluator = SearchQualityEvaluator::new();
734
735 for _ in 0..5 {
736 let _ = evaluator.evaluate(>("q", &["a"], 1), &rs("q", &["a"]));
737 }
738
739 assert_eq!(evaluator.stats.snapshot().total_evaluated, 5);
740 }
741
742 #[test]
745 fn test_recall_k_larger_than_results() {
746 let recall = SearchQualityEvaluator::recall_at_k(
749 &sv(&["a", "b", "c", "d"]),
750 &sv(&["a", "b", "x"]),
751 10,
752 );
753 let expected = 2.0 / 4.0;
754 assert!(
755 (recall - expected).abs() < 1e-9,
756 "recall {recall} ≠ {expected}"
757 );
758 }
759
760 #[test]
763 fn test_full_pipeline() {
764 let evaluator = SearchQualityEvaluator::new();
765
766 let pairs = vec![
767 (gt("q1", &["a", "b", "c"], 3), rs("q1", &["a", "b", "c"])),
769 (gt("q2", &["a", "b", "c"], 3), rs("q2", &["x", "y", "z"])),
771 ];
772
773 let batch = evaluator.batch_evaluate(&pairs);
774 let metrics: Vec<QualityMetrics> = batch
775 .into_iter()
776 .map(|r| r.expect("test: each batch entry should evaluate without error"))
777 .collect();
778
779 let mean = evaluator.mean_metrics(&metrics);
780
781 assert!(
783 (mean.recall_at_k - 0.5).abs() < 1e-9,
784 "mean recall={}",
785 mean.recall_at_k
786 );
787 assert!((mean.precision_at_k - 0.5).abs() < 1e-9);
788 assert!((mean.ndcg_at_k - 0.5).abs() < 1e-9);
789 }
790}