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}