use std::collections::HashSet;
use std::hash::Hash;
use crate::error::EmbeddingError;
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn recall_at_k<Id: Eq + Hash + Copy>(retrieved: &[Id], relevant: &[Id], k: usize) -> f64 {
if relevant.is_empty() {
return 0.0;
}
let relevant_set: HashSet<Id> = relevant.iter().copied().collect();
let keep = k.min(retrieved.len());
let mut hits = 0_usize;
let mut seen: HashSet<Id> = HashSet::new();
for &id in &retrieved[..keep] {
if relevant_set.contains(&id) && seen.insert(id) {
hits += 1;
}
}
hits as f64 / relevant_set.len() as f64
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn reciprocal_rank<Id: Eq + Hash + Copy>(retrieved: &[Id], relevant: &[Id]) -> f64 {
if relevant.is_empty() {
return 0.0;
}
let relevant_set: HashSet<Id> = relevant.iter().copied().collect();
retrieved
.iter()
.position(|id| relevant_set.contains(id))
.map_or(0.0, |pos| 1.0 / (pos as f64 + 1.0))
}
#[allow(clippy::cast_precision_loss)]
pub fn mean_reciprocal_rank<Id: Eq + Hash + Copy>(
retrieved: &[Vec<Id>],
relevant: &[Vec<Id>],
) -> Result<f64, EmbeddingError> {
if retrieved.len() != relevant.len() {
return Err(EmbeddingError::DimensionMismatch);
}
if retrieved.is_empty() {
return Ok(0.0);
}
let sum: f64 = retrieved.iter().zip(relevant.iter()).map(|(r, g)| reciprocal_rank(r, g)).sum();
Ok(sum / retrieved.len() as f64)
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn ndcg_at_k<Id: Eq + Hash + Copy>(retrieved: &[Id], relevant: &[Id], k: usize) -> f64 {
if relevant.is_empty() {
return 0.0;
}
let relevant_set: HashSet<Id> = relevant.iter().copied().collect();
let keep = k.min(retrieved.len());
let dcg: f64 = retrieved[..keep]
.iter()
.enumerate()
.filter(|(_, id)| relevant_set.contains(id))
.map(|(i, _)| 1.0 / ((i as f64) + 2.0).log2())
.sum();
let ideal_hits = keep.min(relevant_set.len());
let idcg: f64 = (0..ideal_hits).map(|i| 1.0 / ((i as f64) + 2.0).log2()).sum();
if idcg == 0.0 { 0.0 } else { dcg / idcg }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recall_counts_fraction_of_relevant_found() {
let retrieved = [1_i64, 2, 3, 4];
let relevant = [2_i64, 4, 9];
assert!((recall_at_k(&retrieved, &relevant, 4) - 2.0 / 3.0).abs() < 1e-12);
}
#[test]
fn recall_respects_k_clamp_window() {
let retrieved = [5_i64, 2, 3, 4];
let relevant = [3_i64, 4];
assert!((recall_at_k(&retrieved, &relevant, 2) - 0.0).abs() < 1e-12);
assert!((recall_at_k(&retrieved, &relevant, 4) - 1.0).abs() < 1e-12);
}
#[test]
fn recall_k_greater_than_len_is_clamped() {
let retrieved = [1_i64, 2];
let relevant = [2_i64];
assert!((recall_at_k(&retrieved, &relevant, 100) - 1.0).abs() < 1e-12);
}
#[test]
fn recall_empty_relevant_is_zero() {
let retrieved = [1_i64, 2];
let relevant: [i64; 0] = [];
assert!(recall_at_k(&retrieved, &relevant, 2).abs() < 1e-12);
}
#[test]
fn recall_ignores_duplicate_retrieved_relevant() {
let retrieved = [2_i64, 2, 2];
let relevant = [2_i64, 5];
assert!((recall_at_k(&retrieved, &relevant, 3) - 0.5).abs() < 1e-12);
}
#[test]
fn reciprocal_rank_uses_first_relevant_position() {
let retrieved = [9_i64, 8, 2, 4];
let relevant = [2_i64, 4];
assert!((reciprocal_rank(&retrieved, &relevant) - 1.0 / 3.0).abs() < 1e-12);
}
#[test]
fn reciprocal_rank_zero_when_none_relevant() {
let retrieved = [9_i64, 8];
let relevant = [1_i64];
assert!(reciprocal_rank(&retrieved, &relevant).abs() < 1e-12);
}
#[test]
fn mean_reciprocal_rank_averages_queries() {
let retrieved = vec![vec![2_i64, 1], vec![5_i64, 6, 3]];
let relevant = vec![vec![2_i64], vec![3_i64]];
let mrr = mean_reciprocal_rank(&retrieved, &relevant).unwrap();
assert!((mrr - 2.0 / 3.0).abs() < 1e-12);
}
#[test]
fn mean_reciprocal_rank_rejects_length_mismatch() {
let retrieved = vec![vec![1_i64]];
let relevant = vec![vec![1_i64], vec![2_i64]];
assert_eq!(
mean_reciprocal_rank(&retrieved, &relevant),
Err(EmbeddingError::DimensionMismatch)
);
}
#[test]
fn mean_reciprocal_rank_empty_is_zero() {
let retrieved: Vec<Vec<i64>> = Vec::new();
let relevant: Vec<Vec<i64>> = Vec::new();
assert!(mean_reciprocal_rank(&retrieved, &relevant).unwrap().abs() < 1e-12);
}
#[test]
fn ndcg_perfect_ranking_is_one() {
let retrieved = [1_i64, 2, 3];
let relevant = [1_i64, 2];
assert!((ndcg_at_k(&retrieved, &relevant, 3) - 1.0).abs() < 1e-12);
}
#[test]
fn ndcg_hand_computed_partial_ranking() {
let retrieved = [9_i64, 2, 7];
let relevant = [2_i64];
let expected = 1.0 / 3.0_f64.log2();
assert!((ndcg_at_k(&retrieved, &relevant, 3) - expected).abs() < 1e-12);
}
#[test]
fn ndcg_empty_relevant_is_zero() {
let retrieved = [1_i64, 2];
let relevant: [i64; 0] = [];
assert!(ndcg_at_k(&retrieved, &relevant, 2).abs() < 1e-12);
}
#[test]
fn ndcg_zero_k_is_zero() {
let retrieved = [1_i64, 2];
let relevant = [1_i64];
assert!(ndcg_at_k(&retrieved, &relevant, 0).abs() < 1e-12);
}
#[test]
fn metrics_work_with_usize_ids() {
let retrieved = [0_usize, 3, 1];
let relevant = [1_usize, 3];
assert!((recall_at_k(&retrieved, &relevant, 3) - 1.0).abs() < 1e-12);
assert!((reciprocal_rank(&retrieved, &relevant) - 0.5).abs() < 1e-12);
}
}