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        "pipelineRev",
368        "pipeline_rev",
369        "pipelineRevision",
370        "pipeline_revision",
371        "harnVersion",
372        "harn_version",
373        "harness",
374        "harnessConfig",
375        "harness_config",
376    ] {
377        if let Some(value) = manifest.metadata.get(key) {
378            harness_metadata.insert(key.to_string(), value.clone());
379        }
380    }
381
382    let mut payload = BTreeMap::new();
383    insert_json_field(&mut payload, "executor", &manifest.executor)?;
384    insert_json_field(&mut payload, "manifest_judge", &manifest.judge)?;
385    insert_json_field(&mut payload, "default_judge", &manifest.defaults.judge)?;
386    insert_json_field(&mut payload, "package", &manifest.package)?;
387    payload.insert(
388        "harness_metadata".to_string(),
389        encode_json(&harness_metadata)?,
390    );
391    payload.insert(
392        "rubric_harness".to_string(),
393        serde_json::Value::Array(rubric_harness),
394    );
395    fingerprint_json(&payload)
396}
397
398fn insert_json_field<T: serde::Serialize>(
399    map: &mut BTreeMap<String, serde_json::Value>,
400    key: &str,
401    value: &T,
402) -> Result<(), VmError> {
403    map.insert(key.to_string(), encode_json(value)?);
404    Ok(())
405}
406
407fn encode_json<T: serde::Serialize>(value: &T) -> Result<serde_json::Value, VmError> {
408    serde_json::to_value(value)
409        .map_err(|e| VmError::Runtime(format!("failed to encode eval pack fingerprint: {e}")))
410}
411
412fn fingerprint_json<T: serde::Serialize>(value: &T) -> Result<String, VmError> {
413    let bytes = serde_json::to_vec(value)
414        .map_err(|e| VmError::Runtime(format!("failed to encode eval pack fingerprint: {e}")))?;
415    let digest = hex::encode(Sha256::digest(bytes));
416    Ok(digest.chars().take(16).collect())
417}
418
419fn eval_pack_case_kind(case: &EvalPackCase) -> EvalPackCaseKind {
420    match normalized_eval_pack_case_kind(case).as_str() {
421        "live-verify" => EvalPackCaseKind::LiveVerify,
422        "friction" => EvalPackCaseKind::Friction,
423        _ => EvalPackCaseKind::Replay,
424    }
425}
426
427fn normalized_eval_pack_case_kind(case: &EvalPackCase) -> String {
428    match case
429        .kind
430        .as_deref()
431        .map(|kind| kind.trim().to_ascii_lowercase().replace('_', "-"))
432        .as_deref()
433    {
434        Some("live") | Some("live-verify") | Some("verify-live") => "live-verify".to_string(),
435        Some("friction") | Some("context-pack-friction") => "friction".to_string(),
436        Some("replay") | Some("fixture") | Some("run-record") => "replay".to_string(),
437        Some(other) if !other.is_empty() => other.to_string(),
438        _ if case.task.is_some()
439            || case.workspace.is_some()
440            || case.project.is_some()
441            || case.verify_command.is_some()
442            || !case.expected_output_paths.is_empty()
443            || !case.required_output_snippets.is_empty() =>
444        {
445            "live-verify".to_string()
446        }
447        _ if case.friction_events.is_some() => "friction".to_string(),
448        _ => "replay".to_string(),
449    }
450}
451
452pub fn validate_eval_pack_split(
453    manifest: &EvalPackManifest,
454) -> Result<EvalPackSplitValidationReport, VmError> {
455    let report = eval_pack_split_validation_report(manifest);
456    if !report.valid {
457        return Err(VmError::Runtime(format!(
458            "eval pack split invalid: {}",
459            render_split_validation_errors(&report).join("; ")
460        )));
461    }
462    Ok(report)
463}
464
465fn eval_pack_split_validation_report(manifest: &EvalPackManifest) -> EvalPackSplitValidationReport {
466    let case_ids = eval_pack_case_ids(manifest);
467    let mut duplicate_case_ids = duplicates(&case_ids);
468    duplicate_case_ids.sort();
469
470    let case_set = case_ids.iter().cloned().collect::<BTreeSet<_>>();
471    let Some(split) = &manifest.split else {
472        return EvalPackSplitValidationReport {
473            valid: duplicate_case_ids.is_empty(),
474            case_count: case_ids.len(),
475            covered_count: 0,
476            duplicate_case_ids,
477            ..EvalPackSplitValidationReport::default()
478        };
479    };
480
481    let mut duplicate_partition_cases = Vec::new();
482    let mut unknown_cases = Vec::new();
483    let mut seen_by_case: BTreeMap<String, Vec<String>> = BTreeMap::new();
484    for (partition, cases) in &split.partitions {
485        let mut local_seen = BTreeSet::new();
486        for case_id in cases {
487            if !local_seen.insert(case_id.clone()) {
488                duplicate_partition_cases.push(format!("{partition}:{case_id}"));
489            }
490            if !case_set.contains(case_id) {
491                unknown_cases.push(format!("{partition}:{case_id}"));
492            }
493            let partitions = seen_by_case.entry(case_id.clone()).or_default();
494            if !partitions.contains(partition) {
495                partitions.push(partition.clone());
496            }
497        }
498    }
499
500    let mut overlap_cases = seen_by_case
501        .iter()
502        .filter(|(case_id, partitions)| case_set.contains(*case_id) && partitions.len() > 1)
503        .map(|(case_id, partitions)| format!("{case_id}:{}", partitions.join(",")))
504        .collect::<Vec<_>>();
505    let mut missing_cases = case_set
506        .iter()
507        .filter(|case_id| !seen_by_case.contains_key(*case_id))
508        .cloned()
509        .collect::<Vec<_>>();
510    duplicate_partition_cases.sort();
511    unknown_cases.sort();
512    overlap_cases.sort();
513    missing_cases.sort();
514
515    let covered_count = case_set
516        .iter()
517        .filter(|case_id| seen_by_case.contains_key(*case_id))
518        .count();
519    let valid = duplicate_case_ids.is_empty()
520        && duplicate_partition_cases.is_empty()
521        && unknown_cases.is_empty()
522        && overlap_cases.is_empty()
523        && missing_cases.is_empty();
524    EvalPackSplitValidationReport {
525        valid,
526        partitions: split.partitions.clone(),
527        case_count: case_ids.len(),
528        covered_count,
529        duplicate_case_ids,
530        duplicate_partition_cases,
531        overlap_cases,
532        unknown_cases,
533        missing_cases,
534    }
535}
536
537fn eval_pack_case_ids(manifest: &EvalPackManifest) -> Vec<String> {
538    manifest
539        .cases
540        .iter()
541        .enumerate()
542        .map(|(index, case)| eval_pack_case_id(case, index))
543        .collect()
544}
545
546fn eval_pack_case_id(case: &EvalPackCase, index: usize) -> String {
547    case.id
548        .clone()
549        .filter(|id| !id.trim().is_empty())
550        .unwrap_or_else(|| format!("case_{}", index + 1))
551}
552
553fn duplicates(values: &[String]) -> Vec<String> {
554    let mut seen = BTreeSet::new();
555    let mut duplicates = BTreeSet::new();
556    for value in values {
557        if !seen.insert(value.clone()) {
558            duplicates.insert(value.clone());
559        }
560    }
561    duplicates.into_iter().collect()
562}
563
564fn render_split_validation_errors(report: &EvalPackSplitValidationReport) -> Vec<String> {
565    let mut errors = Vec::new();
566    if !report.duplicate_case_ids.is_empty() {
567        errors.push(format!(
568            "duplicate case ids: {}",
569            report.duplicate_case_ids.join(", ")
570        ));
571    }
572    if !report.duplicate_partition_cases.is_empty() {
573        errors.push(format!(
574            "duplicate partition entries: {}",
575            report.duplicate_partition_cases.join(", ")
576        ));
577    }
578    if !report.overlap_cases.is_empty() {
579        errors.push(format!(
580            "overlapping cases: {}",
581            report.overlap_cases.join(", ")
582        ));
583    }
584    if !report.unknown_cases.is_empty() {
585        errors.push(format!(
586            "unknown cases: {}",
587            report.unknown_cases.join(", ")
588        ));
589    }
590    if !report.missing_cases.is_empty() {
591        errors.push(format!(
592            "missing cases: {}",
593            report.missing_cases.join(", ")
594        ));
595    }
596    if errors.is_empty() {
597        errors.push("unknown split validation error".to_string());
598    }
599    errors
600}
601
602fn load_replay_fixture(path: &Path) -> Result<ReplayFixture, VmError> {
603    let content = std::fs::read_to_string(path)
604        .map_err(|e| VmError::Runtime(format!("failed to read replay fixture: {e}")))?;
605    serde_json::from_str(&content)
606        .map_err(|e| VmError::Runtime(format!("failed to parse replay fixture: {e}")))
607}
608
609fn load_run_record_from_fixture_ref(
610    fixture: &EvalPackFixtureRef,
611    base_dir: Option<&Path>,
612) -> Result<RunRecord, VmError> {
613    if let Some(inline) = &fixture.inline {
614        let run: RunRecord = serde_json::from_value(inline.clone())
615            .map_err(|e| VmError::Runtime(format!("failed to parse inline run record: {e}")))?;
616        return Ok(run);
617    }
618    let path = fixture.path.as_deref().ok_or_else(|| {
619        VmError::Runtime(format!(
620            "fixture '{}' is missing path or inline run",
621            fixture.id
622        ))
623    })?;
624    load_run_record(&resolve_manifest_path(base_dir, path))
625}
626
627fn load_replay_fixture_from_ref(
628    fixture: &EvalPackFixtureRef,
629    base_dir: Option<&Path>,
630) -> Result<ReplayFixture, VmError> {
631    if let Some(inline) = &fixture.inline {
632        return serde_json::from_value(inline.clone())
633            .map_err(|e| VmError::Runtime(format!("failed to parse inline replay fixture: {e}")));
634    }
635    let path = fixture.path.as_deref().ok_or_else(|| {
636        VmError::Runtime(format!(
637            "fixture '{}' is missing path or inline replay fixture",
638            fixture.id
639        ))
640    })?;
641    load_replay_fixture(&resolve_manifest_path(base_dir, path))
642}
643
644fn resolve_manifest_path(base_dir: Option<&Path>, path: &str) -> PathBuf {
645    let path_buf = PathBuf::from(path);
646    if path_buf.is_absolute() {
647        path_buf
648    } else if let Some(base_dir) = base_dir {
649        base_dir.join(path_buf)
650    } else {
651        path_buf
652    }
653}
654
655pub fn evaluate_run_suite_manifest(
656    manifest: &EvalSuiteManifest,
657) -> Result<ReplayEvalSuiteReport, VmError> {
658    let base_dir = manifest.base_dir.as_deref().map(Path::new);
659    let mut reports = Vec::new();
660    for case in &manifest.cases {
661        let run_path = resolve_manifest_path(base_dir, &case.run_path);
662        let run = load_run_record(&run_path)?;
663        let fixture = match &case.fixture_path {
664            Some(path) => load_replay_fixture(&resolve_manifest_path(base_dir, path))?,
665            None => run
666                .replay_fixture
667                .clone()
668                .unwrap_or_else(|| replay_fixture_from_run(&run)),
669        };
670        let eval = evaluate_run_against_fixture(&run, &fixture);
671        let mut pass = eval.pass;
672        let mut failures = eval.failures;
673        let comparison = match &case.compare_to {
674            Some(path) => {
675                let baseline_path = resolve_manifest_path(base_dir, path);
676                let baseline = load_run_record(&baseline_path)?;
677                let diff = diff_run_records(&baseline, &run);
678                if !diff.identical {
679                    pass = false;
680                    failures.push(format!(
681                        "run differs from baseline {} with {} stage changes",
682                        baseline_path.display(),
683                        diff.stage_diffs.len()
684                    ));
685                }
686                Some(diff)
687            }
688            None => None,
689        };
690        reports.push(ReplayEvalCaseReport {
691            run_id: run.id.clone(),
692            workflow_id: run.workflow_id.clone(),
693            label: case.label.clone(),
694            pass,
695            failures,
696            stage_count: eval.stage_count,
697            source_path: Some(run_path.display().to_string()),
698            comparison,
699        });
700    }
701    let total = reports.len();
702    let passed = reports.iter().filter(|report| report.pass).count();
703    let failed = total.saturating_sub(passed);
704    Ok(ReplayEvalSuiteReport {
705        pass: failed == 0,
706        total,
707        passed,
708        failed,
709        cases: reports,
710    })
711}
712
713pub fn evaluate_eval_pack_manifest(manifest: &EvalPackManifest) -> Result<EvalPackReport, VmError> {
714    let mut live_executor = EvalPackShellLiveExecutor;
715    evaluate_eval_pack_manifest_inner(manifest, false, None, &mut live_executor)
716}
717
718pub fn evaluate_eval_pack_manifest_resumable(
719    manifest: &EvalPackManifest,
720    ledger_options: Option<serde_json::Value>,
721) -> Result<EvalPackReport, VmError> {
722    let mut live_executor = EvalPackShellLiveExecutor;
723    evaluate_eval_pack_manifest_inner(manifest, true, ledger_options, &mut live_executor)
724}
725
726pub fn evaluate_eval_pack_manifest_with_live_executor(
727    manifest: &EvalPackManifest,
728    live_executor: &mut dyn EvalPackLiveExecutor,
729) -> Result<EvalPackReport, VmError> {
730    evaluate_eval_pack_manifest_inner(manifest, false, None, live_executor)
731}
732
733pub fn evaluate_eval_pack_manifest_resumable_with_live_executor(
734    manifest: &EvalPackManifest,
735    ledger_options: Option<serde_json::Value>,
736    live_executor: &mut dyn EvalPackLiveExecutor,
737) -> Result<EvalPackReport, VmError> {
738    evaluate_eval_pack_manifest_inner(manifest, true, ledger_options, live_executor)
739}
740
741fn evaluate_eval_pack_manifest_inner(
742    manifest: &EvalPackManifest,
743    ledger_enabled: bool,
744    ledger_options: Option<serde_json::Value>,
745    live_executor: &mut dyn EvalPackLiveExecutor,
746) -> Result<EvalPackReport, VmError> {
747    let base_dir = manifest.base_dir.as_deref().map(Path::new);
748    let fixture_base_dir_buf = manifest
749        .defaults
750        .fixture_root
751        .as_deref()
752        .map(|root| resolve_manifest_path(base_dir, root));
753    let fixture_base_dir = fixture_base_dir_buf.as_deref().or(base_dir);
754    let fixtures_by_id: BTreeMap<&str, &EvalPackFixtureRef> = manifest
755        .fixtures
756        .iter()
757        .filter(|fixture| !fixture.id.is_empty())
758        .map(|fixture| (fixture.id.as_str(), fixture))
759        .collect();
760    let rubrics_by_id: BTreeMap<&str, &EvalPackRubric> = manifest
761        .rubrics
762        .iter()
763        .filter(|rubric| !rubric.id.is_empty())
764        .map(|rubric| (rubric.id.as_str(), rubric))
765        .collect();
766
767    let split_report = validate_eval_pack_split(manifest)?;
768    let split_by_case = split_by_case_id(&split_report);
769    let harness_config_fingerprint = eval_pack_harness_config_fingerprint(manifest)?;
770    let mut ledger = if ledger_enabled {
771        Some(EvalPackLedgerRun::start(
772            manifest,
773            base_dir,
774            ledger_options,
775        )?)
776    } else {
777        None
778    };
779    let mut requested_cells = 0usize;
780    let mut skipped_cells = 0usize;
781    let mut executed_cells = 0usize;
782    let mut reports = Vec::new();
783    for (index, case) in manifest.cases.iter().enumerate() {
784        let case_id = eval_pack_case_id(case, index);
785        let label = case
786            .name
787            .clone()
788            .or_else(|| case.id.clone())
789            .unwrap_or_else(|| case_id.clone());
790        let severity = eval_pack_case_severity(manifest, case);
791        let blocking = severity == "blocking";
792        let trial_count = case.trials.unwrap_or(manifest.trials);
793        let split = split_by_case.get(&case_id).cloned();
794        requested_cells += trial_count;
795        let mut trials = Vec::with_capacity(trial_count);
796        for trial in 1..=trial_count {
797            if let Some(ledger) = ledger.as_mut() {
798                if let Some(row) = ledger.replay_row_for_cell(
799                    &case_id,
800                    split.as_deref(),
801                    trial,
802                    &case.case_fingerprint,
803                    &harness_config_fingerprint,
804                ) {
805                    skipped_cells += 1;
806                    trials.push(eval_pack_trial_report_from_ledger_row(&row, blocking));
807                    continue;
808                }
809            }
810            let report = match eval_pack_case_kind(case) {
811                EvalPackCaseKind::LiveVerify => evaluate_eval_pack_live_verify_trial(
812                    manifest,
813                    case,
814                    &case_id,
815                    trial,
816                    trial_count,
817                    &severity,
818                    blocking,
819                    base_dir,
820                    live_executor,
821                )?,
822                EvalPackCaseKind::Friction => evaluate_eval_pack_friction_trial(
823                    manifest,
824                    case,
825                    trial,
826                    &severity,
827                    blocking,
828                    base_dir,
829                    fixture_base_dir,
830                    &fixtures_by_id,
831                    &rubrics_by_id,
832                )?,
833                EvalPackCaseKind::Replay => evaluate_eval_pack_run_trial(
834                    manifest,
835                    case,
836                    trial,
837                    &severity,
838                    blocking,
839                    base_dir,
840                    fixture_base_dir,
841                    &fixtures_by_id,
842                    &rubrics_by_id,
843                )?,
844            };
845            if let Some(ledger) = ledger.as_mut() {
846                let row = eval_ledger_row_from_trial(
847                    case,
848                    &case_id,
849                    split.clone(),
850                    &ledger.suite,
851                    &ledger.model,
852                    &ledger.commit,
853                    &ledger.provenance,
854                    &harness_config_fingerprint,
855                    &report,
856                );
857                ledger.append_trial_row(row)?;
858            }
859            executed_cells += 1;
860            trials.push(report);
861        }
862        reports.push(eval_pack_case_report_from_trials(
863            case,
864            case_id,
865            label,
866            severity,
867            split,
868            blocking,
869            harness_config_fingerprint.clone(),
870            trials,
871        ));
872    }
873
874    let mut ladder_reports = Vec::new();
875    for ladder in &manifest.ladders {
876        let mut ladder = ladder.clone();
877        if ladder.base_dir.is_none() {
878            ladder.base_dir = manifest.base_dir.clone();
879        }
880        ladder_reports.push(run_persona_eval_ladder(&ladder)?);
881    }
882
883    let stats_rows = reports
884        .iter()
885        .map(|report| report.stats_row.clone())
886        .collect::<Vec<_>>();
887    let stats = eval_pack_stats_report(&stats_rows);
888    let case_total = reports.len();
889    let ladder_total = ladder_reports.len();
890    let total = case_total + ladder_total;
891    let trial_count = reports.iter().map(|report| report.trial_count).sum();
892    let case_blocking_failed = reports
893        .iter()
894        .filter(|report| report.blocking && report.reliability.status != "all-pass")
895        .count();
896    let ladder_blocking_failed = ladder_reports
897        .iter()
898        .filter(|report| report.blocking && !report.pass)
899        .count();
900    let blocking_failed = case_blocking_failed + ladder_blocking_failed;
901    let warning_failed = reports
902        .iter()
903        .filter(|report| !report.warnings.is_empty())
904        .count()
905        + ladder_reports
906            .iter()
907            .filter(|report| !report.pass && report.severity == "warning")
908            .count();
909    let informational_failed = reports
910        .iter()
911        .filter(|report| !report.informational.is_empty())
912        .count()
913        + ladder_reports
914            .iter()
915            .filter(|report| !report.pass && report.severity == "informational")
916            .count();
917    let passed = reports.iter().filter(|report| report.pass).count()
918        + ladder_reports.iter().filter(|report| report.pass).count();
919    let run_state = match ledger.as_ref() {
920        Some(ledger) => ledger.finish(requested_cells, skipped_cells, executed_cells)?,
921        None => EvalPackRunState {
922            schema: EVAL_LEDGER_RUN_STATE_SCHEMA.to_string(),
923            suite: manifest.id.clone(),
924            model: eval_pack_manifest_model(manifest).unwrap_or_else(|| "unknown".to_string()),
925            requested_cells,
926            completed_cells: requested_cells,
927            executed_cells: requested_cells,
928            ..EvalPackRunState::default()
929        },
930    };
931    Ok(EvalPackReport {
932        pack_id: manifest.id.clone(),
933        harness_config_fingerprint,
934        pass: blocking_failed == 0,
935        total,
936        passed,
937        failed: total.saturating_sub(passed),
938        blocking_failed,
939        warning_failed,
940        informational_failed,
941        trial_count,
942        run_state,
943        split: manifest.split.as_ref().map(|_| split_report),
944        stats,
945        stats_rows,
946        cases: reports,
947        ladders: ladder_reports,
948    })
949}
950
951#[allow(clippy::too_many_arguments)]
952fn evaluate_eval_pack_live_verify_trial(
953    manifest: &EvalPackManifest,
954    case: &EvalPackCase,
955    case_id: &str,
956    trial: usize,
957    trial_count: usize,
958    severity: &str,
959    blocking: bool,
960    base_dir: Option<&Path>,
961    live_executor: &mut dyn EvalPackLiveExecutor,
962) -> Result<EvalPackTrialReport, VmError> {
963    let workspace = eval_pack_live_workspace(case, base_dir)?;
964    let executor = case.executor.as_ref().or(manifest.executor.as_ref());
965    let verify_command = case.verify_command.as_ref().ok_or_else(|| {
966        VmError::Runtime(format!(
967            "eval pack live-verify case '{case_id}' is missing verify_command"
968        ))
969    })?;
970    let Some(executor) = executor else {
971        return Err(VmError::Runtime(format!(
972            "eval pack live-verify case '{case_id}' is missing executor"
973        )));
974    };
975
976    let mut failures = Vec::new();
977    let mut warnings = Vec::new();
978    let mut informational = Vec::new();
979    let request_payload = eval_pack_live_executor_request(
980        manifest,
981        case,
982        case_id,
983        trial,
984        trial_count,
985        &workspace,
986        base_dir,
987    )?;
988    let request = EvalPackLiveExecutorRequest {
989        executor: executor.clone(),
990        payload: request_payload,
991        manifest_id: manifest.id.clone(),
992        case: case.clone(),
993        case_id: case_id.to_string(),
994        trial,
995        trials: trial_count,
996        workspace: workspace.clone(),
997        base_dir: base_dir.map(Path::to_path_buf),
998    };
999    let mut outcome = match live_executor.execute(request) {
1000        Ok(outcome) => outcome,
1001        Err(error) => {
1002            failures.push(format!("live executor failed: {error}"));
1003            EvalPackLiveVerifyOutcome::default()
1004        }
1005    };
1006    failures.append(&mut outcome.failures);
1007    warnings.append(&mut outcome.warnings);
1008    informational.append(&mut outcome.informational);
1009    if outcome.timed_out {
1010        failures.push("live executor timed out".to_string());
1011    }
1012    if live_outcome_verification(&outcome) == "FAIL" {
1013        failures.push("live executor reported verification FAIL".to_string());
1014    }
1015
1016    let verify_output = run_eval_pack_command(
1017        verify_command,
1018        &workspace,
1019        None,
1020        DEFAULT_LIVE_VERIFY_TIMEOUT_SECONDS,
1021    );
1022    let verification_exit_code = match verify_output {
1023        Ok(output) => {
1024            let exit_code = output.exit_code;
1025            if output.timed_out {
1026                outcome.timed_out = true;
1027                failures.push("verify command timed out".to_string());
1028            }
1029            if exit_code != 0 {
1030                failures.push(format!(
1031                    "verify command exited {exit_code}{}",
1032                    command_failure_excerpt(&output)
1033                ));
1034            }
1035            if outcome.wall_time_seconds == 0.0 {
1036                outcome.wall_time_seconds = output.wall_time_seconds;
1037            }
1038            Some(exit_code)
1039        }
1040        Err(error) => {
1041            failures.push(format!("verify command failed: {error}"));
1042            None
1043        }
1044    };
1045
1046    let produced_paths = normalized_live_produced_paths(case, &outcome);
1047    failures.extend(eval_pack_live_expected_path_failures(
1048        &workspace,
1049        &case.expected_output_paths,
1050    ));
1051    failures.extend(eval_pack_live_required_snippet_failures(
1052        &workspace,
1053        &produced_paths,
1054        &case.required_output_snippets,
1055    ));
1056    failures.extend(eval_pack_live_tool_budget_failures(
1057        &case.tool_budgets,
1058        &outcome.tool_call_summary,
1059    ));
1060
1061    let mut report = eval_pack_trial_report(
1062        trial,
1063        severity,
1064        blocking,
1065        outcome
1066            .run_id
1067            .clone()
1068            .unwrap_or_else(|| format!("live:{case_id}:{trial}")),
1069        outcome
1070            .workflow_id
1071            .clone()
1072            .unwrap_or_else(|| "live-verify".to_string()),
1073        outcome
1074            .source_path
1075            .clone()
1076            .or_else(|| Some(workspace.display().to_string())),
1077        outcome.stage_count.unwrap_or_default(),
1078        outcome.timed_out,
1079        outcome.wall_time_seconds,
1080        outcome.cost_usd,
1081        failures,
1082        warnings,
1083        informational,
1084        None,
1085    );
1086    let outcome_verification = live_outcome_verification(&outcome);
1087    if report.failures.is_empty()
1088        && outcome_verification.eq_ignore_ascii_case("skip")
1089        && verification_exit_code.unwrap_or_default() == 0
1090    {
1091        report.verification = "skip".to_string();
1092    }
1093    report.verification_exit_code = verification_exit_code;
1094    report.produced_paths = produced_paths;
1095    report.tool_call_summary = outcome.tool_call_summary;
1096    Ok(report)
1097}
1098
1099#[allow(clippy::too_many_arguments)]
1100fn evaluate_eval_pack_run_trial(
1101    manifest: &EvalPackManifest,
1102    case: &EvalPackCase,
1103    trial: usize,
1104    severity: &str,
1105    blocking: bool,
1106    base_dir: Option<&Path>,
1107    fixture_base_dir: Option<&Path>,
1108    fixtures_by_id: &BTreeMap<&str, &EvalPackFixtureRef>,
1109    rubrics_by_id: &BTreeMap<&str, &EvalPackRubric>,
1110) -> Result<EvalPackTrialReport, VmError> {
1111    let mut failures = Vec::new();
1112    let mut warnings = Vec::new();
1113    let informational = Vec::new();
1114    let run = load_eval_pack_case_run(case, base_dir, fixture_base_dir, fixtures_by_id)?;
1115    let fixture =
1116        load_eval_pack_case_fixture(case, base_dir, fixture_base_dir, fixtures_by_id, &run)?;
1117    let eval = evaluate_run_against_fixture(&run, &fixture);
1118    failures.extend(eval.failures);
1119    apply_eval_pack_thresholds(&run, &manifest.defaults.thresholds, &mut failures);
1120    apply_eval_pack_thresholds(&run, &case.thresholds, &mut failures);
1121
1122    let comparison = match case.compare_to.as_ref().or(manifest.baseline.as_ref()) {
1123        Some(path) => {
1124            let baseline_path = resolve_manifest_path(base_dir, path);
1125            let baseline = load_run_record(&baseline_path)?;
1126            let diff = diff_run_records(&baseline, &run);
1127            if !diff.identical {
1128                failures.push(format!(
1129                    "run differs from baseline {} with {} stage changes",
1130                    baseline_path.display(),
1131                    diff.stage_diffs.len()
1132                ));
1133            }
1134            Some(diff)
1135        }
1136        None => None,
1137    };
1138
1139    for rubric_id in &case.rubrics {
1140        let Some(rubric) = rubrics_by_id.get(rubric_id.as_str()) else {
1141            failures.push(format!("case references unknown rubric '{rubric_id}'"));
1142            continue;
1143        };
1144        apply_eval_pack_rubric(rubric, &run, &mut failures, &mut warnings);
1145    }
1146
1147    Ok(eval_pack_trial_report(
1148        trial,
1149        severity,
1150        blocking,
1151        run.id.clone(),
1152        run.workflow_id.clone(),
1153        eval_pack_case_source_path(case, base_dir, fixture_base_dir, fixtures_by_id),
1154        eval.stage_count,
1155        run.status.to_ascii_lowercase().contains("timeout"),
1156        run.usage
1157            .as_ref()
1158            .map(|usage| usage.total_duration_ms as f64 / 1000.0)
1159            .unwrap_or_default(),
1160        run.usage
1161            .as_ref()
1162            .map(|usage| usage.total_cost)
1163            .unwrap_or_default(),
1164        failures,
1165        warnings,
1166        informational,
1167        comparison,
1168    ))
1169}
1170
1171#[allow(clippy::too_many_arguments)]
1172fn evaluate_eval_pack_friction_trial(
1173    manifest: &EvalPackManifest,
1174    case: &EvalPackCase,
1175    trial: usize,
1176    severity: &str,
1177    blocking: bool,
1178    base_dir: Option<&Path>,
1179    fixture_base_dir: Option<&Path>,
1180    fixtures_by_id: &BTreeMap<&str, &EvalPackFixtureRef>,
1181    rubrics_by_id: &BTreeMap<&str, &EvalPackRubric>,
1182) -> Result<EvalPackTrialReport, VmError> {
1183    let mut failures = Vec::new();
1184    let mut warnings = Vec::new();
1185    let informational = Vec::new();
1186    let events =
1187        load_eval_pack_case_friction_events(case, base_dir, fixture_base_dir, fixtures_by_id)?;
1188    let options = friction_suggestion_options(case, manifest);
1189    let suggestions = generate_context_pack_suggestions(&events, &options);
1190
1191    for rubric_id in &case.rubrics {
1192        let Some(rubric) = rubrics_by_id.get(rubric_id.as_str()) else {
1193            failures.push(format!("case references unknown rubric '{rubric_id}'"));
1194            continue;
1195        };
1196        apply_eval_pack_friction_rubric(rubric, &suggestions, &mut failures, &mut warnings);
1197    }
1198
1199    if case.rubrics.is_empty() && suggestions.is_empty() {
1200        failures.push("friction fixture produced no context-pack suggestions".to_string());
1201    }
1202
1203    Ok(eval_pack_trial_report(
1204        trial,
1205        severity,
1206        blocking,
1207        "friction_events".to_string(),
1208        String::new(),
1209        eval_pack_case_friction_source_path(case, base_dir, fixture_base_dir, fixtures_by_id),
1210        events.len(),
1211        false,
1212        0.0,
1213        0.0,
1214        failures,
1215        warnings,
1216        informational,
1217        None,
1218    ))
1219}
1220
1221#[cfg(test)]
1222mod live_tool_budget_tests {
1223    use super::*;
1224    use std::collections::BTreeMap;
1225
1226    #[test]
1227    fn per_tool_budget_counts_from_sequence_when_no_by_tool_map() {
1228        // The in-process coding-agent executor emits only
1229        // {total, rejected, sequence, successful} — no `by_tool` map.
1230        let summary = serde_json::json!({
1231            "total": 4,
1232            "rejected": 0,
1233            "sequence": ["read", "edit", "edit", "run"],
1234            "successful": ["read", "edit", "edit", "run"],
1235        });
1236        assert_eq!(live_tool_summary_count(&summary, "edit"), Some(2));
1237        assert_eq!(live_tool_summary_count(&summary, "read"), Some(1));
1238        assert_eq!(live_tool_summary_count(&summary, "delete"), Some(0));
1239        assert_eq!(live_tool_summary_count(&summary, "total"), Some(4));
1240    }
1241
1242    #[test]
1243    fn per_tool_budget_is_enforced_against_sequence_only_summary() {
1244        let summary = serde_json::json!({
1245            "total": 3,
1246            "sequence": ["edit", "edit", "run"],
1247        });
1248        let budgets = BTreeMap::from([("edit".to_string(), 1usize)]);
1249        let failures = eval_pack_live_tool_budget_failures(&budgets, &summary);
1250        assert_eq!(failures.len(), 1, "edit budget of 1 must trip on 2 edits");
1251        assert!(failures[0].contains("edit"));
1252
1253        let within = BTreeMap::from([("edit".to_string(), 2usize)]);
1254        assert!(eval_pack_live_tool_budget_failures(&within, &summary).is_empty());
1255    }
1256
1257    #[test]
1258    fn explicit_by_tool_map_still_takes_precedence() {
1259        let summary = serde_json::json!({
1260            "total": 1,
1261            "byTool": {"edit": 1},
1262        });
1263        assert_eq!(live_tool_summary_count(&summary, "edit"), Some(1));
1264    }
1265}