use crate::run;
use crate::workspace::Workspace;
use ostraka_runtime::gate::Refusal;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Instant;
type Failure = Box<dyn std::error::Error>;
pub const FILE: &str = "bench.toml";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Task {
pub id: String,
pub prompt: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Candidate {
pub adapter: String,
#[serde(default)]
pub models: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Suite {
pub reviewers: Vec<String>,
#[serde(default, rename = "task")]
pub tasks: Vec<Task>,
#[serde(default, rename = "candidate")]
pub candidates: Vec<Candidate>,
}
impl Suite {
pub fn parse(text: &str) -> Result<Self, Failure> {
let suite: Suite = toml::from_str(text)?;
suite.validate()?;
Ok(suite)
}
pub fn validate(&self) -> Result<(), Failure> {
if self.tasks.is_empty() {
return Err("a benchmark with no tasks measures nothing".into());
}
if self.candidates.is_empty() {
return Err("a benchmark with no candidates measures nothing".into());
}
if self.reviewers.is_empty() {
return Err(
"name at least one reviewer profile: every run is gated and reviewed, and \
a benchmark that skipped the review would not be measuring what Ostraka does"
.into(),
);
}
let mut seen: Vec<&str> = Vec::new();
for task in &self.tasks {
if seen.contains(&task.id.as_str()) {
return Err(format!("two tasks share the id {:?}", task.id).into());
}
seen.push(&task.id);
}
for candidate in &self.candidates {
if self.reviewers.iter().all(|r| *r == candidate.adapter) {
return Err(format!(
"candidate {:?} is the only reviewer named, and the gate refuses a \
reviewer that is the author — name a second reviewer",
candidate.adapter
)
.into());
}
}
Ok(())
}
pub fn cells(&self) -> Vec<Cell> {
let mut out = Vec::new();
for task in &self.tasks {
for candidate in &self.candidates {
let models: Vec<Option<String>> = if candidate.models.is_empty() {
vec![None]
} else {
candidate.models.iter().cloned().map(Some).collect()
};
for model in models {
let reviewer = self
.reviewers
.iter()
.find(|r| **r != candidate.adapter)
.expect("validate refused a candidate with no possible reviewer")
.clone();
out.push(Cell {
task: task.id.clone(),
prompt: task.prompt.clone(),
adapter: candidate.adapter.clone(),
model,
stood_in: reviewer != self.reviewers[0],
reviewer,
});
}
}
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cell {
pub task: String,
pub prompt: String,
pub adapter: String,
pub model: Option<String>,
pub reviewer: String,
pub stood_in: bool,
}
impl Cell {
pub fn candidate(&self) -> String {
match &self.model {
Some(model) => format!("{}:{model}", self.adapter),
None => format!("{}:default", self.adapter),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum Verdict {
Approved,
ChecksFailed { failed: Vec<String> },
Rejected { reason: String },
NoChange,
Incomplete { said: String },
}
impl Verdict {
pub fn judged(&self) -> bool {
!matches!(self, Verdict::Incomplete { .. })
}
pub fn passed(&self) -> bool {
matches!(self, Verdict::Approved)
}
pub fn gate_passed(&self) -> bool {
matches!(self, Verdict::Approved | Verdict::Rejected { .. })
}
fn describe(&self) -> String {
match self {
Verdict::Approved => "approved".to_string(),
Verdict::ChecksFailed { failed } => format!("checks failed: {}", failed.join(", ")),
Verdict::Rejected { .. } => "gate passed, review rejected".to_string(),
Verdict::NoChange => "wrote nothing".to_string(),
Verdict::Incomplete { said } => format!("did not finish: {said}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Result_ {
pub task: String,
pub candidate: String,
pub adapter: String,
pub model: Option<String>,
pub reviewer: String,
pub stood_in: bool,
pub verdict: Verdict,
pub run_id: Option<String>,
pub seconds: u64,
pub author_tokens: Option<u64>,
}
fn verdict_of(report: &ostraka_runtime::orchestrator::RunReport) -> Verdict {
match &report.refusal {
None => Verdict::Approved,
Some(Refusal::ChecksFailed { failed, .. }) => Verdict::ChecksFailed {
failed: failed.clone(),
},
Some(Refusal::Rejected { reason }) => Verdict::Rejected {
reason: reason.clone(),
},
Some(Refusal::NoChange) => Verdict::NoChange,
Some(Refusal::AuthorFailed { code, .. }) => Verdict::Incomplete {
said: format!("the agent exited {code}"),
},
Some(Refusal::SetupFailed { step, reason }) => Verdict::Incomplete {
said: format!("the worktree was not ready: {step} — {reason}"),
},
Some(Refusal::TimedOut { after_secs }) => Verdict::Incomplete {
said: format!("the ceiling of {after_secs}s ran out"),
},
Some(Refusal::Interrupted) => Verdict::Incomplete {
said: "stopped".to_string(),
},
Some(Refusal::PolicyViolation { reason }) => Verdict::Incomplete {
said: format!("policy: {reason}"),
},
Some(Refusal::SelfApproval { actor }) => Verdict::Incomplete {
said: format!("{} was asked to approve its own change", actor.as_str()),
},
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Standing {
pub candidate: String,
pub judged: usize,
pub approved: usize,
pub gate_passed: usize,
pub incomplete: usize,
pub seconds: u64,
pub tokens: Option<u64>,
pub stood_in: bool,
}
impl Standing {
pub fn rate(&self) -> Option<f64> {
(self.judged > 0).then(|| self.approved as f64 / self.judged as f64)
}
}
pub fn standings(results: &[Result_]) -> Vec<Standing> {
let mut out: Vec<Standing> = Vec::new();
for r in results {
let row = match out.iter_mut().find(|s| s.candidate == r.candidate) {
Some(row) => row,
None => {
out.push(Standing {
candidate: r.candidate.clone(),
judged: 0,
approved: 0,
gate_passed: 0,
incomplete: 0,
seconds: 0,
tokens: None,
stood_in: false,
});
out.last_mut().expect("just pushed")
}
};
row.seconds += r.seconds;
row.stood_in |= r.stood_in;
if let Some(t) = r.author_tokens {
row.tokens = Some(row.tokens.unwrap_or(0) + t);
}
if r.verdict.judged() {
row.judged += 1;
} else {
row.incomplete += 1;
}
if r.verdict.passed() {
row.approved += 1;
}
if r.verdict.gate_passed() {
row.gate_passed += 1;
}
}
out.sort_by(|a, b| {
b.rate()
.unwrap_or(-1.0)
.partial_cmp(&a.rate().unwrap_or(-1.0))
.unwrap_or(std::cmp::Ordering::Equal)
.then(b.gate_passed.cmp(&a.gate_passed))
.then(a.seconds.cmp(&b.seconds))
.then(a.candidate.cmp(&b.candidate))
});
out
}
pub fn load(workspace: &Workspace) -> Result<Suite, Failure> {
let path = workspace.ostraka().join(FILE);
let text = std::fs::read_to_string(&path).map_err(|e| -> Failure {
format!(
"no benchmark declared at {}: {e}\n\n{}",
path.display(),
EXAMPLE
)
.into()
})?;
Suite::parse(&text)
}
pub const EXAMPLE: &str = "\
A benchmark is a file. The shape:
reviewers = [\"profile-a\", \"profile-b\"]
[[task]]
id = \"a-short-name\"
prompt = \"what every candidate is asked to do\"
[[candidate]]
adapter = \"profile-a\"
models = [\"an-id-this-machine-can-run\"]";
pub fn measure(
workspace: &Workspace,
suite: &Suite,
cells: &[Cell],
mut say: impl FnMut(&Cell, usize, usize),
) -> Vec<Result_> {
let _ = suite;
let mut results = Vec::new();
for (i, cell) in cells.iter().enumerate() {
say(cell, i + 1, cells.len());
ostraka_adapter::interrupt::clear();
let started = Instant::now();
let args = run::Args {
prompt: cell.prompt.clone(),
repository: None,
author: cell.adapter.clone(),
reviewer: cell.reviewer.clone(),
adapter: Some(cell.adapter.clone()),
review_adapter: Some(cell.reviewer.clone()),
base_ref: run::BASE_REF.to_string(),
from: None,
model: cell.model.clone(),
};
let (verdict, run_id, tokens) = match run::execute(workspace, &args, None) {
Ok(report) => {
let tokens = report
.record
.usage
.iter()
.filter(|u| u.role == "author")
.map(|u| u.counted())
.reduce(|a, b| a + b);
(
verdict_of(&report),
Some(report.record.run_id.clone()),
tokens,
)
}
Err(e) => (
Verdict::Incomplete {
said: e.to_string().lines().next().unwrap_or("").to_string(),
},
None,
None,
),
};
results.push(Result_ {
task: cell.task.clone(),
candidate: cell.candidate(),
adapter: cell.adapter.clone(),
model: cell.model.clone(),
reviewer: cell.reviewer.clone(),
stood_in: cell.stood_in,
verdict,
run_id,
seconds: started.elapsed().as_secs(),
author_tokens: tokens,
});
if ostraka_adapter::interrupt::requested() {
break;
}
}
results
}
fn keep(workspace: &Workspace, results: &[Result_]) -> Result<std::path::PathBuf, Failure> {
let dir = workspace.ostraka().join("bench");
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{}.json", stamp()));
std::fs::write(&path, serde_json::to_string_pretty(&report(results))?)?;
Ok(path)
}
fn stamp() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("b{secs}-{}", std::process::id())
}
pub fn report(results: &[Result_]) -> serde_json::Value {
serde_json::json!({
"results": results,
"standings": standings(results),
})
}
pub fn table(results: &[Result_]) -> String {
let mut out = String::new();
let mut tasks: Vec<&str> = Vec::new();
for r in results {
if !tasks.contains(&r.task.as_str()) {
tasks.push(&r.task);
}
}
for task in tasks {
out.push_str(&format!("{task}\n"));
let rows: Vec<&Result_> = results.iter().filter(|r| r.task == task).collect();
let pad = rows.iter().map(|r| r.candidate.len()).max().unwrap_or(0);
for r in rows {
let mark = if r.verdict.passed() { "ok " } else { " " };
let note = if r.stood_in {
" (stand-in reviewer)"
} else {
""
};
out.push_str(&format!(
" {mark}{:<pad$} {:>4}s {}{note}\n",
r.candidate,
r.seconds,
r.verdict.describe(),
));
}
out.push('\n');
}
out.push_str("standings\n");
for s in standings(results) {
let rate = match s.rate() {
Some(rate) => format!("{:>3.0}%", rate * 100.0),
None => " —".to_string(),
};
let tokens = match s.tokens {
Some(t) => format!("{:>8}", thousands(t)),
None => " —".to_string(),
};
let note = if s.stood_in {
" a stand-in reviewer judged at least one cell"
} else {
""
};
out.push_str(&format!(
" {rate} {:<24} {} approved / {} judged gate {} {} incomplete {:>5}s {tokens}{note}\n",
s.candidate, s.approved, s.judged, s.gate_passed, s.incomplete, s.seconds
));
}
out
}
fn plural(n: usize, noun: &str) -> String {
if n == 1 {
format!("{n} {noun}")
} else {
format!("{n} {noun}s")
}
}
fn thousands(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
pub fn unknown(suite: &Suite, configured: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let named = suite
.candidates
.iter()
.map(|c| c.adapter.clone())
.chain(suite.reviewers.iter().cloned());
for id in named {
if !configured.contains(&id) && !out.contains(&id) {
out.push(id);
}
}
out
}
pub fn run(workspace: &Workspace, dry_run: bool, json: bool) -> Result<bool, Failure> {
let suite = load(workspace)?;
let configured: Vec<String> = workspace
.profiles()
.map_err(|e| -> Failure {
format!("the benchmark could not read this workspace's adapter profiles: {e}").into()
})?
.into_iter()
.map(|p| p.id)
.collect();
let unknown = unknown(&suite, &configured);
if !unknown.is_empty() {
return Err(format!(
"the benchmark names {} this workspace has not configured: {}\n\nconfigured here: {}",
if unknown.len() == 1 {
"a profile"
} else {
"profiles"
},
unknown.join(", "),
if configured.is_empty() {
"none".to_string()
} else {
configured.join(", ")
},
)
.into());
}
let cells = suite.cells();
if dry_run {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"cells": cells.iter().map(|c| serde_json::json!({
"task": c.task,
"candidate": c.candidate(),
"reviewer": c.reviewer,
"stood_in": c.stood_in,
})).collect::<Vec<_>>(),
}))?
);
} else {
let per_task = cells.len() / suite.tasks.len().max(1);
println!(
"{} cells: {} x {}. Each one is an authoring run and a review.",
cells.len(),
plural(suite.tasks.len(), "task"),
plural(per_task, "candidate/model pair"),
);
for cell in &cells {
let note = if cell.stood_in { " (stand-in)" } else { "" };
println!(
" {:<20} {:<28} reviewed by {}{note}",
cell.task,
cell.candidate(),
cell.reviewer
);
}
}
return Ok(true);
}
let results = measure(workspace, &suite, &cells, |cell, n, of| {
if !json {
eprintln!("[{n}/{of}] {} — {}", cell.task, cell.candidate());
}
});
let kept = keep(workspace, &results)?;
if json {
println!("{}", serde_json::to_string_pretty(&report(&results))?);
} else {
println!();
print!("{}", table(&results));
println!();
println!("kept at {}", relative(workspace, &kept));
}
Ok(true)
}
fn relative(workspace: &Workspace, path: &Path) -> String {
path.strip_prefix(workspace.ostraka().parent().unwrap_or(path))
.unwrap_or(path)
.display()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
const SUITE: &str = r#"
reviewers = ["ref", "alt"]
[[task]]
id = "one"
prompt = "do the first thing"
[[task]]
id = "two"
prompt = "do the second thing"
[[candidate]]
adapter = "cand"
models = ["m1", "m2"]
[[candidate]]
adapter = "ref"
"#;
#[test]
fn a_suite_expands_to_one_cell_per_task_candidate_and_model() {
let suite = Suite::parse(SUITE).expect("parses");
let cells = suite.cells();
assert_eq!(cells.len(), 6, "{cells:#?}");
assert_eq!(
cells[0].candidate(),
"cand:m1",
"a named model is part of the candidate's name"
);
assert_eq!(
cells[2].candidate(),
"ref:default",
"a candidate with no model is not pretending to know which one ran"
);
assert!(cells[..3].iter().all(|c| c.task == "one"), "{cells:#?}");
}
#[test]
fn a_candidate_that_is_the_reviewer_gets_a_stand_in_and_is_marked() {
let suite = Suite::parse(SUITE).expect("parses");
let cells = suite.cells();
let against_ref = cells
.iter()
.find(|c| c.adapter == "ref")
.expect("the reviewer is also a candidate here");
assert_eq!(against_ref.reviewer, "alt");
assert!(
against_ref.stood_in,
"a stand-in was used and the report would not have said so"
);
let ordinary = cells
.iter()
.find(|c| c.adapter == "cand")
.expect("the other candidate");
assert_eq!(ordinary.reviewer, "ref");
assert!(!ordinary.stood_in);
}
#[test]
fn a_suite_that_cannot_be_run_is_refused_before_anything_is_spent() {
for (text, want) in [
("reviewers = [\"a\"]\n", "no tasks"),
(
"reviewers = [\"a\"]\n[[task]]\nid=\"x\"\nprompt=\"y\"\n",
"no candidates",
),
(
"reviewers = []\n[[task]]\nid=\"x\"\nprompt=\"y\"\n[[candidate]]\nadapter=\"a\"\n",
"at least one reviewer",
),
(
"reviewers = [\"a\"]\n[[task]]\nid=\"x\"\nprompt=\"y\"\n[[candidate]]\nadapter=\"a\"\n",
"the only reviewer named",
),
(
"reviewers = [\"a\"]\n[[task]]\nid=\"x\"\nprompt=\"y\"\n[[task]]\nid=\"x\"\nprompt=\"z\"\n[[candidate]]\nadapter=\"b\"\n",
"share the id",
),
] {
let err = Suite::parse(text).expect_err("should be refused");
assert!(err.to_string().contains(want), "wanted {want:?} in {err}");
}
}
fn result(candidate: &str, task: &str, verdict: Verdict, seconds: u64) -> Result_ {
Result_ {
task: task.to_string(),
candidate: candidate.to_string(),
adapter: candidate.to_string(),
model: None,
reviewer: "ref".to_string(),
stood_in: false,
verdict,
run_id: None,
seconds,
author_tokens: None,
}
}
#[test]
fn a_run_that_never_reached_a_verdict_does_not_count_against_the_candidate() {
let results = vec![
result("a", "one", Verdict::Approved, 1),
result(
"a",
"two",
Verdict::Incomplete {
said: "rate limited".into(),
},
1,
),
result("b", "one", Verdict::Approved, 1),
result(
"b",
"two",
Verdict::ChecksFailed {
failed: vec!["test".into()],
},
1,
),
];
let table = standings(&results);
let a = table.iter().find(|s| s.candidate == "a").expect("a");
let b = table.iter().find(|s| s.candidate == "b").expect("b");
assert_eq!((a.judged, a.approved, a.incomplete), (1, 1, 1));
assert_eq!((b.judged, b.approved, b.incomplete), (2, 1, 0));
assert_eq!(a.rate(), Some(1.0));
assert_eq!(b.rate(), Some(0.5));
assert_eq!(table[0].candidate, "a", "{table:#?}");
}
#[test]
fn a_candidate_that_never_arrived_has_no_rate_and_sorts_last() {
let results = vec![
result(
"gone",
"one",
Verdict::Incomplete {
said: "not logged in".into(),
},
0,
),
result(
"here",
"one",
Verdict::ChecksFailed {
failed: vec!["fmt".into()],
},
9,
),
];
let table = standings(&results);
assert_eq!(table[0].candidate, "here", "{table:#?}");
assert_eq!(table[1].candidate, "gone");
assert_eq!(table[1].rate(), None);
let printed = super::table(&results);
let standing = |name: &str| -> String {
printed
.lines()
.find(|l| l.contains(name) && l.contains("judged"))
.unwrap_or_else(|| panic!("no standing for {name} in:\n{printed}"))
.to_string()
};
assert!(standing("gone").contains('—'), "{printed}");
assert!(!standing("gone").contains('%'), "{printed}");
assert!(standing("here").contains("0%"), "{printed}");
}
#[test]
fn the_gate_and_the_review_are_reported_apart() {
let results = vec![
result(
"a",
"one",
Verdict::Rejected {
reason: "naming".into(),
},
1,
),
result(
"b",
"one",
Verdict::ChecksFailed {
failed: vec!["clippy".into()],
},
1,
),
];
let table = standings(&results);
let a = table.iter().find(|s| s.candidate == "a").expect("a");
let b = table.iter().find(|s| s.candidate == "b").expect("b");
assert_eq!((a.approved, a.gate_passed), (0, 1));
assert_eq!((b.approved, b.gate_passed), (0, 0));
}
#[test]
fn a_candidate_listing_models_is_counted_once_per_model() {
let suite = Suite::parse(SUITE).expect("parses");
let per_task = suite.cells().len() / suite.tasks.len();
assert_eq!(
per_task, 3,
"two candidates, one of which lists two models, is three cells a task"
);
assert_eq!(suite.candidates.len(), 2, "and only two candidates");
}
#[test]
fn a_profile_the_workspace_does_not_have_is_named_before_anything_runs() {
let suite = Suite::parse(SUITE).expect("parses");
assert!(unknown(&suite, &["cand".into(), "ref".into(), "alt".into()]).is_empty());
assert_eq!(
unknown(&suite, &["cand".into(), "ref".into()]),
vec!["alt".to_string()],
"a reviewer with no profile went unmentioned"
);
assert_eq!(
unknown(&suite, &[]),
vec!["cand".to_string(), "ref".to_string(), "alt".to_string()],
);
}
#[test]
fn the_table_names_the_run_a_cell_came_from_in_json() {
let mut r = result("a", "one", Verdict::Approved, 3);
r.run_id = Some("r-1".into());
let json = report(&[r]).to_string();
assert!(json.contains("r-1"), "{json}");
}
}