Skip to main content

lean_ctx/core/eval_ab/
scorers.rs

1//! Deterministic scorers (#236): objective, reproducible scoring of a model answer.
2//!
3//! * [`QaScorer`] — SQuAD-style normalization → exact-match, token-overlap F1 and containment.
4//! * [`CodeScorer`] — copies the workspace into a throwaway sandbox, writes the model output to
5//!   the target file, runs the task's unit-test command and reports pass/fail by exit code.
6//!
7//! Both are fully deterministic given a fixed model output, which is what lets the harness
8//! prove a non-regression rather than estimate one.
9
10use 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/// A scored answer. `value` is the continuous metric in `[0,1]`; `passed` is the binary verdict
20/// used for win/tie/loss accounting.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct Score {
23    pub value: f64,
24    pub passed: bool,
25    /// `f1`, `exact_match` or `unit_test`.
26    pub metric: String,
27    /// Short human-readable explanation (e.g. `em=1 f1=1.00 contain=1`).
28    pub detail: String,
29}
30
31/// Anything that can deterministically score a model answer for a task.
32pub trait Scorer {
33    fn score(&self, task: &Task, output: &str, workspace: &Path) -> Result<Score>;
34}
35
36/// Dispatches to the scorer for the task's domain.
37pub 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
44// ---------------------------------------------------------------------------
45// QA scorer
46// ---------------------------------------------------------------------------
47
48/// SQuAD-style QA scorer (exact-match / F1 / containment over a set of gold answers).
49pub 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
83/// SQuAD token-overlap F1 between a prediction and a single gold answer.
84/// Reusable building block for other harnesses (e.g. the LoCoMo memory bench, #291).
85pub 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, &gt)
91}
92
93/// SQuAD exact match between a prediction and a gold answer (after normalization).
94pub fn qa_exact_match(pred: &str, gold: &str) -> bool {
95    normalize(pred) == normalize(gold)
96}
97
98/// True iff the normalized gold answer is contained in the normalized prediction.
99pub fn qa_contains(pred: &str, gold: &str) -> bool {
100    let g = normalize(gold);
101    !g.is_empty() && normalize(pred).contains(&g)
102}
103
104/// SQuAD normalization: lowercase, drop punctuation, drop articles, collapse whitespace.
105fn 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
118/// Token-overlap F1 over multisets (SQuAD definition).
119fn 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
147// ---------------------------------------------------------------------------
148// Code scorer
149// ---------------------------------------------------------------------------
150
151/// Runs the task's unit-test command against the model output inside a sandbox copy.
152pub struct CodeScorer {
153    /// Wall-clock cap for the test command.
154    pub timeout: Duration,
155}
156
157impl Default for CodeScorer {
158    fn default() -> Self {
159        // Seconds granularity is the natural unit for a test timeout.
160        #[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
199/// Strips a single Markdown code fence if the model wrapped its answer in one; otherwise returns
200/// the trimmed text. Keeps the sandboxed file syntactically valid.
201fn extract_code(output: &str) -> String {
202    let trimmed = output.trim();
203    if let Some(rest) = trimmed.strip_prefix("```") {
204        // Drop the optional language tag on the opening fence, then everything up to the close.
205        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
213/// Recursively copies `src` into `dst` (which must already exist).
214fn 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
229/// Runs `cmd` via the POSIX shell in `dir`, returning `true` iff it exits 0 within `timeout`.
230fn 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}