Skip to main content

eval_flake_rs/
lib.rs

1//! # eval-flake-rs
2//!
3//! Detect flaky LLM eval cases by tracking pass/fail outcomes across
4//! repeated runs of the same suite.
5//!
6//! - A case is **stable** when every run agrees.
7//! - A case is **flaky** when runs disagree (at least one pass and at
8//!   least one fail).
9//! - A case is **always failing** when every run failed.
10//!
11//! Flip-rate = fraction of run-to-run transitions where the outcome
12//! changed. 0.0 = stable, 0.5 = maximally flaky.
13//!
14//! ## Example
15//!
16//! ```
17//! use eval_flake_rs::{detect, CaseResult};
18//!
19//! let runs: Vec<Vec<(&str, bool)>> = vec![
20//!     vec![("case-1", true),  ("case-2", true),  ("case-3", false)],
21//!     vec![("case-1", true),  ("case-2", false), ("case-3", false)],
22//!     vec![("case-1", true),  ("case-2", true),  ("case-3", false)],
23//! ];
24//! let results = detect(&runs);
25//! assert!(matches!(results.iter().find(|r| r.case == "case-2").unwrap().status, CaseResult::Flaky));
26//! ```
27
28#![deny(missing_docs)]
29
30use std::collections::BTreeMap;
31
32/// Per-case status.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum CaseResult {
35    /// Every run passed.
36    AlwaysPass,
37    /// Every run failed.
38    AlwaysFail,
39    /// At least one pass and one fail.
40    Flaky,
41}
42
43/// Per-case report.
44#[derive(Debug, Clone)]
45pub struct Report {
46    /// Case name.
47    pub case: String,
48    /// Classification.
49    pub status: CaseResult,
50    /// Number of pass observations.
51    pub passes: u32,
52    /// Number of fail observations.
53    pub fails: u32,
54    /// Flip-rate over run-to-run transitions (0.0–1.0).
55    pub flip_rate: f64,
56}
57
58/// Aggregate per-case status from a list of run outcomes.
59///
60/// `runs[r] = [(case_name, pass)]`. Missing cases in a run are simply
61/// not counted for that case; cases must use the same name across runs
62/// to be aligned.
63pub fn detect(runs: &[Vec<(&str, bool)>]) -> Vec<Report> {
64    let mut grouped: BTreeMap<String, Vec<bool>> = BTreeMap::new();
65    for run in runs {
66        for (case, pass) in run {
67            grouped.entry((*case).to_string()).or_default().push(*pass);
68        }
69    }
70    grouped
71        .into_iter()
72        .map(|(case, outcomes)| {
73            let passes = outcomes.iter().filter(|p| **p).count() as u32;
74            let fails = outcomes.iter().filter(|p| !**p).count() as u32;
75            let status = match (passes, fails) {
76                (0, _) => CaseResult::AlwaysFail,
77                (_, 0) => CaseResult::AlwaysPass,
78                _ => CaseResult::Flaky,
79            };
80            let flip_rate = if outcomes.len() < 2 {
81                0.0
82            } else {
83                let flips = outcomes
84                    .windows(2)
85                    .filter(|w| w[0] != w[1])
86                    .count() as f64;
87                flips / (outcomes.len() - 1) as f64
88            };
89            Report {
90                case,
91                status,
92                passes,
93                fails,
94                flip_rate,
95            }
96        })
97        .collect()
98}