metrale_bench/benchmarks/agentic/
score.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Scoring for the agentic webserver task, ported from the
4//! harness's `score_run.py` and `followed_directions.py`. Two separate axes:
5//!
6//! * `webserver_ok`, the outcome: the scorer builds and runs the code the agent
7//!   left behind and asks `/ping` for `pong`, whether or not the agent built or
8//!   checked anything itself.
9//! * `followed_directions`, the process: whether the commands the agent ran and
10//!   the tree it left evidence each step in `REQUIRED_STEPS`.
11//!
12//! Owner: bench, agentic.
13//! Invariants: `followed_directions` returns one entry per `REQUIRED_STEPS`
14//! name, in that order.
15
16use std::path::Path;
17use std::process::Stdio;
18use std::time::Duration;
19
20use anyhow::Result;
21
22/// 2026-09-26: The prompt-mandated process steps; `Directions::overall` is
23/// their AND.
24pub const REQUIRED_STEPS: [&str; 6] = [
25    "wrote_project",
26    "wrote_tests",
27    "ran_tests",
28    "ran_server",
29    "curled",
30    "tore_down",
31];
32
33#[derive(Clone, Debug, Default)]
34pub struct WebserverResult {
35    pub webserver_ok: bool,
36    pub build_ok: bool,
37    pub error: String,
38    pub port_used: u16,
39}
40
41#[derive(Clone, Debug, Default)]
42pub struct Directions {
43    pub steps: Vec<(&'static str, bool)>,
44}
45
46impl Directions {
47    /// 2026-09-26: True only when there are steps and every one is evidenced.
48    pub fn overall(&self) -> bool {
49        !self.steps.is_empty() && self.steps.iter().all(|(_, ok)| *ok)
50    }
51    /// 2026-09-26: The steps that were not evidenced, in declaration order.
52    pub fn missing(&self) -> Vec<&'static str> {
53        self.steps
54            .iter()
55            .filter(|(_, ok)| !*ok)
56            .map(|(name, _)| *name)
57            .collect()
58    }
59    pub fn met(&self) -> usize {
60        self.steps.iter().filter(|(_, ok)| *ok).count()
61    }
62}
63
64/// 2026-09-26: An OS-assigned port, released on return, for a caller that binds
65/// it soon after (the self-started gate server in the server crate's
66/// `bench_selfstart` and `bench_lease`). The scorer holds its port through the
67/// build with `reserve_port` instead.
68pub fn free_port() -> Result<u16> {
69    let listener = reserve_port()?;
70    Ok(listener.local_addr()?.port())
71}
72
73/// 2026-09-26: An OS-assigned port on 127.0.0.1, held as a listener. A fresh
74/// port per iteration means a server left over from an earlier iteration can
75/// neither collide with this one nor answer its probe. Holding the listener
76/// through the build stops another process from taking the port meanwhile.
77fn reserve_port() -> Result<std::net::TcpListener> {
78    Ok(std::net::TcpListener::bind("127.0.0.1:0")?)
79}
80
81/// 2026-09-26: Builds the project in release, runs it on a reserved port and
82/// checks that `/ping` answers `pong` within `serve_timeout`. Every failure is
83/// reported in `WebserverResult::error`, not as an `Err`.
84pub async fn webserver_test(
85    sandbox: &Path,
86    cargo_target_dir: Option<&Path>,
87    build_timeout: Duration,
88    serve_timeout: Duration,
89) -> WebserverResult {
90    let mut out = WebserverResult::default();
91    // 2026-09-26: As in `score_run.py`'s `webserver_test`, no build is tried
92    // without a `Cargo.toml` and a `src/`.
93    if !sandbox.join("Cargo.toml").is_file() {
94        out.error = "no Cargo.toml was written".into();
95        return out;
96    }
97    if !sandbox.join("src").is_dir() {
98        out.error = "no src/ was written — skipping webserver test".into();
99        return out;
100    }
101    let port_reservation = match reserve_port() {
102        Ok(reservation) => reservation,
103        Err(e) => {
104            out.error = format!("could not reserve a port: {e}");
105            return out;
106        }
107    };
108    let port = match port_reservation.local_addr() {
109        Ok(address) => address.port(),
110        Err(e) => {
111            out.error = format!("could not inspect the reserved port: {e}");
112            return out;
113        }
114    };
115    out.port_used = port;
116
117    let mut build = tokio::process::Command::new("cargo");
118    build
119        .args(["build", "--release"])
120        .current_dir(sandbox)
121        // 2026-09-26: Set for the build too, as `score_run.py` does, since a
122        // project may read it with `env!`. The listener still holds the port.
123        .env("METRALE_HARNESS_PORT", port.to_string())
124        // 2026-09-26: The agent's shell sets METRALE_AGENT_SHELL, and the
125        // harness's cargo shim detaches `cargo run` when it is set; the scorer
126        // must not inherit it, or the server it supervises would detach.
127        .env_remove("METRALE_AGENT_SHELL")
128        .stdin(Stdio::null())
129        .stdout(Stdio::null())
130        .stderr(Stdio::piped())
131        .kill_on_drop(true);
132    if let Some(dir) = cargo_target_dir {
133        build.env("CARGO_TARGET_DIR", dir);
134    }
135    match tokio::time::timeout(build_timeout, build.output()).await {
136        Ok(Ok(o)) if o.status.success() => out.build_ok = true,
137        Ok(Ok(o)) => {
138            let err = String::from_utf8_lossy(&o.stderr);
139            out.error =
140                super::super::one_line(err.lines().rev().take(6).collect::<Vec<_>>().join(" "));
141            return out;
142        }
143        Ok(Err(e)) => {
144            out.error = format!("cargo build could not start: {e}");
145            return out;
146        }
147        Err(_) => {
148            out.error = format!("cargo build exceeded {}s", build_timeout.as_secs());
149            return out;
150        }
151    }
152
153    // 2026-09-26: The server's stderr goes to a file, as in `score_run.py`, so
154    // a bind panic shows in the error instead of a bare `/ping` timeout.
155    let err_log = std::env::temp_dir().join(format!(
156        "metrale-ws-stderr-{}-{port}.log",
157        std::process::id()
158    ));
159    // 2026-09-26: `create_new`, because the name is predictable and the code
160    // being scored may still have processes running: `create` would follow a
161    // symlink planted at that path and truncate its target. If the file cannot
162    // be created, stderr is discarded.
163    let sink = match std::fs::File::options()
164        .write(true)
165        .create_new(true)
166        .open(&err_log)
167    {
168        Ok(f) => Stdio::from(f),
169        Err(_) => Stdio::null(),
170    };
171
172    let mut serve = tokio::process::Command::new("cargo");
173    serve
174        .args(["run", "--release"])
175        .current_dir(sandbox)
176        // 2026-09-26: The prompt tells the model to read this variable. A
177        // project that hardcodes a port binds elsewhere and correctly fails.
178        .env("METRALE_HARNESS_PORT", port.to_string())
179        // 2026-09-26: As `score_run.py` sets for the server.
180        .env("RUST_LOG", "warn")
181        .env_remove("METRALE_AGENT_SHELL")
182        .stdin(Stdio::null())
183        .stdout(Stdio::null())
184        .stderr(sink)
185        .kill_on_drop(true);
186    if let Some(dir) = cargo_target_dir {
187        serve.env("CARGO_TARGET_DIR", dir);
188    }
189    // 2026-09-26: The port is released just before the spawn; a short race
190    // with other processes remains.
191    drop(port_reservation);
192    let mut child = match serve.spawn() {
193        Ok(c) => c,
194        Err(e) => {
195            out.error = format!("cargo run could not start: {e}");
196            let _ = std::fs::remove_file(&err_log);
197            return out;
198        }
199    };
200    // 2026-09-26: `child` is killed on drop (`kill_on_drop` above), so every
201    // return below also kills it.
202    let deadline = tokio::time::Instant::now() + serve_timeout;
203    let mut exited = None;
204    while tokio::time::Instant::now() < deadline {
205        if let Some(body) = ping(port).await
206            && body.to_lowercase().contains("pong")
207        {
208            out.webserver_ok = true;
209            let _ = child.kill().await;
210            let _ = std::fs::remove_file(&err_log);
211            return out;
212        }
213        // 2026-09-26: An exited process will never answer; stop now and report
214        // its exit status instead of a timeout.
215        if let Ok(Some(status)) = child.try_wait() {
216            exited = Some(status);
217            break;
218        }
219        tokio::time::sleep(Duration::from_millis(250)).await;
220    }
221    let _ = child.kill().await;
222    let detail = server_stderr(&err_log);
223    let _ = std::fs::remove_file(&err_log);
224    out.error = match exited {
225        Some(status) => format!("server exited ({status}) before answering /ping{detail}"),
226        None => format!(
227            "/ping did not answer 'pong' within {}s{detail}",
228            serve_timeout.as_secs()
229        ),
230    };
231    out
232}
233
234/// 2026-09-26: The last 800 characters of the server's stderr, with a `port in
235/// use` note when it contains `Address already in use` or `EADDRINUSE`, as in
236/// `score_run.py`. Empty when the file is empty or unreadable.
237fn server_stderr(path: &Path) -> String {
238    let text = std::fs::read_to_string(path).unwrap_or_default();
239    if text.trim().is_empty() {
240        return String::new();
241    }
242    let mut note = String::new();
243    if text.contains("Address already in use") || text.contains("EADDRINUSE") {
244        note.push_str(" | server bind failed (port in use)");
245    }
246    let mut tail: Vec<char> = text.chars().rev().take(800).collect();
247    tail.reverse();
248    let tail: String = tail.into_iter().collect();
249    format!("{note} | stderr: {}", super::super::one_line(tail))
250}
251
252/// 2026-09-26: One `GET /ping`. `None` when the connection or the request
253/// write fails, which is normal while the server is starting.
254async fn ping(port: u16) -> Option<String> {
255    use tokio::io::{AsyncReadExt, AsyncWriteExt};
256    let mut sock = tokio::time::timeout(
257        Duration::from_millis(500),
258        tokio::net::TcpStream::connect(("127.0.0.1", port)),
259    )
260    .await
261    .ok()?
262    .ok()?;
263    let req = format!("GET /ping HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n");
264    sock.write_all(req.as_bytes()).await.ok()?;
265    // 2026-09-26: Raw bytes for up to 2 s, then whatever arrived, as `curl -sS
266    // -m 2` in `score_run.py` does, so a server that ignores `Connection:
267    // close` is still read.
268    let mut buf = Vec::new();
269    let read = async {
270        let mut chunk = [0u8; 4096];
271        while let Ok(n) = sock.read(&mut chunk).await {
272            if n == 0 || buf.len() > 64 * 1024 {
273                break;
274            }
275            buf.extend_from_slice(&chunk[..n]);
276        }
277    };
278    let _ = tokio::time::timeout(Duration::from_secs(2), read).await;
279    Some(String::from_utf8_lossy(&buf).into_owned())
280}
281
282/// 2026-09-26: Whether the agent performed each step in `REQUIRED_STEPS`. The
283/// evidence is the shell commands it ran and the tree it left, the two sources
284/// `followed_directions.py` uses.
285pub fn followed_directions(commands: &[String], sandbox: &Path) -> Directions {
286    let joined = commands.join("\n").to_lowercase();
287    // 2026-09-26: Ports of the `_RE_*` detectors in `followed_directions.py`,
288    // matched on the lowercased commands. Each is anchored on word boundaries,
289    // so "killed" or "skill" is not evidence of `kill`.
290    let wrote_project = regular(&sandbox.join("Cargo.toml")) && has_main(sandbox);
291    let wrote_tests = has_tests(sandbox);
292    let ran_tests = contains_cargo(&joined, &["test", "nextest"]);
293    let ran_server = contains_cargo(&joined, &["run"]) || ran_binary(&joined);
294    let curled = ["curl", "wget", "httpie", "httpx"]
295        .iter()
296        .any(|k| word(&joined, k))
297        || word_then(&joined, "nc", "-z");
298    let tore_down =
299        word(&joined, "kill") || word(&joined, "pkill") || word_then(&joined, "fuser", "-k");
300    Directions {
301        steps: REQUIRED_STEPS
302            .iter()
303            .zip([
304                wrote_project,
305                wrote_tests,
306                ran_tests,
307                ran_server,
308                curled,
309                tore_down,
310            ])
311            .map(|(name, ok)| (*name, ok))
312            .collect(),
313    }
314}
315
316fn is_word(c: char) -> bool {
317    c.is_alphanumeric() || c == '_'
318}
319
320/// 2026-09-26: Byte offsets just past every occurrence of `needle` that starts
321/// on a word boundary.
322fn after_word_start<'a>(hay: &'a str, needle: &'a str) -> impl Iterator<Item = usize> + 'a {
323    hay.match_indices(needle)
324        .filter(|(i, _)| !hay[..*i].chars().next_back().is_some_and(is_word))
325        .map(|(i, m)| i + m.len())
326}
327
328/// 2026-09-26: `\bneedle\b`.
329fn word(hay: &str, needle: &str) -> bool {
330    after_word_start(hay, needle).any(|end| !hay[end..].starts_with(is_word))
331}
332
333/// 2026-09-26: `\bfirst\s+second\b`, the shape of `_RE_KILL`'s `fuser -k` and
334/// `_RE_CURL`'s `nc -z`.
335fn word_then(hay: &str, first: &str, second: &str) -> bool {
336    after_word_start(hay, first).any(|end| {
337        let rest = &hay[end..];
338        let head = rest.trim_start();
339        head.len() < rest.len()
340            && head
341                .strip_prefix(second)
342                .is_some_and(|after| !after.starts_with(is_word))
343    })
344}
345
346/// 2026-09-26: `\btarget/(?:debug|release)/\S`, the part of `_RE_RUN` for a
347/// binary run directly instead of through `cargo run`.
348fn ran_binary(hay: &str) -> bool {
349    ["target/debug/", "target/release/"].iter().any(|p| {
350        after_word_start(hay, p).any(|end| hay[end..].starts_with(|c: char| !c.is_whitespace()))
351    })
352}
353
354/// 2026-09-26: `\bcargo\s+<sub>\b` for any of `subs`, the shape of `_RE_TEST`
355/// and `_RE_RUN`.
356fn contains_cargo(haystack: &str, subs: &[&str]) -> bool {
357    after_word_start(haystack, "cargo").any(|end| {
358        let rest = &haystack[end..];
359        let head = rest.trim_start();
360        head.len() < rest.len()
361            && subs.iter().any(|s| {
362                head.strip_prefix(s)
363                    .is_some_and(|after| !after.starts_with(is_word))
364            })
365    })
366}
367
368/// 2026-09-26: A regular `src/main.rs`, or a `main.rs` anywhere in the walked
369/// tree, as `followed_directions.py` requires for `wrote_project`.
370fn has_main(sandbox: &Path) -> bool {
371    regular(&sandbox.join("src/main.rs"))
372        || walk(sandbox).any(|p| p.file_name().is_some_and(|n| n == "main.rs"))
373}
374
375/// 2026-09-26: A real `tests/` directory holding a `.rs` file, or `#[test]`,
376/// `#[cfg(test)]` or `#[tokio::test]` in any walked `.rs` file, as
377/// `followed_directions.py`'s `_has_tests` checks.
378fn has_tests(sandbox: &Path) -> bool {
379    let tests = sandbox.join("tests");
380    let real_dir = std::fs::symlink_metadata(&tests).is_ok_and(|m| m.is_dir());
381    if real_dir && walk(&tests).any(|p| p.extension().is_some_and(|e| e == "rs")) {
382        return true;
383    }
384    walk(sandbox)
385        .filter(|p| p.extension().is_some_and(|e| e == "rs"))
386        .filter_map(|p| std::fs::read_to_string(p).ok())
387        .any(|s| {
388            s.contains("#[test]") || s.contains("#[cfg(test)]") || s.contains("#[tokio::test]")
389        })
390}
391
392/// 2026-09-26: Every regular file under `root`, recursively, skipping any
393/// directory named `target` or `.git`; unreadable directories are skipped.
394///
395/// Symlinks are neither followed nor collected, because the tree was written by
396/// the agent being scored. A few `ln -s . a` links would make the walk explode
397/// combinatorially, and it has no timeout. And a link is not evidence: `ln -s
398/// ~/metrale/tests/foo.rs tests/foo.rs` would otherwise credit `wrote_tests` to
399/// an agent that wrote no tests.
400fn walk(root: &Path) -> impl Iterator<Item = std::path::PathBuf> {
401    let mut stack = vec![root.to_path_buf()];
402    let mut files = Vec::new();
403    while let Some(dir) = stack.pop() {
404        let Ok(entries) = std::fs::read_dir(&dir) else {
405            continue;
406        };
407        for entry in entries.flatten() {
408            let name = entry.file_name();
409            if matches!(name.to_str(), Some("target") | Some(".git")) {
410                continue;
411            }
412            let Ok(kind) = entry.file_type() else {
413                continue;
414            };
415            match (kind.is_dir(), kind.is_file()) {
416                (true, _) => stack.push(entry.path()),
417                (_, true) => files.push(entry.path()),
418                _ => {}
419            }
420        }
421    }
422    files.into_iter()
423}
424
425/// 2026-09-26: A regular file, not a symlink to one.
426fn regular(path: &Path) -> bool {
427    std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file())
428}
429
430#[cfg(test)]
431#[path = "score_tests.rs"]
432mod tests;
433
434/// 2026-09-26: How many iterations evidenced each step, keyed `step:<name>`, in
435/// the first iteration's step order; empty for no iterations.
436/// `followed_directions` is all-or-nothing per iteration, so without this the
437/// tier record cannot say which step failed.
438pub fn per_step_tallies(all: &[&Directions]) -> Vec<(String, f64)> {
439    let Some(first) = all.first() else {
440        return Vec::new();
441    };
442    first
443        .steps
444        .iter()
445        .map(|(name, _)| {
446            let met = all
447                .iter()
448                .filter(|d| d.steps.iter().any(|(n, ok)| n == name && *ok))
449                .count();
450            (format!("step:{name}"), met as f64)
451        })
452        .collect()
453}