Skip to main content

lean_ctx/core/locomo/
report.rs

1//! Aggregate per-question results into publishable LoCoMo metrics (#291).
2
3use serde::{Deserialize, Serialize};
4
5use super::runner::SampleResult;
6
7/// Aggregated metrics for one slice of questions (a category, or `category = 0`
8/// for the overall row).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CategoryMetrics {
11    /// LoCoMo category, or 0 for "overall".
12    pub category: u8,
13    pub label: String,
14    pub questions: usize,
15    /// Fraction of questions whose gold answer was contained in recalled context.
16    pub containment_rate: f64,
17    pub mean_f1: f64,
18    pub exact_match_rate: f64,
19    pub mean_recall_tokens: f64,
20}
21
22/// A complete, committable benchmark report.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct LocomoReport {
25    pub suite: String,
26    pub generated_at: String,
27    pub top_k: usize,
28    pub samples: usize,
29    pub questions: usize,
30    pub overall: CategoryMetrics,
31    pub by_category: Vec<CategoryMetrics>,
32    pub mean_transcript_tokens: f64,
33    pub mean_recall_tokens: f64,
34    /// Token reduction of recalled context vs. dumping the full transcript.
35    pub token_reduction_pct: f64,
36}
37
38fn category_label(category: u8) -> &'static str {
39    match category {
40        0 => "overall",
41        1 => "single-hop",
42        2 => "multi-hop",
43        3 => "temporal",
44        4 => "open-domain",
45        5 => "adversarial",
46        _ => "other",
47    }
48}
49
50fn mean(values: impl Iterator<Item = f64>) -> f64 {
51    let mut n = 0usize;
52    let mut sum = 0.0;
53    for v in values {
54        sum += v;
55        n += 1;
56    }
57    if n == 0 {
58        0.0
59    } else {
60        sum / n as f64
61    }
62}
63
64fn round3(x: f64) -> f64 {
65    (x * 1000.0).round() / 1000.0
66}
67
68fn metrics_for(category: u8, qa: &[&super::runner::QaResult]) -> CategoryMetrics {
69    let questions = qa.len();
70    CategoryMetrics {
71        category,
72        label: category_label(category).to_string(),
73        questions,
74        containment_rate: round3(mean(qa.iter().map(|q| f64::from(u8::from(q.contained))))),
75        mean_f1: round3(mean(qa.iter().map(|q| q.f1))),
76        exact_match_rate: round3(mean(qa.iter().map(|q| f64::from(u8::from(q.exact_match))))),
77        mean_recall_tokens: round3(mean(qa.iter().map(|q| q.recall_tokens as f64))),
78    }
79}
80
81/// Aggregate sample results into a report.
82pub fn aggregate(suite: &str, top_k: usize, results: &[SampleResult]) -> LocomoReport {
83    let all: Vec<&super::runner::QaResult> = results.iter().flat_map(|r| r.qa.iter()).collect();
84    let overall = metrics_for(0, &all);
85
86    let mut categories: Vec<u8> = all.iter().map(|q| q.category).collect();
87    categories.sort_unstable();
88    categories.dedup();
89    let by_category: Vec<CategoryMetrics> = categories
90        .into_iter()
91        .map(|cat| {
92            let slice: Vec<&super::runner::QaResult> =
93                all.iter().copied().filter(|q| q.category == cat).collect();
94            metrics_for(cat, &slice)
95        })
96        .collect();
97
98    let mean_transcript_tokens = round3(mean(
99        results
100            .iter()
101            .flat_map(|r| r.qa.iter().map(|_| r.transcript_tokens as f64)),
102    ));
103    let mean_recall_tokens = overall.mean_recall_tokens;
104    let token_reduction_pct = if mean_transcript_tokens > 0.0 {
105        round3((1.0 - mean_recall_tokens / mean_transcript_tokens) * 100.0)
106    } else {
107        0.0
108    };
109
110    LocomoReport {
111        suite: suite.to_string(),
112        generated_at: chrono::Utc::now().to_rfc3339(),
113        top_k,
114        samples: results.len(),
115        questions: all.len(),
116        overall,
117        by_category,
118        mean_transcript_tokens,
119        mean_recall_tokens,
120        token_reduction_pct,
121    }
122}
123
124impl LocomoReport {
125    pub fn to_json(&self) -> String {
126        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
127    }
128
129    /// Human/publishable Markdown summary.
130    pub fn to_markdown(&self) -> String {
131        let mut out = String::new();
132        out.push_str("# LoCoMo Memory Benchmark — lean-ctx\n\n");
133        out.push_str(&format!(
134            "Suite: `{}` · samples: {} · questions: {} · top_k: {}\n\n",
135            self.suite, self.samples, self.questions, self.top_k
136        ));
137        out.push_str("Retrieval-recall benchmark: each conversation turn is stored as a memory, then for every question the top-k memories are recalled and scored against the gold answers. Model-free and deterministic.\n\n");
138        out.push_str("## Overall\n\n");
139        out.push_str("| metric | value |\n|---|---|\n");
140        out.push_str(&format!(
141            "| answer containment (recall@{}) | {:.1}% |\n",
142            self.top_k,
143            self.overall.containment_rate * 100.0
144        ));
145        out.push_str(&format!(
146            "| mean best-memory token-F1 | {:.3} |\n",
147            self.overall.mean_f1
148        ));
149        out.push_str(&format!(
150            "| exact-match rate | {:.1}% |\n",
151            self.overall.exact_match_rate * 100.0
152        ));
153        out.push_str(&format!(
154            "| mean recalled-context tokens | {:.0} |\n",
155            self.mean_recall_tokens
156        ));
157        out.push_str(&format!(
158            "| mean full-transcript tokens | {:.0} |\n",
159            self.mean_transcript_tokens
160        ));
161        out.push_str(&format!(
162            "| token reduction vs. full transcript | {:.1}% |\n\n",
163            self.token_reduction_pct
164        ));
165
166        out.push_str("## By category\n\n");
167        out.push_str("| category | questions | containment | mean F1 | recall tokens |\n");
168        out.push_str("|---|---|---|---|---|\n");
169        for c in &self.by_category {
170            out.push_str(&format!(
171                "| {} | {} | {:.1}% | {:.3} | {:.0} |\n",
172                c.label,
173                c.questions,
174                c.containment_rate * 100.0,
175                c.mean_f1,
176                c.mean_recall_tokens
177            ));
178        }
179        out.push('\n');
180        out.push_str(&format!("_Generated {}._\n", self.generated_at));
181        out
182    }
183}