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