mod baseline;
mod extract;
mod report;
mod run;
mod score;
mod task;
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::Args;
use crate::mcp::BasemindServer;
use crate::mcp::tokens::TOKENS_ARE_COUNTED;
pub use report::{Regression, Report, TaskResult, aggregate, compare, markdown};
pub use score::{Item, Score, score};
pub use task::{EvalMode, Scoring, Task, parse_tasks};
#[derive(Args, Debug, Clone)]
pub struct EvalArgs {
#[arg(long, value_name = "FILE")]
pub tasks: PathBuf,
#[arg(long, value_name = "FILE")]
pub out: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
pub report: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
pub markdown: Option<PathBuf>,
#[arg(long, value_name = "REPORT_JSON")]
pub baseline: Option<PathBuf>,
#[arg(long, default_value_t = 0.02)]
pub tolerance: f64,
#[arg(long, default_value_t = 0.25)]
pub cost_tolerance: f64,
#[arg(long, default_value_t = 0.8)]
pub min_recall: f64,
#[arg(long)]
pub warmup: bool,
#[arg(long = "mode", value_name = "MODE", value_parser = parse_mode)]
pub modes: Vec<EvalMode>,
}
fn parse_mode(s: &str) -> std::result::Result<EvalMode, String> {
serde_json::from_value(serde_json::Value::String(s.to_string())).map_err(|_| {
format!("unknown mode `{s}` (symbols, outline, references, callers, grep, find, dependents, git_search, docs)")
})
}
pub async fn run(server: &BasemindServer, args: &EvalArgs, out: &mut impl Write) -> Result<()> {
let text = std::fs::read_to_string(&args.tasks).with_context(|| format!("read {}", args.tasks.display()))?;
let mut tasks = parse_tasks(&text)?;
if !args.modes.is_empty() {
tasks.retain(|t| args.modes.contains(&t.mode));
}
if tasks.is_empty() {
bail!("no tasks to run in {}", args.tasks.display());
}
let root = server.state.shared.root.clone();
if args.warmup {
for t in &tasks {
let _ = run::run_task(server, &root, t, args.min_recall).await;
}
}
let mut results = Vec::with_capacity(tasks.len());
for t in &tasks {
results.push(run::run_task(server, &root, t, args.min_recall).await);
}
let ndjson: String = results
.iter()
.map(|r| serde_json::to_string(r).map(|l| l + "\n"))
.collect::<Result<_, _>>()
.context("serialize results")?;
match &args.out {
Some(p) => std::fs::write(p, &ndjson).with_context(|| format!("write {}", p.display()))?,
None => out.write_all(ndjson.as_bytes()).context("write results")?,
}
let tokenizer = if TOKENS_ARE_COUNTED { "o200k" } else { "bytes/4" };
let rep = aggregate(
&results,
tokenizer,
&root.display().to_string(),
&args.tasks.display().to_string(),
args.min_recall,
);
if let Some(p) = &args.report {
let json = serde_json::to_string_pretty(&rep).context("serialize report")?;
std::fs::write(p, json + "\n").with_context(|| format!("write {}", p.display()))?;
}
let md = markdown(&rep);
match &args.markdown {
Some(p) => std::fs::write(p, &md).with_context(|| format!("write {}", p.display()))?,
None => eprintln!("{md}"),
}
for r in results.iter().filter(|r| !r.ok) {
eprintln!("task {} failed: {}", r.id, r.error.as_deref().unwrap_or("?"));
}
if let Some(path) = &args.baseline {
let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let base: Report = serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
if base.tokenizer != rep.tokenizer {
bail!(
"baseline report used tokenizer `{}` but this run used `{}`; token counts are not comparable",
base.tokenizer,
rep.tokenizer
);
}
let regressions = compare(&base, &rep, args.tolerance, args.cost_tolerance);
if !regressions.is_empty() {
for r in ®ressions {
eprintln!("REGRESSION {r}");
}
bail!("{} regression(s) against {}", regressions.len(), path.display());
}
eprintln!("no regressions against {}", path.display());
}
Ok(())
}