Skip to main content

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;