use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetrievalLabel {
Decisive,
Supporting,
Background,
Irrelevant,
AdjacentWrong,
}
impl RetrievalLabel {
pub fn gain(self) -> f64 {
match self {
Self::Decisive => 3.0,
Self::Supporting => 2.0,
Self::Background => 0.5,
Self::Irrelevant => 0.0,
Self::AdjacentWrong => -2.0,
}
}
pub fn is_relevant(self) -> bool {
matches!(self, Self::Decisive | Self::Supporting)
}
pub fn is_distractor(self) -> bool {
matches!(self, Self::AdjacentWrong)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabeledResult {
pub section_id: Uuid,
pub score: f64,
pub label: RetrievalLabel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrievalMetrics {
pub recall_at_k: Vec<(usize, f64)>,
pub ndcg_at_10: f64,
pub precision_at_5: f64,
pub precision_at_10: f64,
pub distractor_at_10: f64,
pub net_evidence_at_10: f64,
pub mrr: f64,
pub flip_ratio: Option<f64>,
}
pub fn recall_at_k(results: &[LabeledResult], k: usize) -> f64 {
let total_relevant: usize = results.iter().filter(|r| r.label.is_relevant()).count();
if total_relevant == 0 {
return 1.0;
}
let k = k.min(results.len());
let found: usize = results[..k]
.iter()
.filter(|r| r.label.is_relevant())
.count();
found as f64 / total_relevant as f64
}
pub fn precision_at_k(results: &[LabeledResult], k: usize) -> f64 {
if k == 0 || results.is_empty() {
return 0.0;
}
let k = k.min(results.len());
let relevant: usize = results[..k]
.iter()
.filter(|r| r.label.is_relevant())
.count();
relevant as f64 / k as f64
}
pub fn distractor_at_k(results: &[LabeledResult], k: usize) -> f64 {
if k == 0 || results.is_empty() {
return 0.0;
}
let k = k.min(results.len());
let distractors: usize = results[..k]
.iter()
.filter(|r| r.label.is_distractor())
.count();
distractors as f64 / k as f64
}
pub fn net_evidence_at_k(results: &[LabeledResult], k: usize) -> f64 {
if k == 0 || results.is_empty() {
return 0.0;
}
let k = k.min(results.len());
results[..k]
.iter()
.enumerate()
.map(|(i, r)| r.label.gain() / (i as f64 + 2.0).log2())
.sum()
}
pub fn ndcg_at_k(results: &[LabeledResult], k: usize) -> f64 {
if k == 0 || results.is_empty() {
return 0.0;
}
let k = k.min(results.len());
let dcg = results[..k]
.iter()
.enumerate()
.map(|(i, r)| r.label.gain() / (i as f64 + 2.0).log2())
.sum::<f64>();
let mut gains: Vec<f64> = results.iter().map(|r| r.label.gain()).collect();
gains.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
let idcg = gains[..k]
.iter()
.enumerate()
.map(|(i, &g)| g / (i as f64 + 2.0).log2())
.sum::<f64>();
if idcg == 0.0 {
return 1.0;
}
if idcg < 0.0 {
return 0.0;
}
(dcg / idcg).clamp(0.0, 1.0)
}
pub fn mrr(results: &[LabeledResult]) -> f64 {
for (i, r) in results.iter().enumerate() {
if r.label == RetrievalLabel::Decisive {
return 1.0 / (i as f64 + 1.0);
}
}
0.0
}
pub fn compute_all(results: &[LabeledResult]) -> RetrievalMetrics {
let recall_at_k_vals = vec![
(1, recall_at_k(results, 1)),
(3, recall_at_k(results, 3)),
(5, recall_at_k(results, 5)),
(10, recall_at_k(results, 10)),
];
RetrievalMetrics {
recall_at_k: recall_at_k_vals,
ndcg_at_10: ndcg_at_k(results, 10),
precision_at_5: precision_at_k(results, 5),
precision_at_10: precision_at_k(results, 10),
distractor_at_10: distractor_at_k(results, 10),
net_evidence_at_10: net_evidence_at_k(results, 10),
mrr: mrr(results),
flip_ratio: None,
}
}
#[cfg(test)]
#[path = "engine_eval/tests.rs"]
mod tests;