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