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