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 "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 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}