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    /// Transcripts the scan could not read at all — a headerless file the
58    /// listing skipped, or one whose body failed to parse. Never folded into
59    /// `sessions_read`: an unreadable store is a finding, not an empty
60    /// queue, and a store rotting one file at a time was invisible from
61    /// every reader before this was counted.
62    pub unreadable: usize,
63}
64
65/// How to bound a scan. Both limits are honest about cost rather than about
66/// relevance: the caller decides how much reading it can afford.
67#[derive(Debug, Clone, Default)]
68pub struct Scan {
69    /// Stop after this many sessions, newest first.
70    pub max_sessions: Option<usize>,
71    /// Skip sessions started before this.
72    pub since: Option<DateTime<Utc>>,
73}
74
75impl Corpus {
76    /// Read outcomes out of the session store.
77    ///
78    /// Best-effort per session, like every other reader over this store: an
79    /// unreadable or torn transcript contributes nothing and does not stop
80    /// the ones after it.
81    pub fn scan(dir: &Path, scan: &Scan) -> Result<Corpus> {
82        let mut out = Corpus::default();
83        let (listed, skipped) = Session::list_counting(dir)?;
84        out.unreadable = skipped;
85        for (meta, path) in listed {
86            if scan.since.is_some_and(|t| meta.created_at < t) {
87                continue;
88            }
89            if scan.max_sessions.is_some_and(|n| out.sessions_read >= n) {
90                break;
91            }
92            // Attributed rather than taken from the header: a mid-session
93            // model switch writes a `Config`, and crediting those runs to the
94            // header's model would defeat `by_model` in the one case where a
95            // corpus genuinely blends two.
96            //
97            // Counted as read only *after* the read succeeds — found on
98            // review: incrementing first put a torn-body transcript in both
99            // counters, which is exactly the "never folded into
100            // `sessions_read`" the field's own doc promises, broken two
101            // lines down. The two numbers are disjoint by construction now.
102            let Ok(rows) = Session::outcomes_attributed(&path) else {
103                out.unreadable += 1;
104                continue;
105            };
106            out.sessions_read += 1;
107            for (i, (provider, model, s)) in rows.into_iter().enumerate() {
108                out.rows.push(RunRow {
109                    session_id: meta.id.clone(),
110                    started_at: meta.created_at,
111                    provider,
112                    model,
113                    title: meta.title.clone(),
114                    run: i as u32 + 1,
115                    stats: s,
116                });
117            }
118        }
119        Ok(out)
120    }
121
122    pub fn len(&self) -> usize {
123        self.rows.len()
124    }
125
126    pub fn is_empty(&self) -> bool {
127        self.rows.is_empty()
128    }
129
130    /// Keep only the rows a predicate accepts. `sessions_read` and
131    /// `unreadable` are preserved, because they describe the scan and not
132    /// the selection.
133    pub fn filter(&self, keep: impl Fn(&RunRow) -> bool) -> Corpus {
134        Corpus {
135            rows: self.rows.iter().filter(|r| keep(r)).cloned().collect(),
136            sessions_read: self.sessions_read,
137            unreadable: self.unreadable,
138        }
139    }
140
141    pub fn tool_calls(&self) -> u64 {
142        self.rows
143            .iter()
144            .map(|r| u64::from(r.stats.tool_calls))
145            .sum()
146    }
147
148    pub fn tool_errors(&self) -> u64 {
149        self.rows
150            .iter()
151            .map(|r| u64::from(r.stats.tool_errors))
152            .sum()
153    }
154
155    /// Share of attempted calls the environment refused.
156    ///
157    /// `None` when nothing was attempted — the denominator is zero, and a
158    /// rate over no calls is undefined rather than perfect. That distinction
159    /// is the one a threshold silent on zero gets wrong, which is how a
160    /// trigger that stopped working entirely read as healthy.
161    pub fn tool_error_rate(&self) -> Option<f64> {
162        let calls = self.tool_calls();
163        (calls > 0).then(|| self.tool_errors() as f64 / calls as f64)
164    }
165
166    /// Runs that decided they were done with their last call failed.
167    pub fn ended_on_failed_call(&self) -> usize {
168        self.rows
169            .iter()
170            .filter(|r| r.stats.ended_on_failed_call)
171            .count()
172    }
173
174    /// Share of runs that did. `None` on an empty corpus, for the reason
175    /// above.
176    pub fn rate_of(&self, of: impl Fn(&RunRow) -> bool) -> Option<f64> {
177        (!self.rows.is_empty())
178            .then(|| self.rows.iter().filter(|r| of(r)).count() as f64 / self.rows.len() as f64)
179    }
180
181    /// How runs ended. Runs recorded before `stop_cause` existed, or written
182    /// by a path that did not set it, count under `None` rather than being
183    /// assumed complete.
184    pub fn stop_causes(&self) -> BTreeMap<Option<StopCause>, usize> {
185        let mut out = BTreeMap::new();
186        for row in &self.rows {
187            *out.entry(row.stats.stop_cause).or_insert(0) += 1;
188        }
189        out
190    }
191
192    pub fn compactions(&self) -> u64 {
193        self.rows
194            .iter()
195            .map(|r| u64::from(r.stats.compactions))
196            .sum()
197    }
198
199    /// Overflow recoveries, and how many rows had the sensor.
200    ///
201    /// A pair for `cost_usd`'s reason one field over: a total drawn from part
202    /// of the corpus is a lower bound, and one that does not say so is a wrong
203    /// number. Here the stakes are sharper than for cost, because the corpus
204    /// this is read from deliberately spans the introduction of the field —
205    /// so a caller that ignores the second element is comparing runs that
206    /// could report an overflow against runs that could not.
207    pub fn context_overflows(&self) -> (u64, usize) {
208        let sensed: Vec<u32> = self
209            .rows
210            .iter()
211            .filter_map(|r| r.stats.context_overflows)
212            .collect();
213        (sensed.iter().map(|n| u64::from(*n)).sum(), sensed.len())
214    }
215
216    /// Share of runs that hit at least one overflow, over the rows that could
217    /// have reported one.
218    ///
219    /// `None` when no row carried the sensor — not zero, which would make a
220    /// corpus written before the field indistinguishable from one where the
221    /// threshold never failed.
222    pub fn overflow_rate(&self) -> Option<f64> {
223        let sensed: Vec<u32> = self
224            .rows
225            .iter()
226            .filter_map(|r| r.stats.context_overflows)
227            .collect();
228        (!sensed.is_empty())
229            .then(|| sensed.iter().filter(|n| **n > 0).count() as f64 / sensed.len() as f64)
230    }
231
232    /// Share of runs the harness told at least once that an approach had
233    /// stopped teaching them anything, over the rows that could have reported
234    /// it (`GOAL-SYSTEM-DESIGN.md` §9.1).
235    ///
236    /// The number every threshold in `boredom.rs` is answerable against, and
237    /// it is a rate rather than a total on purpose: what the constants get
238    /// wrong is *how often* a run is spoken to, and a total over a corpus of
239    /// unknown size answers that only if you already know the size.
240    ///
241    /// `None` over no sensed rows, like every rate here — a corpus written
242    /// before the detector existed and one where nothing ever got stuck are
243    /// opposite findings.
244    pub fn boredom_rate(&self) -> Option<f64> {
245        let sensed: Vec<u32> = self
246            .rows
247            .iter()
248            .filter_map(|r| r.stats.boredom_notices)
249            .collect();
250        (!sensed.is_empty())
251            .then(|| sensed.iter().filter(|n| **n > 0).count() as f64 / sensed.len() as f64)
252    }
253
254    /// Average of `Homeostat::peak_context_pressure` over the rows that
255    /// sensed it (`GOAL-SYSTEM-DESIGN.md` §4, feeding `diagnose::Evidence`).
256    ///
257    /// A mean rather than a rate against a threshold, on purpose: this module
258    /// counts and never judges, and a fixed "high pressure" cutoff would be
259    /// exactly the judgement the reader — `diagnose::Evidence`'s consumer —
260    /// is supposed to make. `None` over no sensed rows, like every reading
261    /// here: a corpus written before the homeostat existed and one where
262    /// every run had headroom to spare are opposite findings.
263    pub fn mean_peak_context_pressure(&self) -> Option<f64> {
264        let sensed: Vec<f64> = self
265            .rows
266            .iter()
267            .filter_map(|r| r.stats.homeostat.as_ref())
268            .filter_map(|h| h.peak_context_pressure)
269            .map(f64::from)
270            .collect();
271        (!sensed.is_empty()).then(|| sensed.iter().sum::<f64>() / sensed.len() as f64)
272    }
273
274    /// Average of `Homeostat::anticipated_guilt` over the rows that sensed
275    /// it. See [`crate::guilt`] — the sensor has no consumer yet, and this is
276    /// the corpus existing before anything is built on it, same as every
277    /// other reading here.
278    ///
279    /// `None` over no sensed rows, not zero — a corpus predating the sensor
280    /// must not read as one where nothing was ever owed.
281    pub fn mean_anticipated_guilt(&self) -> Option<f64> {
282        let sensed: Vec<f64> = self
283            .rows
284            .iter()
285            .filter_map(|r| r.stats.homeostat.as_ref())
286            .filter_map(|h| h.anticipated_guilt)
287            .map(f64::from)
288            .collect();
289        (!sensed.is_empty()).then(|| sensed.iter().sum::<f64>() / sensed.len() as f64)
290    }
291
292    /// Total cost, and how many rows knew theirs. Reported as a pair because
293    /// a total over partial data is a lower bound, and one that does not say
294    /// so is a wrong number.
295    pub fn cost_usd(&self) -> (f64, usize) {
296        let priced: Vec<f64> = self.rows.iter().filter_map(|r| r.stats.cost_usd).collect();
297        // `+ 0.0` normalizes the sign: Rust's `Sum for f64` folds from -0.0 to
298        // preserve the sign of a negative-zero summand, so an empty corpus
299        // otherwise reports a cost of `-0.00`, which reads as a bug in the
300        // price table rather than as an absence of priced runs.
301        let total: f64 = priced.iter().sum::<f64>() + 0.0;
302        (total, priced.len())
303    }
304
305    /// Split by model, so a rate can be read against the thing that produced
306    /// it. A corpus spanning two models has no single error rate worth
307    /// quoting.
308    pub fn by_model(&self) -> BTreeMap<String, Corpus> {
309        let mut out: BTreeMap<String, Corpus> = BTreeMap::new();
310        for row in &self.rows {
311            let bucket = out.entry(row.model.clone()).or_default();
312            bucket.rows.push(row.clone());
313            // The scan's denominator, not the slice's: `sessions_read`
314            // describes how much of the store was looked at, which is the
315            // same for every bucket. Left at zero it reads as "from 0
316            // sessions", which is a lie in the one direction that matters —
317            // it makes a well-sampled rate look like it came from nowhere.
318            bucket.sessions_read = self.sessions_read;
319        }
320        out
321    }
322}
323
324/// Every `Record` variant a corpus scan ignores, named so the compiler
325/// complains when a new one appears and nobody decided what it means here.
326#[allow(dead_code)]
327fn exhaustive(record: &Record) {
328    match record {
329        Record::Meta(_)
330        | Record::Message(_)
331        | Record::Summary { .. }
332        | Record::Config(_)
333        | Record::Taint(_)
334        | Record::Rewrite { .. }
335        | Record::Outcome(_) => {}
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::agent::Taint;
343    use crate::message::Usage;
344    use crate::session::SessionMeta;
345    use std::path::PathBuf;
346
347    fn tmpdir() -> PathBuf {
348        let dir = std::env::temp_dir().join(format!(
349            "mecha-runlog-test-{}-{:?}",
350            std::process::id(),
351            std::thread::current().id()
352        ));
353        let _ = std::fs::remove_dir_all(&dir);
354        std::fs::create_dir_all(&dir).unwrap();
355        dir
356    }
357
358    fn session_with(dir: &Path, id: &str, model: &str, runs: Vec<RunStats>) -> Session {
359        let s = Session::create(
360            dir,
361            SessionMeta {
362                id: id.to_string(),
363                created_at: DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
364                    .unwrap()
365                    .with_timezone(&Utc),
366                provider: "local".into(),
367                model: model.to_string(),
368                workspace: PathBuf::from("/tmp"),
369                title: None,
370            },
371        )
372        .unwrap();
373        for stats in runs {
374            s.append(&Record::Outcome(stats)).unwrap();
375        }
376        s
377    }
378
379    fn stats(calls: u32, errors: u32, ended_failed: bool, cause: StopCause) -> RunStats {
380        RunStats {
381            homeostat: None,
382            context_overflows: None,
383            boredom_notices: None,
384            step_escalations_attempted: None,
385            step_escalations_revised: None,
386            turns: 3,
387            usage: Usage::default(),
388            cost_usd: Some(0.25),
389            usage_complete: true,
390            stop_cause: Some(cause),
391            exhausted: false,
392            ended_on_failed_call: ended_failed,
393            tool_calls: calls,
394            tool_errors: errors,
395            tool_denied: 0,
396            tool_staged: 0,
397            malformed_tool_args: 0,
398            blocked_sends: 0,
399            compactions: 1,
400            taint: Taint::default(),
401        }
402    }
403
404    /// The whole reason `context_overflows` is an `Option` where every other
405    /// counter here is a plain `u32`.
406    ///
407    /// This corpus is the shape the field will actually be read in: rows from
408    /// before the sensor existed sitting beside rows from after it. Under a
409    /// plain `u32` the old rows arrive as *zero overflows* and land in the
410    /// denominator, so the rate they dilute is the one the field was added to
411    /// establish — a change measured against it would look better the more
412    /// stale corpus it was averaged over.
413    #[test]
414    fn a_row_without_the_sensor_is_unknown_and_never_a_zero() {
415        let dir = tmpdir();
416        let sensed = |n: u32| {
417            let mut st = stats(4, 0, false, StopCause::Completed);
418            st.context_overflows = Some(n);
419            st
420        };
421        session_with(
422            &dir,
423            "20260801T000000-mixed",
424            "opus",
425            vec![
426                // Written before the field existed: knows nothing.
427                stats(4, 0, false, StopCause::Completed),
428                sensed(0),
429                sensed(3),
430            ],
431        );
432
433        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
434        assert_eq!(corpus.len(), 3, "all three rows are in the corpus");
435        // Three recoveries, but only two rows could have reported any — and
436        // the pair says so rather than implying a rate over three.
437        assert_eq!(corpus.context_overflows(), (3, 2));
438        // One of the two sensed rows hit an overflow. Reading the unsensed row
439        // as a clean run would give 1/3 here, which is the quiet dilution.
440        assert_eq!(corpus.overflow_rate(), Some(0.5));
441
442        let _ = std::fs::remove_dir_all(&dir);
443    }
444
445    /// "Nobody has the sensor" and "nobody overflowed" are opposite findings,
446    /// and a corpus predating the field must not report the reassuring one.
447    #[test]
448    fn a_corpus_with_no_sensor_at_all_has_no_rate() {
449        let dir = tmpdir();
450        session_with(
451            &dir,
452            "20260801T000000-old",
453            "opus",
454            vec![stats(4, 0, false, StopCause::Completed)],
455        );
456
457        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
458        assert_eq!(corpus.context_overflows(), (0, 0));
459        assert_eq!(corpus.overflow_rate(), None, "not Some(0.0)");
460
461        let _ = std::fs::remove_dir_all(&dir);
462    }
463
464    /// `boredom_notices` is the same shape as `context_overflows` — an
465    /// `Option` because a run written before the sensor existed must not
466    /// read as a run it definitely fired zero times in.
467    #[test]
468    fn a_row_without_the_boredom_sensor_is_unknown_and_never_a_zero() {
469        let dir = tmpdir();
470        let sensed = |n: u32| {
471            let mut st = stats(4, 0, false, StopCause::Completed);
472            st.boredom_notices = Some(n);
473            st
474        };
475        session_with(
476            &dir,
477            "20260801T000000-mixed",
478            "opus",
479            vec![
480                // Written before the field existed: knows nothing.
481                stats(4, 0, false, StopCause::Completed),
482                sensed(0),
483                sensed(1),
484            ],
485        );
486
487        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
488        assert_eq!(corpus.len(), 3, "all three rows are in the corpus");
489        // One of the two sensed rows was told at least once. Reading the
490        // unsensed row as a quiet one would give 1/3 here, which is the
491        // same dilution `overflow_rate` exists to avoid.
492        assert_eq!(corpus.boredom_rate(), Some(0.5));
493
494        let _ = std::fs::remove_dir_all(&dir);
495    }
496
497    /// "Nobody has the sensor" and "nobody ever got stuck" are opposite
498    /// findings, and a corpus predating the field must not report the
499    /// reassuring one.
500    #[test]
501    fn a_corpus_with_no_boredom_sensor_at_all_has_no_rate() {
502        let dir = tmpdir();
503        session_with(
504            &dir,
505            "20260801T000000-old",
506            "opus",
507            vec![stats(4, 0, false, StopCause::Completed)],
508        );
509
510        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
511        assert_eq!(corpus.boredom_rate(), None, "not Some(0.0)");
512
513        let _ = std::fs::remove_dir_all(&dir);
514    }
515
516    #[test]
517    fn a_row_without_a_homeostat_snapshot_is_unknown_and_never_a_zero_for_either_mean() {
518        let dir = tmpdir();
519        let sensed = |pressure: f32, guilt: f32| {
520            let mut st = stats(4, 0, false, StopCause::Completed);
521            st.homeostat = Some(crate::homeostat::Homeostat {
522                peak_context_pressure: Some(pressure),
523                anticipated_guilt: Some(guilt),
524                ..Default::default()
525            });
526            st
527        };
528        session_with(
529            &dir,
530            "20260801T000000-mixed",
531            "opus",
532            vec![
533                // Written before Homeostat was recorded: knows nothing.
534                stats(4, 0, false, StopCause::Completed),
535                sensed(0.25, 0.0),
536                sensed(0.75, 0.5),
537            ],
538        );
539
540        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
541        assert_eq!(corpus.len(), 3, "all three rows are in the corpus");
542        // Averaged over the two sensed rows only — the unsensed row must not
543        // dilute it toward zero, the same dilution `boredom_rate` guards
544        // against. Chosen as exact binary fractions so the f32→f64 widening
545        // this method does cannot introduce rounding noise into the assertion.
546        assert_eq!(corpus.mean_peak_context_pressure(), Some(0.5));
547        assert_eq!(corpus.mean_anticipated_guilt(), Some(0.25));
548
549        let _ = std::fs::remove_dir_all(&dir);
550    }
551
552    #[test]
553    fn a_corpus_predating_the_homeostat_has_neither_mean() {
554        let dir = tmpdir();
555        session_with(
556            &dir,
557            "20260801T000000-old",
558            "opus",
559            vec![stats(4, 0, false, StopCause::Completed)],
560        );
561
562        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
563        assert_eq!(corpus.mean_peak_context_pressure(), None, "not Some(0.0)");
564        assert_eq!(corpus.mean_anticipated_guilt(), None, "not Some(0.0)");
565
566        let _ = std::fs::remove_dir_all(&dir);
567    }
568
569    #[test]
570    fn a_scan_collects_every_run_across_every_session() {
571        let dir = tmpdir();
572        session_with(
573            &dir,
574            "20260801T000000-a",
575            "opus",
576            vec![
577                stats(4, 1, false, StopCause::Completed),
578                // A resumed session: several runs, one row each, numbered.
579                stats(6, 0, true, StopCause::MaxTurns),
580            ],
581        );
582        session_with(
583            &dir,
584            "20260801T000001-b",
585            "opus",
586            vec![stats(10, 4, false, StopCause::Completed)],
587        );
588
589        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
590        assert_eq!(corpus.len(), 3);
591        assert_eq!(corpus.sessions_read, 2);
592        assert_eq!(corpus.tool_calls(), 20);
593        assert_eq!(corpus.tool_errors(), 5);
594        assert_eq!(corpus.compactions(), 3);
595        assert_eq!(corpus.ended_on_failed_call(), 1);
596        assert_eq!(corpus.cost_usd(), (0.75, 3));
597
598        let runs: Vec<u32> = corpus
599            .rows
600            .iter()
601            .filter(|r| r.session_id.ends_with('a'))
602            .map(|r| r.run)
603            .collect();
604        assert_eq!(
605            runs,
606            vec![1, 2],
607            "runs within a session are numbered in order"
608        );
609
610        let causes = corpus.stop_causes();
611        assert_eq!(causes[&Some(StopCause::Completed)], 2);
612        assert_eq!(causes[&Some(StopCause::MaxTurns)], 1);
613
614        let _ = std::fs::remove_dir_all(&dir);
615    }
616
617    #[test]
618    fn a_mid_session_model_switch_attributes_each_run_to_the_model_that_ran_it() {
619        // The TUI can change model mid-session and records a `Config` when it
620        // does. Reading the header instead would credit the second model's
621        // runs to the first — defeating `by_model` in the one case where a
622        // corpus genuinely blends two, and pointing a threshold at the wrong
623        // model.
624        let dir = tmpdir();
625        let s = session_with(
626            &dir,
627            "20260801T000000-switch",
628            "first-model",
629            vec![stats(4, 0, false, StopCause::Completed)],
630        );
631        s.append(&Record::Config(crate::session::RunConfig {
632            provider: "local".into(),
633            model: "second-model".into(),
634            ..Default::default()
635        }))
636        .unwrap();
637        s.append(&Record::Outcome(stats(6, 3, false, StopCause::Completed)))
638            .unwrap();
639
640        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
641        assert_eq!(corpus.len(), 2);
642        let by_model = corpus.by_model();
643        assert_eq!(by_model["first-model"].tool_error_rate(), Some(0.0));
644        assert_eq!(by_model["second-model"].tool_error_rate(), Some(0.5));
645
646        let _ = std::fs::remove_dir_all(&dir);
647    }
648
649    #[test]
650    fn a_rate_over_nothing_is_unknown_rather_than_perfect() {
651        // The distinction a threshold silent on zero gets wrong: no calls is
652        // not a clean record, it is no evidence. An empty corpus and a corpus
653        // of tool-less runs both answer `None`, and a reader that wants to
654        // treat that as healthy has to say so itself.
655        let dir = tmpdir();
656        let empty = Corpus::scan(&dir, &Scan::default()).unwrap();
657        assert!(empty.is_empty());
658        assert_eq!(empty.tool_error_rate(), None);
659        assert_eq!(empty.rate_of(|r| r.stats.ended_on_failed_call), None);
660
661        session_with(
662            &dir,
663            "20260801T000000-c",
664            "opus",
665            vec![stats(0, 0, false, StopCause::Completed)],
666        );
667        let tool_less = Corpus::scan(&dir, &Scan::default()).unwrap();
668        assert_eq!(tool_less.len(), 1);
669        assert_eq!(
670            tool_less.tool_error_rate(),
671            None,
672            "no calls is not a clean record"
673        );
674        // A run-level rate is still defined: there was a run, it just made no
675        // calls. The two denominators are different questions.
676        assert_eq!(
677            tool_less.rate_of(|r| r.stats.ended_on_failed_call),
678            Some(0.0)
679        );
680
681        let _ = std::fs::remove_dir_all(&dir);
682    }
683
684    #[test]
685    fn a_scan_is_bounded_and_says_how_much_it_read() {
686        let dir = tmpdir();
687        for i in 0..5 {
688            session_with(
689                &dir,
690                &format!("20260801T00000{i}-s"),
691                "opus",
692                vec![stats(2, 0, false, StopCause::Completed)],
693            );
694        }
695        let bounded = Corpus::scan(
696            &dir,
697            &Scan {
698                max_sessions: Some(2),
699                since: None,
700            },
701        )
702        .unwrap();
703        assert_eq!(bounded.sessions_read, 2);
704        assert_eq!(bounded.len(), 2);
705
706        // The cutoff is on the session's own stamp, and every fixture here
707        // predates this one, so nothing survives it.
708        let cut = Corpus::scan(
709            &dir,
710            &Scan {
711                max_sessions: None,
712                since: Some(
713                    DateTime::parse_from_rfc3339("2026-08-02T00:00:00Z")
714                        .unwrap()
715                        .with_timezone(&Utc),
716                ),
717            },
718        )
719        .unwrap();
720        assert!(cut.is_empty());
721        assert_eq!(cut.sessions_read, 0);
722
723        let _ = std::fs::remove_dir_all(&dir);
724    }
725
726    #[test]
727    fn a_session_with_no_outcomes_is_read_and_contributes_nothing() {
728        // Transcripts written before the record existed, and runs that died
729        // before producing one. They must not read as a run with zero of
730        // everything, which would drag every rate toward a fiction.
731        let dir = tmpdir();
732        let s = session_with(&dir, "20260801T000000-d", "opus", vec![]);
733        s.append_messages(&[crate::message::Message::user("go")])
734            .unwrap();
735        session_with(
736            &dir,
737            "20260801T000001-e",
738            "opus",
739            vec![stats(4, 2, false, StopCause::Completed)],
740        );
741
742        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
743        assert_eq!(corpus.sessions_read, 2, "both sessions were read");
744        assert_eq!(corpus.len(), 1, "only one contributed a run");
745        assert_eq!(corpus.tool_error_rate(), Some(0.5));
746
747        let _ = std::fs::remove_dir_all(&dir);
748    }
749
750    #[test]
751    fn rates_split_by_model_because_a_mixed_corpus_has_no_single_one() {
752        let dir = tmpdir();
753        session_with(
754            &dir,
755            "20260801T000000-f",
756            "opus",
757            vec![stats(10, 1, false, StopCause::Completed)],
758        );
759        session_with(
760            &dir,
761            "20260801T000001-g",
762            "tiny-local",
763            vec![stats(10, 9, false, StopCause::Completed)],
764        );
765
766        let corpus = Corpus::scan(&dir, &Scan::default()).unwrap();
767        // The blended number is true and useless: neither model behaves this
768        // way, and a threshold on it fires for the wrong one.
769        assert_eq!(corpus.tool_error_rate(), Some(0.5));
770        let by_model = corpus.by_model();
771        assert_eq!(by_model["opus"].tool_error_rate(), Some(0.1));
772        assert_eq!(by_model["tiny-local"].tool_error_rate(), Some(0.9));
773
774        let _ = std::fs::remove_dir_all(&dir);
775    }
776}