lean_ctx/core/locomo/
report.rs1use serde::{Deserialize, Serialize};
4
5use super::runner::SampleResult;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CategoryMetrics {
11 pub category: u8,
13 pub label: String,
14 pub questions: usize,
15 pub containment_rate: f64,
17 pub mean_f1: f64,
18 pub exact_match_rate: f64,
19 pub mean_recall_tokens: f64,
20}
21
22#[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 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
81pub 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 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}