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