weavatrix_memory/
evaluation.rs1use crate::error::{MemoryError, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct EvaluationCase {
7 pub id: String,
8 pub category: String,
9 pub relevant_ids: BTreeSet<String>,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct RankedPrediction {
14 pub case_id: String,
15 pub ranked_ids: Vec<String>,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct RetrievalMetrics {
20 pub cases: usize,
21 pub hit_at: BTreeMap<usize, f64>,
22 pub recall_at: BTreeMap<usize, f64>,
23 pub ndcg_at: BTreeMap<usize, f64>,
24 pub mean_reciprocal_rank: f64,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct EvaluationReport {
29 pub overall: RetrievalMetrics,
30 pub by_category: BTreeMap<String, RetrievalMetrics>,
31}
32
33pub fn evaluate_retrieval(
44 cases: &[EvaluationCase],
45 predictions: &[RankedPrediction],
46 cutoffs: &[usize],
47) -> Result<EvaluationReport> {
48 validate_cutoffs(cutoffs)?;
49 let cases_by_id = validate_cases(cases)?;
50 let predictions = validate_predictions(predictions, &cases_by_id)?;
51 let overall = score(cases.iter(), &predictions, cutoffs);
52 let mut categories = BTreeMap::<String, Vec<&EvaluationCase>>::new();
53 for case in cases {
54 categories
55 .entry(case.category.clone())
56 .or_default()
57 .push(case);
58 }
59 let by_category = categories
60 .into_iter()
61 .map(|(category, cases)| (category, score(cases.into_iter(), &predictions, cutoffs)))
62 .collect();
63 Ok(EvaluationReport {
64 overall,
65 by_category,
66 })
67}
68
69fn validate_cutoffs(cutoffs: &[usize]) -> Result<()> {
70 if cutoffs.is_empty() || cutoffs.contains(&0) {
71 return Err(invalid("cutoffs must be non-empty and greater than zero"));
72 }
73 Ok(())
74}
75
76fn validate_cases(cases: &[EvaluationCase]) -> Result<BTreeMap<&str, &EvaluationCase>> {
77 let mut indexed = BTreeMap::new();
78 for case in cases {
79 if case.id.is_empty()
80 || case.category.is_empty()
81 || case.relevant_ids.is_empty()
82 || indexed.insert(case.id.as_str(), case).is_some()
83 {
84 return Err(invalid(
85 "cases need unique non-empty ids, categories, and relevance",
86 ));
87 }
88 }
89 Ok(indexed)
90}
91
92fn validate_predictions<'a>(
93 predictions: &'a [RankedPrediction],
94 cases: &BTreeMap<&str, &EvaluationCase>,
95) -> Result<BTreeMap<&'a str, Vec<&'a str>>> {
96 let mut indexed = BTreeMap::new();
97 for prediction in predictions {
98 if !cases.contains_key(prediction.case_id.as_str()) {
99 return Err(invalid("prediction references an unknown case"));
100 }
101 let mut seen = BTreeSet::new();
102 let ranked = prediction
103 .ranked_ids
104 .iter()
105 .map(String::as_str)
106 .filter(|id| seen.insert(*id))
107 .collect::<Vec<_>>();
108 if indexed
109 .insert(prediction.case_id.as_str(), ranked)
110 .is_some()
111 {
112 return Err(invalid("prediction case ids must be unique"));
113 }
114 }
115 Ok(indexed)
116}
117
118fn score<'a>(
119 cases: impl Iterator<Item = &'a EvaluationCase>,
120 predictions: &BTreeMap<&str, Vec<&str>>,
121 cutoffs: &[usize],
122) -> RetrievalMetrics {
123 let cases = cases.collect::<Vec<_>>();
124 let mut hit_at = cutoffs
125 .iter()
126 .map(|cutoff| (*cutoff, 0.0))
127 .collect::<BTreeMap<_, _>>();
128 let mut recall_at = hit_at.clone();
129 let mut ndcg_at = hit_at.clone();
130 let mut reciprocal_rank = 0.0;
131 for case in &cases {
132 let ranked = predictions
133 .get(case.id.as_str())
134 .map_or(&[][..], Vec::as_slice);
135 reciprocal_rank += ranked
136 .iter()
137 .position(|id| case.relevant_ids.contains(*id))
138 .map_or(0.0, |index| 1.0 / float(index + 1));
139 for cutoff in cutoffs {
140 let selected = &ranked[..ranked.len().min(*cutoff)];
141 let relevant = selected
142 .iter()
143 .filter(|id| case.relevant_ids.contains(**id))
144 .count();
145 hit_at.entry(*cutoff).and_modify(|value| {
146 *value += f64::from(relevant > 0);
147 });
148 recall_at.entry(*cutoff).and_modify(|value| {
149 *value += float(relevant) / float(case.relevant_ids.len());
150 });
151 ndcg_at
152 .entry(*cutoff)
153 .and_modify(|value| *value += ndcg(selected, &case.relevant_ids, *cutoff));
154 }
155 }
156 let denominator = float(cases.len().max(1));
157 for metrics in [&mut hit_at, &mut recall_at, &mut ndcg_at] {
158 for value in metrics.values_mut() {
159 *value /= denominator;
160 }
161 }
162 RetrievalMetrics {
163 cases: cases.len(),
164 hit_at,
165 recall_at,
166 ndcg_at,
167 mean_reciprocal_rank: reciprocal_rank / denominator,
168 }
169}
170
171fn ndcg(selected: &[&str], relevant: &BTreeSet<String>, cutoff: usize) -> f64 {
172 let dcg = selected
173 .iter()
174 .enumerate()
175 .filter(|(_, id)| relevant.contains(**id))
176 .map(|(index, _)| 1.0 / float(index + 2).log2())
177 .sum::<f64>();
178 let ideal = (0..relevant.len().min(cutoff))
179 .map(|index| 1.0 / float(index + 2).log2())
180 .sum::<f64>();
181 if ideal == 0.0 { 0.0 } else { dcg / ideal }
182}
183
184fn invalid(reason: &'static str) -> MemoryError {
185 MemoryError::InvalidValue {
186 field: "evaluation",
187 reason,
188 }
189}
190
191fn float(value: usize) -> f64 {
192 f64::from(u32::try_from(value).unwrap_or(u32::MAX))
193}