Files
optimclaw/benchmarks/src/results.rs
T
8e6e84a08d feat: Add benchmarking harness with spot suite (#10)
* feat: Add benchmarking harness for agent evaluation

Introduces ironclaw-bench, a Rust-native benchmarking crate that drives the
real agent loop headlessly. Supports standard benchmarks (GAIA, Tau-bench,
SWE-bench Pro) and custom JSONL task sets with parallel execution, resume
support, and incremental JSONL output.

Key components:
- BenchChannel: headless Channel impl with auto-approval and response capture
- InstrumentedLlm: LlmProvider wrapper recording per-call token/cost metrics
- BenchRunner: task orchestration with parallel execution and JSONL resume
- Scoring utilities: exact match, contains, regex (all with normalization)
- CLI: run, results, compare, list subcommands via clap
- Four suite adapters: custom, gaia, tau_bench, swe_bench

Also fixes a pre-existing missing SseEvent::ToolResult match arm in the web
gateway and adds FinishReason to the LLM module's public re-exports.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Add spot benchmark suite for end-to-end agent verification

Adds a "spot" suite with 13 scenarios across 4 categories (smoke,
tool use, multi-tool chaining, robustness) using multi-criterion
assertions instead of simple text matching. Also adds an `error`
field to TaskSubmission so suites can hard-fail on agent errors.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address audit findings in benchmarks crate

- Fix O(n²) scoring loop by indexing tasks in a HashMap (was re-parsing JSONL per result)
- Add UTF-8-safe truncation to prevent panic on multi-byte chars in channel capture
- Wire setup_task/teardown_task into both sequential and parallel runner paths
- Convert BenchRunner.suite from Box to Arc for parallel task setup/teardown
- Add tracing::warn for placeholder scores in custom, swe_bench, tau_bench adapters
- Add spot suite to CLI help text
- Add doc comment clarifying tools_used HashSet behavior in SpotAssertions
- Reorder match arms in create_suite to match KNOWN_SUITES alphabetical order

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: rewrite tasks.jsonl with scored results after scoring

The JSONL file was only written during execution (pre-scoring), so the
`results` command showed "pending" scores even after scoring completed.
Now the runner rewrites the JSONL with final scored results, keeping
task-level and aggregate data consistent.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: prefix benchmark runs with model name and commit hash

Run logs and results table now show the base model and short git commit
hash, making it easy to correlate results with code versions. The commit
hash is also persisted in run.json for historical tracking.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add 8 memory benchmark scenarios to spot suite

Tests save-and-recall workflows using file tools:
- daily tasks, reminders, meeting notes, append logs
- detail extraction, todo priorities, multi-file ops
- context updates (write-read-rewrite-verify)

Total spot scenarios: 13 -> 21

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: fmt channel.rs and gitignore bench-results

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address critical and high findings from PR review

- Fix race condition: parallel mode now writes JSONL after all tasks
  complete instead of concurrent unsynchronized appends
- Fix UTF-8 panic: use .chars().take(25) instead of byte slicing on
  task_id which could panic on multi-byte characters
- Remove dead code: max_iterations (parsed but never used),
  tool_whitelist() (declared but never called), MatrixEntry.tools
  (declared but never applied)
- Eliminate double load_tasks(): cache task list on first load and
  reuse the index for scoring instead of re-reading from disk

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: relax smoke-greeting assertion to not demand parrot greeting

The LLM often introduces itself without echoing "hello" back. Use a
regex that accepts any reasonable self-introduction (hello, hi, hey,
assistant, agent, help) instead of demanding a specific word.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: 100% spot baseline (GPT-5.2 @ 2c43b83, 21/21 pass)

Relax two brittle assertions:
- smoke-greeting: use regex for any reasonable self-intro instead of
  demanding the model parrot "hello"
- memory-update-context: drop response_not_contains PST since the
  model correctly says "not PST" which triggers the literal check
- memory-multifile: lower min_tool_calls from 4 to 3, the model can
  batch two writes in one LLM turn

Baseline results committed to benchmarks/baselines/ for regression
tracking. Local runs stay in bench-results/ (gitignored).

Results: 100.0% pass, 1.000 avg, $0.31 cost, 111s wall time

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address remaining PR review comments

- Replace .expect("semaphore closed") with proper error handling
- Derive PartialEq on BenchScore for cleaner test assertions
- Use ToPrimitive::to_f64() instead of string roundtrip in estimated_cost()
- Validate SWE-bench inputs: task_id (path traversal), repo (owner/repo format),
  base_commit (valid git ref) with 5 new tests
- Skip "pending" (unscored) entries during resume so they get re-executed
- Use run.json mtime for find_latest_run (falls back to tasks.jsonl, then dir)
- Move additional_tools() outside parallel loop to share Arc<[Tool]> across tasks
- Add doc comments documenting known limitations (single-turn, resources, conversation)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: reject absolute paths in SWE-bench and validate matrix config

- is_safe_path_component now rejects paths starting with '/'
- BenchConfig::from_file validates matrix is non-empty
- Added tests for both validations

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: fail tasks on setup_task error and compute git hash once

- setup_task failure now records an error TaskResult instead of
  continuing to run the task (both sequential and parallel paths)
- git_short_hash() computed once per run instead of twice

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-17 23:34:02 +00:00

484 lines
15 KiB
Rust

use std::collections::HashSet;
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::error::BenchError;
use crate::suite::BenchScore;
/// Metrics from a single task run: LLM usage, timing, tool calls.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Trace {
pub wall_time_ms: u64,
pub llm_calls: u32,
pub input_tokens: u32,
pub output_tokens: u32,
pub estimated_cost_usd: f64,
pub tool_calls: Vec<TraceToolCall>,
pub turns: u32,
pub hit_iteration_limit: bool,
pub hit_timeout: bool,
}
impl Trace {
pub fn wall_time(&self) -> Duration {
Duration::from_millis(self.wall_time_ms)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TraceToolCall {
pub name: String,
pub duration_ms: u64,
pub success: bool,
}
/// Result of running a single benchmark task.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TaskResult {
pub task_id: String,
pub suite_id: String,
pub score: BenchScore,
pub trace: Trace,
pub response: String,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
pub config_label: String,
#[serde(default)]
pub error: Option<String>,
}
/// Aggregate results for a full benchmark run.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RunResult {
pub run_id: Uuid,
pub suite_id: String,
pub config_label: String,
pub model: String,
/// Short git commit hash at the time of the run.
#[serde(default)]
pub commit_hash: String,
pub pass_rate: f64,
pub avg_score: f64,
pub total_tasks: usize,
pub completed_tasks: usize,
pub total_cost_usd: f64,
pub total_wall_time_ms: u64,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
}
impl RunResult {
/// Build aggregate from individual task results.
pub fn from_tasks(
run_id: Uuid,
suite_id: &str,
config_label: &str,
model: &str,
commit_hash: &str,
total_tasks: usize,
tasks: &[TaskResult],
started_at: DateTime<Utc>,
) -> Self {
let pass_count = tasks.iter().filter(|t| t.score.value >= 1.0).count();
let pass_rate = if tasks.is_empty() {
0.0
} else {
pass_count as f64 / tasks.len() as f64
};
let avg_score = if tasks.is_empty() {
0.0
} else {
tasks.iter().map(|t| t.score.value).sum::<f64>() / tasks.len() as f64
};
let total_cost: f64 = tasks.iter().map(|t| t.trace.estimated_cost_usd).sum();
let total_wall: u64 = tasks.iter().map(|t| t.trace.wall_time_ms).sum();
Self {
run_id,
suite_id: suite_id.to_string(),
config_label: config_label.to_string(),
model: model.to_string(),
commit_hash: commit_hash.to_string(),
pass_rate,
avg_score,
total_tasks,
completed_tasks: tasks.len(),
total_cost_usd: total_cost,
total_wall_time_ms: total_wall,
started_at,
finished_at: Utc::now(),
}
}
pub fn total_wall_time(&self) -> Duration {
Duration::from_millis(self.total_wall_time_ms)
}
}
/// Append a single task result as one JSON line to the JSONL file.
pub fn append_task_result(path: &Path, result: &TaskResult) -> Result<(), BenchError> {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
let line = serde_json::to_string(result)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Overwrite the JSONL file with the given results (used after scoring).
pub fn write_task_results(path: &Path, results: &[TaskResult]) -> Result<(), BenchError> {
let mut file = std::fs::File::create(path)?;
for result in results {
let line = serde_json::to_string(result)?;
writeln!(file, "{line}")?;
}
Ok(())
}
/// Read all task results from a JSONL file.
pub fn read_task_results(path: &Path) -> Result<Vec<TaskResult>, BenchError> {
if !path.exists() {
return Ok(Vec::new());
}
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let mut results = Vec::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let result: TaskResult = serde_json::from_str(trimmed)?;
results.push(result);
}
Ok(results)
}
/// Write the aggregate run result as JSON.
pub fn write_run_result(path: &Path, result: &RunResult) -> Result<(), BenchError> {
let json = serde_json::to_string_pretty(result)?;
std::fs::write(path, json)?;
Ok(())
}
/// Read the aggregate run result from JSON.
pub fn read_run_result(path: &Path) -> Result<RunResult, BenchError> {
let json = std::fs::read_to_string(path)?;
let result: RunResult = serde_json::from_str(&json)?;
Ok(result)
}
/// Get the set of already-completed task IDs from a JSONL file (for resume).
///
/// Only includes tasks that have been scored (label != "pending"). Tasks that
/// were written but not scored (e.g., from an interrupted run) will be re-executed.
pub fn completed_task_ids(path: &Path) -> Result<HashSet<String>, BenchError> {
let results = read_task_results(path)?;
Ok(results
.into_iter()
.filter(|r| r.score.label != "pending")
.map(|r| r.task_id)
.collect())
}
/// Get the results directory for a specific run.
pub fn run_dir(base: &Path, run_id: Uuid) -> PathBuf {
base.join(run_id.to_string())
}
/// Get the tasks JSONL path for a run.
pub fn tasks_jsonl_path(base: &Path, run_id: Uuid) -> PathBuf {
run_dir(base, run_id).join("tasks.jsonl")
}
/// Get the run JSON path for a run.
pub fn run_json_path(base: &Path, run_id: Uuid) -> PathBuf {
run_dir(base, run_id).join("run.json")
}
/// Find the latest run directory by the modification time of its `run.json`.
///
/// Falls back to `tasks.jsonl` mtime, then directory mtime. This avoids the
/// issue where modifying files inside a directory doesn't update the directory's
/// mtime on many filesystems.
pub fn find_latest_run(base: &Path) -> Result<Option<Uuid>, BenchError> {
if !base.exists() {
return Ok(None);
}
let mut entries: Vec<_> = std::fs::read_dir(base)?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let uuid = Uuid::parse_str(&name).ok()?;
let dir_path = e.path();
// Prefer run.json mtime, fall back to tasks.jsonl, then directory
let modified = std::fs::metadata(dir_path.join("run.json"))
.and_then(|m| m.modified())
.or_else(|_| {
std::fs::metadata(dir_path.join("tasks.jsonl")).and_then(|m| m.modified())
})
.or_else(|_| e.metadata().and_then(|m| m.modified()))
.ok()?;
Some((uuid, modified))
})
.collect();
entries.sort_by(|a, b| b.1.cmp(&a.1));
Ok(entries.first().map(|(uuid, _)| *uuid))
}
/// Print a summary table of task results.
pub fn print_results_table(tasks: &[TaskResult], run: &RunResult) {
println!();
let commit_suffix = if run.commit_hash.is_empty() {
String::new()
} else {
format!(" | Commit: {}", run.commit_hash)
};
println!(
"Run: {} | Suite: {} | Model: {}{}",
run.run_id, run.suite_id, run.model, commit_suffix
);
println!(
"Pass rate: {:.1}% | Avg score: {:.3} | Tasks: {}/{} | Cost: ${:.4} | Time: {:.1}s",
run.pass_rate * 100.0,
run.avg_score,
run.completed_tasks,
run.total_tasks,
run.total_cost_usd,
run.total_wall_time_ms as f64 / 1000.0,
);
println!();
// Header
println!(
"{:<30} {:>6} {:>7} {:>8} {:>10} {:>6} {:>8}",
"Task ID", "Score", "Label", "Tokens", "Cost", "Turns", "Time"
);
println!("{}", "-".repeat(80));
for task in tasks {
let total_tokens = task.trace.input_tokens + task.trace.output_tokens;
let task_id_display = if task.task_id.len() > 28 {
let truncated: String = task.task_id.chars().take(25).collect();
format!("{truncated}...")
} else {
task.task_id.clone()
};
println!(
"{:<30} {:>6.3} {:>7} {:>8} {:>10.4} {:>6} {:>7.1}s",
task_id_display,
task.score.value,
task.score.label,
total_tokens,
task.trace.estimated_cost_usd,
task.trace.turns,
task.trace.wall_time_ms as f64 / 1000.0,
);
}
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_run_result_from_tasks() {
let tasks = vec![
TaskResult {
task_id: "t1".to_string(),
suite_id: "custom".to_string(),
score: BenchScore {
value: 1.0,
label: "pass".to_string(),
details: None,
},
trace: Trace {
wall_time_ms: 1000,
llm_calls: 2,
input_tokens: 100,
output_tokens: 50,
estimated_cost_usd: 0.01,
tool_calls: vec![],
turns: 1,
hit_iteration_limit: false,
hit_timeout: false,
},
response: "answer".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
config_label: "default".to_string(),
error: None,
},
TaskResult {
task_id: "t2".to_string(),
suite_id: "custom".to_string(),
score: BenchScore {
value: 0.0,
label: "fail".to_string(),
details: Some("wrong".to_string()),
},
trace: Trace {
wall_time_ms: 2000,
llm_calls: 3,
input_tokens: 200,
output_tokens: 100,
estimated_cost_usd: 0.02,
tool_calls: vec![],
turns: 2,
hit_iteration_limit: false,
hit_timeout: false,
},
response: "wrong answer".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
config_label: "default".to_string(),
error: None,
},
];
let run = RunResult::from_tasks(
Uuid::new_v4(),
"custom",
"default",
"test-model",
"abc1234",
2,
&tasks,
Utc::now(),
);
assert_eq!(run.pass_rate, 0.5);
assert_eq!(run.avg_score, 0.5);
assert_eq!(run.total_tasks, 2);
assert_eq!(run.completed_tasks, 2);
assert!((run.total_cost_usd - 0.03).abs() < f64::EPSILON);
assert_eq!(run.total_wall_time_ms, 3000);
}
#[test]
fn test_jsonl_roundtrip() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("tasks.jsonl");
let result = TaskResult {
task_id: "round-trip-test".to_string(),
suite_id: "custom".to_string(),
score: BenchScore::pass(),
trace: Trace {
wall_time_ms: 500,
llm_calls: 1,
input_tokens: 10,
output_tokens: 5,
estimated_cost_usd: 0.001,
tool_calls: vec![],
turns: 1,
hit_iteration_limit: false,
hit_timeout: false,
},
response: "hello".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
config_label: "test".to_string(),
error: None,
};
append_task_result(&path, &result).expect("append");
append_task_result(&path, &result).expect("append");
let loaded = read_task_results(&path).expect("read");
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].task_id, "round-trip-test");
}
#[test]
fn test_completed_task_ids() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("tasks.jsonl");
let result = TaskResult {
task_id: "unique-id-1".to_string(),
suite_id: "custom".to_string(),
score: BenchScore::pass(),
trace: Trace {
wall_time_ms: 100,
llm_calls: 1,
input_tokens: 10,
output_tokens: 5,
estimated_cost_usd: 0.0,
tool_calls: vec![],
turns: 1,
hit_iteration_limit: false,
hit_timeout: false,
},
response: "x".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
config_label: "test".to_string(),
error: None,
};
append_task_result(&path, &result).expect("append");
let ids = completed_task_ids(&path).expect("ids");
assert!(ids.contains("unique-id-1"));
assert!(!ids.contains("unique-id-2"));
}
#[test]
fn test_write_task_results_overwrites() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("tasks.jsonl");
// Write initial "pending" result via append
let pending = TaskResult {
task_id: "t1".to_string(),
suite_id: "spot".to_string(),
score: BenchScore {
value: 0.0,
label: "pending".to_string(),
details: None,
},
trace: Trace {
wall_time_ms: 100,
llm_calls: 1,
input_tokens: 10,
output_tokens: 5,
estimated_cost_usd: 0.001,
tool_calls: vec![],
turns: 1,
hit_iteration_limit: false,
hit_timeout: false,
},
response: "42".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
config_label: "default".to_string(),
error: None,
};
append_task_result(&path, &pending).expect("append");
// Verify pending score
let before = read_task_results(&path).expect("read");
assert_eq!(before.len(), 1);
assert_eq!(before[0].score.label, "pending");
// Overwrite with scored result
let mut scored = pending;
scored.score = BenchScore::pass();
write_task_results(&path, &[scored]).expect("write");
// Verify scored result replaced pending
let after = read_task_results(&path).expect("read");
assert_eq!(after.len(), 1);
assert_eq!(after[0].score.label, "pass");
assert_eq!(after[0].score.value, 1.0);
}
}