1pub mod clone;
19pub mod findings;
20pub mod lockfile;
21pub mod recording;
22
23use std::path::Path;
24use std::time::Instant;
25
26use anyhow::{Context, Result};
27use serde::{Deserialize, Serialize};
28
29use super::artifact::determinism_digest;
30use super::conditions::{Condition, assemble};
31use super::judge::LlmJudge;
32use super::model::{ModelFingerprint, ModelRunner};
33use super::report::{AbReport, PairRecord, Verdict};
34use super::scorers::{CodeScorer, Score, Scorer};
35use super::suite::{Domain, EvalSuite, Task};
36use super::{AbRunConfig, build_request, sha256_hex};
37use lockfile::TestbenchLock;
38
39pub const TESTBENCH_REPORT_KIND: &str = "lean-ctx.testbench-report";
41
42#[derive(Debug, Clone, Copy, Default)]
44pub struct TestbenchConfig {
45 pub run: AbRunConfig,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct RepoReport {
52 pub name: String,
53 pub off_walltime_ms: u64,
55 pub on_walltime_ms: u64,
57 pub report: AbReport,
58}
59
60impl RepoReport {
61 pub fn turns(&self) -> usize {
63 self.report.records.len()
64 }
65
66 pub fn off_tokens(&self) -> usize {
68 self.report.records.iter().map(|r| r.baseline_tokens).sum()
69 }
70
71 pub fn on_tokens(&self) -> usize {
73 self.report.records.iter().map(|r| r.lean_ctx_tokens).sum()
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct TestbenchReport {
80 pub kind: String,
81 pub created_at: String,
82 pub lean_ctx_version: String,
83 pub model: ModelFingerprint,
84 pub budget_tokens: usize,
85 pub lock_digest: String,
86 pub determinism_digest: String,
88 pub verdict: Verdict,
90 pub repos: Vec<RepoReport>,
91}
92
93impl TestbenchReport {
94 pub fn gate_passes(&self) -> bool {
96 self.verdict.gate_passes()
97 }
98
99 pub fn to_json(&self) -> String {
101 serde_json::to_string_pretty(self).unwrap_or_default()
102 }
103}
104
105pub fn run_testbench(
108 lock: &TestbenchLock,
109 cache_dir: &Path,
110 runner: &dyn ModelRunner,
111 cfg: &TestbenchConfig,
112) -> Result<TestbenchReport> {
113 let judge = LlmJudge;
114 let mut repos = Vec::with_capacity(lock.repos.len());
115 for entry in &lock.repos {
116 let repo_dir = clone::materialize(entry, lock.dir(), cache_dir)?;
117 let suite_path = lock.dir().join(&entry.suite);
118 let raw = std::fs::read_to_string(&suite_path)
119 .with_context(|| format!("reading suite {}", suite_path.display()))?;
120 let suite = EvalSuite::parse(&raw, repo_dir)
122 .with_context(|| format!("parsing suite for repo {}", entry.name))?;
123 repos.push(run_repo(&suite, &entry.name, runner, &judge, &cfg.run)?);
124 }
125
126 let model = runner.fingerprint().clone();
127 let determinism_digest =
128 aggregate_digest(&repos, &lock.digest(), &model, cfg.run.budget_tokens);
129 let verdict = worst_verdict(&repos);
130
131 Ok(TestbenchReport {
132 kind: TESTBENCH_REPORT_KIND.to_string(),
133 created_at: chrono::Utc::now().to_rfc3339(),
134 lean_ctx_version: env!("CARGO_PKG_VERSION").to_string(),
135 model,
136 budget_tokens: cfg.run.budget_tokens,
137 lock_digest: lock.digest(),
138 determinism_digest,
139 verdict,
140 repos,
141 })
142}
143
144fn run_repo(
146 suite: &EvalSuite,
147 name: &str,
148 runner: &dyn ModelRunner,
149 judge: &LlmJudge,
150 cfg: &AbRunConfig,
151) -> Result<RepoReport> {
152 let mut records = Vec::with_capacity(suite.tasks.len());
153 let (mut off_ms, mut on_ms): (u128, u128) = (0, 0);
154
155 for task in &suite.tasks {
156 let workspace = task.workspace_path(&suite.dir);
157 let off_ctx = assemble(
158 Condition::Baseline,
159 &workspace,
160 task.query(),
161 cfg.budget_tokens,
162 )?;
163 let on_ctx = assemble(
164 Condition::LeanCtx,
165 &workspace,
166 task.query(),
167 cfg.budget_tokens,
168 )?;
169
170 let t0 = Instant::now();
171 let off_resp = runner.run(&build_request(&off_ctx.text, &task.prompt))?;
172 off_ms += t0.elapsed().as_millis();
173 let t1 = Instant::now();
174 let on_resp = runner.run(&build_request(&on_ctx.text, &task.prompt))?;
175 on_ms += t1.elapsed().as_millis();
176
177 let (off_score, on_score) = score_pair(
178 task,
179 &off_resp.text,
180 &on_resp.text,
181 &workspace,
182 runner,
183 judge,
184 )?;
185
186 records.push(PairRecord {
187 task_id: task.id.clone(),
188 domain: task.domain.label().to_string(),
189 baseline_value: off_score.value,
190 lean_ctx_value: on_score.value,
191 baseline_passed: off_score.passed,
192 lean_ctx_passed: on_score.passed,
193 baseline_tokens: off_ctx.tokens,
194 lean_ctx_tokens: on_ctx.tokens,
195 baseline_context_digest: off_ctx.digest,
196 lean_ctx_context_digest: on_ctx.digest,
197 baseline_answer_digest: off_resp.digest(),
198 lean_ctx_answer_digest: on_resp.digest(),
199 });
200 }
201
202 let report = AbReport::build(
203 name,
204 cfg.budget_tokens,
205 runner.fingerprint().clone(),
206 records,
207 cfg.report,
208 );
209 Ok(RepoReport {
210 name: name.to_string(),
211 off_walltime_ms: u64::try_from(off_ms).unwrap_or(u64::MAX),
212 on_walltime_ms: u64::try_from(on_ms).unwrap_or(u64::MAX),
213 report,
214 })
215}
216
217fn score_pair(
219 task: &Task,
220 off_answer: &str,
221 on_answer: &str,
222 workspace: &Path,
223 runner: &dyn ModelRunner,
224 judge: &LlmJudge,
225) -> Result<(Score, Score)> {
226 match task.domain {
227 Domain::Qa => Ok((
228 judge.score(runner, task, off_answer)?,
229 judge.score(runner, task, on_answer)?,
230 )),
231 Domain::Code => {
232 let scorer = CodeScorer::default();
233 Ok((
234 scorer.score(task, off_answer, workspace)?,
235 scorer.score(task, on_answer, workspace)?,
236 ))
237 }
238 }
239}
240
241fn worst_verdict(repos: &[RepoReport]) -> Verdict {
244 let mut worst = Verdict::Improved;
245 let mut any = false;
246 for r in repos {
247 any = true;
248 worst = match (worst, r.report.verdict) {
249 (Verdict::Regressed, _) | (_, Verdict::Regressed) => Verdict::Regressed,
250 (Verdict::NonInferior, _) | (_, Verdict::NonInferior) => Verdict::NonInferior,
251 _ => Verdict::Improved,
252 };
253 }
254 if any { worst } else { Verdict::NonInferior }
255}
256
257fn aggregate_digest(
260 repos: &[RepoReport],
261 lock_digest: &str,
262 model: &ModelFingerprint,
263 budget_tokens: usize,
264) -> String {
265 #[derive(Serialize)]
266 struct Row {
267 name: String,
268 evidence: String,
269 verdict: Verdict,
270 }
271 #[derive(Serialize)]
272 struct Agg<'a> {
273 lock_digest: &'a str,
274 model_fingerprint: String,
275 budget_tokens: usize,
276 repos: Vec<Row>,
277 }
278 let mut rows: Vec<Row> = repos
279 .iter()
280 .map(|r| Row {
281 name: r.name.clone(),
282 evidence: determinism_digest(&r.report),
283 verdict: r.report.verdict,
284 })
285 .collect();
286 rows.sort_by(|a, b| a.name.cmp(&b.name));
287 let agg = Agg {
288 lock_digest,
289 model_fingerprint: model.digest(),
290 budget_tokens,
291 repos: rows,
292 };
293 sha256_hex(&serde_json::to_vec(&agg).unwrap_or_default())
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::core::eval_ab::model::{ModelParams, RecordedRunner};
300 use std::path::PathBuf;
301
302 fn testbench_dir() -> PathBuf {
304 Path::new(env!("CARGO_MANIFEST_DIR")).join("eval/testbench")
305 }
306
307 fn load_committed_lock() -> TestbenchLock {
308 TestbenchLock::load(&testbench_dir().join("testbench.lock.json"))
309 .expect("committed testbench lock must load + validate")
310 }
311
312 #[test]
315 fn committed_subset_replays_and_gates() {
316 let lock = load_committed_lock();
317 let rec_path = testbench_dir().join("recording.json");
318 assert!(
319 rec_path.exists(),
320 "committed recording missing at {} — regenerate with `cargo run --example gen_testbench_recording`",
321 rec_path.display()
322 );
323 let runner = RecordedRunner::from_file(&rec_path).expect("load committed recording");
324 let cache = tempfile::tempdir().unwrap();
325
326 let report = run_testbench(&lock, cache.path(), &runner, &TestbenchConfig::default())
327 .expect("committed recording must cover every replay key");
328 assert!(
329 report.gate_passes(),
330 "committed subset must not encode a regression, got {}",
331 report.verdict.label()
332 );
333 assert!(!report.repos.is_empty());
334 }
335
336 #[test]
338 fn determinism_digest_is_stable_across_runs() {
339 let lock = load_committed_lock();
340 let rec_path = testbench_dir().join("recording.json");
341 let runner = RecordedRunner::from_file(&rec_path).expect("load committed recording");
342 let cache = tempfile::tempdir().unwrap();
343
344 let a = run_testbench(&lock, cache.path(), &runner, &TestbenchConfig::default()).unwrap();
345 let b = run_testbench(&lock, cache.path(), &runner, &TestbenchConfig::default()).unwrap();
346 assert_eq!(a.determinism_digest, b.determinism_digest);
347 }
348
349 #[test]
350 fn worst_verdict_prefers_regression() {
351 use crate::core::eval_ab::report::ReportConfig;
352 let mk = |v: Verdict| {
354 let mut rep = AbReport::build(
355 "x",
356 4000,
357 ModelFingerprint {
358 provider: "recorded".into(),
359 endpoint: "rec".into(),
360 params: ModelParams::default(),
361 },
362 vec![],
363 ReportConfig::default(),
364 );
365 rep.verdict = v;
366 RepoReport {
367 name: "x".into(),
368 off_walltime_ms: 0,
369 on_walltime_ms: 0,
370 report: rep,
371 }
372 };
373 assert_eq!(
374 worst_verdict(&[mk(Verdict::Improved), mk(Verdict::Regressed)]),
375 Verdict::Regressed
376 );
377 assert_eq!(
378 worst_verdict(&[mk(Verdict::Improved), mk(Verdict::NonInferior)]),
379 Verdict::NonInferior
380 );
381 assert_eq!(
382 worst_verdict(&[mk(Verdict::Improved), mk(Verdict::Improved)]),
383 Verdict::Improved
384 );
385 }
386}