1use std::path::Path;
11use std::process::{Command, Stdio};
12use std::time::{Duration, Instant};
13
14use anyhow::{Context, Result, anyhow};
15use serde::{Deserialize, Serialize};
16
17use super::suite::{Domain, Task};
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct Score {
23 pub value: f64,
24 pub passed: bool,
25 pub metric: String,
27 pub detail: String,
29}
30
31pub trait Scorer {
33 fn score(&self, task: &Task, output: &str, workspace: &Path) -> Result<Score>;
34}
35
36pub fn score_task(task: &Task, output: &str, workspace: &Path) -> Result<Score> {
38 match task.domain {
39 Domain::Qa => QaScorer.score(task, output, workspace),
40 Domain::Code => CodeScorer::default().score(task, output, workspace),
41 }
42}
43
44pub struct QaScorer;
50
51impl Scorer for QaScorer {
52 fn score(&self, task: &Task, output: &str, _workspace: &Path) -> Result<Score> {
53 let pred = normalize(output);
54 let pred_tokens: Vec<&str> = pred.split_whitespace().collect();
55
56 let mut best_em = false;
57 let mut best_contain = false;
58 let mut best_f1 = 0.0f64;
59 for gold in &task.answers {
60 let g = normalize(gold);
61 if g.is_empty() {
62 continue;
63 }
64 let g_tokens: Vec<&str> = g.split_whitespace().collect();
65 best_em |= pred == g;
66 best_contain |= !g.is_empty() && pred.contains(&g);
67 best_f1 = best_f1.max(token_f1(&pred_tokens, &g_tokens));
68 }
69
70 Ok(Score {
71 value: best_f1,
72 passed: best_em || best_contain,
73 metric: "f1".to_string(),
74 detail: format!(
75 "em={} f1={best_f1:.2} contain={}",
76 u8::from(best_em),
77 u8::from(best_contain)
78 ),
79 })
80 }
81}
82
83pub fn qa_f1(pred: &str, gold: &str) -> f64 {
86 let p = normalize(pred);
87 let g = normalize(gold);
88 let pt: Vec<&str> = p.split_whitespace().collect();
89 let gt: Vec<&str> = g.split_whitespace().collect();
90 token_f1(&pt, >)
91}
92
93pub fn qa_exact_match(pred: &str, gold: &str) -> bool {
95 normalize(pred) == normalize(gold)
96}
97
98pub fn qa_contains(pred: &str, gold: &str) -> bool {
100 let g = normalize(gold);
101 !g.is_empty() && normalize(pred).contains(&g)
102}
103
104fn normalize(s: &str) -> String {
106 let lowered = s.to_lowercase();
107 let cleaned: String = lowered
108 .chars()
109 .map(|c| if c.is_alphanumeric() { c } else { ' ' })
110 .collect();
111 cleaned
112 .split_whitespace()
113 .filter(|w| !matches!(*w, "a" | "an" | "the"))
114 .collect::<Vec<_>>()
115 .join(" ")
116}
117
118fn token_f1(pred: &[&str], gold: &[&str]) -> f64 {
120 if pred.is_empty() && gold.is_empty() {
121 return 1.0;
122 }
123 if pred.is_empty() || gold.is_empty() {
124 return 0.0;
125 }
126 let mut gold_counts: std::collections::HashMap<&str, i64> = std::collections::HashMap::new();
127 for &t in gold {
128 *gold_counts.entry(t).or_insert(0) += 1;
129 }
130 let mut common = 0i64;
131 let mut pred_counts: std::collections::HashMap<&str, i64> = std::collections::HashMap::new();
132 for &t in pred {
133 let entry = pred_counts.entry(t).or_insert(0);
134 *entry += 1;
135 if *entry <= gold_counts.get(t).copied().unwrap_or(0) {
136 common += 1;
137 }
138 }
139 if common == 0 {
140 return 0.0;
141 }
142 let precision = common as f64 / pred.len() as f64;
143 let recall = common as f64 / gold.len() as f64;
144 2.0 * precision * recall / (precision + recall)
145}
146
147pub struct CodeScorer {
153 pub timeout: Duration,
155}
156
157impl Default for CodeScorer {
158 fn default() -> Self {
159 #[allow(clippy::duration_suboptimal_units)]
161 Self {
162 timeout: Duration::from_secs(60),
163 }
164 }
165}
166
167impl Scorer for CodeScorer {
168 fn score(&self, task: &Task, output: &str, workspace: &Path) -> Result<Score> {
169 let target = task
170 .target_file
171 .as_deref()
172 .ok_or_else(|| anyhow!("code task {} has no target_file", task.id))?;
173 let test_cmd = task
174 .test_cmd
175 .as_deref()
176 .ok_or_else(|| anyhow!("code task {} has no test_cmd", task.id))?;
177
178 let sandbox = tempfile::tempdir().context("creating sandbox")?;
179 copy_dir_all(workspace, sandbox.path())
180 .with_context(|| format!("copying workspace {}", workspace.display()))?;
181
182 let target_path = sandbox.path().join(target);
183 if let Some(parent) = target_path.parent() {
184 std::fs::create_dir_all(parent).ok();
185 }
186 std::fs::write(&target_path, extract_code(output))
187 .with_context(|| format!("writing solution to {}", target_path.display()))?;
188
189 let passed = run_with_timeout(test_cmd, sandbox.path(), self.timeout)?;
190 Ok(Score {
191 value: f64::from(u8::from(passed)),
192 passed,
193 metric: "unit_test".to_string(),
194 detail: format!("test_cmd={test_cmd:?} passed={passed}"),
195 })
196 }
197}
198
199fn extract_code(output: &str) -> String {
202 let trimmed = output.trim();
203 if let Some(rest) = trimmed.strip_prefix("```") {
204 let after_lang = rest.find('\n').map_or(rest, |i| &rest[i + 1..]);
206 if let Some(end) = after_lang.rfind("```") {
207 return after_lang[..end].trim_end().to_string();
208 }
209 }
210 trimmed.to_string()
211}
212
213fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
215 for entry in std::fs::read_dir(src)? {
216 let entry = entry?;
217 let from = entry.path();
218 let to = dst.join(entry.file_name());
219 if entry.file_type()?.is_dir() {
220 std::fs::create_dir_all(&to)?;
221 copy_dir_all(&from, &to)?;
222 } else {
223 std::fs::copy(&from, &to)?;
224 }
225 }
226 Ok(())
227}
228
229fn run_with_timeout(cmd: &str, dir: &Path, timeout: Duration) -> Result<bool> {
231 let mut child = Command::new("sh")
232 .arg("-c")
233 .arg(cmd)
234 .current_dir(dir)
235 .stdout(Stdio::null())
236 .stderr(Stdio::null())
237 .stdin(Stdio::null())
238 .spawn()
239 .with_context(|| format!("spawning test command: {cmd}"))?;
240
241 let start = Instant::now();
242 loop {
243 if let Some(status) = child.try_wait()? {
244 return Ok(status.success());
245 }
246 if start.elapsed() > timeout {
247 let _ = child.kill();
248 let _ = child.wait();
249 return Ok(false);
250 }
251 std::thread::sleep(Duration::from_millis(25));
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 fn qa_task() -> Task {
260 Task {
261 id: "q".into(),
262 domain: Domain::Qa,
263 prompt: "p".into(),
264 workspace: "w".into(),
265 retrieval_query: None,
266 answers: vec!["bm25 graph knowledge session".into()],
267 target_file: None,
268 test_cmd: None,
269 }
270 }
271
272 #[test]
273 fn qa_exact_match_scores_one() {
274 let s = QaScorer
275 .score(
276 &qa_task(),
277 "BM25, graph, knowledge, session.",
278 Path::new("."),
279 )
280 .unwrap();
281 assert!(s.passed);
282 assert_eq!(s.value, 1.0);
283 }
284
285 #[test]
286 fn qa_partial_overlap_gives_fractional_f1() {
287 let s = QaScorer
288 .score(&qa_task(), "the graph and session stores", Path::new("."))
289 .unwrap();
290 assert!(s.value > 0.0 && s.value < 1.0, "f1 was {}", s.value);
291 }
292
293 #[test]
294 fn qa_unrelated_answer_scores_zero() {
295 let s = QaScorer
296 .score(&qa_task(), "cats and weather forecasts", Path::new("."))
297 .unwrap();
298 assert_eq!(s.value, 0.0);
299 assert!(!s.passed);
300 }
301
302 #[test]
303 fn extract_code_unwraps_fence() {
304 assert_eq!(extract_code("```sh\necho hi\n```"), "echo hi");
305 assert_eq!(extract_code("plain text"), "plain text");
306 }
307
308 #[cfg(unix)]
309 #[test]
310 fn code_scorer_runs_unit_test() {
311 let ws = tempfile::tempdir().unwrap();
312 std::fs::write(
313 ws.path().join("test.sh"),
314 ". ./solution.sh\n[ \"$(add 2 3)\" = \"5\" ] || exit 1\n",
315 )
316 .unwrap();
317 std::fs::write(ws.path().join("solution.sh"), "add() { echo 0; }\n").unwrap();
318
319 let task = Task {
320 id: "c".into(),
321 domain: Domain::Code,
322 prompt: "implement add".into(),
323 workspace: "code".into(),
324 retrieval_query: None,
325 answers: vec![],
326 target_file: Some("solution.sh".into()),
327 test_cmd: Some("sh test.sh".into()),
328 };
329
330 let good = CodeScorer::default()
331 .score(&task, "add() { echo $(( $1 + $2 )); }", ws.path())
332 .unwrap();
333 assert!(good.passed, "correct solution should pass: {good:?}");
334
335 let bad = CodeScorer::default()
336 .score(&task, "add() { echo 99; }", ws.path())
337 .unwrap();
338 assert!(!bad.passed, "wrong solution should fail");
339 }
340}