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