1pub 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
32pub(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
40const 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#[derive(Debug, Clone, Copy)]
46pub struct AbRunConfig {
47 pub budget_tokens: usize,
49 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
62fn 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
70pub 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 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 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 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 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 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 #[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 #[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 #[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}