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::{assemble, Condition, DEFAULT_BUDGET_TOKENS};
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 format!("{:x}", hasher.finalize())
38}
39
40const SYSTEM_PROMPT: &str =
42 "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#[derive(Debug, Clone, Copy)]
47pub struct AbRunConfig {
48 pub budget_tokens: usize,
50 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
63fn 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
71pub 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 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 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 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 assert!(run_ab(&suite, "s", &runner, &AbRunConfig::default()).is_err());
215 let _ = PathBuf::new();
216 }
217}