klieo_eval/lib.rs
1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! Recall-quality metrics for klieo memory pipelines.
4//!
5//! Pure scoring — no I/O, no async, no dependency on the memory
6//! traits. Callers run the recall against whatever pipeline they
7//! want (Qdrant, sqlite-vec, hybrid GraphRAG, …), collect the
8//! returned ids into a [`RecallSample`], and hand the slice to
9//! [`score_recall`].
10//!
11//! # Metrics
12//!
13//! - **hit_rate** — fraction of queries with at least one expected
14//! id in the top-K result list.
15//! - **mean reciprocal rank (MRR)** — mean of `1 / rank-of-first-hit`
16//! across queries; non-hit contributes 0.
17//! - **mean precision@K** — `|expected ∩ top-K| / K`, averaged
18//! across queries.
19//! - **mean recall@K** — `|expected ∩ top-K| / |expected|`,
20//! averaged across queries with at least one expected id.
21//! Queries with empty `expected` do not contribute to the mean.
22//! - **mean NDCG@K** — normalised discounted cumulative gain over
23//! the top-K results, binary relevance (`1` if expected, `0`
24//! otherwise), averaged across queries with at least one
25//! expected id.
26//!
27//! # Example
28//!
29//! ```
30//! use klieo_eval::{score_recall, RecallSample};
31//!
32//! let samples = vec![RecallSample::new(
33//! "ICT risk management",
34//! vec!["dora-art-5".into(), "dora-art-6".into()],
35//! vec![
36//! "dora-art-5".into(),
37//! "ai-act-art-6".into(),
38//! "dora-art-6".into(),
39//! ],
40//! )];
41//! let report = score_recall(&samples, 5);
42//! assert!((report.hit_rate() - 1.0).abs() < 1e-9);
43//! assert!(report.mean_reciprocal_rank() > 0.99);
44//! ```
45
46use std::collections::HashSet;
47
48use serde::Serialize;
49
50#[cfg(feature = "agent-eval")]
51pub mod agent_eval;
52#[cfg(feature = "agent-eval")]
53#[allow(deprecated)]
54pub use agent_eval::eval_capture;
55#[cfg(feature = "agent-eval")]
56pub use agent_eval::{check_regression, eval_capture_determinism, EvalMetrics, Regression};
57#[cfg(feature = "agent-eval")]
58pub mod live_eval;
59#[cfg(feature = "agent-eval")]
60pub use live_eval::{eval_capture_live, LiveEvalMetrics};
61
62pub mod classifier_eval;
63pub use classifier_eval::{
64 extract_label, score_classification, ClassificationCase, ClassificationReport, LabelSpec,
65 LabelStat, Miss,
66};
67
68/// One scored query — the question, the ground-truth ids that
69/// should land in the top-K, and the actual ranked id list
70/// returned by the recall pipeline (top result first).
71#[derive(Debug, Clone)]
72#[non_exhaustive]
73pub struct RecallSample {
74 /// Query text (used only for trace output; not consumed by the metrics).
75 pub query: String,
76 /// Ground-truth ids that should land in the top-K window.
77 pub expected: Vec<String>,
78 /// Actual ranked id list returned by the recall pipeline (top result first).
79 pub returned_ids: Vec<String>,
80}
81
82impl RecallSample {
83 /// Construct a sample. An empty `expected` makes recall and NDCG
84 /// undefined — filter such samples before scoring with [`score_recall`].
85 pub fn new(query: impl Into<String>, expected: Vec<String>, returned_ids: Vec<String>) -> Self {
86 Self {
87 query: query.into(),
88 expected,
89 returned_ids,
90 }
91 }
92}
93
94/// Per-query trace plus aggregate metrics computed across the
95/// scored sample slice.
96#[derive(Debug, Clone, Serialize)]
97#[non_exhaustive]
98pub struct RecallReport {
99 /// Top-K window used for every per-query metric.
100 pub k: usize,
101 /// One [`PerQueryScore`] per input sample, in input order.
102 pub per_query: Vec<PerQueryScore>,
103}
104
105/// One row of the report — every metric for one query, plus the
106/// rank of the first hit (1-based) or `None` when none of the
107/// expected ids appeared in the top-K window. `expected_count`
108/// is preserved so recall- and NDCG-shaped means can correctly
109/// skip queries with empty `expected`.
110#[derive(Debug, Clone, Serialize)]
111#[non_exhaustive]
112pub struct PerQueryScore {
113 /// Query text, copied from the input sample for trace output.
114 pub query: String,
115 /// `|expected|` for this sample — used by aggregators to skip
116 /// queries where recall / NDCG would be undefined.
117 pub expected_count: usize,
118 /// 1-based rank of the first expected id within the top-K window,
119 /// or `None` if no expected id landed in the window.
120 pub first_hit_rank: Option<usize>,
121 /// `|expected ∩ top-K| / k` for this query (always defined; `0.0` when `k == 0`).
122 pub precision_at_k: f64,
123 /// `|expected ∩ top-K| / |expected|` for this query (`0.0` when `expected` is empty).
124 pub recall_at_k: f64,
125 /// Normalised DCG@K with binary relevance for this query
126 /// (`0.0` when `expected` is empty or `k == 0`).
127 pub ndcg_at_k: f64,
128}
129
130impl RecallReport {
131 /// Fraction of queries where any expected id appeared in the
132 /// top-K. `0.0` when there are no queries.
133 pub fn hit_rate(&self) -> f64 {
134 if self.per_query.is_empty() {
135 return 0.0;
136 }
137 let hits = self
138 .per_query
139 .iter()
140 .filter(|q| q.first_hit_rank.is_some())
141 .count();
142 hits as f64 / self.per_query.len() as f64
143 }
144
145 /// Mean of `1 / rank` of the first expected hit across all
146 /// queries. Non-hits contribute `0`. `0.0` when there are no
147 /// queries.
148 pub fn mean_reciprocal_rank(&self) -> f64 {
149 if self.per_query.is_empty() {
150 return 0.0;
151 }
152 let sum: f64 = self
153 .per_query
154 .iter()
155 .map(|q| {
156 q.first_hit_rank
157 .map(|rank| 1.0 / rank as f64)
158 .unwrap_or(0.0)
159 })
160 .sum();
161 sum / self.per_query.len() as f64
162 }
163
164 /// Mean precision@K across all queries. `0.0` when there are
165 /// no queries.
166 pub fn mean_precision_at_k(&self) -> f64 {
167 mean(self.per_query.iter().map(|q| q.precision_at_k))
168 }
169
170 /// Mean recall@K across queries with at least one expected
171 /// id. Queries with empty `expected` are excluded from the
172 /// denominator — recall is undefined when there is nothing
173 /// to retrieve. `0.0` when no query qualifies.
174 pub fn mean_recall_at_k(&self) -> f64 {
175 mean(
176 self.per_query
177 .iter()
178 .filter(|q| q.expected_count > 0)
179 .map(|q| q.recall_at_k),
180 )
181 }
182
183 /// Mean NDCG@K across queries with at least one expected id.
184 /// Same skip rule as [`Self::mean_recall_at_k`] — NDCG is
185 /// undefined when ideal DCG is zero. `0.0` when no query
186 /// qualifies.
187 pub fn mean_ndcg_at_k(&self) -> f64 {
188 mean(
189 self.per_query
190 .iter()
191 .filter(|q| q.expected_count > 0)
192 .map(|q| q.ndcg_at_k),
193 )
194 }
195}
196
197/// Score a batch of samples against a top-K window.
198///
199/// `k` is the window size used for every per-query metric — the
200/// caller must truncate or pad `returned_ids` to honour their own
201/// retrieval-limit semantics before scoring. The harness will
202/// only ever inspect the first `k` entries of each
203/// `returned_ids` list.
204pub fn score_recall(samples: &[RecallSample], k: usize) -> RecallReport {
205 let per_query = samples
206 .iter()
207 .map(|sample| score_sample(sample, k))
208 .collect();
209 RecallReport { k, per_query }
210}
211
212fn score_sample(sample: &RecallSample, k: usize) -> PerQueryScore {
213 let expected_set: HashSet<&str> = sample.expected.iter().map(String::as_str).collect();
214 let window: Vec<&str> = sample
215 .returned_ids
216 .iter()
217 .take(k)
218 .map(String::as_str)
219 .collect();
220 let expected_count = sample.expected.len();
221 let first_hit_rank = first_hit_rank(&window, &expected_set);
222 let precision_at_k = precision_at_k(&window, &expected_set, k);
223 let recall_at_k = recall_at_k(&window, &expected_set, expected_count);
224 let ndcg_at_k = ndcg_at_k(&window, &expected_set, expected_count, k);
225 PerQueryScore {
226 query: sample.query.clone(),
227 expected_count,
228 first_hit_rank,
229 precision_at_k,
230 recall_at_k,
231 ndcg_at_k,
232 }
233}
234
235fn first_hit_rank(window: &[&str], expected: &HashSet<&str>) -> Option<usize> {
236 window
237 .iter()
238 .position(|id| expected.contains(*id))
239 .map(|i| i + 1)
240}
241
242fn precision_at_k(window: &[&str], expected: &HashSet<&str>, k: usize) -> f64 {
243 if k == 0 {
244 return 0.0;
245 }
246 let intersect = window.iter().filter(|id| expected.contains(**id)).count();
247 intersect as f64 / k as f64
248}
249
250fn recall_at_k(window: &[&str], expected: &HashSet<&str>, expected_count: usize) -> f64 {
251 if expected_count == 0 {
252 return 0.0;
253 }
254 let intersect = window.iter().filter(|id| expected.contains(**id)).count();
255 intersect as f64 / expected_count as f64
256}
257
258fn ndcg_at_k(window: &[&str], expected: &HashSet<&str>, expected_count: usize, k: usize) -> f64 {
259 if expected_count == 0 || k == 0 {
260 return 0.0;
261 }
262 let dcg: f64 = window
263 .iter()
264 .enumerate()
265 .map(|(idx, id)| {
266 let relevance = if expected.contains(*id) { 1.0 } else { 0.0 };
267 relevance / ((idx + 2) as f64).log2()
268 })
269 .sum();
270 let ideal_hits = expected_count.min(k);
271 let idcg: f64 = (0..ideal_hits)
272 .map(|idx| 1.0 / ((idx + 2) as f64).log2())
273 .sum();
274 if idcg == 0.0 {
275 0.0
276 } else {
277 dcg / idcg
278 }
279}
280
281fn mean(iter: impl IntoIterator<Item = f64>) -> f64 {
282 let (sum, count) = iter
283 .into_iter()
284 .fold((0.0, 0usize), |(s, c), v| (s + v, c + 1));
285 if count == 0 {
286 0.0
287 } else {
288 sum / count as f64
289 }
290}
291
292#[cfg(test)]
293mod tests;