1use crate::agent::StopCause;
28use crate::session::{Record, RunStats, Session};
29use anyhow::Result;
30use chrono::{DateTime, Utc};
31use std::collections::BTreeMap;
32use std::path::Path;
33
34#[derive(Debug, Clone)]
36pub struct RunRow {
37 pub session_id: String,
38 pub started_at: DateTime<Utc>,
42 pub provider: String,
43 pub model: String,
44 pub title: Option<String>,
45 pub run: u32,
47 pub stats: RunStats,
48}
49
50#[derive(Debug, Clone, Default)]
52pub struct Corpus {
53 pub rows: Vec<RunRow>,
54 pub sessions_read: usize,
57}
58
59#[derive(Debug, Clone, Default)]
62pub struct Scan {
63 pub max_sessions: Option<usize>,
65 pub since: Option<DateTime<Utc>>,
67}
68
69impl Corpus {
70 pub fn scan(dir: &Path, scan: &Scan) -> Result<Corpus> {
76 let mut out = Corpus::default();
77 for (meta, path) in Session::list(dir)? {
78 if scan.since.is_some_and(|t| meta.created_at < t) {
79 continue;
80 }
81 if scan.max_sessions.is_some_and(|n| out.sessions_read >= n) {
82 break;
83 }
84 out.sessions_read += 1;
85 let Ok(rows) = Session::outcomes_attributed(&path) else {
90 continue;
91 };
92 for (i, (provider, model, s)) in rows.into_iter().enumerate() {
93 out.rows.push(RunRow {
94 session_id: meta.id.clone(),
95 started_at: meta.created_at,
96 provider,
97 model,
98 title: meta.title.clone(),
99 run: i as u32 + 1,
100 stats: s,
101 });
102 }
103 }
104 Ok(out)
105 }
106
107 pub fn len(&self) -> usize {
108 self.rows.len()
109 }
110
111 pub fn is_empty(&self) -> bool {
112 self.rows.is_empty()
113 }
114
115 pub fn filter(&self, keep: impl Fn(&RunRow) -> bool) -> Corpus {
118 Corpus {
119 rows: self.rows.iter().filter(|r| keep(r)).cloned().collect(),
120 sessions_read: self.sessions_read,
121 }
122 }
123
124 pub fn tool_calls(&self) -> u64 {
125 self.rows
126 .iter()
127 .map(|r| u64::from(r.stats.tool_calls))
128 .sum()
129 }
130
131 pub fn tool_errors(&self) -> u64 {
132 self.rows
133 .iter()
134 .map(|r| u64::from(r.stats.tool_errors))
135 .sum()
136 }
137
138 pub fn tool_error_rate(&self) -> Option<f64> {
145 let calls = self.tool_calls();
146 (calls > 0).then(|| self.tool_errors() as f64 / calls as f64)
147 }
148
149 pub fn ended_on_failed_call(&self) -> usize {
151 self.rows
152 .iter()
153 .filter(|r| r.stats.ended_on_failed_call)
154 .count()
155 }
156
157 pub fn rate_of(&self, of: impl Fn(&RunRow) -> bool) -> Option<f64> {
160 (!self.rows.is_empty())
161 .then(|| self.rows.iter().filter(|r| of(r)).count() as f64 / self.rows.len() as f64)
162 }
163
164 pub fn stop_causes(&self) -> BTreeMap<Option<StopCause>, usize> {
168 let mut out = BTreeMap::new();
169 for row in &self.rows {
170 *out.entry(row.stats.stop_cause).or_insert(0) += 1;
171 }
172 out
173 }
174
175 pub fn compactions(&self) -> u64 {
176 self.rows
177 .iter()
178 .map(|r| u64::from(r.stats.compactions))
179 .sum()
180 }
181
182 pub fn cost_usd(&self) -> (f64, usize) {
186 let priced: Vec<f64> = self.rows.iter().filter_map(|r| r.stats.cost_usd).collect();
187 let total: f64 = priced.iter().sum::<f64>() + 0.0;
192 (total, priced.len())
193 }
194
195 pub fn by_model(&self) -> BTreeMap<String, Corpus> {
199 let mut out: BTreeMap<String, Corpus> = BTreeMap::new();
200 for row in &self.rows {
201 let bucket = out.entry(row.model.clone()).or_default();
202 bucket.rows.push(row.clone());
203 bucket.sessions_read = self.sessions_read;
209 }
210 out
211 }
212}
213
214#[allow(dead_code)]
217fn exhaustive(record: &Record) {
218 match record {
219 Record::Meta(_)
220 | Record::Message(_)
221 | Record::Summary { .. }
222 | Record::Config(_)
223 | Record::Taint(_)
224 | Record::Rewrite { .. }
225 | Record::Outcome(_) => {}
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::agent::Taint;
233 use crate::message::Usage;
234 use crate::session::SessionMeta;
235 use std::path::PathBuf;
236
237 fn tmpdir() -> PathBuf {
238 let dir = std::env::temp_dir().join(format!(
239 "mecha-runlog-test-{}-{:?}",
240 std::process::id(),
241 std::thread::current().id()
242 ));
243 let _ = std::fs::remove_dir_all(&dir);
244 std::fs::create_dir_all(&dir).unwrap();
245 dir
246 }
247
248 fn session_with(dir: &Path, id: &str, model: &str, runs: Vec<RunStats>) -> Session {
249 let s = Session::create(
250 dir,
251 SessionMeta {
252 id: id.to_string(),
253 created_at: DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
254 .unwrap()
255 .with_timezone(&Utc),
256 provider: "local".into(),
257 model: model.to_string(),
258 workspace: PathBuf::from("/tmp"),
259 title: None,
260 },
261 )
262 .unwrap();
263 for stats in runs {
264 s.append(&Record::Outcome(stats)).unwrap();
265 }
266 s
267 }
268
269 fn stats(calls: u32, errors: u32, ended_failed: bool, cause: StopCause) -> RunStats {
270 RunStats {
271 turns: 3,
272 usage: Usage::default(),
273 cost_usd: Some(0.25),
274 usage_complete: true,
275 stop_cause: Some(cause),
276 exhausted: false,
277 ended_on_failed_call: ended_failed,
278 tool_calls: calls,
279 tool_errors: errors,
280 tool_denied: 0,
281 tool_staged: 0,
282 malformed_tool_args: 0,
283 blocked_sends: 0,
284 compactions: 1,
285 taint: Taint::default(),
286 }
287 }
288
289 #[test]
290 fn a_scan_collects_every_run_across_every_session() {
291 let dir = tmpdir();
292 session_with(
293 &dir,
294 "20260801T000000-a",
295 "opus",
296 vec![
297 stats(4, 1, false, StopCause::Completed),
298 stats(6, 0, true, StopCause::MaxTurns),
300 ],
301 );
302 session_with(
303 &dir,
304 "20260801T000001-b",
305 "opus",
306 vec![stats(10, 4, false, StopCause::Completed)],
307 );
308
309 let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
310 assert_eq!(corpus.len(), 3);
311 assert_eq!(corpus.sessions_read, 2);
312 assert_eq!(corpus.tool_calls(), 20);
313 assert_eq!(corpus.tool_errors(), 5);
314 assert_eq!(corpus.compactions(), 3);
315 assert_eq!(corpus.ended_on_failed_call(), 1);
316 assert_eq!(corpus.cost_usd(), (0.75, 3));
317
318 let runs: Vec<u32> = corpus
319 .rows
320 .iter()
321 .filter(|r| r.session_id.ends_with('a'))
322 .map(|r| r.run)
323 .collect();
324 assert_eq!(
325 runs,
326 vec![1, 2],
327 "runs within a session are numbered in order"
328 );
329
330 let causes = corpus.stop_causes();
331 assert_eq!(causes[&Some(StopCause::Completed)], 2);
332 assert_eq!(causes[&Some(StopCause::MaxTurns)], 1);
333
334 let _ = std::fs::remove_dir_all(&dir);
335 }
336
337 #[test]
338 fn a_mid_session_model_switch_attributes_each_run_to_the_model_that_ran_it() {
339 let dir = tmpdir();
345 let s = session_with(
346 &dir,
347 "20260801T000000-switch",
348 "first-model",
349 vec![stats(4, 0, false, StopCause::Completed)],
350 );
351 s.append(&Record::Config(crate::session::RunConfig {
352 provider: "local".into(),
353 model: "second-model".into(),
354 ..Default::default()
355 }))
356 .unwrap();
357 s.append(&Record::Outcome(stats(6, 3, false, StopCause::Completed)))
358 .unwrap();
359
360 let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
361 assert_eq!(corpus.len(), 2);
362 let by_model = corpus.by_model();
363 assert_eq!(by_model["first-model"].tool_error_rate(), Some(0.0));
364 assert_eq!(by_model["second-model"].tool_error_rate(), Some(0.5));
365
366 let _ = std::fs::remove_dir_all(&dir);
367 }
368
369 #[test]
370 fn a_rate_over_nothing_is_unknown_rather_than_perfect() {
371 let dir = tmpdir();
376 let empty = Corpus::scan(&dir, &Scan::default()).unwrap();
377 assert!(empty.is_empty());
378 assert_eq!(empty.tool_error_rate(), None);
379 assert_eq!(empty.rate_of(|r| r.stats.ended_on_failed_call), None);
380
381 session_with(
382 &dir,
383 "20260801T000000-c",
384 "opus",
385 vec![stats(0, 0, false, StopCause::Completed)],
386 );
387 let tool_less = Corpus::scan(&dir, &Scan::default()).unwrap();
388 assert_eq!(tool_less.len(), 1);
389 assert_eq!(
390 tool_less.tool_error_rate(),
391 None,
392 "no calls is not a clean record"
393 );
394 assert_eq!(
397 tool_less.rate_of(|r| r.stats.ended_on_failed_call),
398 Some(0.0)
399 );
400
401 let _ = std::fs::remove_dir_all(&dir);
402 }
403
404 #[test]
405 fn a_scan_is_bounded_and_says_how_much_it_read() {
406 let dir = tmpdir();
407 for i in 0..5 {
408 session_with(
409 &dir,
410 &format!("20260801T00000{i}-s"),
411 "opus",
412 vec![stats(2, 0, false, StopCause::Completed)],
413 );
414 }
415 let bounded = Corpus::scan(
416 &dir,
417 &Scan {
418 max_sessions: Some(2),
419 since: None,
420 },
421 )
422 .unwrap();
423 assert_eq!(bounded.sessions_read, 2);
424 assert_eq!(bounded.len(), 2);
425
426 let cut = Corpus::scan(
429 &dir,
430 &Scan {
431 max_sessions: None,
432 since: Some(
433 DateTime::parse_from_rfc3339("2026-08-02T00:00:00Z")
434 .unwrap()
435 .with_timezone(&Utc),
436 ),
437 },
438 )
439 .unwrap();
440 assert!(cut.is_empty());
441 assert_eq!(cut.sessions_read, 0);
442
443 let _ = std::fs::remove_dir_all(&dir);
444 }
445
446 #[test]
447 fn a_session_with_no_outcomes_is_read_and_contributes_nothing() {
448 let dir = tmpdir();
452 let s = session_with(&dir, "20260801T000000-d", "opus", vec![]);
453 s.append_messages(&[crate::message::Message::user("go")])
454 .unwrap();
455 session_with(
456 &dir,
457 "20260801T000001-e",
458 "opus",
459 vec![stats(4, 2, false, StopCause::Completed)],
460 );
461
462 let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
463 assert_eq!(corpus.sessions_read, 2, "both sessions were read");
464 assert_eq!(corpus.len(), 1, "only one contributed a run");
465 assert_eq!(corpus.tool_error_rate(), Some(0.5));
466
467 let _ = std::fs::remove_dir_all(&dir);
468 }
469
470 #[test]
471 fn rates_split_by_model_because_a_mixed_corpus_has_no_single_one() {
472 let dir = tmpdir();
473 session_with(
474 &dir,
475 "20260801T000000-f",
476 "opus",
477 vec![stats(10, 1, false, StopCause::Completed)],
478 );
479 session_with(
480 &dir,
481 "20260801T000001-g",
482 "tiny-local",
483 vec![stats(10, 9, false, StopCause::Completed)],
484 );
485
486 let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
487 assert_eq!(corpus.tool_error_rate(), Some(0.5));
490 let by_model = corpus.by_model();
491 assert_eq!(by_model["opus"].tool_error_rate(), Some(0.1));
492 assert_eq!(by_model["tiny-local"].tool_error_rate(), Some(0.9));
493
494 let _ = std::fs::remove_dir_all(&dir);
495 }
496}