Skip to main content

navi_core/
eval.rs

1//! Local eval harness for measuring NAVI harness behavior.
2//!
3//! This is the B0 "superiority baseline" foundation: versioned eval cases,
4//! verifier replay, aggregate metrics, and trace-to-eval candidate generation.
5
6use 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/// A versioned, replayable eval case.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(default)]
17pub struct EvalCase {
18    /// Schema version for forward compatibility.
19    pub version: u32,
20    /// Stable eval id, unique within a suite.
21    pub id: String,
22    /// Human-readable title.
23    pub title: String,
24    /// Suite category, e.g. "simple_repo_task" or "security_stress".
25    pub category: String,
26    /// Harness mode this case is intended to measure.
27    pub mode: EvalMode,
28    /// User-facing task prompt or objective.
29    pub task: String,
30    /// Optional setup commands, run before verifiers.
31    pub setup: Vec<VerifierSpec>,
32    /// Required and optional verification commands.
33    pub verifiers: Vec<VerifierSpec>,
34    /// Tags for filtering/reporting.
35    pub tags: Vec<String>,
36    /// Optional notes for humans.
37    pub notes: Option<String>,
38    /// Extensible metadata for future routing/training.
39    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/// Harness mode under measurement.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
66#[serde(rename_all = "kebab-case")]
67pub enum EvalMode {
68    /// Current linear/parity harness behavior.
69    #[default]
70    Parity,
71    /// Verifier-first mode. B0 records the intent before the runtime mode exists.
72    VerifierFirst,
73    /// Branch-race mode. B0 records the intent before the runtime mode exists.
74    BranchRace,
75}
76
77/// A loaded eval suite.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct EvalSuite {
80    pub name: String,
81    pub cases: Vec<EvalCase>,
82}
83
84impl EvalSuite {
85    /// Loads one eval case file or all `.json`/`.toml` case files in a directory.
86    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/// Result for a single eval case replay.
137#[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/// Metrics for one eval case.
150#[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/// Aggregate eval run.
184#[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/// Aggregate metrics for comparing harness modes.
201#[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
213/// Runs verifier-based eval replay.
214pub 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
274/// Converts a successful trace with verifier evidence into an eval candidate.
275pub 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}