Skip to main content

ipfrs_semantic/
search_quality.rs

1//! Search quality evaluation for HNSW-based retrieval systems.
2//!
3//! This module provides standard information retrieval metrics for evaluating
4//! the quality of approximate nearest neighbour search results against a
5//! ground-truth relevance set.
6//!
7//! ## Metrics
8//!
9//! | Metric | Description |
10//! |--------|-------------|
11//! | Recall\@K | Fraction of relevant documents retrieved in top-K |
12//! | Precision\@K | Fraction of top-K results that are relevant |
13//! | NDCG\@K | Normalised Discounted Cumulative Gain (binary relevance) |
14//! | Average Precision | Area under the precision-recall curve |
15//! | Reciprocal Rank | `1 / rank` of the first relevant result |
16//!
17//! ## Example
18//!
19//! ```rust
20//! use ipfrs_semantic::search_quality::{
21//!     GroundTruth, SearchResultSet, SearchQualityEvaluator,
22//! };
23//!
24//! let gt = GroundTruth {
25//!     query_id: "q1".to_string(),
26//!     relevant_ids: vec!["a".to_string(), "b".to_string(), "c".to_string()],
27//!     top_k: 3,
28//! };
29//!
30//! let results = SearchResultSet {
31//!     query_id: "q1".to_string(),
32//!     result_ids: vec!["a".to_string(), "b".to_string(), "d".to_string()],
33//! };
34//!
35//! let evaluator = SearchQualityEvaluator::new();
36//! let metrics = evaluator.evaluate(&gt, &results).unwrap();
37//!
38//! assert!((metrics.recall_at_k - 2.0 / 3.0).abs() < 1e-9);
39//! assert!((metrics.precision_at_k - 2.0 / 3.0).abs() < 1e-9);
40//! ```
41
42use std::collections::HashSet;
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::sync::Arc;
45
46use thiserror::Error;
47
48// ─────────────────────────────────────────────────────────────────────────────
49// Error type
50// ─────────────────────────────────────────────────────────────────────────────
51
52/// Errors returned by [`SearchQualityEvaluator`].
53#[derive(Debug, Error, PartialEq, Eq, Clone)]
54pub enum EvalError {
55    /// The `query_id` fields of the ground-truth and result set do not match.
56    #[error("query-id mismatch: ground truth is '{ground_truth}', results are '{results}'")]
57    QueryIdMismatch {
58        /// Query ID from the ground-truth.
59        ground_truth: String,
60        /// Query ID from the result set.
61        results: String,
62    },
63
64    /// The result set contains no items.
65    #[error("result set is empty")]
66    EmptyResults,
67
68    /// The ground-truth contains no relevant documents.
69    #[error("ground truth contains no relevant documents")]
70    EmptyGroundTruth,
71}
72
73// ─────────────────────────────────────────────────────────────────────────────
74// Data structures
75// ─────────────────────────────────────────────────────────────────────────────
76
77/// Ground-truth specification for a single query.
78///
79/// The `relevant_ids` list is **ordered by decreasing relevance**, which is
80/// used when computing NDCG ideal rankings.  For binary-relevance metrics
81/// (Recall, Precision, AP, RR) the ordering does not matter.
82#[derive(Debug, Clone)]
83pub struct GroundTruth {
84    /// Unique identifier for the query.
85    pub query_id: String,
86    /// Document IDs that are relevant for this query, ordered by relevance.
87    pub relevant_ids: Vec<String>,
88    /// Evaluation cut-off (K).
89    pub top_k: usize,
90}
91
92/// System output for a single query.
93#[derive(Debug, Clone)]
94pub struct SearchResultSet {
95    /// Unique identifier for the query (must match the corresponding [`GroundTruth`]).
96    pub query_id: String,
97    /// Retrieved document IDs in rank order (highest-scoring first).
98    pub result_ids: Vec<String>,
99}
100
101/// All quality metrics for a single query evaluation.
102#[derive(Debug, Clone, PartialEq)]
103pub struct QualityMetrics {
104    /// |relevant ∩ results\@K| / min(|relevant|, K)
105    pub recall_at_k: f64,
106    /// |relevant ∩ results\@K| / K
107    pub precision_at_k: f64,
108    /// Normalised Discounted Cumulative Gain at K (binary relevance).
109    pub ndcg_at_k: f64,
110    /// Average Precision — area under the precision-recall curve up to K.
111    pub average_precision: f64,
112    /// Reciprocal Rank — `1 / rank` of the first relevant result (0 if none).
113    pub reciprocal_rank: f64,
114}
115
116impl QualityMetrics {
117    /// Returns a zero-valued metrics instance (used as additive identity when
118    /// computing macro-averages).
119    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// ─────────────────────────────────────────────────────────────────────────────
131// Atomic statistics
132// ─────────────────────────────────────────────────────────────────────────────
133
134/// A point-in-time snapshot of [`EvaluatorStats`].
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct EvaluatorStatsSnapshot {
137    /// Total number of individual query evaluations performed.
138    pub total_evaluated: u64,
139    /// Total number of [`SearchQualityEvaluator::batch_evaluate`] calls.
140    pub total_batches: u64,
141}
142
143/// Lock-free atomic counters for the evaluator.
144#[derive(Debug)]
145pub struct EvaluatorStats {
146    /// Monotonically increasing count of single-query evaluations.
147    pub total_evaluated: AtomicU64,
148    /// Monotonically increasing count of batch evaluation calls.
149    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    /// Returns a consistent snapshot of the current counter values.
161    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// ─────────────────────────────────────────────────────────────────────────────
170// Evaluator
171// ─────────────────────────────────────────────────────────────────────────────
172
173/// Computes information-retrieval quality metrics for HNSW search results.
174///
175/// The evaluator is cheap to clone because all mutable state lives behind an
176/// [`Arc`].  Multiple threads can share a single evaluator instance.
177#[derive(Debug, Clone)]
178pub struct SearchQualityEvaluator {
179    /// Operational statistics.
180    pub stats: Arc<EvaluatorStats>,
181}
182
183impl Default for SearchQualityEvaluator {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189impl SearchQualityEvaluator {
190    /// Creates a new evaluator with zeroed statistics.
191    pub fn new() -> Self {
192        Self {
193            stats: Arc::new(EvaluatorStats::new()),
194        }
195    }
196
197    // ── Public API ────────────────────────────────────────────────────────────
198
199    /// Evaluates a single query, computing all five quality metrics.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`EvalError::QueryIdMismatch`] when the `query_id` fields
204    /// differ, [`EvalError::EmptyResults`] when the result set is empty, or
205    /// [`EvalError::EmptyGroundTruth`] when the ground-truth has no relevant
206    /// documents.
207    pub fn evaluate(
208        &self,
209        ground_truth: &GroundTruth,
210        results: &SearchResultSet,
211    ) -> Result<QualityMetrics, EvalError> {
212        // ── Guard checks ──────────────────────────────────────────────────────
213        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    /// Evaluates a batch of (ground-truth, result-set) pairs in order.
250    ///
251    /// Each entry is evaluated independently; errors are captured per-entry
252    /// rather than aborting the batch.
253    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    /// Computes the macro-average of a slice of [`QualityMetrics`].
262    ///
263    /// Returns zero metrics when the slice is empty.
264    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    // ── Static helpers ────────────────────────────────────────────────────────
290
291    /// Computes Recall\@K.
292    ///
293    /// `|relevant ∩ results[..k]| / min(|relevant|, k)`
294    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    /// Computes Precision\@K.
311    ///
312    /// `|relevant ∩ results[..k]| / k`
313    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    /// Computes binary-relevance NDCG\@K.
329    ///
330    /// DCG  = Σ `rel_i / log2(i + 2)` for `i` in `0..k` (0-indexed rank).
331    /// IDCG = Σ `1 / log2(i + 2)`     for `i` in `0..min(|relevant|, k)`.
332    /// NDCG = DCG / IDCG.
333    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    /// Computes Average Precision\@K (AP\@K).
367    ///
368    /// AP = (1 / |relevant|) × Σ P\@i × rel_i  for i in 1..=k.
369    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                // P@(i+1)
384                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    /// Computes Reciprocal Rank (RR).
396    ///
397    /// Returns `1 / rank` of the first relevant result in `results` (1-indexed),
398    /// or `0.0` when no relevant document appears in the list.
399    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// ─────────────────────────────────────────────────────────────────────────────
415// Tests
416// ─────────────────────────────────────────────────────────────────────────────
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    // ── Helper ────────────────────────────────────────────────────────────────
423
424    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    // ── 1. Perfect recall ─────────────────────────────────────────────────────
444
445    #[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    // ── 2. Zero recall ────────────────────────────────────────────────────────
456
457    #[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    // ── 3. Partial recall ─────────────────────────────────────────────────────
468
469    #[test]
470    fn test_partial_recall() {
471        // 2 out of 3 relevant returned in top-3, denominator = min(3,3) = 3
472        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    // ── 4. Precision@K ────────────────────────────────────────────────────────
482
483    #[test]
484    fn test_precision_at_k() {
485        // 2 hits in top-4, P@4 = 2/4 = 0.5
486        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    // ── 5. NDCG@K perfect ranking ─────────────────────────────────────────────
509
510    #[test]
511    fn test_ndcg_perfect_ranking() {
512        // All top-k are relevant → NDCG = 1.0
513        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    // ── 6. NDCG@K worst ranking ───────────────────────────────────────────────
522
523    #[test]
524    fn test_ndcg_worst_ranking() {
525        // No relevant documents in top-3 → NDCG = 0.0
526        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        // Only first position is relevant.
537        // DCG  = 1/log2(2) = 1.0
538        // IDCG = 1/log2(2) + 1/log2(3) = 1.0 + 0.6309… = 1.6309…
539        // NDCG = 1.0 / 1.6309… ≈ 0.6131
540        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    // ── 7. Average Precision ──────────────────────────────────────────────────
550
551    #[test]
552    fn test_average_precision_perfect() {
553        let evaluator = SearchQualityEvaluator::new();
554        let m = evaluator
555            .evaluate(&gt("q", &["a", "b", "c"], 3), &rs("q", &["a", "b", "c"]))
556            .expect("test: evaluate should succeed for valid perfect-match inputs");
557        // P@1=1, P@2=1, P@3=1 → AP = (1+1+1)/3 = 1.0
558        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        // relevant = [a, b, c], results = [a, x, b, y, c]
568        // hits at ranks 1, 3, 5 → P@1=1/1, P@3=2/3, P@5=3/5
569        // AP = (1 + 2/3 + 3/5) / 3
570        let evaluator = SearchQualityEvaluator::new();
571        let m = evaluator
572            .evaluate(
573                &gt("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    // ── 8. Reciprocal Rank ────────────────────────────────────────────────────
586
587    #[test]
588    fn test_reciprocal_rank_first_hit_rank1() {
589        let evaluator = SearchQualityEvaluator::new();
590        let m = evaluator
591            .evaluate(&gt("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(&gt("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(&gt("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    // ── 9. batch_evaluate ─────────────────────────────────────────────────────
627
628    #[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        // All should be Ok
642        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    // ── 10. mean_metrics ──────────────────────────────────────────────────────
652
653    #[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    // ── 11. QueryId mismatch error ────────────────────────────────────────────
688
689    #[test]
690    fn test_query_id_mismatch_error() {
691        let evaluator = SearchQualityEvaluator::new();
692        let err = evaluator
693            .evaluate(&gt("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    // ── 12. Empty results error ───────────────────────────────────────────────
706
707    #[test]
708    fn test_empty_results_error() {
709        let evaluator = SearchQualityEvaluator::new();
710        let err = evaluator
711            .evaluate(&gt("q", &["a"], 3), &rs("q", &[]))
712            .unwrap_err();
713
714        assert_eq!(err, EvalError::EmptyResults);
715    }
716
717    // ── 13. Empty ground truth error ──────────────────────────────────────────
718
719    #[test]
720    fn test_empty_ground_truth_error() {
721        let evaluator = SearchQualityEvaluator::new();
722        let err = evaluator
723            .evaluate(&gt("q", &[], 3), &rs("q", &["a"]))
724            .unwrap_err();
725
726        assert_eq!(err, EvalError::EmptyGroundTruth);
727    }
728
729    // ── 14. Stats increments ──────────────────────────────────────────────────
730
731    #[test]
732    fn test_stats_increment_on_evaluate() {
733        let evaluator = SearchQualityEvaluator::new();
734
735        for _ in 0..5 {
736            let _ = evaluator.evaluate(&gt("q", &["a"], 1), &rs("q", &["a"]));
737        }
738
739        assert_eq!(evaluator.stats.snapshot().total_evaluated, 5);
740    }
741
742    // ── 15. recall_at_k with k > len(results) ────────────────────────────────
743
744    #[test]
745    fn test_recall_k_larger_than_results() {
746        // k=10 but only 3 results available; 2 are relevant out of 4 ground-truth
747        // hits = 2, denominator = min(4, 10) = 4 → recall = 0.5
748        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    // ── 16. Full pipeline: evaluate + batch + mean ────────────────────────────
761
762    #[test]
763    fn test_full_pipeline() {
764        let evaluator = SearchQualityEvaluator::new();
765
766        let pairs = vec![
767            // Perfect results
768            (gt("q1", &["a", "b", "c"], 3), rs("q1", &["a", "b", "c"])),
769            // No relevant results
770            (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        // Mean of 1.0 and 0.0 = 0.5 for all metrics involving pure hits
782        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}