Skip to main content

lean_ctx/core/eval_ab/testbench/
mod.rs

1//! Public off-vs-on answer-quality testbench (#611).
2//!
3//! One command runs every pinned repo in a [`lockfile::TestbenchLock`] under both
4//! arms — **off** ([`Condition::Baseline`], a raw file dump) and **on**
5//! ([`Condition::LeanCtx`], retrieve + compress) — at an identical token budget, then
6//! scores each answer:
7//!
8//! * free-form QA → real [`LlmJudge`] (the pinned model grades correctness),
9//! * code → [`CodeScorer`], the SWE-style test-oracle (apply the answer, run the
10//!   repo's own test command, pass = exit 0).
11//!
12//! Results reuse the [`AbReport`] statistics + verdict per repo and are aggregated
13//! into a [`TestbenchReport`] whose `determinism_digest` excludes wall-clock time, so
14//! a recorded subset is byte-identical everywhere and can gate CI. [`findings`]
15//! renders the human `FINDINGS.md` (per-repo tokens / turns / walltime / quality) and
16//! the honest machine-readable regressions file.
17
18pub 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
39/// Report schema discriminator.
40pub const TESTBENCH_REPORT_KIND: &str = "lean-ctx.testbench-report";
41
42/// Knobs for a testbench run (token budget + the per-repo report/gate config).
43#[derive(Debug, Clone, Copy, Default)]
44pub struct TestbenchConfig {
45    pub run: AbRunConfig,
46}
47
48/// One repo's outcome: the reused paired [`AbReport`] plus informational wall-clock
49/// time per arm (deliberately *outside* the determinism digest).
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct RepoReport {
52    pub name: String,
53    /// Total model wall-clock for the off arm across this repo's tasks, milliseconds.
54    pub off_walltime_ms: u64,
55    /// Total model wall-clock for the on arm across this repo's tasks, milliseconds.
56    pub on_walltime_ms: u64,
57    pub report: AbReport,
58}
59
60impl RepoReport {
61    /// Single-shot harness: one model call per task per arm, so "turns" == task count.
62    pub fn turns(&self) -> usize {
63        self.report.records.len()
64    }
65
66    /// Total assembled context tokens for the off arm.
67    pub fn off_tokens(&self) -> usize {
68        self.report.records.iter().map(|r| r.baseline_tokens).sum()
69    }
70
71    /// Total assembled context tokens for the on arm.
72    pub fn on_tokens(&self) -> usize {
73        self.report.records.iter().map(|r| r.lean_ctx_tokens).sum()
74    }
75}
76
77/// The aggregate attestation written by a run.
78#[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    /// Machine-independent digest over every repo's evidence (no walltime, no clock).
87    pub determinism_digest: String,
88    /// Worst per-repo verdict — drives the CI gate.
89    pub verdict: Verdict,
90    pub repos: Vec<RepoReport>,
91}
92
93impl TestbenchReport {
94    /// Whether the CI quality gate should pass (no repo regressed).
95    pub fn gate_passes(&self) -> bool {
96        self.verdict.gate_passes()
97    }
98
99    /// Pretty JSON for the machine-readable artifact.
100    pub fn to_json(&self) -> String {
101        serde_json::to_string_pretty(self).unwrap_or_default()
102    }
103}
104
105/// Runs every repo in `lock` under both arms through `runner`, returning the aggregate.
106/// `runner` answers *and* judges (judge requests have distinct content → distinct keys).
107pub 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        // Task workspaces resolve INSIDE the materialized repo, not next to the suite.
121        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
144/// Runs one repo's suite under both arms, timing the model calls per arm.
145fn 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
217/// Scores both arms' answers: QA via the LLM judge, code via the test-oracle.
218fn 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
241/// Worst (most conservative) verdict across repos: any regression dominates, then any
242/// "no regression", else "improved". An empty set is treated as non-inferior.
243fn 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
257/// Aggregate determinism digest: per-repo evidence digests (sorted by name) bound to
258/// the lockfile, model fingerprint and budget. Wall-clock time is excluded by design.
259fn 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    /// Path to the committed deterministic subset under `rust/eval/testbench`.
303    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    /// The committed local-fixture subset must run fully offline (RecordedRunner),
313    /// cover every replay key, and not encode a regression — this is the CI gate.
314    #[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    /// Two replays of the same subset yield the same evidence digest (no wall-clock leak).
337    #[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        // worst_verdict reads only `.verdict`, so build an empty report and set it.
353        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}