Skip to main content

harn_vm/orchestration/records/
eval_pack.rs

1//! Eval-suite manifest + eval-pack manifest loading, evaluation, replay-fixture comparison.
2
3mod ledger;
4mod live;
5mod replay;
6mod report;
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::sync::Arc;
13use std::time::Duration;
14
15use crate::event_log::EventLog;
16use sha2::{Digest, Sha256};
17use wait_timeout::ChildExt;
18
19use super::super::{
20    evaluate_context_pack_suggestion_expectations, generate_context_pack_suggestions, new_id,
21    normalize_friction_events_json, now_unix_seconds_text, parse_json_value,
22    run_persona_eval_ladder, ContextPackSuggestionExpectation, ContextPackSuggestionOptions,
23    FrictionEvent,
24};
25use super::diff::diff_run_records;
26use super::json::{clarifying_max_questions, clarifying_min_questions, normalize_question_text};
27use super::persistence::load_run_record;
28use super::types::{
29    EvalLedgerAppendReport, EvalLedgerFingerprintMismatch, EvalLedgerPriorCommitReport,
30    EvalLedgerProvenance, EvalLedgerReadReport, EvalLedgerResumeCell, EvalLedgerResumePlan,
31    EvalLedgerRow, EvalPackAssertion, EvalPackCase, EvalPackCaseReport, EvalPackCommandObject,
32    EvalPackCommandSpec, EvalPackFixtureRef, EvalPackManifest, EvalPackReliabilityBreakdown,
33    EvalPackReliabilityReport, EvalPackReport, EvalPackRubric, EvalPackRunState,
34    EvalPackSplitValidationReport, EvalPackStatsReport, EvalPackStatsRow, EvalPackTrialReport,
35    EvalSuiteManifest, ReplayEvalCaseReport, ReplayEvalReport, ReplayEvalSuiteReport,
36    ReplayFixture, ReplayStageAssertion, RunDiffReport, RunRecord, RunStageRecord,
37};
38use crate::value::{VmError, VmValue};
39
40use ledger::eval_pack_manifest_model;
41pub use ledger::{
42    eval_ledger_append_rows_report, eval_ledger_prior_commit_rows_report, eval_ledger_read_report,
43    eval_ledger_resume_plan_report,
44};
45use live::*;
46use replay::*;
47pub use replay::{evaluate_run_against_fixture, evaluate_run_suite, replay_fixture_from_run};
48use report::*;
49
50const EVAL_LEDGER_ROW_SCHEMA: &str = "harn.eval.ledger.row.v1";
51const EVAL_LEDGER_RUN_STATE_SCHEMA: &str = "harn.eval.run-state.v1";
52const EVAL_LEDGER_RESUME_PLAN_SCHEMA: &str = "harn.eval.resume-plan.v1";
53const EVAL_LEDGER_ROW_KIND: &str = "eval.ledger.row";
54const EVAL_LEDGER_RUN_STATE_KIND: &str = "eval.ledger.run_state";
55const EVAL_LEDGER_TOPIC_PREFIX: &str = "eval.ledger";
56const EVAL_LEDGER_IDENTITY_HEADER: &str = "eval_ledger_identity";
57const EVAL_LEDGER_QUEUE_DEPTH: usize =
58    crate::runtime_limits::RuntimeLimits::DEFAULT.default_event_log_queue_depth;
59const EVAL_LEDGER_READ_BATCH_LIMIT: usize = 1024;
60const LIVE_EXECUTOR_REQUEST_SCHEMA: &str = "harn.eval.live_verify.executor_request.v1";
61const DEFAULT_LIVE_EXECUTOR_TIMEOUT_SECONDS: f64 = 600.0;
62const DEFAULT_LIVE_VERIFY_TIMEOUT_SECONDS: f64 = 120.0;
63
64#[derive(Clone, Debug, Default, serde::Deserialize)]
65#[serde(default)]
66struct EvalLedgerOptions {
67    namespace: Option<String>,
68    suite: Option<String>,
69    model: Option<String>,
70    split: Option<String>,
71    commit: Option<String>,
72    branch: Option<String>,
73    #[serde(alias = "case")]
74    case_name: Option<String>,
75    case_fingerprint: Option<String>,
76    harness_config_fingerprint: Option<String>,
77    limit: Option<usize>,
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81enum EvalPackCaseKind {
82    Replay,
83    Friction,
84    LiveVerify,
85}
86
87#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
88#[serde(default)]
89pub struct EvalPackLiveVerifyOutcome {
90    pub verification: Option<String>,
91    #[serde(alias = "verificationExitCode")]
92    pub verification_exit_code: Option<i64>,
93    #[serde(alias = "pass", alias = "success")]
94    pub passed: Option<bool>,
95    #[serde(alias = "timedOut")]
96    pub timed_out: bool,
97    #[serde(alias = "wallTimeSeconds")]
98    pub wall_time_seconds: f64,
99    #[serde(alias = "costUsd")]
100    pub cost_usd: f64,
101    #[serde(default, alias = "producedPaths")]
102    pub produced_paths: Vec<String>,
103    #[serde(default, alias = "toolCallSummary", alias = "tool_summary")]
104    pub tool_call_summary: serde_json::Value,
105    pub failures: Vec<String>,
106    pub warnings: Vec<String>,
107    pub informational: Vec<String>,
108    #[serde(alias = "runId")]
109    pub run_id: Option<String>,
110    #[serde(alias = "workflowId")]
111    pub workflow_id: Option<String>,
112    #[serde(alias = "sourcePath")]
113    pub source_path: Option<String>,
114    #[serde(alias = "stageCount")]
115    pub stage_count: Option<usize>,
116}
117
118#[derive(Clone, Debug)]
119pub struct EvalPackLiveExecutorRequest {
120    pub executor: EvalPackCommandSpec,
121    pub payload: serde_json::Value,
122    pub manifest_id: String,
123    pub case: EvalPackCase,
124    pub case_id: String,
125    pub trial: usize,
126    pub trials: usize,
127    pub workspace: PathBuf,
128    pub base_dir: Option<PathBuf>,
129}
130
131pub trait EvalPackLiveExecutor {
132    fn execute(
133        &mut self,
134        request: EvalPackLiveExecutorRequest,
135    ) -> Result<EvalPackLiveVerifyOutcome, VmError>;
136}
137
138struct EvalPackShellLiveExecutor;
139
140impl EvalPackLiveExecutor for EvalPackShellLiveExecutor {
141    fn execute(
142        &mut self,
143        request: EvalPackLiveExecutorRequest,
144    ) -> Result<EvalPackLiveVerifyOutcome, VmError> {
145        let output = run_eval_pack_command(
146            &request.executor,
147            &request.workspace,
148            Some(&request.payload),
149            DEFAULT_LIVE_EXECUTOR_TIMEOUT_SECONDS,
150        )?;
151        let mut failures = Vec::new();
152        let mut outcome = live_outcome_from_executor_output(output, &mut failures);
153        outcome.failures.extend(failures);
154        Ok(outcome)
155    }
156}
157
158#[derive(Clone, Debug)]
159struct EvalPackCommandOutput {
160    exit_code: i64,
161    stdout: String,
162    stderr: String,
163    timed_out: bool,
164    wall_time_seconds: f64,
165}
166
167struct EvalPackLedgerRun {
168    log: Arc<crate::event_log::AnyEventLog>,
169    topic: crate::event_log::Topic,
170    rows: Vec<EvalLedgerRow>,
171    suite: String,
172    model: String,
173    commit: String,
174    branch: Option<String>,
175    provenance: EvalLedgerProvenance,
176    inserted: usize,
177    duplicates: usize,
178    fingerprint_refusals: Vec<EvalLedgerFingerprintMismatch>,
179}
180
181pub fn normalize_eval_suite_manifest(value: &VmValue) -> Result<EvalSuiteManifest, VmError> {
182    let mut manifest: EvalSuiteManifest = parse_json_value(value)?;
183    if manifest.type_name.is_empty() {
184        manifest.type_name = "eval_suite_manifest".to_string();
185    }
186    if manifest.id.is_empty() {
187        manifest.id = new_id("eval_suite");
188    }
189    Ok(manifest)
190}
191
192pub fn load_eval_suite_manifest(path: &Path) -> Result<EvalSuiteManifest, VmError> {
193    let content = std::fs::read_to_string(path)
194        .map_err(|e| VmError::Runtime(format!("failed to read eval suite manifest: {e}")))?;
195    let mut manifest: EvalSuiteManifest = serde_json::from_str(&content)
196        .map_err(|e| VmError::Runtime(format!("failed to parse eval suite manifest: {e}")))?;
197    if manifest.base_dir.is_none() {
198        manifest.base_dir = path.parent().map(|parent| parent.display().to_string());
199    }
200    Ok(manifest)
201}
202
203pub fn load_eval_pack_manifest(path: &Path) -> Result<EvalPackManifest, VmError> {
204    let content = std::fs::read_to_string(path)
205        .map_err(|e| VmError::Runtime(format!("failed to read eval pack manifest: {e}")))?;
206    let mut manifest: EvalPackManifest =
207        if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
208            serde_json::from_str(&content)
209                .map_err(|e| VmError::Runtime(format!("failed to parse eval pack JSON: {e}")))?
210        } else {
211            toml::from_str(&content)
212                .map_err(|e| VmError::Runtime(format!("failed to parse eval pack TOML: {e}")))?
213        };
214    normalize_eval_pack_manifest(&mut manifest)?;
215    if manifest.base_dir.is_none() {
216        manifest.base_dir = path.parent().map(|parent| parent.display().to_string());
217    }
218    Ok(manifest)
219}
220
221pub fn normalize_eval_pack_manifest_value(value: &VmValue) -> Result<EvalPackManifest, VmError> {
222    let mut manifest: EvalPackManifest = parse_json_value(value)?;
223    normalize_eval_pack_manifest(&mut manifest)?;
224    Ok(manifest)
225}
226
227fn normalize_eval_pack_manifest(manifest: &mut EvalPackManifest) -> Result<(), VmError> {
228    if manifest.version == 0 {
229        manifest.version = 1;
230    }
231    if manifest.trials == 0 {
232        manifest.trials = 1;
233    }
234    if manifest.id.is_empty() {
235        manifest.id = manifest
236            .name
237            .clone()
238            .filter(|name| !name.trim().is_empty())
239            .unwrap_or_else(|| new_id("eval_pack"));
240    }
241    let rubrics_by_id = manifest
242        .rubrics
243        .iter()
244        .filter(|rubric| !rubric.id.is_empty())
245        .map(|rubric| (rubric.id.as_str(), rubric))
246        .collect::<BTreeMap<_, _>>();
247    let fixtures_by_id = manifest
248        .fixtures
249        .iter()
250        .filter(|fixture| !fixture.id.is_empty())
251        .map(|fixture| (fixture.id.as_str(), fixture))
252        .collect::<BTreeMap<_, _>>();
253    for case in &mut manifest.cases {
254        if case.trials == Some(0) {
255            return Err(VmError::Runtime(format!(
256                "eval pack case '{}' has trials = 0",
257                case.id.as_deref().unwrap_or("<unnamed>")
258            )));
259        }
260        case.case_fingerprint =
261            eval_pack_case_fingerprint_with_refs(case, &rubrics_by_id, &fixtures_by_id)?;
262    }
263    for ladder in &mut manifest.ladders {
264        super::super::normalize_persona_eval_ladder_manifest(ladder);
265    }
266    Ok(())
267}
268
269pub fn eval_pack_case_fingerprint(case: &EvalPackCase) -> Result<String, VmError> {
270    eval_pack_case_fingerprint_with_refs(case, &BTreeMap::new(), &BTreeMap::new())
271}
272
273fn eval_pack_case_fingerprint_with_refs(
274    case: &EvalPackCase,
275    rubrics_by_id: &BTreeMap<&str, &EvalPackRubric>,
276    fixtures_by_id: &BTreeMap<&str, &EvalPackFixtureRef>,
277) -> Result<String, VmError> {
278    let mut task = BTreeMap::new();
279    insert_json_field(&mut task, "kind", &normalized_eval_pack_case_kind(case))?;
280    insert_json_field(&mut task, "run", &case.run)?;
281    insert_json_field(&mut task, "run_path", &case.run_path)?;
282    insert_json_field(&mut task, "friction_events", &case.friction_events)?;
283    insert_json_field(&mut task, "task", &case.task)?;
284    insert_json_field(&mut task, "workspace", &case.workspace)?;
285    insert_json_field(&mut task, "project", &case.project)?;
286
287    let mut expected_outputs = BTreeMap::new();
288    insert_json_field(&mut expected_outputs, "fixture", &case.fixture)?;
289    insert_json_field(&mut expected_outputs, "fixture_path", &case.fixture_path)?;
290    insert_json_field(
291        &mut expected_outputs,
292        "expected_output_paths",
293        &case.expected_output_paths,
294    )?;
295    insert_json_field(
296        &mut expected_outputs,
297        "required_output_snippets",
298        &case.required_output_snippets,
299    )?;
300    if let Some(fixture_ref) = case.fixture.as_deref().or(case.fixture_path.as_deref()) {
301        if let Some(fixture) = fixtures_by_id.get(fixture_ref) {
302            insert_json_field(&mut expected_outputs, "fixture_ref", *fixture)?;
303        }
304    }
305
306    let resolved_rubrics = case
307        .rubrics
308        .iter()
309        .filter_map(|rubric_id| rubrics_by_id.get(rubric_id.as_str()))
310        .map(|rubric| {
311            serde_json::to_value(rubric)
312                .map_err(|e| VmError::Runtime(format!("failed to encode eval pack rubric: {e}")))
313        })
314        .collect::<Result<Vec<_>, _>>()?;
315    let mut verify = BTreeMap::new();
316    insert_json_field(&mut verify, "compare_to", &case.compare_to)?;
317    insert_json_field(&mut verify, "verify_command", &case.verify_command)?;
318    insert_json_field(&mut verify, "tool_budgets", &case.tool_budgets)?;
319    insert_json_field(&mut verify, "rubric_ids", &case.rubrics)?;
320    verify.insert(
321        "rubrics".to_string(),
322        serde_json::Value::Array(resolved_rubrics),
323    );
324
325    let mut flags = BTreeMap::new();
326    insert_json_field(&mut flags, "severity", &case.severity)?;
327    insert_json_field(&mut flags, "thresholds", &case.thresholds)?;
328    insert_json_field(&mut flags, "metadata", &case.metadata)?;
329    insert_json_field(&mut flags, "executor", &case.executor)?;
330
331    let mut payload = BTreeMap::new();
332    payload.insert("task".to_string(), encode_json(&task)?);
333    payload.insert(
334        "expected_outputs".to_string(),
335        encode_json(&expected_outputs)?,
336    );
337    payload.insert("verify".to_string(), encode_json(&verify)?);
338    payload.insert("flags".to_string(), encode_json(&flags)?);
339    fingerprint_json(&payload)
340}
341
342pub fn eval_pack_harness_config_fingerprint(
343    manifest: &EvalPackManifest,
344) -> Result<String, VmError> {
345    let rubric_harness = manifest
346        .rubrics
347        .iter()
348        .map(|rubric| {
349            let mut item = BTreeMap::new();
350            insert_json_field(&mut item, "id", &rubric.id)?;
351            insert_json_field(&mut item, "kind", &rubric.kind)?;
352            insert_json_field(&mut item, "prompt", &rubric.prompt)?;
353            insert_json_field(&mut item, "judge", &rubric.judge)?;
354            encode_json(&item)
355        })
356        .collect::<Result<Vec<_>, VmError>>()?;
357    let mut harness_metadata = BTreeMap::new();
358    for key in [
359        "model",
360        "provider",
361        "route",
362        "prompt",
363        "promptVersion",
364        "prompt_version",
365        "toolFormat",
366        "tool_format",
367        "replicate",
368        "pipelineRev",
369        "pipeline_rev",
370        "pipelineRevision",
371        "pipeline_revision",
372        "harnVersion",
373        "harn_version",
374        "harness",
375        "harnessConfig",
376        "harness_config",
377    ] {
378        if let Some(value) = manifest.metadata.get(key) {
379            harness_metadata.insert(key.to_string(), value.clone());
380        }
381    }
382
383    let mut payload = BTreeMap::new();
384    insert_json_field(&mut payload, "executor", &manifest.executor)?;
385    insert_json_field(&mut payload, "manifest_judge", &manifest.judge)?;
386    insert_json_field(&mut payload, "default_judge", &manifest.defaults.judge)?;
387    insert_json_field(&mut payload, "package", &manifest.package)?;
388    payload.insert(
389        "harness_metadata".to_string(),
390        encode_json(&harness_metadata)?,
391    );
392    payload.insert(
393        "rubric_harness".to_string(),
394        serde_json::Value::Array(rubric_harness),
395    );
396    fingerprint_json(&payload)
397}
398
399fn insert_json_field<T: serde::Serialize>(
400    map: &mut BTreeMap<String, serde_json::Value>,
401    key: &str,
402    value: &T,
403) -> Result<(), VmError> {
404    map.insert(key.to_string(), encode_json(value)?);
405    Ok(())
406}
407
408fn encode_json<T: serde::Serialize>(value: &T) -> Result<serde_json::Value, VmError> {
409    serde_json::to_value(value)
410        .map_err(|e| VmError::Runtime(format!("failed to encode eval pack fingerprint: {e}")))
411}
412
413fn fingerprint_json<T: serde::Serialize>(value: &T) -> Result<String, VmError> {
414    let bytes = serde_json::to_vec(value)
415        .map_err(|e| VmError::Runtime(format!("failed to encode eval pack fingerprint: {e}")))?;
416    let digest = hex::encode(Sha256::digest(bytes));
417    Ok(digest.chars().take(16).collect())
418}
419
420fn eval_pack_case_kind(case: &EvalPackCase) -> EvalPackCaseKind {
421    match normalized_eval_pack_case_kind(case).as_str() {
422        "live-verify" => EvalPackCaseKind::LiveVerify,
423        "friction" => EvalPackCaseKind::Friction,
424        _ => EvalPackCaseKind::Replay,
425    }
426}
427
428fn normalized_eval_pack_case_kind(case: &EvalPackCase) -> String {
429    match case
430        .kind
431        .as_deref()
432        .map(|kind| kind.trim().to_ascii_lowercase().replace('_', "-"))
433        .as_deref()
434    {
435        Some("live") | Some("live-verify") | Some("verify-live") => "live-verify".to_string(),
436        Some("friction") | Some("context-pack-friction") => "friction".to_string(),
437        Some("replay") | Some("fixture") | Some("run-record") => "replay".to_string(),
438        Some(other) if !other.is_empty() => other.to_string(),
439        _ if case.task.is_some()
440            || case.workspace.is_some()
441            || case.project.is_some()
442            || case.verify_command.is_some()
443            || !case.expected_output_paths.is_empty()
444            || !case.required_output_snippets.is_empty() =>
445        {
446            "live-verify".to_string()
447        }
448        _ if case.friction_events.is_some() => "friction".to_string(),
449        _ => "replay".to_string(),
450    }
451}
452
453pub fn validate_eval_pack_split(
454    manifest: &EvalPackManifest,
455) -> Result<EvalPackSplitValidationReport, VmError> {
456    let report = eval_pack_split_validation_report(manifest);
457    if !report.valid {
458        return Err(VmError::Runtime(format!(
459            "eval pack split invalid: {}",
460            render_split_validation_errors(&report).join("; ")
461        )));
462    }
463    Ok(report)
464}
465
466fn eval_pack_split_validation_report(manifest: &EvalPackManifest) -> EvalPackSplitValidationReport {
467    let case_ids = eval_pack_case_ids(manifest);
468    let mut duplicate_case_ids = duplicates(&case_ids);
469    duplicate_case_ids.sort();
470
471    let case_set = case_ids.iter().cloned().collect::<BTreeSet<_>>();
472    let Some(split) = &manifest.split else {
473        return EvalPackSplitValidationReport {
474            valid: duplicate_case_ids.is_empty(),
475            case_count: case_ids.len(),
476            covered_count: 0,
477            duplicate_case_ids,
478            ..EvalPackSplitValidationReport::default()
479        };
480    };
481
482    let mut duplicate_partition_cases = Vec::new();
483    let mut unknown_cases = Vec::new();
484    let mut seen_by_case: BTreeMap<String, Vec<String>> = BTreeMap::new();
485    for (partition, cases) in &split.partitions {
486        let mut local_seen = BTreeSet::new();
487        for case_id in cases {
488            if !local_seen.insert(case_id.clone()) {
489                duplicate_partition_cases.push(format!("{partition}:{case_id}"));
490            }
491            if !case_set.contains(case_id) {
492                unknown_cases.push(format!("{partition}:{case_id}"));
493            }
494            let partitions = seen_by_case.entry(case_id.clone()).or_default();
495            if !partitions.contains(partition) {
496                partitions.push(partition.clone());
497            }
498        }
499    }
500
501    let mut overlap_cases = seen_by_case
502        .iter()
503        .filter(|(case_id, partitions)| case_set.contains(*case_id) && partitions.len() > 1)
504        .map(|(case_id, partitions)| format!("{case_id}:{}", partitions.join(",")))
505        .collect::<Vec<_>>();
506    let mut missing_cases = case_set
507        .iter()
508        .filter(|case_id| !seen_by_case.contains_key(*case_id))
509        .cloned()
510        .collect::<Vec<_>>();
511    duplicate_partition_cases.sort();
512    unknown_cases.sort();
513    overlap_cases.sort();
514    missing_cases.sort();
515
516    let covered_count = case_set
517        .iter()
518        .filter(|case_id| seen_by_case.contains_key(*case_id))
519        .count();
520    let valid = duplicate_case_ids.is_empty()
521        && duplicate_partition_cases.is_empty()
522        && unknown_cases.is_empty()
523        && overlap_cases.is_empty()
524        && missing_cases.is_empty();
525    EvalPackSplitValidationReport {
526        valid,
527        partitions: split.partitions.clone(),
528        case_count: case_ids.len(),
529        covered_count,
530        duplicate_case_ids,
531        duplicate_partition_cases,
532        overlap_cases,
533        unknown_cases,
534        missing_cases,
535    }
536}
537
538fn eval_pack_case_ids(manifest: &EvalPackManifest) -> Vec<String> {
539    manifest
540        .cases
541        .iter()
542        .enumerate()
543        .map(|(index, case)| eval_pack_case_id(case, index))
544        .collect()
545}
546
547fn eval_pack_case_id(case: &EvalPackCase, index: usize) -> String {
548    case.id
549        .clone()
550        .filter(|id| !id.trim().is_empty())
551        .unwrap_or_else(|| format!("case_{}", index + 1))
552}
553
554fn duplicates(values: &[String]) -> Vec<String> {
555    let mut seen = BTreeSet::new();
556    let mut duplicates = BTreeSet::new();
557    for value in values {
558        if !seen.insert(value.clone()) {
559            duplicates.insert(value.clone());
560        }
561    }
562    duplicates.into_iter().collect()
563}
564
565fn render_split_validation_errors(report: &EvalPackSplitValidationReport) -> Vec<String> {
566    let mut errors = Vec::new();
567    if !report.duplicate_case_ids.is_empty() {
568        errors.push(format!(
569            "duplicate case ids: {}",
570            report.duplicate_case_ids.join(", ")
571        ));
572    }
573    if !report.duplicate_partition_cases.is_empty() {
574        errors.push(format!(
575            "duplicate partition entries: {}",
576            report.duplicate_partition_cases.join(", ")
577        ));
578    }
579    if !report.overlap_cases.is_empty() {
580        errors.push(format!(
581            "overlapping cases: {}",
582            report.overlap_cases.join(", ")
583        ));
584    }
585    if !report.unknown_cases.is_empty() {
586        errors.push(format!(
587            "unknown cases: {}",
588            report.unknown_cases.join(", ")
589        ));
590    }
591    if !report.missing_cases.is_empty() {
592        errors.push(format!(
593            "missing cases: {}",
594            report.missing_cases.join(", ")
595        ));
596    }
597    if errors.is_empty() {
598        errors.push("unknown split validation error".to_string());
599    }
600    errors
601}
602
603fn load_replay_fixture(path: &Path) -> Result<ReplayFixture, VmError> {
604    let content = std::fs::read_to_string(path)
605        .map_err(|e| VmError::Runtime(format!("failed to read replay fixture: {e}")))?;
606    serde_json::from_str(&content)
607        .map_err(|e| VmError::Runtime(format!("failed to parse replay fixture: {e}")))
608}
609
610fn load_run_record_from_fixture_ref(
611    fixture: &EvalPackFixtureRef,
612    base_dir: Option<&Path>,
613) -> Result<RunRecord, VmError> {
614    if let Some(inline) = &fixture.inline {
615        let run: RunRecord = serde_json::from_value(inline.clone())
616            .map_err(|e| VmError::Runtime(format!("failed to parse inline run record: {e}")))?;
617        return Ok(run);
618    }
619    let path = fixture.path.as_deref().ok_or_else(|| {
620        VmError::Runtime(format!(
621            "fixture '{}' is missing path or inline run",
622            fixture.id
623        ))
624    })?;
625    load_run_record(&resolve_manifest_path(base_dir, path))
626}
627
628fn load_replay_fixture_from_ref(
629    fixture: &EvalPackFixtureRef,
630    base_dir: Option<&Path>,
631) -> Result<ReplayFixture, VmError> {
632    if let Some(inline) = &fixture.inline {
633        return serde_json::from_value(inline.clone())
634            .map_err(|e| VmError::Runtime(format!("failed to parse inline replay fixture: {e}")));
635    }
636    let path = fixture.path.as_deref().ok_or_else(|| {
637        VmError::Runtime(format!(
638            "fixture '{}' is missing path or inline replay fixture",
639            fixture.id
640        ))
641    })?;
642    load_replay_fixture(&resolve_manifest_path(base_dir, path))
643}
644
645fn resolve_manifest_path(base_dir: Option<&Path>, path: &str) -> PathBuf {
646    let path_buf = PathBuf::from(path);
647    if path_buf.is_absolute() {
648        path_buf
649    } else if let Some(base_dir) = base_dir {
650        base_dir.join(path_buf)
651    } else {
652        path_buf
653    }
654}
655
656pub fn evaluate_run_suite_manifest(
657    manifest: &EvalSuiteManifest,
658) -> Result<ReplayEvalSuiteReport, VmError> {
659    let base_dir = manifest.base_dir.as_deref().map(Path::new);
660    let mut reports = Vec::new();
661    for case in &manifest.cases {
662        let run_path = resolve_manifest_path(base_dir, &case.run_path);
663        let run = load_run_record(&run_path)?;
664        let fixture = match &case.fixture_path {
665            Some(path) => load_replay_fixture(&resolve_manifest_path(base_dir, path))?,
666            None => run
667                .replay_fixture
668                .clone()
669                .unwrap_or_else(|| replay_fixture_from_run(&run)),
670        };
671        let eval = evaluate_run_against_fixture(&run, &fixture);
672        let mut pass = eval.pass;
673        let mut failures = eval.failures;
674        let comparison = match &case.compare_to {
675            Some(path) => {
676                let baseline_path = resolve_manifest_path(base_dir, path);
677                let baseline = load_run_record(&baseline_path)?;
678                let diff = diff_run_records(&baseline, &run);
679                if !diff.identical {
680                    pass = false;
681                    failures.push(format!(
682                        "run differs from baseline {} with {} stage changes",
683                        baseline_path.display(),
684                        diff.stage_diffs.len()
685                    ));
686                }
687                Some(diff)
688            }
689            None => None,
690        };
691        reports.push(ReplayEvalCaseReport {
692            run_id: run.id.clone(),
693            workflow_id: run.workflow_id.clone(),
694            label: case.label.clone(),
695            pass,
696            failures,
697            stage_count: eval.stage_count,
698            source_path: Some(run_path.display().to_string()),
699            comparison,
700        });
701    }
702    let total = reports.len();
703    let passed = reports.iter().filter(|report| report.pass).count();
704    let failed = total.saturating_sub(passed);
705    Ok(ReplayEvalSuiteReport {
706        pass: failed == 0,
707        total,
708        passed,
709        failed,
710        cases: reports,
711    })
712}
713
714pub fn evaluate_eval_pack_manifest(manifest: &EvalPackManifest) -> Result<EvalPackReport, VmError> {
715    let mut live_executor = EvalPackShellLiveExecutor;
716    evaluate_eval_pack_manifest_inner(manifest, false, None, &mut live_executor)
717}
718
719pub fn evaluate_eval_pack_manifest_resumable(
720    manifest: &EvalPackManifest,
721    ledger_options: Option<serde_json::Value>,
722) -> Result<EvalPackReport, VmError> {
723    let mut live_executor = EvalPackShellLiveExecutor;
724    evaluate_eval_pack_manifest_inner(manifest, true, ledger_options, &mut live_executor)
725}
726
727pub fn evaluate_eval_pack_manifest_with_live_executor(
728    manifest: &EvalPackManifest,
729    live_executor: &mut dyn EvalPackLiveExecutor,
730) -> Result<EvalPackReport, VmError> {
731    evaluate_eval_pack_manifest_inner(manifest, false, None, live_executor)
732}
733
734pub fn evaluate_eval_pack_manifest_resumable_with_live_executor(
735    manifest: &EvalPackManifest,
736    ledger_options: Option<serde_json::Value>,
737    live_executor: &mut dyn EvalPackLiveExecutor,
738) -> Result<EvalPackReport, VmError> {
739    evaluate_eval_pack_manifest_inner(manifest, true, ledger_options, live_executor)
740}
741
742fn evaluate_eval_pack_manifest_inner(
743    manifest: &EvalPackManifest,
744    ledger_enabled: bool,
745    ledger_options: Option<serde_json::Value>,
746    live_executor: &mut dyn EvalPackLiveExecutor,
747) -> Result<EvalPackReport, VmError> {
748    let base_dir = manifest.base_dir.as_deref().map(Path::new);
749    let fixture_base_dir_buf = manifest
750        .defaults
751        .fixture_root
752        .as_deref()
753        .map(|root| resolve_manifest_path(base_dir, root));
754    let fixture_base_dir = fixture_base_dir_buf.as_deref().or(base_dir);
755    let fixtures_by_id: BTreeMap<&str, &EvalPackFixtureRef> = manifest
756        .fixtures
757        .iter()
758        .filter(|fixture| !fixture.id.is_empty())
759        .map(|fixture| (fixture.id.as_str(), fixture))
760        .collect();
761    let rubrics_by_id: BTreeMap<&str, &EvalPackRubric> = manifest
762        .rubrics
763        .iter()
764        .filter(|rubric| !rubric.id.is_empty())
765        .map(|rubric| (rubric.id.as_str(), rubric))
766        .collect();
767
768    let split_report = validate_eval_pack_split(manifest)?;
769    let split_by_case = split_by_case_id(&split_report);
770    let harness_config_fingerprint = eval_pack_harness_config_fingerprint(manifest)?;
771    let mut ledger = if ledger_enabled {
772        Some(EvalPackLedgerRun::start(
773            manifest,
774            base_dir,
775            ledger_options,
776        )?)
777    } else {
778        None
779    };
780    let mut requested_cells = 0usize;
781    let mut skipped_cells = 0usize;
782    let mut executed_cells = 0usize;
783    let mut reports = Vec::new();
784    for (index, case) in manifest.cases.iter().enumerate() {
785        let case_id = eval_pack_case_id(case, index);
786        let label = case
787            .name
788            .clone()
789            .or_else(|| case.id.clone())
790            .unwrap_or_else(|| case_id.clone());
791        let severity = eval_pack_case_severity(manifest, case);
792        let blocking = severity == "blocking";
793        let trial_count = case.trials.unwrap_or(manifest.trials);
794        let split = split_by_case.get(&case_id).cloned();
795        requested_cells += trial_count;
796        let mut trials = Vec::with_capacity(trial_count);
797        for trial in 1..=trial_count {
798            if let Some(ledger) = ledger.as_mut() {
799                if let Some(row) = ledger.replay_row_for_cell(
800                    &case_id,
801                    split.as_deref(),
802                    trial,
803                    &case.case_fingerprint,
804                    &harness_config_fingerprint,
805                ) {
806                    skipped_cells += 1;
807                    trials.push(eval_pack_trial_report_from_ledger_row(&row, blocking));
808                    continue;
809                }
810            }
811            let report = match eval_pack_case_kind(case) {
812                EvalPackCaseKind::LiveVerify => evaluate_eval_pack_live_verify_trial(
813                    manifest,
814                    case,
815                    &case_id,
816                    trial,
817                    trial_count,
818                    &severity,
819                    blocking,
820                    base_dir,
821                    live_executor,
822                )?,
823                EvalPackCaseKind::Friction => evaluate_eval_pack_friction_trial(
824                    manifest,
825                    case,
826                    trial,
827                    &severity,
828                    blocking,
829                    base_dir,
830                    fixture_base_dir,
831                    &fixtures_by_id,
832                    &rubrics_by_id,
833                )?,
834                EvalPackCaseKind::Replay => evaluate_eval_pack_run_trial(
835                    manifest,
836                    case,
837                    trial,
838                    &severity,
839                    blocking,
840                    base_dir,
841                    fixture_base_dir,
842                    &fixtures_by_id,
843                    &rubrics_by_id,
844                )?,
845            };
846            if let Some(ledger) = ledger.as_mut() {
847                let row = eval_ledger_row_from_trial(
848                    case,
849                    &case_id,
850                    split.clone(),
851                    &ledger.suite,
852                    &ledger.model,
853                    &ledger.commit,
854                    &ledger.provenance,
855                    &harness_config_fingerprint,
856                    &report,
857                );
858                ledger.append_trial_row(row)?;
859            }
860            executed_cells += 1;
861            trials.push(report);
862        }
863        reports.push(eval_pack_case_report_from_trials(
864            case,
865            case_id,
866            label,
867            severity,
868            split,
869            blocking,
870            harness_config_fingerprint.clone(),
871            trials,
872        ));
873    }
874
875    let mut ladder_reports = Vec::new();
876    for ladder in &manifest.ladders {
877        let mut ladder = ladder.clone();
878        if ladder.base_dir.is_none() {
879            ladder.base_dir = manifest.base_dir.clone();
880        }
881        ladder_reports.push(run_persona_eval_ladder(&ladder)?);
882    }
883
884    let stats_rows = reports
885        .iter()
886        .map(|report| report.stats_row.clone())
887        .collect::<Vec<_>>();
888    let stats = eval_pack_stats_report(&stats_rows);
889    let case_total = reports.len();
890    let ladder_total = ladder_reports.len();
891    let total = case_total + ladder_total;
892    let trial_count = reports.iter().map(|report| report.trial_count).sum();
893    let case_blocking_failed = reports
894        .iter()
895        .filter(|report| report.blocking && report.reliability.status != "all-pass")
896        .count();
897    let ladder_blocking_failed = ladder_reports
898        .iter()
899        .filter(|report| report.blocking && !report.pass)
900        .count();
901    let blocking_failed = case_blocking_failed + ladder_blocking_failed;
902    let warning_failed = reports
903        .iter()
904        .filter(|report| !report.warnings.is_empty())
905        .count()
906        + ladder_reports
907            .iter()
908            .filter(|report| !report.pass && report.severity == "warning")
909            .count();
910    let informational_failed = reports
911        .iter()
912        .filter(|report| !report.informational.is_empty())
913        .count()
914        + ladder_reports
915            .iter()
916            .filter(|report| !report.pass && report.severity == "informational")
917            .count();
918    let passed = reports.iter().filter(|report| report.pass).count()
919        + ladder_reports.iter().filter(|report| report.pass).count();
920    let run_state = match ledger.as_ref() {
921        Some(ledger) => ledger.finish(requested_cells, skipped_cells, executed_cells)?,
922        None => EvalPackRunState {
923            schema: EVAL_LEDGER_RUN_STATE_SCHEMA.to_string(),
924            suite: manifest.id.clone(),
925            model: eval_pack_manifest_model(manifest).unwrap_or_else(|| "unknown".to_string()),
926            requested_cells,
927            completed_cells: requested_cells,
928            executed_cells: requested_cells,
929            ..EvalPackRunState::default()
930        },
931    };
932    Ok(EvalPackReport {
933        pack_id: manifest.id.clone(),
934        harness_config_fingerprint,
935        pass: blocking_failed == 0,
936        total,
937        passed,
938        failed: total.saturating_sub(passed),
939        blocking_failed,
940        warning_failed,
941        informational_failed,
942        trial_count,
943        run_state,
944        split: manifest.split.as_ref().map(|_| split_report),
945        stats,
946        stats_rows,
947        cases: reports,
948        ladders: ladder_reports,
949    })
950}
951
952#[allow(clippy::too_many_arguments)]
953fn evaluate_eval_pack_live_verify_trial(
954    manifest: &EvalPackManifest,
955    case: &EvalPackCase,
956    case_id: &str,
957    trial: usize,
958    trial_count: usize,
959    severity: &str,
960    blocking: bool,
961    base_dir: Option<&Path>,
962    live_executor: &mut dyn EvalPackLiveExecutor,
963) -> Result<EvalPackTrialReport, VmError> {
964    let workspace = eval_pack_live_workspace(case, base_dir)?;
965    let executor = case.executor.as_ref().or(manifest.executor.as_ref());
966    let verify_command = case.verify_command.as_ref().ok_or_else(|| {
967        VmError::Runtime(format!(
968            "eval pack live-verify case '{case_id}' is missing verify_command"
969        ))
970    })?;
971    let Some(executor) = executor else {
972        return Err(VmError::Runtime(format!(
973            "eval pack live-verify case '{case_id}' is missing executor"
974        )));
975    };
976
977    let mut failures = Vec::new();
978    let mut warnings = Vec::new();
979    let mut informational = Vec::new();
980    let request_payload = eval_pack_live_executor_request(
981        manifest,
982        case,
983        case_id,
984        trial,
985        trial_count,
986        &workspace,
987        base_dir,
988    )?;
989    let request = EvalPackLiveExecutorRequest {
990        executor: executor.clone(),
991        payload: request_payload,
992        manifest_id: manifest.id.clone(),
993        case: case.clone(),
994        case_id: case_id.to_string(),
995        trial,
996        trials: trial_count,
997        workspace: workspace.clone(),
998        base_dir: base_dir.map(Path::to_path_buf),
999    };
1000    let mut outcome = match live_executor.execute(request) {
1001        Ok(outcome) => outcome,
1002        Err(error) => {
1003            failures.push(format!("live executor failed: {error}"));
1004            EvalPackLiveVerifyOutcome::default()
1005        }
1006    };
1007    failures.append(&mut outcome.failures);
1008    warnings.append(&mut outcome.warnings);
1009    informational.append(&mut outcome.informational);
1010    if outcome.timed_out {
1011        failures.push("live executor timed out".to_string());
1012    }
1013    if live_outcome_verification(&outcome) == "FAIL" {
1014        failures.push("live executor reported verification FAIL".to_string());
1015    }
1016
1017    let verify_output = run_eval_pack_command(
1018        verify_command,
1019        &workspace,
1020        None,
1021        DEFAULT_LIVE_VERIFY_TIMEOUT_SECONDS,
1022    );
1023    let verification_exit_code = match verify_output {
1024        Ok(output) => {
1025            let exit_code = output.exit_code;
1026            if output.timed_out {
1027                outcome.timed_out = true;
1028                failures.push("verify command timed out".to_string());
1029            }
1030            if exit_code != 0 {
1031                failures.push(format!(
1032                    "verify command exited {exit_code}{}",
1033                    command_failure_excerpt(&output)
1034                ));
1035            }
1036            if outcome.wall_time_seconds == 0.0 {
1037                outcome.wall_time_seconds = output.wall_time_seconds;
1038            }
1039            Some(exit_code)
1040        }
1041        Err(error) => {
1042            failures.push(format!("verify command failed: {error}"));
1043            None
1044        }
1045    };
1046
1047    let produced_paths = normalized_live_produced_paths(case, &outcome);
1048    failures.extend(eval_pack_live_expected_path_failures(
1049        &workspace,
1050        &case.expected_output_paths,
1051    ));
1052    failures.extend(eval_pack_live_required_snippet_failures(
1053        &workspace,
1054        &produced_paths,
1055        &case.required_output_snippets,
1056    ));
1057    failures.extend(eval_pack_live_tool_budget_failures(
1058        &case.tool_budgets,
1059        &outcome.tool_call_summary,
1060    ));
1061
1062    let mut report = eval_pack_trial_report(
1063        trial,
1064        severity,
1065        blocking,
1066        outcome
1067            .run_id
1068            .clone()
1069            .unwrap_or_else(|| format!("live:{case_id}:{trial}")),
1070        outcome
1071            .workflow_id
1072            .clone()
1073            .unwrap_or_else(|| "live-verify".to_string()),
1074        outcome
1075            .source_path
1076            .clone()
1077            .or_else(|| Some(workspace.display().to_string())),
1078        outcome.stage_count.unwrap_or_default(),
1079        outcome.timed_out,
1080        outcome.wall_time_seconds,
1081        outcome.cost_usd,
1082        failures,
1083        warnings,
1084        informational,
1085        None,
1086    );
1087    let outcome_verification = live_outcome_verification(&outcome);
1088    if report.failures.is_empty()
1089        && outcome_verification.eq_ignore_ascii_case("skip")
1090        && verification_exit_code.unwrap_or_default() == 0
1091    {
1092        report.verification = "skip".to_string();
1093    }
1094    report.verification_exit_code = verification_exit_code;
1095    report.produced_paths = produced_paths;
1096    report.tool_call_summary = outcome.tool_call_summary;
1097    Ok(report)
1098}
1099
1100#[allow(clippy::too_many_arguments)]
1101fn evaluate_eval_pack_run_trial(
1102    manifest: &EvalPackManifest,
1103    case: &EvalPackCase,
1104    trial: usize,
1105    severity: &str,
1106    blocking: bool,
1107    base_dir: Option<&Path>,
1108    fixture_base_dir: Option<&Path>,
1109    fixtures_by_id: &BTreeMap<&str, &EvalPackFixtureRef>,
1110    rubrics_by_id: &BTreeMap<&str, &EvalPackRubric>,
1111) -> Result<EvalPackTrialReport, VmError> {
1112    let mut failures = Vec::new();
1113    let mut warnings = Vec::new();
1114    let informational = Vec::new();
1115    let run = load_eval_pack_case_run(case, base_dir, fixture_base_dir, fixtures_by_id)?;
1116    let fixture =
1117        load_eval_pack_case_fixture(case, base_dir, fixture_base_dir, fixtures_by_id, &run)?;
1118    let eval = evaluate_run_against_fixture(&run, &fixture);
1119    failures.extend(eval.failures);
1120    apply_eval_pack_thresholds(&run, &manifest.defaults.thresholds, &mut failures);
1121    apply_eval_pack_thresholds(&run, &case.thresholds, &mut failures);
1122
1123    let comparison = match case.compare_to.as_ref().or(manifest.baseline.as_ref()) {
1124        Some(path) => {
1125            let baseline_path = resolve_manifest_path(base_dir, path);
1126            let baseline = load_run_record(&baseline_path)?;
1127            let diff = diff_run_records(&baseline, &run);
1128            if !diff.identical {
1129                failures.push(format!(
1130                    "run differs from baseline {} with {} stage changes",
1131                    baseline_path.display(),
1132                    diff.stage_diffs.len()
1133                ));
1134            }
1135            Some(diff)
1136        }
1137        None => None,
1138    };
1139
1140    for rubric_id in &case.rubrics {
1141        let Some(rubric) = rubrics_by_id.get(rubric_id.as_str()) else {
1142            failures.push(format!("case references unknown rubric '{rubric_id}'"));
1143            continue;
1144        };
1145        apply_eval_pack_rubric(rubric, &run, &mut failures, &mut warnings);
1146    }
1147
1148    Ok(eval_pack_trial_report(
1149        trial,
1150        severity,
1151        blocking,
1152        run.id.clone(),
1153        run.workflow_id.clone(),
1154        eval_pack_case_source_path(case, base_dir, fixture_base_dir, fixtures_by_id),
1155        eval.stage_count,
1156        run.status.to_ascii_lowercase().contains("timeout"),
1157        run.usage
1158            .as_ref()
1159            .map(|usage| usage.total_duration_ms as f64 / 1000.0)
1160            .unwrap_or_default(),
1161        run.usage
1162            .as_ref()
1163            .map(|usage| usage.total_cost)
1164            .unwrap_or_default(),
1165        failures,
1166        warnings,
1167        informational,
1168        comparison,
1169    ))
1170}
1171
1172#[allow(clippy::too_many_arguments)]
1173fn evaluate_eval_pack_friction_trial(
1174    manifest: &EvalPackManifest,
1175    case: &EvalPackCase,
1176    trial: usize,
1177    severity: &str,
1178    blocking: bool,
1179    base_dir: Option<&Path>,
1180    fixture_base_dir: Option<&Path>,
1181    fixtures_by_id: &BTreeMap<&str, &EvalPackFixtureRef>,
1182    rubrics_by_id: &BTreeMap<&str, &EvalPackRubric>,
1183) -> Result<EvalPackTrialReport, VmError> {
1184    let mut failures = Vec::new();
1185    let mut warnings = Vec::new();
1186    let informational = Vec::new();
1187    let events =
1188        load_eval_pack_case_friction_events(case, base_dir, fixture_base_dir, fixtures_by_id)?;
1189    let options = friction_suggestion_options(case, manifest);
1190    let suggestions = generate_context_pack_suggestions(&events, &options);
1191
1192    for rubric_id in &case.rubrics {
1193        let Some(rubric) = rubrics_by_id.get(rubric_id.as_str()) else {
1194            failures.push(format!("case references unknown rubric '{rubric_id}'"));
1195            continue;
1196        };
1197        apply_eval_pack_friction_rubric(rubric, &suggestions, &mut failures, &mut warnings);
1198    }
1199
1200    if case.rubrics.is_empty() && suggestions.is_empty() {
1201        failures.push("friction fixture produced no context-pack suggestions".to_string());
1202    }
1203
1204    Ok(eval_pack_trial_report(
1205        trial,
1206        severity,
1207        blocking,
1208        "friction_events".to_string(),
1209        String::new(),
1210        eval_pack_case_friction_source_path(case, base_dir, fixture_base_dir, fixtures_by_id),
1211        events.len(),
1212        false,
1213        0.0,
1214        0.0,
1215        failures,
1216        warnings,
1217        informational,
1218        None,
1219    ))
1220}
1221
1222#[cfg(test)]
1223mod live_tool_budget_tests {
1224    use super::*;
1225    use std::collections::BTreeMap;
1226
1227    #[test]
1228    fn per_tool_budget_counts_from_sequence_when_no_by_tool_map() {
1229        // The in-process coding-agent executor emits only
1230        // {total, rejected, sequence, successful} — no `by_tool` map.
1231        let summary = serde_json::json!({
1232            "total": 4,
1233            "rejected": 0,
1234            "sequence": ["read", "edit", "edit", "run"],
1235            "successful": ["read", "edit", "edit", "run"],
1236        });
1237        assert_eq!(live_tool_summary_count(&summary, "edit"), Some(2));
1238        assert_eq!(live_tool_summary_count(&summary, "read"), Some(1));
1239        assert_eq!(live_tool_summary_count(&summary, "delete"), Some(0));
1240        assert_eq!(live_tool_summary_count(&summary, "total"), Some(4));
1241    }
1242
1243    #[test]
1244    fn per_tool_budget_is_enforced_against_sequence_only_summary() {
1245        let summary = serde_json::json!({
1246            "total": 3,
1247            "sequence": ["edit", "edit", "run"],
1248        });
1249        let budgets = BTreeMap::from([("edit".to_string(), 1usize)]);
1250        let failures = eval_pack_live_tool_budget_failures(&budgets, &summary);
1251        assert_eq!(failures.len(), 1, "edit budget of 1 must trip on 2 edits");
1252        assert!(failures[0].contains("edit"));
1253
1254        let within = BTreeMap::from([("edit".to_string(), 2usize)]);
1255        assert!(eval_pack_live_tool_budget_failures(&within, &summary).is_empty());
1256    }
1257
1258    #[test]
1259    fn explicit_by_tool_map_still_takes_precedence() {
1260        let summary = serde_json::json!({
1261            "total": 1,
1262            "byTool": {"edit": 1},
1263        });
1264        assert_eq!(live_tool_summary_count(&summary, "edit"), Some(1));
1265    }
1266}