mod candidate;
mod dataset;
mod experiment;
mod render;
use std::{ffi::OsString, num::NonZeroUsize, path::PathBuf, process::ExitCode, time::Duration};
use anyhow::{Context, Result};
use candidate::ProcessCandidate;
use clap::{Args, Parser, Subcommand, ValueEnum};
use runifold_testkit::{
EvaluationReport, EvaluationRunner, JsonExactMatchScorer, RegressionComparison,
RegressionPolicy, TokenOverlapScorer,
};
const QUALITY_GATE_FAILURE: u8 = 2;
#[derive(Debug, Parser)]
#[command(name = "runifold-eval", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Run(RunArgs),
Compare(CompareArgs),
Experiment(experiment::ExperimentArgs),
Merge(experiment::MergeArgs),
}
#[derive(Debug, Args)]
struct RunArgs {
#[arg(long)]
dataset: PathBuf,
#[arg(long)]
dataset_name: String,
#[arg(long)]
dataset_version: String,
#[arg(long)]
candidate_version: String,
#[arg(long)]
output: PathBuf,
#[arg(long)]
baseline: Option<PathBuf>,
#[arg(long)]
junit: Option<PathBuf>,
#[arg(long)]
markdown: Option<PathBuf>,
#[arg(long, default_value = "4")]
concurrency: NonZeroUsize,
#[arg(long, default_value_t = 60_000)]
timeout_ms: u64,
#[arg(long, default_value_t = 1_048_576)]
max_output_bytes: usize,
#[arg(long, value_enum, default_value_t = ScorerKind::Exact)]
scorer: ScorerKind,
#[arg(long, default_value_t = 1.0)]
score_threshold: f64,
#[arg(long, default_value_t = 0.02)]
max_mean_drop: f64,
#[arg(long, default_value_t = 0.05)]
max_pass_rate_drop: f64,
#[arg(long, default_value_t = 0.0)]
max_execution_drop: f64,
#[arg(required = true, last = true)]
candidate_command: Vec<OsString>,
}
#[derive(Debug, Args)]
struct CompareArgs {
#[arg(long)]
candidate: PathBuf,
#[arg(long)]
baseline: PathBuf,
#[arg(long)]
junit: Option<PathBuf>,
#[arg(long)]
markdown: Option<PathBuf>,
#[arg(long, default_value_t = 0.02)]
max_mean_drop: f64,
#[arg(long, default_value_t = 0.05)]
max_pass_rate_drop: f64,
#[arg(long, default_value_t = 0.0)]
max_execution_drop: f64,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub(crate) enum ScorerKind {
Exact,
TokenOverlap,
}
#[tokio::main]
async fn main() -> ExitCode {
match execute(Cli::parse()).await {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(QUALITY_GATE_FAILURE),
Err(error) => {
eprintln!("{error:#}");
ExitCode::FAILURE
}
}
}
async fn execute(cli: Cli) -> Result<bool> {
match cli.command {
Command::Run(args) => run(args).await,
Command::Compare(args) => compare(args).await,
Command::Experiment(args) => experiment::run(args).await,
Command::Merge(args) => experiment::merge(args).await,
}
}
async fn run(args: RunArgs) -> Result<bool> {
let dataset =
dataset::load_jsonl(&args.dataset, &args.dataset_name, &args.dataset_version).await?;
let candidate = ProcessCandidate::new(
args.candidate_command,
Duration::from_millis(args.timeout_ms),
args.max_output_bytes,
)
.context("invalid Candidate process configuration")?;
let runner = EvaluationRunner::new(candidate).with_concurrency(args.concurrency);
let report = match args.scorer {
ScorerKind::Exact => {
runner
.with_scorer(JsonExactMatchScorer)
.run(&dataset, args.candidate_version)
.await?
}
ScorerKind::TokenOverlap => {
runner
.with_scorer(TokenOverlapScorer::new(
"token_overlap",
args.score_threshold,
)?)
.run(&dataset, args.candidate_version)
.await?
}
};
let comparison = compare_baseline(
&report,
args.baseline.as_ref(),
policy(
args.max_mean_drop,
args.max_pass_rate_drop,
args.max_execution_drop,
)?,
)
.await?;
write_outputs(
&report,
comparison.as_ref(),
Some(&args.output),
args.junit.as_ref(),
args.markdown.as_ref(),
)
.await?;
Ok(render::absolute_passed(&report)
&& comparison
.as_ref()
.is_none_or(|comparison| comparison.passed))
}
async fn compare(args: CompareArgs) -> Result<bool> {
let candidate = dataset::load_report(&args.candidate).await?;
let baseline = dataset::load_report(&args.baseline).await?;
let comparison = candidate.compare(
&baseline,
&policy(
args.max_mean_drop,
args.max_pass_rate_drop,
args.max_execution_drop,
)?,
)?;
write_outputs(
&candidate,
Some(&comparison),
None,
args.junit.as_ref(),
args.markdown.as_ref(),
)
.await?;
Ok(render::absolute_passed(&candidate) && comparison.passed)
}
fn policy(mean: f64, pass_rate: f64, execution: f64) -> Result<RegressionPolicy> {
RegressionPolicy::new(mean, pass_rate, execution).context("invalid regression policy")
}
async fn compare_baseline(
report: &EvaluationReport,
baseline: Option<&PathBuf>,
policy: RegressionPolicy,
) -> Result<Option<RegressionComparison>> {
let Some(path) = baseline else {
return Ok(None);
};
let baseline = dataset::load_report(path).await?;
Ok(Some(report.compare(&baseline, &policy)?))
}
async fn write_outputs(
report: &EvaluationReport,
comparison: Option<&RegressionComparison>,
json: Option<&PathBuf>,
junit: Option<&PathBuf>,
markdown: Option<&PathBuf>,
) -> Result<()> {
if let Some(path) = json {
dataset::write(path, report.to_json_pretty()?.as_bytes()).await?;
}
if let Some(path) = junit {
dataset::write(path, render::junit(report, comparison).as_bytes()).await?;
}
if let Some(path) = markdown {
dataset::write(path, render::markdown(report, comparison).as_bytes()).await?;
}
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use std::{ffi::OsString, num::NonZeroUsize, time::Duration};
use runifold_core::RunId;
use super::{Cli, Command, RunArgs, ScorerKind, execute};
#[tokio::test]
async fn run_executes_candidate_without_disclosing_reference_and_writes_ci_reports() {
let root = std::env::temp_dir().join(format!("runifold-eval-cli-{}", RunId::new()));
tokio::fs::create_dir_all(&root).await.unwrap();
let dataset = root.join("dataset.jsonl");
let output = root.join("report.json");
let junit = root.join("report.xml");
let markdown = root.join("report.md");
tokio::fs::write(
&dataset,
r#"{"id":"one","input":"question","expected":"answer","tags":["smoke"]}"#,
)
.await
.unwrap();
let script = r#"payload=$(cat); case "$payload" in *expected*) exit 9;; esac; printf '{"output":"answer"}'"#;
let passed = execute(Cli {
command: Command::Run(RunArgs {
dataset,
dataset_name: "answers".into(),
dataset_version: "1".into(),
candidate_version: "candidate".into(),
output: output.clone(),
baseline: None,
junit: Some(junit.clone()),
markdown: Some(markdown.clone()),
concurrency: NonZeroUsize::MIN,
timeout_ms: u64::try_from(Duration::from_secs(1).as_millis()).unwrap(),
max_output_bytes: 1024,
scorer: ScorerKind::Exact,
score_threshold: 1.0,
max_mean_drop: 0.0,
max_pass_rate_drop: 0.0,
max_execution_drop: 0.0,
candidate_command: vec![
OsString::from("sh"),
OsString::from("-c"),
OsString::from(script),
],
}),
})
.await
.unwrap();
assert!(passed);
assert!(
tokio::fs::read_to_string(output)
.await
.unwrap()
.contains("\"candidate_version\": \"candidate\"")
);
assert!(
tokio::fs::read_to_string(junit)
.await
.unwrap()
.contains("<testsuite")
);
assert!(
tokio::fs::read_to_string(markdown)
.await
.unwrap()
.contains("Absolute gate: **PASS**")
);
tokio::fs::remove_dir_all(root).await.unwrap();
}
}