1use crate::trace::{TurnOutcome, TurnTrace};
7use crate::verifier::{VerifierRunner, VerifierSpec};
8use anyhow::{Context, Result, bail};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Path, PathBuf};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(default)]
17pub struct EvalCase {
18 pub version: u32,
20 pub id: String,
22 pub title: String,
24 pub category: String,
26 pub mode: EvalMode,
28 pub task: String,
30 pub setup: Vec<VerifierSpec>,
32 pub verifiers: Vec<VerifierSpec>,
34 pub tags: Vec<String>,
36 pub notes: Option<String>,
38 pub metadata: BTreeMap<String, String>,
40}
41
42impl EvalCase {
43 pub const CURRENT_VERSION: u32 = 1;
44}
45
46impl Default for EvalCase {
47 fn default() -> Self {
48 Self {
49 version: Self::CURRENT_VERSION,
50 id: String::new(),
51 title: String::new(),
52 category: "simple_repo_task".to_string(),
53 mode: EvalMode::Parity,
54 task: String::new(),
55 setup: Vec::new(),
56 verifiers: Vec::new(),
57 tags: Vec::new(),
58 notes: None,
59 metadata: BTreeMap::new(),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
66#[serde(rename_all = "kebab-case")]
67pub enum EvalMode {
68 #[default]
70 Parity,
71 VerifierFirst,
73 BranchRace,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct EvalSuite {
80 pub name: String,
81 pub cases: Vec<EvalCase>,
82}
83
84impl EvalSuite {
85 pub fn load(path: &Path) -> Result<Self> {
87 if path.is_file() {
88 let case = load_case(path)?;
89 return Ok(Self {
90 name: path
91 .file_stem()
92 .and_then(|value| value.to_str())
93 .unwrap_or("eval")
94 .to_string(),
95 cases: vec![case],
96 });
97 }
98
99 if !path.is_dir() {
100 bail!("eval path does not exist: {}", path.display());
101 }
102
103 let mut files = Vec::new();
104 for entry in std::fs::read_dir(path)
105 .with_context(|| format!("failed to read eval suite {}", path.display()))?
106 {
107 let entry = entry?;
108 let entry_path = entry.path();
109 if is_eval_case_file(&entry_path) {
110 files.push(entry_path);
111 }
112 }
113 files.sort();
114
115 let mut cases = Vec::new();
116 for file in files {
117 cases.push(load_case(&file)?);
118 }
119
120 if cases.is_empty() {
121 bail!("eval suite has no .json or .toml cases: {}", path.display());
122 }
123 validate_unique_case_ids(&cases, path)?;
124
125 Ok(Self {
126 name: path
127 .file_name()
128 .and_then(|value| value.to_str())
129 .unwrap_or("eval-suite")
130 .to_string(),
131 cases,
132 })
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138pub struct EvalCaseResult {
139 pub case_id: String,
140 pub title: String,
141 pub category: String,
142 pub mode: EvalMode,
143 pub passed: bool,
144 pub setup_results: Vec<crate::verifier::VerifierResult>,
145 pub verifier_results: Vec<crate::verifier::VerifierResult>,
146 pub metrics: EvalCaseMetrics,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
151pub struct EvalCaseMetrics {
152 pub input_tokens: u64,
153 pub output_tokens: u64,
154 pub total_tokens: u64,
155 pub tool_calls: usize,
156 pub failed_tool_calls: usize,
157 pub verifier_count: usize,
158 pub wall_time_ms: u64,
159}
160
161impl EvalCaseMetrics {
162 fn from_verifier_results(
163 setup_results: &[crate::verifier::VerifierResult],
164 verifier_results: &[crate::verifier::VerifierResult],
165 ) -> Self {
166 let wall_time_ms = setup_results
167 .iter()
168 .chain(verifier_results)
169 .map(|result| result.duration_ms)
170 .sum();
171 Self {
172 input_tokens: 0,
173 output_tokens: 0,
174 total_tokens: 0,
175 tool_calls: 0,
176 failed_tool_calls: 0,
177 verifier_count: verifier_results.len(),
178 wall_time_ms,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185pub struct EvalRun {
186 pub version: u32,
187 pub run_id: String,
188 pub suite_name: String,
189 pub started_at: u64,
190 pub ended_at: u64,
191 pub project_root: PathBuf,
192 pub metrics: EvalRunMetrics,
193 pub results: Vec<EvalCaseResult>,
194}
195
196impl EvalRun {
197 pub const CURRENT_VERSION: u32 = 1;
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
202pub struct EvalRunMetrics {
203 pub total_cases: usize,
204 pub passed_cases: usize,
205 pub failed_cases: usize,
206 pub verified_success_rate: f64,
207 pub verified_success_per_1k_tokens: Option<f64>,
208 pub tokens_per_success: Option<f64>,
209 pub tool_calls_per_success: Option<f64>,
210 pub wall_time_ms: u64,
211}
212
213pub struct EvalRunner;
215
216impl EvalRunner {
217 pub async fn run_suite(suite: EvalSuite, project_root: &Path) -> EvalRun {
218 let started_at = current_unix_millis();
219 let mut results = Vec::new();
220
221 for case in suite.cases {
222 results.push(Self::run_case(case, project_root).await);
223 }
224
225 let ended_at = current_unix_millis();
226 let metrics = aggregate_metrics(&results, ended_at.saturating_sub(started_at));
227 EvalRun {
228 version: EvalRun::CURRENT_VERSION,
229 run_id: format!("eval-{started_at}"),
230 suite_name: suite.name,
231 started_at,
232 ended_at,
233 project_root: project_root.to_path_buf(),
234 metrics,
235 results,
236 }
237 }
238
239 pub async fn run_case(case: EvalCase, project_root: &Path) -> EvalCaseResult {
240 let mut setup_results = Vec::new();
241 for spec in &case.setup {
242 setup_results.push(VerifierRunner::run(spec, project_root).await);
243 }
244
245 let setup_failed = setup_results
246 .iter()
247 .any(|result| matches!(result.status.as_str(), "fail" | "error"));
248
249 let mut verifier_results = Vec::new();
250 if !setup_failed {
251 for spec in &case.verifiers {
252 verifier_results.push(VerifierRunner::run(spec, project_root).await);
253 }
254 }
255
256 let passed = !setup_failed
257 && required_verifiers_passed(&case.verifiers, &verifier_results)
258 && !case.verifiers.is_empty();
259 let metrics = EvalCaseMetrics::from_verifier_results(&setup_results, &verifier_results);
260
261 EvalCaseResult {
262 case_id: case.id,
263 title: case.title,
264 category: case.category,
265 mode: case.mode,
266 passed,
267 setup_results,
268 verifier_results,
269 metrics,
270 }
271 }
272}
273
274pub fn eval_case_from_trace(trace: &TurnTrace) -> Option<EvalCase> {
276 let verified = trace
277 .verifier_results
278 .iter()
279 .filter(|verifier| verifier.passed)
280 .collect::<Vec<_>>();
281 if verified.is_empty() {
282 return None;
283 }
284
285 let success_like = matches!(
286 trace.outcome,
287 TurnOutcome::Success | TurnOutcome::PartialSuccess
288 );
289 if !success_like {
290 return None;
291 }
292
293 Some(EvalCase {
294 id: sanitize_eval_id(&format!("trace-{}-{}", trace.session_id, trace.turn_id)),
295 title: format!("Trace replay: {}", trace.task),
296 category: "trace_replay_candidate".to_string(),
297 mode: EvalMode::Parity,
298 task: trace.task.clone(),
299 verifiers: verified
300 .into_iter()
301 .map(|verifier| VerifierSpec {
302 verifier_type: verifier.verifier.clone(),
303 command: verifier.command.clone(),
304 cwd: None,
305 timeout_ms: Some(verifier.duration_ms.max(1).saturating_mul(4).max(30_000)),
306 required: true,
307 })
308 .collect(),
309 tags: vec!["trace-generated".to_string()],
310 notes: Some(
311 "Generated from a successful trace with passing verifier evidence.".to_string(),
312 ),
313 ..EvalCase::default()
314 })
315}
316
317fn load_case(path: &Path) -> Result<EvalCase> {
318 let content = std::fs::read_to_string(path)
319 .with_context(|| format!("failed to read eval case {}", path.display()))?;
320 let case = match path.extension().and_then(|ext| ext.to_str()) {
321 Some("json") => serde_json::from_str(&content)
322 .with_context(|| format!("failed to parse JSON eval case {}", path.display()))?,
323 Some("toml") => toml::from_str(&content)
324 .with_context(|| format!("failed to parse TOML eval case {}", path.display()))?,
325 _ => bail!("unsupported eval case format: {}", path.display()),
326 };
327 validate_case(case, path)
328}
329
330fn validate_case(case: EvalCase, path: &Path) -> Result<EvalCase> {
331 if case.version != EvalCase::CURRENT_VERSION {
332 bail!(
333 "unsupported eval case version {} in {}",
334 case.version,
335 path.display()
336 );
337 }
338 if case.id.trim().is_empty() {
339 bail!("eval case missing id: {}", path.display());
340 }
341 if case.title.trim().is_empty() {
342 bail!("eval case missing title: {}", path.display());
343 }
344 if case.category.trim().is_empty() {
345 bail!("eval case missing category: {}", path.display());
346 }
347 if case.task.trim().is_empty() {
348 bail!("eval case missing task: {}", path.display());
349 }
350 if case.verifiers.is_empty() {
351 bail!(
352 "eval case must define at least one verifier: {}",
353 path.display()
354 );
355 }
356 for spec in case.setup.iter().chain(&case.verifiers) {
357 if spec.command.trim().is_empty() {
358 bail!(
359 "eval case has an empty verifier command: {}",
360 path.display()
361 );
362 }
363 }
364 Ok(case)
365}
366
367fn validate_unique_case_ids(cases: &[EvalCase], suite_path: &Path) -> Result<()> {
368 let mut seen = BTreeSet::new();
369 for case in cases {
370 if !seen.insert(case.id.as_str()) {
371 bail!(
372 "duplicate eval case id `{}` in suite {}",
373 case.id,
374 suite_path.display()
375 );
376 }
377 }
378 Ok(())
379}
380
381fn is_eval_case_file(path: &Path) -> bool {
382 path.is_file()
383 && matches!(
384 path.extension().and_then(|ext| ext.to_str()),
385 Some("json" | "toml")
386 )
387}
388
389fn required_verifiers_passed(
390 specs: &[VerifierSpec],
391 results: &[crate::verifier::VerifierResult],
392) -> bool {
393 specs
394 .iter()
395 .zip(results)
396 .all(|(spec, result)| !spec.required || result.is_ok())
397}
398
399fn aggregate_metrics(results: &[EvalCaseResult], wall_time_ms: u64) -> EvalRunMetrics {
400 let total_cases = results.len();
401 let passed_cases = results.iter().filter(|result| result.passed).count();
402 let failed_cases = total_cases.saturating_sub(passed_cases);
403 let total_tokens: u64 = results
404 .iter()
405 .map(|result| result.metrics.total_tokens)
406 .sum();
407 let tool_calls: usize = results.iter().map(|result| result.metrics.tool_calls).sum();
408
409 EvalRunMetrics {
410 total_cases,
411 passed_cases,
412 failed_cases,
413 verified_success_rate: ratio(passed_cases, total_cases),
414 verified_success_per_1k_tokens: (total_tokens > 0)
415 .then(|| passed_cases as f64 / (total_tokens as f64 / 1000.0)),
416 tokens_per_success: (passed_cases > 0 && total_tokens > 0)
417 .then(|| total_tokens as f64 / passed_cases as f64),
418 tool_calls_per_success: (passed_cases > 0 && tool_calls > 0)
419 .then(|| tool_calls as f64 / passed_cases as f64),
420 wall_time_ms,
421 }
422}
423
424fn ratio(numerator: usize, denominator: usize) -> f64 {
425 if denominator == 0 {
426 0.0
427 } else {
428 numerator as f64 / denominator as f64
429 }
430}
431
432fn sanitize_eval_id(value: &str) -> String {
433 let mut out = String::new();
434 for ch in value.chars() {
435 if ch.is_ascii_alphanumeric() {
436 out.push(ch.to_ascii_lowercase());
437 } else if !out.ends_with('-') {
438 out.push('-');
439 }
440 }
441 out.trim_matches('-').to_string()
442}
443
444fn current_unix_millis() -> u64 {
445 SystemTime::now()
446 .duration_since(UNIX_EPOCH)
447 .map(|duration| duration.as_millis() as u64)
448 .unwrap_or(0)
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::trace::TurnOutcome;
455
456 fn verifier(command: &str) -> VerifierSpec {
457 VerifierSpec {
458 verifier_type: "command".to_string(),
459 command: command.to_string(),
460 cwd: None,
461 timeout_ms: Some(10_000),
462 required: true,
463 }
464 }
465
466 #[test]
467 fn loads_eval_suite_from_toml_directory() {
468 let dir = tempfile::tempdir().unwrap();
469 std::fs::write(
470 dir.path().join("case.toml"),
471 r#"
472version = 1
473id = "case-one"
474title = "Case One"
475category = "simple_repo_task"
476task = "check file"
477
478[[verifiers]]
479verifier_type = "command"
480command = "test -f Cargo.toml"
481required = true
482"#,
483 )
484 .unwrap();
485
486 let suite = EvalSuite::load(dir.path()).unwrap();
487
488 assert_eq!(suite.cases.len(), 1);
489 assert_eq!(suite.cases[0].id, "case-one");
490 }
491
492 #[test]
493 fn rejects_duplicate_eval_case_ids() {
494 let dir = tempfile::tempdir().unwrap();
495 for name in ["one.toml", "two.toml"] {
496 std::fs::write(
497 dir.path().join(name),
498 r#"
499version = 1
500id = "duplicate"
501title = "Duplicate"
502category = "simple_repo_task"
503task = "check file"
504
505[[verifiers]]
506verifier_type = "command"
507command = "true"
508required = true
509"#,
510 )
511 .unwrap();
512 }
513
514 let error = EvalSuite::load(dir.path()).expect_err("duplicate id must fail");
515
516 assert!(error.to_string().contains("duplicate eval case id"));
517 }
518
519 #[test]
520 fn rejects_empty_verifier_command() {
521 let dir = tempfile::tempdir().unwrap();
522 std::fs::write(
523 dir.path().join("case.toml"),
524 r#"
525version = 1
526id = "bad-command"
527title = "Bad Command"
528category = "simple_repo_task"
529task = "check file"
530
531[[verifiers]]
532verifier_type = "command"
533command = ""
534required = true
535"#,
536 )
537 .unwrap();
538
539 let error = EvalSuite::load(dir.path()).expect_err("empty command must fail");
540
541 assert!(error.to_string().contains("empty verifier command"));
542 }
543
544 #[tokio::test]
545 async fn eval_runner_fails_reproducibly_when_verifier_fails() {
546 let dir = tempfile::tempdir().unwrap();
547 let case = EvalCase {
548 id: "missing-file".to_string(),
549 title: "Missing file".to_string(),
550 task: "prove missing file fails".to_string(),
551 verifiers: vec![verifier("test -f definitely-missing-file")],
552 ..EvalCase::default()
553 };
554
555 let result = EvalRunner::run_case(case, dir.path()).await;
556
557 assert!(!result.passed);
558 assert_eq!(result.verifier_results[0].status, "fail");
559 }
560
561 #[tokio::test]
562 async fn eval_runner_aggregates_success_metrics() {
563 let dir = tempfile::tempdir().unwrap();
564 let case = EvalCase {
565 id: "passing".to_string(),
566 title: "Passing".to_string(),
567 task: "prove pass".to_string(),
568 verifiers: vec![verifier("true")],
569 ..EvalCase::default()
570 };
571 let suite = EvalSuite {
572 name: "test-suite".to_string(),
573 cases: vec![case],
574 };
575
576 let run = EvalRunner::run_suite(suite, dir.path()).await;
577
578 assert_eq!(run.metrics.total_cases, 1);
579 assert_eq!(run.metrics.passed_cases, 1);
580 assert_eq!(run.metrics.verified_success_rate, 1.0);
581 assert_eq!(run.metrics.verified_success_per_1k_tokens, None);
582 assert_eq!(run.metrics.tokens_per_success, None);
583 }
584
585 #[test]
586 fn trace_with_passing_verifier_generates_eval_candidate() {
587 let mut trace = TurnTrace::new("turn-1", "session-1", "openai", "gpt-5", "fix bug");
588 trace.outcome = TurnOutcome::Success;
589 trace.record_verifier("test", "just test-crate navi-core", true, 100, Some(0));
590
591 let case = eval_case_from_trace(&trace).expect("candidate");
592
593 assert_eq!(case.category, "trace_replay_candidate");
594 assert_eq!(case.verifiers.len(), 1);
595 assert_eq!(case.verifiers[0].command, "just test-crate navi-core");
596 }
597
598 #[test]
599 fn trace_without_verifier_does_not_generate_eval_candidate() {
600 let trace = TurnTrace::new("turn-1", "session-1", "openai", "gpt-5", "answer");
601
602 assert!(eval_case_from_trace(&trace).is_none());
603 }
604}