Skip to main content

weavatrix_memory/
evaluation.rs

1use 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
33/// Scores ranked evidence retrieval without an LLM judge.
34///
35/// The same metrics are usable for `LoCoMo` dialog evidence, `LongMemEval`
36/// session evidence, and coding-agent provenance. Missing predictions count as
37/// misses; unknown and duplicate case identifiers are rejected.
38///
39/// # Errors
40///
41/// Rejects empty cutoffs, duplicate identifiers, empty relevance sets, and
42/// predictions for unknown cases.
43pub 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}