#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use std::collections::HashSet;
use serde::Serialize;
#[cfg(feature = "agent-eval")]
pub mod agent_eval;
#[cfg(feature = "agent-eval")]
#[allow(deprecated)]
pub use agent_eval::eval_capture;
#[cfg(feature = "agent-eval")]
pub use agent_eval::{check_regression, eval_capture_determinism, EvalMetrics, Regression};
#[cfg(feature = "agent-eval")]
pub mod live_eval;
#[cfg(feature = "agent-eval")]
pub use live_eval::{eval_capture_live, LiveEvalMetrics};
pub mod classifier_eval;
pub use classifier_eval::{
extract_label, score_classification, ClassificationCase, ClassificationReport, LabelSpec,
LabelStat, Miss,
};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RecallSample {
pub query: String,
pub expected: Vec<String>,
pub returned_ids: Vec<String>,
}
impl RecallSample {
pub fn new(query: impl Into<String>, expected: Vec<String>, returned_ids: Vec<String>) -> Self {
Self {
query: query.into(),
expected,
returned_ids,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct RecallReport {
pub k: usize,
pub per_query: Vec<PerQueryScore>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct PerQueryScore {
pub query: String,
pub expected_count: usize,
pub first_hit_rank: Option<usize>,
pub precision_at_k: f64,
pub recall_at_k: f64,
pub ndcg_at_k: f64,
}
impl RecallReport {
pub fn hit_rate(&self) -> f64 {
if self.per_query.is_empty() {
return 0.0;
}
let hits = self
.per_query
.iter()
.filter(|q| q.first_hit_rank.is_some())
.count();
hits as f64 / self.per_query.len() as f64
}
pub fn mean_reciprocal_rank(&self) -> f64 {
if self.per_query.is_empty() {
return 0.0;
}
let sum: f64 = self
.per_query
.iter()
.map(|q| {
q.first_hit_rank
.map(|rank| 1.0 / rank as f64)
.unwrap_or(0.0)
})
.sum();
sum / self.per_query.len() as f64
}
pub fn mean_precision_at_k(&self) -> f64 {
mean(self.per_query.iter().map(|q| q.precision_at_k))
}
pub fn mean_recall_at_k(&self) -> f64 {
mean(
self.per_query
.iter()
.filter(|q| q.expected_count > 0)
.map(|q| q.recall_at_k),
)
}
pub fn mean_ndcg_at_k(&self) -> f64 {
mean(
self.per_query
.iter()
.filter(|q| q.expected_count > 0)
.map(|q| q.ndcg_at_k),
)
}
}
pub fn score_recall(samples: &[RecallSample], k: usize) -> RecallReport {
let per_query = samples
.iter()
.map(|sample| score_sample(sample, k))
.collect();
RecallReport { k, per_query }
}
fn score_sample(sample: &RecallSample, k: usize) -> PerQueryScore {
let expected_set: HashSet<&str> = sample.expected.iter().map(String::as_str).collect();
let window: Vec<&str> = sample
.returned_ids
.iter()
.take(k)
.map(String::as_str)
.collect();
let expected_count = sample.expected.len();
let first_hit_rank = first_hit_rank(&window, &expected_set);
let precision_at_k = precision_at_k(&window, &expected_set, k);
let recall_at_k = recall_at_k(&window, &expected_set, expected_count);
let ndcg_at_k = ndcg_at_k(&window, &expected_set, expected_count, k);
PerQueryScore {
query: sample.query.clone(),
expected_count,
first_hit_rank,
precision_at_k,
recall_at_k,
ndcg_at_k,
}
}
fn first_hit_rank(window: &[&str], expected: &HashSet<&str>) -> Option<usize> {
window
.iter()
.position(|id| expected.contains(*id))
.map(|i| i + 1)
}
fn precision_at_k(window: &[&str], expected: &HashSet<&str>, k: usize) -> f64 {
if k == 0 {
return 0.0;
}
let intersect = window.iter().filter(|id| expected.contains(**id)).count();
intersect as f64 / k as f64
}
fn recall_at_k(window: &[&str], expected: &HashSet<&str>, expected_count: usize) -> f64 {
if expected_count == 0 {
return 0.0;
}
let intersect = window.iter().filter(|id| expected.contains(**id)).count();
intersect as f64 / expected_count as f64
}
fn ndcg_at_k(window: &[&str], expected: &HashSet<&str>, expected_count: usize, k: usize) -> f64 {
if expected_count == 0 || k == 0 {
return 0.0;
}
let dcg: f64 = window
.iter()
.enumerate()
.map(|(idx, id)| {
let relevance = if expected.contains(*id) { 1.0 } else { 0.0 };
relevance / ((idx + 2) as f64).log2()
})
.sum();
let ideal_hits = expected_count.min(k);
let idcg: f64 = (0..ideal_hits)
.map(|idx| 1.0 / ((idx + 2) as f64).log2())
.sum();
if idcg == 0.0 {
0.0
} else {
dcg / idcg
}
}
fn mean(iter: impl IntoIterator<Item = f64>) -> f64 {
let (sum, count) = iter
.into_iter()
.fold((0.0, 0usize), |(s, c), v| (s + v, c + 1));
if count == 0 {
0.0
} else {
sum / count as f64
}
}
#[cfg(test)]
mod tests;