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 format!("{:x}", 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}