Skip to main content

mecha_core/
runlog.rs

1//! The run-quality corpus: every recorded outcome, across every session.
2//!
3//! [`crate::session::RunStats`] is written one row per finished run. This is
4//! the reader that puts them side by side, which is the whole point of having
5//! recorded them: one run's counters say almost nothing, and a thousand runs'
6//! counters say what normal looks like and when it stopped.
7//!
8//! ## Why this reads the transcripts instead of keeping a ledger
9//!
10//! A second file would be faster and would be a second source of truth. The
11//! transcript already holds the rows, written by the process that produced
12//! them; a ledger beside it can disagree with it, and then someone has to
13//! decide which is right. Same reasoning as the TUI reading a trigger's last
14//! answer back from the session record rather than caching it.
15//!
16//! The cost is that a scan reads files, so every scan is **bounded** —
17//! newest-first, with a session cap and an optional cutoff. A corpus reader
18//! that must read everything before it answers is one nobody runs
19//! interactively, and doctor runs in one pass with no network and no model.
20//!
21//! ## What it deliberately does not do
22//!
23//! No judgement. Nothing here decides that a rate is bad, because "bad"
24//! depends on what a run was for, and the thresholds belong with the reader
25//! that acts on them. This module counts.
26
27use 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/// One finished run, with enough of its session to be identifiable.
35#[derive(Debug, Clone)]
36pub struct RunRow {
37    pub session_id: String,
38    /// When the *session* started. A resumed session's later runs share it;
39    /// the transcript records no per-run stamp, and inventing one from file
40    /// mtime would be a guess dressed as a measurement.
41    pub started_at: DateTime<Utc>,
42    pub provider: String,
43    pub model: String,
44    pub title: Option<String>,
45    /// Which run within the session, 1-based. A resumed session has several.
46    pub run: u32,
47    pub stats: RunStats,
48}
49
50/// Every run the scan looked at, newest session first.
51#[derive(Debug, Clone, Default)]
52pub struct Corpus {
53    pub rows: Vec<RunRow>,
54    /// Sessions read, including those that contributed no rows — the
55    /// denominator for "how much of the store did this answer come from".
56    pub sessions_read: usize,
57}
58
59/// How to bound a scan. Both limits are honest about cost rather than about
60/// relevance: the caller decides how much reading it can afford.
61#[derive(Debug, Clone, Default)]
62pub struct Scan {
63    /// Stop after this many sessions, newest first.
64    pub max_sessions: Option<usize>,
65    /// Skip sessions started before this.
66    pub since: Option<DateTime<Utc>>,
67}
68
69impl Corpus {
70    /// Read outcomes out of the session store.
71    ///
72    /// Best-effort per session, like every other reader over this store: an
73    /// unreadable or torn transcript contributes nothing and does not stop
74    /// the ones after it.
75    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            // Attributed rather than taken from the header: a mid-session
86            // model switch writes a `Config`, and crediting those runs to the
87            // header's model would defeat `by_model` in the one case where a
88            // corpus genuinely blends two.
89            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    /// Keep only the rows a predicate accepts. `sessions_read` is preserved,
116    /// because it describes the scan and not the selection.
117    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    /// Share of attempted calls the environment refused.
139    ///
140    /// `None` when nothing was attempted — the denominator is zero, and a
141    /// rate over no calls is undefined rather than perfect. That distinction
142    /// is the one a threshold silent on zero gets wrong, which is how a
143    /// trigger that stopped working entirely read as healthy.
144    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    /// Runs that decided they were done with their last call failed.
150    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    /// Share of runs that did. `None` on an empty corpus, for the reason
158    /// above.
159    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    /// How runs ended. Runs recorded before `stop_cause` existed, or written
165    /// by a path that did not set it, count under `None` rather than being
166    /// assumed complete.
167    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    /// Total cost, and how many rows knew theirs. Reported as a pair because
183    /// a total over partial data is a lower bound, and one that does not say
184    /// so is a wrong number.
185    pub fn cost_usd(&self) -> (f64, usize) {
186        let priced: Vec<f64> = self.rows.iter().filter_map(|r| r.stats.cost_usd).collect();
187        // `+ 0.0` normalizes the sign: Rust's `Sum for f64` folds from -0.0 to
188        // preserve the sign of a negative-zero summand, so an empty corpus
189        // otherwise reports a cost of `-0.00`, which reads as a bug in the
190        // price table rather than as an absence of priced runs.
191        let total: f64 = priced.iter().sum::<f64>() + 0.0;
192        (total, priced.len())
193    }
194
195    /// Split by model, so a rate can be read against the thing that produced
196    /// it. A corpus spanning two models has no single error rate worth
197    /// quoting.
198    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            // The scan's denominator, not the slice's: `sessions_read`
204            // describes how much of the store was looked at, which is the
205            // same for every bucket. Left at zero it reads as "from 0
206            // sessions", which is a lie in the one direction that matters —
207            // it makes a well-sampled rate look like it came from nowhere.
208            bucket.sessions_read = self.sessions_read;
209        }
210        out
211    }
212}
213
214/// Every `Record` variant a corpus scan ignores, named so the compiler
215/// complains when a new one appears and nobody decided what it means here.
216#[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                // A resumed session: several runs, one row each, numbered.
299                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        // The TUI can change model mid-session and records a `Config` when it
340        // does. Reading the header instead would credit the second model's
341        // runs to the first — defeating `by_model` in the one case where a
342        // corpus genuinely blends two, and pointing a threshold at the wrong
343        // model.
344        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        // The distinction a threshold silent on zero gets wrong: no calls is
372        // not a clean record, it is no evidence. An empty corpus and a corpus
373        // of tool-less runs both answer `None`, and a reader that wants to
374        // treat that as healthy has to say so itself.
375        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        // A run-level rate is still defined: there was a run, it just made no
395        // calls. The two denominators are different questions.
396        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        // The cutoff is on the session's own stamp, and every fixture here
427        // predates this one, so nothing survives it.
428        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        // Transcripts written before the record existed, and runs that died
449        // before producing one. They must not read as a run with zero of
450        // everything, which would drag every rate toward a fiction.
451        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        // The blended number is true and useless: neither model behaves this
488        // way, and a threshold on it fires for the wrong one.
489        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}