Skip to main content

lean_ctx/core/eval_ab/
mod.rs

1//! Deterministic with/without output-quality eval (#232).
2//!
3//! Proves — reproducibly and with a signature — whether putting lean-ctx in front of a model
4//! changes the *quality of its answers*, not just the token count. The design separates the two
5//! sources of variance:
6//!
7//! * **Context** is deterministic. Both the baseline ("raw dump") and the lean-ctx
8//!   ("retrieve + compress") window are assembled byte-for-byte reproducibly and digested.
9//! * **The model** is the only stochastic part. It is pinned (`temperature = 0`, fixed `seed`)
10//!   and, for CI, replaced by [`model::RecordedRunner`] replaying captured real responses, so a
11//!   run is byte-identical everywhere.
12//!
13//! The pipeline per task is: [`conditions::assemble`] → [`model::ModelRunner`] →
14//! [`scorers::score_task`]. Results become a paired [`report::AbReport`], which a
15//! [`artifact::SignedAbReportV1`] turns into a portable, verifiable attestation.
16
17pub mod artifact;
18pub mod conditions;
19pub mod footprint;
20pub mod judge;
21pub mod model;
22pub mod report;
23pub mod routing_eval;
24pub mod scorers;
25pub mod suite;
26pub mod testbench;
27
28use anyhow::Result;
29
30use conditions::{Condition, DEFAULT_BUDGET_TOKENS, assemble};
31use model::{ModelRequest, ModelRunner};
32use report::{AbReport, PairRecord, ReportConfig};
33use scorers::score_task;
34use suite::EvalSuite;
35
36/// Shared hex SHA-256 used across the eval modules for context/answer/fingerprint digests.
37pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
38    use sha2::{Digest, Sha256};
39    let mut hasher = Sha256::new();
40    hasher.update(bytes);
41    crate::core::agent_identity::hex_encode(&hasher.finalize())
42}
43
44/// Identical framing for both conditions — only the CONTEXT block differs between A and B.
45/// `pub(crate)` so the testbench builds byte-identical requests (recordings interchange).
46pub(crate) const SYSTEM_PROMPT: &str = "You are a precise engineering assistant. Answer using only the provided CONTEXT. \
47If the context does not contain the answer, say so. Be concise and correct.";
48
49/// Configuration for one A/B run.
50#[derive(Debug, Clone, Copy)]
51pub struct AbRunConfig {
52    /// Token budget enforced identically on both conditions.
53    pub budget_tokens: usize,
54    /// Statistics + gate configuration.
55    pub report: ReportConfig,
56}
57
58impl Default for AbRunConfig {
59    fn default() -> Self {
60        Self {
61            budget_tokens: DEFAULT_BUDGET_TOKENS,
62            report: ReportConfig::default(),
63        }
64    }
65}
66
67/// Builds the user turn from a context window + the task prompt. `pub(crate)` so the
68/// testbench answers tasks with the exact same framing as [`run_ab`].
69pub(crate) fn build_request(context: &str, prompt: &str) -> ModelRequest {
70    ModelRequest {
71        system: SYSTEM_PROMPT.to_string(),
72        user: format!("CONTEXT:\n{context}\n\nTASK:\n{prompt}"),
73    }
74}
75
76/// Runs every task in `suite` under both conditions through `runner`, scoring each answer, and
77/// assembles the paired report. The model is the only non-deterministic input.
78pub fn run_ab(
79    suite: &EvalSuite,
80    suite_name: &str,
81    runner: &dyn ModelRunner,
82    cfg: &AbRunConfig,
83) -> Result<AbReport> {
84    let mut records = Vec::with_capacity(suite.tasks.len());
85    for task in &suite.tasks {
86        let workspace = task.workspace_path(&suite.dir);
87
88        let base_ctx = assemble(
89            Condition::Baseline,
90            &workspace,
91            task.query(),
92            cfg.budget_tokens,
93        )?;
94        let lean_ctx = assemble(
95            Condition::LeanCtx,
96            &workspace,
97            task.query(),
98            cfg.budget_tokens,
99        )?;
100
101        let base_resp = runner.run(&build_request(&base_ctx.text, &task.prompt))?;
102        let lean_resp = runner.run(&build_request(&lean_ctx.text, &task.prompt))?;
103
104        let base_score = score_task(task, &base_resp.text, &workspace)?;
105        let lean_score = score_task(task, &lean_resp.text, &workspace)?;
106
107        records.push(PairRecord {
108            task_id: task.id.clone(),
109            domain: task.domain.label().to_string(),
110            baseline_value: base_score.value,
111            lean_ctx_value: lean_score.value,
112            baseline_passed: base_score.passed,
113            lean_ctx_passed: lean_score.passed,
114            baseline_tokens: base_ctx.tokens,
115            lean_ctx_tokens: lean_ctx.tokens,
116            baseline_context_digest: base_ctx.digest,
117            lean_ctx_context_digest: lean_ctx.digest,
118            baseline_answer_digest: base_resp.digest(),
119            lean_ctx_answer_digest: lean_resp.digest(),
120        });
121    }
122
123    Ok(AbReport::build(
124        suite_name,
125        cfg.budget_tokens,
126        runner.fingerprint().clone(),
127        records,
128        cfg.report,
129    ))
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use model::{ModelFingerprint, ModelParams, ModelResponse, RecordedRunner, Recording};
136    use std::path::PathBuf;
137
138    /// Builds a corpus where naive path order exhausts the budget on irrelevant text.
139    fn workspace(dir: &std::path::Path) {
140        std::fs::write(
141            dir.join("a-noise.md"),
142            "Completely unrelated notes about weather, cats, and lunch plans.\n".repeat(1_000),
143        )
144        .unwrap();
145        std::fs::write(
146            dir.join("z-answer.md"),
147            "Consolidation persists artifacts to bm25, graph, knowledge and session stores.",
148        )
149        .unwrap();
150    }
151
152    #[test]
153    fn full_pipeline_runs_and_scores_deterministically() {
154        let root = tempfile::tempdir().unwrap();
155        let ws = root.path().join("corpus");
156        std::fs::create_dir_all(&ws).unwrap();
157        workspace(&ws);
158
159        let raw = r#"{"id":"t1","domain":"qa","prompt":"Which stores does consolidation persist to?","workspace":"corpus","answers":["bm25 graph knowledge session"]}"#;
160        let suite = EvalSuite::parse(raw, root.path().to_path_buf()).unwrap();
161        let task = &suite.tasks[0];
162
163        // Pre-compute the exact requests so we can record canned answers (replay scaffolding).
164        let cfg = AbRunConfig::default();
165        let base_ctx = assemble(Condition::Baseline, &ws, task.query(), cfg.budget_tokens).unwrap();
166        let lean_ctx = assemble(Condition::LeanCtx, &ws, task.query(), cfg.budget_tokens).unwrap();
167        let base_req = build_request(&base_ctx.text, &task.prompt);
168        let lean_req = build_request(&lean_ctx.text, &task.prompt);
169        assert_ne!(
170            base_req.key(),
171            lean_req.key(),
172            "fixture must keep baseline and lean-ctx requests distinct",
173        );
174
175        let fp = ModelFingerprint {
176            provider: model::PROVIDER_RECORDED.into(),
177            endpoint: "test".into(),
178            params: ModelParams {
179                model: "fixture".into(),
180                ..ModelParams::default()
181            },
182        };
183        let mut rec = Recording::new(fp);
184        rec.entries
185            .insert(base_req.key(), ModelResponse::new("I don't know."));
186        rec.entries.insert(
187            lean_req.key(),
188            ModelResponse::new("bm25, graph, knowledge and session"),
189        );
190        let runner = RecordedRunner::new(rec);
191
192        let report = run_ab(&suite, "fixture-suite", &runner, &cfg).unwrap();
193        assert_eq!(report.records.len(), 1);
194        assert!(
195            report.stats.lean_ctx_mean > report.stats.baseline_mean,
196            "lean-ctx answer should outscore the baseline: {:?}",
197            report.stats
198        );
199
200        // Determinism: a second identical run yields the same evidence digest.
201        let report2 = run_ab(&suite, "fixture-suite", &runner, &cfg).unwrap();
202        assert_eq!(
203            artifact::determinism_digest(&report),
204            artifact::determinism_digest(&report2)
205        );
206    }
207
208    #[test]
209    fn run_ab_propagates_recorded_miss() {
210        let root = tempfile::tempdir().unwrap();
211        let ws = root.path().join("corpus");
212        std::fs::create_dir_all(&ws).unwrap();
213        workspace(&ws);
214        let raw = r#"{"id":"t1","domain":"qa","prompt":"q","workspace":"corpus","answers":["x"]}"#;
215        let suite = EvalSuite::parse(raw, root.path().to_path_buf()).unwrap();
216
217        let fp = ModelFingerprint {
218            provider: model::PROVIDER_RECORDED.into(),
219            endpoint: "test".into(),
220            params: ModelParams::default(),
221        };
222        let runner = RecordedRunner::new(Recording::new(fp));
223        // Empty recording → first request misses → run errors (no silent fallback).
224        assert!(run_ab(&suite, "s", &runner, &AbRunConfig::default()).is_err());
225        let _ = PathBuf::new();
226    }
227}
228
229#[cfg(test)]
230mod accuracy_suite_tests {
231    //! Guards the committed accuracy suite (`rust/eval/accuracy-suite.ndjson`, #730)
232    //! in-process so a corpus/answer drift fails in `cargo test` — i.e. during
233    //! `dev-install` — not only when someone runs the live gate.
234
235    use super::*;
236    use std::path::Path;
237    use suite::Domain;
238
239    fn load_accuracy_suite() -> EvalSuite {
240        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("eval/accuracy-suite.ndjson");
241        EvalSuite::load(&path).expect("committed accuracy suite must load + validate")
242    }
243
244    /// The suite covers all three TTC-relevant shapes (needle, long-context QA, code).
245    #[test]
246    fn accuracy_suite_has_all_three_shapes() {
247        let suite = load_accuracy_suite();
248        let qa = suite
249            .tasks
250            .iter()
251            .filter(|t| t.domain == Domain::Qa)
252            .count();
253        let code = suite
254            .tasks
255            .iter()
256            .filter(|t| t.domain == Domain::Code)
257            .count();
258        assert!(qa >= 2, "need needle + long-context QA, got {qa}");
259        assert!(code >= 1, "need a code-edit task, got {code}");
260    }
261
262    /// Model-free accuracy floor (#730): for every QA task, lean-ctx's own
263    /// retrieve+compress context must still CONTAIN a gold answer at the default
264    /// budget — compression preserves the answer-bearing signal. We reuse the SQuAD
265    /// containment scorer over the assembled context itself, so this is the
266    /// deterministic lower bound of the "compressed ≥ raw" claim with no live model.
267    #[test]
268    fn lean_ctx_compression_preserves_every_qa_answer() {
269        let suite = load_accuracy_suite();
270        let budget = AbRunConfig::default().budget_tokens;
271        let qa = suite.tasks.iter().filter(|t| t.domain == Domain::Qa);
272        for task in qa {
273            let ws = task.workspace_path(&suite.dir);
274            let ctx = assemble(Condition::LeanCtx, &ws, task.query(), budget)
275                .unwrap_or_else(|e| panic!("assemble {}: {e:#}", task.id));
276            let score = score_task(task, &ctx.text, &ws)
277                .unwrap_or_else(|e| panic!("score {}: {e:#}", task.id));
278            assert!(
279                score.passed,
280                "lean-ctx compression dropped the answer for '{}' ({})",
281                task.id, score.detail
282            );
283        }
284    }
285
286    /// #942: the dedicated `json_crush` condition must clear the same accuracy
287    /// floor (the gold answer survives the lossless array crush) while packing the
288    /// answer in strictly fewer tokens than the raw baseline — proving the crush is
289    /// a real, answer-preserving saving on a redundant JSON payload, model-free.
290    #[test]
291    fn json_crush_condition_preserves_answer_and_beats_baseline() {
292        let suite = load_accuracy_suite();
293        let budget = AbRunConfig::default().budget_tokens;
294        let task = suite
295            .tasks
296            .iter()
297            .find(|t| t.id == "jsonqa-operator-clearance")
298            .expect("json-qa fixture present");
299        let ws = task.workspace_path(&suite.dir);
300
301        let crushed = assemble(Condition::JsonCrush, &ws, task.query(), budget)
302            .unwrap_or_else(|e| panic!("assemble json_crush: {e:#}"));
303        let baseline = assemble(Condition::Baseline, &ws, task.query(), budget)
304            .unwrap_or_else(|e| panic!("assemble baseline: {e:#}"));
305
306        let score = score_task(task, &crushed.text, &ws)
307            .unwrap_or_else(|e| panic!("score {}: {e:#}", task.id));
308        assert!(
309            score.passed,
310            "json_crush dropped the answer for '{}' ({})",
311            task.id, score.detail
312        );
313        assert!(
314            crushed.tokens < baseline.tokens,
315            "json_crush ({}) must beat the raw baseline ({}) on a redundant array",
316            crushed.tokens,
317            baseline.tokens
318        );
319    }
320
321    /// The code-edit task must be genuinely solvable: a correct reference solution
322    /// passes the committed unit test and the shipped failing stub does not. Proves
323    /// the harness end-to-end (sandbox copy + `test_cmd`) with zero model calls.
324    #[test]
325    fn code_task_is_solvable_and_stub_fails() {
326        let suite = load_accuracy_suite();
327        let task = suite
328            .tasks
329            .iter()
330            .find(|t| t.domain == Domain::Code)
331            .expect("code task present");
332        let ws = task.workspace_path(&suite.dir);
333
334        let reference = "factorial() { n=$1; [ \"$n\" -le 1 ] && { echo 1; return; }; \
335             r=1; i=2; while [ \"$i\" -le \"$n\" ]; do r=$((r * i)); i=$((i + 1)); done; echo \"$r\"; }";
336        let good = score_task(task, reference, &ws).unwrap();
337        assert!(good.passed, "reference solution must pass: {}", good.detail);
338
339        let stub = "factorial() { echo 0; }";
340        let bad = score_task(task, stub, &ws).unwrap();
341        assert!(!bad.passed, "wrong solution must fail the unit test");
342    }
343}