lean_ctx/core/eval_ab/testbench/
findings.rs1use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use serde::{Deserialize, Serialize};
15
16use super::{RepoReport, TestbenchReport};
17
18const EPS: f64 = 1e-9;
20
21pub const REGRESSIONS_KIND: &str = "lean-ctx.testbench-regressions";
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct Regression {
27 pub repo: String,
28 pub task_id: String,
29 pub domain: String,
30 pub baseline_value: f64,
31 pub lean_ctx_value: f64,
32 pub baseline_passed: bool,
33 pub lean_ctx_passed: bool,
34 pub delta: f64,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct Regressions {
41 pub kind: String,
42 pub verdict: String,
43 pub determinism_digest: String,
44 pub count: usize,
45 pub regressions: Vec<Regression>,
46}
47
48impl Regressions {
49 pub fn to_json(&self) -> String {
51 serde_json::to_string_pretty(self).unwrap_or_default()
52 }
53}
54
55pub fn collect_regressions(report: &TestbenchReport) -> Regressions {
57 let mut out = Vec::new();
58 for repo in &report.repos {
59 for r in &repo.report.records {
60 let lost_pass = repo_lost_pass(r.baseline_passed, r.lean_ctx_passed);
61 let lower_score = r.lean_ctx_value + EPS < r.baseline_value;
62 if lost_pass || lower_score {
63 out.push(Regression {
64 repo: repo.name.clone(),
65 task_id: r.task_id.clone(),
66 domain: r.domain.clone(),
67 baseline_value: r.baseline_value,
68 lean_ctx_value: r.lean_ctx_value,
69 baseline_passed: r.baseline_passed,
70 lean_ctx_passed: r.lean_ctx_passed,
71 delta: r.lean_ctx_value - r.baseline_value,
72 });
73 }
74 }
75 }
76 Regressions {
77 kind: REGRESSIONS_KIND.to_string(),
78 verdict: report.verdict.label().to_string(),
79 determinism_digest: report.determinism_digest.clone(),
80 count: out.len(),
81 regressions: out,
82 }
83}
84
85fn repo_lost_pass(baseline_passed: bool, lean_ctx_passed: bool) -> bool {
87 baseline_passed && !lean_ctx_passed
88}
89
90pub fn render_findings(report: &TestbenchReport) -> String {
92 let mut s = String::new();
93 s.push_str("# lean-ctx testbench — off vs on\n\n");
94 s.push_str(&format!(
95 "- Model: `{}` `{}` (temp={}, seed={})\n",
96 report.model.provider,
97 report.model.params.model,
98 report.model.params.temperature,
99 report.model.params.seed
100 ));
101 s.push_str(&format!(
102 "- Budget: {} tokens / condition\n",
103 report.budget_tokens
104 ));
105 s.push_str(&format!("- Lock digest: `{}`\n", report.lock_digest));
106 s.push_str(&format!(
107 "- Determinism digest: `{}`\n",
108 report.determinism_digest
109 ));
110 s.push_str(&format!("- **Verdict: {}**\n\n", report.verdict.label()));
111
112 s.push_str(
113 "| repo | tasks | quality off→on | pass off→on | ctx tokens off→on | Δtokens | walltime off→on |\n",
114 );
115 s.push_str("|---|--:|:--:|:--:|:--:|:--:|:--:|\n");
116 for repo in &report.repos {
117 s.push_str(&render_repo_row(repo));
118 }
119
120 s.push_str("\n## Notes\n");
121 s.push_str(
122 "- A run over local fixtures with a recorded model is a deterministic *mechanism* \
123 gate: it proves the off-vs-on pipeline is reproducible and free of regressions. \
124 Real quality deltas come from a live run against the pinned remote repos \
125 (`eval testbench --record`).\n",
126 );
127 s.push_str(
128 "- `quality` is the mean per-task score (LLM-judge for QA, unit-test pass for code); \
129 `ctx tokens` is the assembled context size each arm sent to the model.\n",
130 );
131 s
132}
133
134fn render_repo_row(repo: &RepoReport) -> String {
135 let st = &repo.report.stats;
136 let off_tokens = repo.off_tokens();
137 let on_tokens = repo.on_tokens();
138 format!(
139 "| {} | {} | {:.2}→{:.2} | {:.0}%→{:.0}% | {}→{} | {} | {}ms→{}ms |\n",
140 repo.name,
141 repo.turns(),
142 st.baseline_mean,
143 st.lean_ctx_mean,
144 st.baseline_pass_rate * 100.0,
145 st.lean_ctx_pass_rate * 100.0,
146 off_tokens,
147 on_tokens,
148 pct_delta(off_tokens, on_tokens),
149 repo.off_walltime_ms,
150 repo.on_walltime_ms,
151 )
152}
153
154fn pct_delta(from: usize, to: usize) -> String {
156 if from == 0 {
157 return "n/a".to_string();
158 }
159 let pct = (to as f64 - from as f64) / from as f64 * 100.0;
160 format!("{pct:+.1}%")
161}
162
163pub fn write(report: &TestbenchReport, out_dir: &Path) -> Result<(PathBuf, PathBuf)> {
165 std::fs::create_dir_all(out_dir)
166 .with_context(|| format!("creating output dir {}", out_dir.display()))?;
167 let findings_path = out_dir.join("FINDINGS.md");
168 let regressions_path = out_dir.join("regressions.json");
169 std::fs::write(&findings_path, render_findings(report))
170 .with_context(|| format!("writing {}", findings_path.display()))?;
171 std::fs::write(®ressions_path, collect_regressions(report).to_json())
172 .with_context(|| format!("writing {}", regressions_path.display()))?;
173 Ok((findings_path, regressions_path))
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::core::eval_ab::model::{ModelFingerprint, ModelParams};
180 use crate::core::eval_ab::report::{AbReport, PairRecord, ReportConfig, Verdict};
181 use crate::core::eval_ab::testbench::TESTBENCH_REPORT_KIND;
182
183 fn record(id: &str, base: f64, lean: f64) -> PairRecord {
184 PairRecord {
185 task_id: id.into(),
186 domain: "qa".into(),
187 baseline_value: base,
188 lean_ctx_value: lean,
189 baseline_passed: base >= 0.5,
190 lean_ctx_passed: lean >= 0.5,
191 baseline_tokens: 300,
192 lean_ctx_tokens: 90,
193 baseline_context_digest: "ca".into(),
194 lean_ctx_context_digest: "cb".into(),
195 baseline_answer_digest: "aa".into(),
196 lean_ctx_answer_digest: "ab".into(),
197 }
198 }
199
200 fn repo(name: &str, records: Vec<PairRecord>) -> RepoReport {
201 let fp = ModelFingerprint {
202 provider: "recorded".into(),
203 endpoint: "rec".into(),
204 params: ModelParams::default(),
205 };
206 let report = AbReport::build(name, 4000, fp, records, ReportConfig::default());
207 RepoReport {
208 name: name.into(),
209 off_walltime_ms: 5,
210 on_walltime_ms: 4,
211 report,
212 }
213 }
214
215 fn testbench(repos: Vec<RepoReport>, verdict: Verdict) -> TestbenchReport {
216 TestbenchReport {
217 kind: TESTBENCH_REPORT_KIND.into(),
218 created_at: "x".into(),
219 lean_ctx_version: "0".into(),
220 model: ModelFingerprint {
221 provider: "recorded".into(),
222 endpoint: "rec".into(),
223 params: ModelParams::default(),
224 },
225 budget_tokens: 4000,
226 lock_digest: "lock".into(),
227 determinism_digest: "det".into(),
228 verdict,
229 repos,
230 }
231 }
232
233 #[test]
234 fn regressions_flags_only_real_drops() {
235 let r = repo("r", vec![record("ok", 1.0, 1.0), record("drop", 1.0, 0.0)]);
236 let report = testbench(vec![r], Verdict::Regressed);
237 let regs = collect_regressions(&report);
238 assert_eq!(regs.count, 1);
239 assert_eq!(regs.regressions[0].task_id, "drop");
240 assert!(regs.regressions[0].delta < 0.0);
241 }
242
243 #[test]
244 fn ties_produce_no_regressions() {
245 let r = repo("r", vec![record("a", 1.0, 1.0), record("b", 0.7, 0.7)]);
246 let report = testbench(vec![r], Verdict::NonInferior);
247 assert_eq!(collect_regressions(&report).count, 0);
248 }
249
250 #[test]
251 fn findings_table_reports_token_delta_and_verdict() {
252 let r = repo("qa-api", vec![record("a", 1.0, 1.0)]);
253 let md = render_findings(&testbench(vec![r], Verdict::NonInferior));
254 assert!(md.contains("NO REGRESSION"));
255 assert!(md.contains("qa-api"));
256 assert!(md.contains("300→90"));
257 assert!(md.contains("-70.0%"), "expected token saving in: {md}");
258 }
259
260 #[test]
261 fn pct_delta_handles_zero_and_savings() {
262 assert_eq!(pct_delta(0, 5), "n/a");
263 assert_eq!(pct_delta(100, 90), "-10.0%");
264 assert_eq!(pct_delta(100, 130), "+30.0%");
265 }
266}