1use serde::Serialize;
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::time::Instant;
27use warble_eval_compare::{compare, CompareRequest, CompareResult, MatchMode, Table, Tolerance};
28
29mod ablation;
30mod cache;
31mod capture;
32mod compliance;
33mod context;
34mod filter;
35mod gate;
36
37pub use ablation::{
38 format_ablation, run_ablation, AblationConfig, AblationPoint, AblationReport,
39 StepRecommendation,
40};
41pub use cache::{rescore, CaseKey, Trace, TraceStore};
42pub use capture::{build_candidate_yaml, candidates_header, CaptureInput};
43pub use compliance::{
44 format_compliance, score_compliance, Check, CheckStatus, ComplianceIr, ComplianceReport,
45 ComplianceTrace, TraceEvent,
46};
47pub use context::{
48 classify, compute_mdl_sha, parse_context_version, stamp_context_version, verify_context,
49 Freshness, VerifyResult,
50};
51pub use filter::{select_cases, CaseFilter, Sample, Selection};
52pub use gate::{format_gate, run_gate, FlakyCase, GateResult, Regression};
53
54#[derive(Debug, serde::Deserialize)]
56pub struct Golden {
57 #[serde(default)]
58 pub dataset: Option<String>,
59 #[serde(default)]
60 pub context_version: Option<String>,
61 pub cases: Vec<GoldenCase>,
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum ResultKind {
73 #[default]
74 Table,
75 Verdict,
76}
77
78#[derive(Debug, Clone, serde::Deserialize)]
79pub struct GoldenCase {
80 pub id: String,
81 pub question: String,
82 #[serde(default)]
83 pub tags: Vec<String>,
84 #[serde(rename = "match")]
85 pub match_mode: MatchMode,
86 #[serde(default)]
87 pub tolerance: Tolerance,
88 pub expected: Table,
89 #[serde(default)]
91 pub result_kind: ResultKind,
92 #[serde(default)]
96 pub verdict_field: Option<String>,
97}
98
99struct SampleOutcome {
103 pass: bool,
104 reason: String,
105 cost: f64,
106 latency_ms: u64,
107 turns: u64,
108 cache_hit: bool,
109 answer: Option<String>,
113}
114
115#[derive(Debug, Clone, Serialize, serde::Deserialize)]
118pub struct SampleVerdict {
119 pub pass: bool,
120 pub reason: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub answer: Option<String>,
123}
124
125#[derive(Debug, Clone, Serialize, serde::Deserialize)]
129pub struct CaseResult {
130 pub id: String,
131 pub tags: Vec<String>,
132 #[serde(default)]
136 pub samples: u32,
137 #[serde(default)]
139 pub passes: u32,
140 #[serde(default)]
143 pub pass_rate: f64,
144 pub pass: bool,
147 #[serde(default)]
149 pub flaky: bool,
150 pub reason: String,
153 pub cost: f64,
156 pub latency_ms: u64,
158 #[serde(default)]
160 pub turns: u64,
161 #[serde(default)]
163 pub cache_hits: u32,
164 #[serde(default)]
165 pub cache_misses: u32,
166 #[serde(default)]
168 pub samples_detail: Vec<SampleVerdict>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub answer_dist: Option<BTreeMap<String, u32>>,
173}
174
175fn fold_samples(
179 id: String,
180 tags: Vec<String>,
181 outcomes: Vec<SampleOutcome>,
182 record_answers: bool,
183) -> CaseResult {
184 let samples = outcomes.len() as u32;
185 let passes = outcomes.iter().filter(|o| o.pass).count() as u32;
186 let pass_rate = if samples > 0 {
187 passes as f64 / samples as f64
188 } else {
189 0.0
190 };
191 let pass = samples > 0 && passes == samples;
192 let flaky = passes > 0 && passes < samples;
193 let reason = if passes == samples {
194 "match".to_string()
195 } else {
196 modal_reason(
197 outcomes
198 .iter()
199 .filter(|o| !o.pass)
200 .map(|o| o.reason.as_str()),
201 )
202 };
203 let cost = outcomes.iter().map(|o| o.cost).sum();
204 let latency_ms = if samples > 0 {
205 outcomes.iter().map(|o| o.latency_ms).sum::<u64>() / samples as u64
206 } else {
207 0
208 };
209 let turns = if samples > 0 {
210 outcomes.iter().map(|o| o.turns).sum::<u64>() / samples as u64
211 } else {
212 0
213 };
214 let cache_hits = outcomes.iter().filter(|o| o.cache_hit).count() as u32;
215 let cache_misses = samples - cache_hits;
216
217 let answer_dist = record_answers.then(|| {
218 let mut dist: BTreeMap<String, u32> = BTreeMap::new();
219 for o in &outcomes {
220 if let Some(answer) = &o.answer {
221 *dist.entry(answer.clone()).or_insert(0) += 1;
222 }
223 }
224 dist
225 });
226 let samples_detail = outcomes
227 .into_iter()
228 .map(|o| SampleVerdict {
229 pass: o.pass,
230 reason: o.reason,
231 answer: if record_answers { o.answer } else { None },
232 })
233 .collect();
234
235 CaseResult {
236 id,
237 tags,
238 samples,
239 passes,
240 pass_rate,
241 pass,
242 flaky,
243 reason,
244 cost,
245 latency_ms,
246 turns,
247 cache_hits,
248 cache_misses,
249 samples_detail,
250 answer_dist,
251 }
252}
253
254fn modal_reason<'a>(reasons: impl Iterator<Item = &'a str>) -> String {
257 let mut counts: Vec<(&str, u32)> = Vec::new();
258 for r in reasons {
259 match counts.iter_mut().find(|(seen, _)| *seen == r) {
260 Some(entry) => entry.1 += 1,
261 None => counts.push((r, 1)),
262 }
263 }
264 let mut best: Option<(&str, u32)> = None;
265 for (r, c) in counts {
266 if best.is_none_or(|(_, bc)| c > bc) {
267 best = Some((r, c));
268 }
269 }
270 best.map(|(r, _)| r.to_string()).unwrap_or_default()
271}
272
273fn render_answer(result: &serde_json::Value) -> String {
284 result.to_string()
285}
286
287#[derive(Debug, Clone, Serialize, serde::Deserialize)]
288pub struct TagStat {
289 pub pass: u32,
290 pub n: u32,
291 #[serde(default)]
295 pub pass_rate_sum: f64,
296}
297
298#[derive(Debug, Clone, Serialize, serde::Deserialize)]
299pub struct ConfigReport {
300 pub model: String,
301 pub n: usize,
302 pub accuracy: f64,
305 pub cost_total_usd: f64,
306 pub latency_ms_avg: u64,
307 #[serde(default)]
310 pub turns_avg: u64,
311 #[serde(default)]
314 pub cache_hits: usize,
315 #[serde(default)]
316 pub cache_misses: usize,
317 #[serde(default)]
320 pub flaky_cases: u32,
321 pub by_tag: BTreeMap<String, TagStat>,
322 pub cases: Vec<CaseResult>,
323}
324
325#[derive(Debug, Clone, Serialize, serde::Deserialize)]
328pub struct Report {
329 pub dataset: Option<String>,
330 pub context_version: Option<String>,
331 #[serde(default = "default_parallel")]
334 pub parallel: usize,
335 #[serde(default)]
340 pub selected_cases: usize,
341 #[serde(default)]
342 pub total_cases: usize,
343 pub configs: Vec<ConfigReport>,
344}
345
346fn default_parallel() -> usize {
347 1
348}
349
350pub struct RunConfig {
351 pub project: PathBuf,
352 pub agent_dir: PathBuf,
353 pub golden_path: PathBuf,
354 pub models: Vec<String>,
355 pub out: Option<PathBuf>,
356 pub parallel: usize,
359 pub filter: CaseFilter,
361 pub no_cache: bool,
364 pub cache_dir: Option<PathBuf>,
366 pub samples: usize,
370 pub record_answers: bool,
374}
375
376pub(crate) struct CaseCtx<'a> {
382 pub model: &'a str,
383 pub agent_sha: &'a str,
384 pub context_sha: &'a str,
385 pub context_version: Option<&'a str>,
386 pub store: &'a TraceStore,
387 pub record_answers: bool,
390}
391
392impl CaseCtx<'_> {
393 fn model_override(&self) -> Option<&str> {
396 (self.model != cache::FRONTMATTER_MODEL).then_some(self.model)
397 }
398}
399
400pub fn extract_result(text: &str) -> Option<Table> {
405 serde_json::from_value(extract_result_json(text)?).ok()
406}
407
408pub fn extract_result_json(text: &str) -> Option<serde_json::Value> {
411 let candidate = strip_fence(text);
412 let start = candidate.find('{')?;
413 let end = candidate.rfind('}')?;
414 if end <= start {
415 return None;
416 }
417 let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
418 if !value.get("rows").map(|r| r.is_array()).unwrap_or(false) {
420 return None;
421 }
422 Some(value)
423}
424
425pub fn extract_verdict_json(text: &str) -> Option<serde_json::Value> {
431 let candidate = strip_fence(text);
432 let start = candidate.find('{')?;
433 let end = candidate.rfind('}')?;
434 if end <= start {
435 return None;
436 }
437 let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
438 let has_verdict = value.get("verdict").is_some();
439 let has_blocks = value.get("blocks").map(Value::is_array).unwrap_or(false);
440 if !has_verdict && !has_blocks {
441 return None;
442 }
443 Some(value)
444}
445
446pub fn project_verdict_field(envelope: &serde_json::Value, field: &str) -> Option<Table> {
452 match field {
453 "fresh" => {
454 let fresh = envelope.get("verdict")?.get("fresh")?.as_bool()?;
455 Some(Table {
456 columns: vec!["fresh".to_string()],
457 rows: vec![vec![Value::Bool(fresh)]],
458 })
459 }
460 "severity" => {
461 let status_severity =
462 envelope
463 .get("blocks")
464 .and_then(Value::as_array)
465 .and_then(|blocks| {
466 blocks
467 .iter()
468 .find(|b| b.get("type").and_then(Value::as_str) == Some("status"))
469 .and_then(|b| b.get("severity"))
470 .and_then(Value::as_str)
471 });
472 let severity = status_severity.or_else(|| {
473 envelope
474 .get("verdict")
475 .and_then(|v| v.get("severity"))
476 .and_then(Value::as_str)
477 })?;
478 Some(Table {
479 columns: vec!["severity".to_string()],
480 rows: vec![vec![Value::String(severity.to_string())]],
481 })
482 }
483 _ => None,
484 }
485}
486
487pub fn score_value(result_value: &Value, case: &GoldenCase) -> Option<CompareResult> {
493 let actual = match case.result_kind {
494 ResultKind::Table => serde_json::from_value(result_value.clone()).ok()?,
495 ResultKind::Verdict => {
496 let field = case.verdict_field.as_deref().unwrap_or("fresh");
497 project_verdict_field(result_value, field)?
498 }
499 };
500 Some(compare(&CompareRequest {
501 match_mode: case.match_mode,
502 tolerance: case.tolerance,
503 expected: case.expected.clone(),
504 actual,
505 }))
506}
507
508fn strip_fence(text: &str) -> &str {
510 let Some(open) = text.find("```") else {
511 return text;
512 };
513 let after = &text[open + 3..];
514 let content_start = match after.find('\n') {
516 Some(i) => open + 3 + i + 1,
517 None => return text,
518 };
519 match text[content_start..].find("```") {
520 Some(close_rel) => &text[content_start..content_start + close_rel],
521 None => text,
522 }
523}
524
525pub fn aggregate(model: &str, rows: Vec<CaseResult>) -> ConfigReport {
527 let n = rows.len();
528 let pass_rate_sum_total: f64 = rows.iter().map(|r| r.pass_rate).sum();
531 let accuracy = if n > 0 {
532 pass_rate_sum_total / n as f64
533 } else {
534 0.0
535 };
536 let flaky_cases = rows.iter().filter(|r| r.flaky).count() as u32;
537 let cost_total_usd = rows.iter().map(|r| r.cost).sum();
538 let latency_sum: u64 = rows.iter().map(|r| r.latency_ms).sum();
539 let latency_ms_avg = if n > 0 { latency_sum / n as u64 } else { 0 };
540 let turns_sum: u64 = rows.iter().map(|r| r.turns).sum();
541 let turns_avg = if n > 0 { turns_sum / n as u64 } else { 0 };
542 let cache_hits = rows.iter().map(|r| r.cache_hits as usize).sum();
543 let cache_misses = rows.iter().map(|r| r.cache_misses as usize).sum();
544
545 let mut by_tag: BTreeMap<String, TagStat> = BTreeMap::new();
546 for r in &rows {
547 for t in &r.tags {
548 let e = by_tag.entry(t.clone()).or_insert(TagStat {
549 pass: 0,
550 n: 0,
551 pass_rate_sum: 0.0,
552 });
553 e.n += 1;
554 if r.pass {
555 e.pass += 1;
556 }
557 e.pass_rate_sum += r.pass_rate;
558 }
559 }
560
561 ConfigReport {
562 model: model.to_string(),
563 n,
564 accuracy,
565 cost_total_usd,
566 latency_ms_avg,
567 turns_avg,
568 cache_hits,
569 cache_misses,
570 flaky_cases,
571 by_tag,
572 cases: rows,
573 }
574}
575
576pub fn format_pareto(report: &Report) -> String {
578 let mut out = String::new();
579 out.push_str("\n=== Warble eval — Pareto (accuracy vs cost vs latency) ===\n");
580 if report.selected_cases < report.total_cases {
581 out.push_str(&format!(
582 " (subset: {}/{} golden cases — smoke run, not a full scoring)\n",
583 report.selected_cases, report.total_cases
584 ));
585 }
586 out.push_str(&format!(
587 "{:<16} {:<7} {:<10} {:<12} {:<7} by_tag\n",
588 "binding", "acc", "cost($)", "lat(ms)", "turns"
589 ));
590 for c in &report.configs {
591 let tags = c
592 .by_tag
593 .iter()
594 .map(|(t, v)| format!("{t}:{:.2}", v.pass_rate_sum / v.n.max(1) as f64))
595 .collect::<Vec<_>>()
596 .join(" ");
597 let cost = if c.cost_total_usd > 0.0 {
598 format!("{:.4}", c.cost_total_usd)
599 } else {
600 "n/a".to_string()
601 };
602 out.push_str(&format!(
603 "{:<16} {:<7} {:<10} {:<12} {:<7} {tags}\n",
604 format!("strong→{}", c.model),
605 format!("{:.2}", c.accuracy),
606 cost,
607 c.latency_ms_avg,
608 c.turns_avg
609 ));
610 }
611 if report.configs.iter().any(|c| c.cache_hits > 0) {
614 out.push_str("cache (miss = LLM call this run):\n");
615 for c in &report.configs {
616 let note = if c.cache_misses == 0 {
617 "re-score only, 0 LLM calls this run".to_string()
618 } else {
619 format!("{} LLM calls this run", c.cache_misses)
620 };
621 out.push_str(&format!(
622 " strong→{:<10} {} hit / {} miss — {note}\n",
623 c.model, c.cache_hits, c.cache_misses
624 ));
625 }
626 }
627 if report.configs.iter().any(|c| c.flaky_cases > 0) {
630 out.push_str("flaky cases (inconsistent across samples):\n");
631 for c in &report.configs {
632 for case in c.cases.iter().filter(|case| case.flaky) {
633 out.push_str(&format!(
634 " strong→{:<10} {} pass_rate={:.2} ({}/{})",
635 c.model, case.id, case.pass_rate, case.passes, case.samples
636 ));
637 if let Some(dist) = &case.answer_dist {
638 let mut answers: Vec<(&String, &u32)> = dist.iter().collect();
639 answers.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
640 let top = answers
641 .into_iter()
642 .take(3)
643 .map(|(a, n)| format!("{a}×{n}"))
644 .collect::<Vec<_>>()
645 .join(", ");
646 out.push_str(&format!(" — {top}"));
647 }
648 out.push('\n');
649 }
650 }
651 }
652 out
653}
654
655struct InstalledAgents {
660 files: Vec<PathBuf>,
661 agents_dir: PathBuf,
662 claude_dir: PathBuf,
663}
664
665impl Drop for InstalledAgents {
666 fn drop(&mut self) {
667 for f in &self.files {
668 let _ = fs::remove_file(f);
669 }
670 let _ = fs::remove_dir(&self.agents_dir);
671 let _ = fs::remove_dir(&self.claude_dir);
672 }
673}
674
675pub(crate) fn install_agents(agent_dir: &Path, project: &Path) -> Result<InstalledAgents, String> {
676 let claude_dir = project.join(".claude");
677 let agents_dir = claude_dir.join("agents");
678 fs::create_dir_all(&agents_dir).map_err(|e| format!("mkdir {}: {e}", agents_dir.display()))?;
679
680 let src_agents = agent_dir.join(".claude").join("agents");
681 let mut files = Vec::new();
682 for entry in
683 fs::read_dir(&src_agents).map_err(|e| format!("read {}: {e}", src_agents.display()))?
684 {
685 let entry = entry.map_err(|e| e.to_string())?;
686 let dst = agents_dir.join(entry.file_name());
687 fs::copy(entry.path(), &dst).map_err(|e| format!("copy agent: {e}"))?;
688 files.push(dst);
689 }
690
691 let settings_src = {
693 let split = agent_dir.join(".claude").join("settings.json");
694 if split.is_file() {
695 Some(split)
696 } else {
697 let flat = agent_dir.join("settings.json");
698 flat.is_file().then_some(flat)
699 }
700 };
701 if let Some(src) = settings_src {
702 let dst = claude_dir.join("settings.json");
703 fs::copy(&src, &dst).map_err(|e| format!("copy settings: {e}"))?;
704 files.push(dst);
705 }
706
707 Ok(InstalledAgents {
708 files,
709 agents_dir,
710 claude_dir,
711 })
712}
713
714pub(crate) fn agent_name(agent_dir: &Path) -> Result<String, String> {
716 let dir = agent_dir.join(".claude").join("agents");
717 let mut names: Vec<String> = fs::read_dir(&dir)
718 .map_err(|e| format!("read {}: {e}", dir.display()))?
719 .filter_map(|e| e.ok())
720 .filter_map(|e| e.file_name().to_str().map(str::to_string))
721 .filter_map(|f| f.strip_suffix(".md").map(str::to_string))
722 .collect();
723 names.sort();
724 names
725 .into_iter()
726 .next()
727 .ok_or_else(|| format!("no agent files in {}", dir.display()))
728}
729
730pub(crate) fn run_path(project: &Path) -> String {
732 let base = std::env::var("PATH").unwrap_or_default();
733 let venv = project.join(".venv").join("bin");
734 if venv.is_dir() {
735 format!("{}:{base}", venv.display())
736 } else {
737 base
738 }
739}
740
741fn run_case(
753 project: &Path,
754 agent: &str,
755 path_env: &str,
756 case: &GoldenCase,
757 sample: u32,
758 ctx: &CaseCtx,
759) -> SampleOutcome {
760 let key = CaseKey {
761 case_id: &case.id,
762 question: &case.question,
763 agent_sha: ctx.agent_sha,
764 model: ctx.model,
765 context_sha: ctx.context_sha,
766 sample,
767 };
768
769 if let Some(trace) = ctx.store.load(&key) {
772 if let Some(verdict) = rescore(&trace, case) {
773 return SampleOutcome {
774 pass: verdict.pass,
775 reason: verdict.reason,
776 cost: trace.cost,
777 latency_ms: trace.latency_ms,
778 turns: trace.turns,
779 cache_hit: true,
780 answer: ctx.record_answers.then(|| render_answer(&trace.result)),
781 };
782 }
783 }
784
785 let started = Instant::now();
786 let fail = |reason: &str, cost: f64, latency_ms: u64| SampleOutcome {
787 pass: false,
788 reason: reason.to_string(),
789 cost,
790 latency_ms,
791 turns: 0,
792 cache_hit: false,
793 answer: None,
794 };
795
796 let mut args: Vec<&str> = vec!["-p", &case.question, "--agent", agent];
797 if let Some(model) = ctx.model_override() {
798 args.extend_from_slice(&["--model", model]);
799 }
800 args.extend_from_slice(&[
801 "--output-format",
802 "json",
803 "--allowedTools",
804 "Read",
805 "Bash(wren:*)",
806 ]);
807 let output = Command::new("claude")
808 .args(&args)
809 .current_dir(project)
810 .env("PATH", path_env)
811 .output();
812
813 let raw = match output {
814 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
815 _ => {
816 return fail(
817 "claude invocation failed",
818 0.0,
819 started.elapsed().as_millis() as u64,
820 )
821 }
822 };
823
824 let meta: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
825 let result_text = meta.get("result").and_then(|v| v.as_str()).unwrap_or(&raw);
826 let latency_ms = meta
827 .get("duration_ms")
828 .and_then(|v| v.as_u64())
829 .unwrap_or_else(|| started.elapsed().as_millis() as u64);
830 let cost = meta
831 .get("total_cost_usd")
832 .and_then(|v| v.as_f64())
833 .unwrap_or(0.0);
834 let turns = meta.get("num_turns").and_then(|v| v.as_u64()).unwrap_or(0);
836
837 let extracted = match case.result_kind {
842 ResultKind::Table => extract_result_json(result_text),
843 ResultKind::Verdict => extract_verdict_json(result_text),
844 };
845 let Some(result_value) = extracted else {
846 let reason = match case.result_kind {
847 ResultKind::Table => "no parseable {columns,rows} in output",
848 ResultKind::Verdict => "no parseable verdict envelope in output",
849 };
850 return fail(reason, cost, latency_ms);
851 };
852 let Some(verdict) = score_value(&result_value, case) else {
853 let reason = match case.result_kind {
854 ResultKind::Table => "no parseable {columns,rows} in output",
855 ResultKind::Verdict => "verdict envelope missing the expected field",
856 };
857 return fail(reason, cost, latency_ms);
858 };
859
860 let trace = Trace {
862 case_id: case.id.clone(),
863 agent_sha: ctx.agent_sha.to_string(),
864 model: ctx.model.to_string(),
865 context_version: ctx.context_version.map(str::to_string),
866 context_sha: ctx.context_sha.to_string(),
867 question: case.question.clone(),
868 sql_executed: None,
869 result: result_value,
870 cost,
871 latency_ms,
872 turns,
873 tool_calls: None,
874 };
875 if let Err(e) = ctx.store.store(&key, &trace) {
876 eprintln!(
877 " note: could not cache trace for {} (sample {sample}): {e}",
878 case.id
879 );
880 }
881
882 SampleOutcome {
883 pass: verdict.pass,
884 reason: verdict.reason,
885 cost,
886 latency_ms,
887 turns,
888 cache_hit: false,
889 answer: ctx.record_answers.then(|| render_answer(&trace.result)),
890 }
891}
892
893pub fn run_eval(cfg: &RunConfig) -> Result<Report, String> {
897 let golden_text = fs::read_to_string(&cfg.golden_path)
898 .map_err(|e| format!("read {}: {e}", cfg.golden_path.display()))?;
899 let golden: Golden =
900 serde_yaml::from_str(&golden_text).map_err(|e| format!("parse golden: {e}"))?;
901
902 let total_cases = golden.cases.len();
904 let cases = select_and_subset(&golden, &cfg.filter)?;
905 let selected_cases = cases.len();
906
907 let agent = agent_name(&cfg.agent_dir)?;
908 let path_env = run_path(&cfg.project);
909
910 let (mut store, context_sha) =
914 build_store(cfg.no_cache, cfg.cache_dir.as_deref(), &cfg.project);
915 let agent_sha = if store.is_enabled() {
916 match context::compute_dir_sha(&cfg.agent_dir) {
917 Ok(s) => s,
918 Err(e) => {
919 eprintln!("cache: disabled — cannot hash agent dir: {e}");
920 store = TraceStore::disabled();
921 String::new()
922 }
923 }
924 } else {
925 String::new()
926 };
927
928 let _installed = install_agents(&cfg.agent_dir, &cfg.project)?;
929
930 let parallel = cfg.parallel.max(1);
931 let mut configs = Vec::new();
932 for model in &cfg.models {
933 let par = if parallel > 1 {
934 format!(", parallel={parallel}")
935 } else {
936 String::new()
937 };
938 eprintln!("\n### binding: strong→{model} (n={selected_cases}{par})");
939 let ctx = CaseCtx {
940 model,
941 agent_sha: &agent_sha,
942 context_sha: &context_sha,
943 context_version: golden.context_version.as_deref(),
944 store: &store,
945 record_answers: cfg.record_answers,
946 };
947 let rows = run_cases(
948 &cfg.project,
949 &agent,
950 &path_env,
951 &cases,
952 cfg.samples,
953 parallel,
954 &ctx,
955 );
956 configs.push(aggregate(model, rows));
957 }
958
959 Ok(Report {
960 dataset: golden.dataset,
961 context_version: golden.context_version,
962 parallel,
963 selected_cases,
964 total_cases,
965 configs,
966 })
967}
968
969pub(crate) fn build_store(
975 no_cache: bool,
976 cache_dir: Option<&Path>,
977 project: &Path,
978) -> (TraceStore, String) {
979 if no_cache {
980 if cache_dir.is_some() {
981 eprintln!("cache: --cache-dir ignored — --no-cache disables the cache entirely");
982 }
983 eprintln!("cache: disabled (--no-cache) — every case re-runs and refreshes its trace");
984 return (TraceStore::disabled(), String::new());
985 }
986 let context_sha = match compute_mdl_sha(project) {
987 Ok(s) => s,
988 Err(e) => {
989 eprintln!("cache: disabled — cannot compute MDL context SHA: {e}");
990 return (TraceStore::disabled(), String::new());
991 }
992 };
993 let dir = cache_dir
994 .map(Path::to_path_buf)
995 .unwrap_or_else(|| project.join(".warble").join("eval-cache"));
996 eprintln!(
997 "cache: enabled at {} (context_sha {})",
998 dir.display(),
999 &context_sha[..context_sha.len().min(12)]
1000 );
1001 (TraceStore::new(dir, true), context_sha)
1002}
1003
1004pub(crate) fn select_and_subset(
1008 golden: &Golden,
1009 filter: &CaseFilter,
1010) -> Result<Vec<GoldenCase>, String> {
1011 let selection = select_cases(&golden.cases, filter);
1012 if filter.is_active() {
1013 eprintln!("{}", selection.note);
1014 }
1015 if filter.is_active() && selection.indices.is_empty() {
1016 return Err(format!(
1017 "no golden cases match the filter — {} (nothing to run)",
1018 selection.note
1019 ));
1020 }
1021 Ok(selection
1022 .indices
1023 .iter()
1024 .map(|&i| golden.cases[i].clone())
1025 .collect())
1026}
1027
1028pub(crate) fn run_cases(
1039 project: &Path,
1040 agent: &str,
1041 path_env: &str,
1042 cases: &[GoldenCase],
1043 samples: usize,
1044 parallel: usize,
1045 ctx: &CaseCtx,
1046) -> Vec<CaseResult> {
1047 let samples = samples.max(1);
1048 let outcomes = run_indexed(cases.len() * samples, parallel, |j| {
1049 let case = &cases[j / samples];
1050 let sample = (j % samples) as u32;
1051 let r = run_case(project, agent, path_env, case, sample, ctx);
1052 let extra = if r.pass {
1053 String::new()
1054 } else {
1055 format!(" — {}", r.reason)
1056 };
1057 let cost = if r.cost > 0.0 {
1058 format!(", ${:.4}", r.cost)
1059 } else {
1060 String::new()
1061 };
1062 let cache = if r.cache_hit { " [cache]" } else { "" };
1064 if samples == 1 {
1065 eprintln!(
1066 " {} {}{cache} ({:.1}s{cost}, {}t){extra}",
1067 if r.pass { "PASS" } else { "FAIL" },
1068 case.id,
1069 r.latency_ms as f64 / 1000.0,
1070 r.turns
1071 );
1072 } else {
1073 eprintln!(
1074 " {} {} sample {sample}{cache} ({:.1}s{cost}, {}t){extra}",
1075 if r.pass { "PASS" } else { "FAIL" },
1076 case.id,
1077 r.latency_ms as f64 / 1000.0,
1078 r.turns
1079 );
1080 }
1081 r
1082 });
1083
1084 let mut it = outcomes.into_iter();
1088 cases
1089 .iter()
1090 .map(|case| {
1091 let chunk: Vec<SampleOutcome> = (&mut it).take(samples).collect();
1092 let result = fold_samples(
1093 case.id.clone(),
1094 case.tags.clone(),
1095 chunk,
1096 ctx.record_answers,
1097 );
1098 if samples > 1 {
1099 let marker = if result.flaky {
1100 "FLAKY"
1101 } else if result.pass {
1102 "PASS"
1103 } else {
1104 "FAIL"
1105 };
1106 eprintln!(
1107 " {marker} {} pass_rate={:.2} ({}/{})",
1108 result.id, result.pass_rate, result.passes, result.samples
1109 );
1110 }
1111 result
1112 })
1113 .collect()
1114}
1115
1116pub(crate) fn run_indexed<T, F>(n: usize, parallel: usize, job: F) -> Vec<T>
1121where
1122 T: Send,
1123 F: Fn(usize) -> T + Sync,
1124{
1125 if parallel <= 1 || n <= 1 {
1126 return (0..n).map(job).collect();
1127 }
1128
1129 use std::sync::atomic::{AtomicUsize, Ordering};
1130 use std::sync::Mutex;
1131
1132 let next = AtomicUsize::new(0);
1133 let slots: Vec<Mutex<Option<T>>> = (0..n).map(|_| Mutex::new(None)).collect();
1134
1135 std::thread::scope(|scope| {
1136 for _ in 0..parallel.min(n) {
1137 scope.spawn(|| loop {
1138 let i = next.fetch_add(1, Ordering::Relaxed);
1139 if i >= n {
1140 break;
1141 }
1142 let r = job(i);
1143 *slots[i].lock().expect("result slot poisoned") = Some(r);
1144 });
1145 }
1146 });
1147
1148 slots
1149 .into_iter()
1150 .map(|slot| {
1151 slot.into_inner()
1152 .expect("result slot poisoned")
1153 .expect("every index 0..n is claimed exactly once by the shared counter")
1154 })
1155 .collect()
1156}
1157
1158#[cfg(test)]
1159mod run_indexed_tests {
1160 use super::run_indexed;
1161 use std::sync::atomic::{AtomicUsize, Ordering};
1162 use std::time::Duration;
1163
1164 #[test]
1165 fn serial_path_preserves_order_and_runs_each_once() {
1166 let calls = AtomicUsize::new(0);
1167 let out = run_indexed(5, 1, |i| {
1168 calls.fetch_add(1, Ordering::Relaxed);
1169 i * 10
1170 });
1171 assert_eq!(out, vec![0, 10, 20, 30, 40]);
1172 assert_eq!(calls.load(Ordering::Relaxed), 5);
1173 }
1174
1175 #[test]
1176 fn parallel_results_are_in_index_order_despite_completion_order() {
1177 let out = run_indexed(8, 4, |i| {
1180 std::thread::sleep(Duration::from_millis((8 - i as u64) * 3));
1181 i
1182 });
1183 assert_eq!(out, (0..8).collect::<Vec<_>>());
1184 }
1185
1186 #[test]
1187 fn parallel_runs_each_index_exactly_once() {
1188 let per_index: Vec<AtomicUsize> = (0..32).map(|_| AtomicUsize::new(0)).collect();
1189 let out = run_indexed(32, 6, |i| {
1190 per_index[i].fetch_add(1, Ordering::Relaxed);
1191 i
1192 });
1193 assert_eq!(out.len(), 32);
1194 for (i, c) in per_index.iter().enumerate() {
1195 assert_eq!(c.load(Ordering::Relaxed), 1, "index {i} ran once");
1196 }
1197 }
1198
1199 #[test]
1200 fn parallelism_wider_than_n_and_empty_input_are_fine() {
1201 assert_eq!(run_indexed(2, 16, |i| i), vec![0, 1]);
1202 assert_eq!(run_indexed(0, 4, |i| i), Vec::<usize>::new());
1203 }
1204
1205 #[test]
1206 fn parallel_actually_overlaps_work() {
1207 let in_flight = AtomicUsize::new(0);
1214 let max_in_flight = AtomicUsize::new(0);
1215 run_indexed(4, 4, |_| {
1216 let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1217 max_in_flight.fetch_max(now, Ordering::SeqCst);
1218 std::thread::sleep(Duration::from_millis(40));
1219 in_flight.fetch_sub(1, Ordering::SeqCst);
1220 });
1221 let max = max_in_flight.load(Ordering::SeqCst);
1222 assert!(
1223 max > 1,
1224 "expected overlapping execution, but max concurrent was {max}"
1225 );
1226 }
1227}
1228
1229#[cfg(test)]
1230mod fold_samples_tests {
1231 use super::*;
1232
1233 fn outcome(pass: bool, reason: &str, cost: f64, latency_ms: u64, turns: u64) -> SampleOutcome {
1234 SampleOutcome {
1235 pass,
1236 reason: reason.to_string(),
1237 cost,
1238 latency_ms,
1239 turns,
1240 cache_hit: false,
1241 answer: None,
1242 }
1243 }
1244
1245 #[test]
1246 fn fully_passing_is_not_flaky_and_reason_is_match() {
1247 let outcomes = vec![
1248 outcome(true, "match", 0.1, 1_000, 2),
1249 outcome(true, "match", 0.1, 1_000, 2),
1250 ];
1251 let r = fold_samples("a".into(), vec![], outcomes, false);
1252 assert_eq!(r.samples, 2);
1253 assert_eq!(r.passes, 2);
1254 assert!((r.pass_rate - 1.0).abs() < 1e-9);
1255 assert!(r.pass);
1256 assert!(!r.flaky);
1257 assert_eq!(r.reason, "match");
1258 }
1259
1260 #[test]
1261 fn fully_failing_is_not_flaky() {
1262 let outcomes = vec![
1263 outcome(false, "mismatch", 0.0, 500, 1),
1264 outcome(false, "mismatch", 0.0, 500, 1),
1265 ];
1266 let r = fold_samples("a".into(), vec![], outcomes, false);
1267 assert_eq!(r.passes, 0);
1268 assert_eq!(r.pass_rate, 0.0);
1269 assert!(!r.pass);
1270 assert!(!r.flaky, "0 passes out of N is a hard fail, not flaky");
1271 }
1272
1273 #[test]
1274 fn partial_pass_is_flaky_and_reason_is_the_modal_failure() {
1275 let outcomes = vec![
1276 outcome(true, "match", 0.0, 0, 0),
1277 outcome(false, "wrong total", 0.0, 0, 0),
1278 outcome(false, "wrong total", 0.0, 0, 0),
1279 outcome(false, "timeout", 0.0, 0, 0),
1280 ];
1281 let r = fold_samples("a".into(), vec![], outcomes, false);
1282 assert_eq!(r.passes, 1);
1283 assert_eq!(r.samples, 4);
1284 assert!((r.pass_rate - 0.25).abs() < 1e-9);
1285 assert!(!r.pass);
1286 assert!(r.flaky);
1287 assert_eq!(r.reason, "wrong total", "modal failure reason wins");
1288 }
1289
1290 #[test]
1291 fn modal_reason_tie_break_is_first_occurrence() {
1292 let outcomes = vec![
1294 outcome(false, "a", 0.0, 0, 0),
1295 outcome(false, "b", 0.0, 0, 0),
1296 outcome(false, "b", 0.0, 0, 0),
1297 outcome(false, "a", 0.0, 0, 0),
1298 ];
1299 let r = fold_samples("a".into(), vec![], outcomes, false);
1300 assert_eq!(r.reason, "a");
1301 }
1302
1303 #[test]
1304 fn cost_sums_latency_and_turns_average() {
1305 let outcomes = vec![
1306 outcome(true, "match", 0.10, 1_000, 4),
1307 outcome(true, "match", 0.20, 3_000, 6),
1308 ];
1309 let r = fold_samples("a".into(), vec![], outcomes, false);
1310 assert!((r.cost - 0.30).abs() < 1e-9, "cost sums across samples");
1311 assert_eq!(r.latency_ms, 2_000, "latency averages across samples");
1312 assert_eq!(r.turns, 5, "turns averages across samples");
1313 }
1314
1315 #[test]
1316 fn cache_hits_and_misses_are_counted_per_sample() {
1317 let outcomes = vec![
1318 SampleOutcome {
1319 cache_hit: true,
1320 ..outcome(true, "match", 0.0, 0, 0)
1321 },
1322 SampleOutcome {
1323 cache_hit: false,
1324 ..outcome(true, "match", 0.0, 0, 0)
1325 },
1326 SampleOutcome {
1327 cache_hit: true,
1328 ..outcome(true, "match", 0.0, 0, 0)
1329 },
1330 ];
1331 let r = fold_samples("a".into(), vec![], outcomes, false);
1332 assert_eq!(r.cache_hits, 2);
1333 assert_eq!(r.cache_misses, 1);
1334 }
1335
1336 #[test]
1337 fn answer_dist_is_only_populated_when_record_answers_is_set() {
1338 let build = || {
1339 vec![
1340 SampleOutcome {
1341 answer: Some("42".to_string()),
1342 ..outcome(true, "match", 0.0, 0, 0)
1343 },
1344 SampleOutcome {
1345 answer: Some("42".to_string()),
1346 ..outcome(true, "match", 0.0, 0, 0)
1347 },
1348 SampleOutcome {
1349 answer: Some("7".to_string()),
1350 ..outcome(false, "wrong total", 0.0, 0, 0)
1351 },
1352 ]
1353 };
1354
1355 let without = fold_samples("a".into(), vec![], build(), false);
1356 assert!(without.answer_dist.is_none());
1357 assert!(
1358 without.samples_detail.iter().all(|s| s.answer.is_none()),
1359 "no answer strings recorded unless --record-answers"
1360 );
1361
1362 let with = fold_samples("a".into(), vec![], build(), true);
1363 let dist = with.answer_dist.expect("populated when record_answers");
1364 assert_eq!(dist.get("42"), Some(&2));
1365 assert_eq!(dist.get("7"), Some(&1));
1366 assert_eq!(
1367 with.samples_detail
1368 .iter()
1369 .filter(|s| s.answer.is_some())
1370 .count(),
1371 3
1372 );
1373 }
1374
1375 #[test]
1376 fn modal_reason_of_empty_iterator_is_empty_string() {
1377 assert_eq!(modal_reason(std::iter::empty()), "");
1378 }
1379}
1380
1381#[cfg(test)]
1382mod verdict_tests {
1383 use super::*;
1384
1385 fn envelope(fresh: bool, severity: &str) -> serde_json::Value {
1388 serde_json::json!({
1389 "blocks": [
1390 { "type": "status", "state": if fresh { "fresh" } else { "stale" },
1391 "label": "orders freshness", "detail": "…", "severity": severity }
1392 ],
1393 "verdict": { "type": "freshness_verdict", "fresh": fresh, "observed_lag_hours": 51,
1394 "expected_cadence": "24h" },
1395 "emitted": ["freshness_breach"],
1396 "verified": true,
1397 })
1398 }
1399
1400 fn fenced(value: &serde_json::Value) -> String {
1401 format!("Some prose.\n\n```json\n{value}\n```\n")
1402 }
1403
1404 #[test]
1405 fn extract_verdict_json_accepts_a_real_envelope() {
1406 let text = fenced(&envelope(false, "critical"));
1407 let got = extract_verdict_json(&text).expect("envelope parses");
1408 assert_eq!(got["verdict"]["fresh"], false);
1409 }
1410
1411 #[test]
1412 fn extract_verdict_json_rejects_a_columns_rows_table() {
1413 let text = fenced(&serde_json::json!({"columns": ["n"], "rows": [[42]]}));
1414 assert!(
1415 extract_verdict_json(&text).is_none(),
1416 "a table has neither `verdict` nor `blocks`"
1417 );
1418 }
1419
1420 #[test]
1421 fn extract_verdict_json_rejects_malformed_json() {
1422 assert!(extract_verdict_json("not json at all").is_none());
1423 assert!(extract_verdict_json("```json\n{ not valid\n```").is_none());
1424 }
1425
1426 #[test]
1427 fn extract_result_json_rejects_a_verdict_envelope() {
1428 let text = fenced(&envelope(true, "critical"));
1431 assert!(extract_result_json(&text).is_none());
1432 }
1433
1434 #[test]
1435 fn project_verdict_field_reads_fresh_true_and_false() {
1436 let fresh_table = project_verdict_field(&envelope(true, "critical"), "fresh").unwrap();
1437 assert_eq!(fresh_table.rows, vec![vec![Value::Bool(true)]]);
1438
1439 let stale_table = project_verdict_field(&envelope(false, "critical"), "fresh").unwrap();
1440 assert_eq!(stale_table.rows, vec![vec![Value::Bool(false)]]);
1441 }
1442
1443 #[test]
1444 fn project_verdict_field_reads_severity_warn_and_critical() {
1445 let warn = project_verdict_field(&envelope(false, "warn"), "severity").unwrap();
1446 assert_eq!(warn.rows, vec![vec![Value::String("warn".to_string())]]);
1447
1448 let critical = project_verdict_field(&envelope(false, "critical"), "severity").unwrap();
1449 assert_eq!(
1450 critical.rows,
1451 vec![vec![Value::String("critical".to_string())]]
1452 );
1453 }
1454
1455 #[test]
1456 fn project_verdict_field_falls_back_to_verdict_severity() {
1457 let envelope = serde_json::json!({
1459 "blocks": [{ "type": "status", "state": "stale" }],
1460 "verdict": { "type": "freshness_verdict", "fresh": false, "severity": "warn" },
1461 });
1462 let got = project_verdict_field(&envelope, "severity").unwrap();
1463 assert_eq!(got.rows, vec![vec![Value::String("warn".to_string())]]);
1464 }
1465
1466 #[test]
1467 fn project_verdict_field_is_none_on_missing_or_unknown_field() {
1468 let e = envelope(true, "critical");
1469 assert!(
1470 project_verdict_field(&e, "observed_lag_hours").is_none(),
1471 "unrecognized field fails closed, never silently passes"
1472 );
1473 let no_verdict = serde_json::json!({"blocks": []});
1474 assert!(project_verdict_field(&no_verdict, "fresh").is_none());
1475 let no_severity_anywhere = serde_json::json!({
1476 "blocks": [{"type": "status", "state": "stale"}],
1477 "verdict": {"type": "freshness_verdict", "fresh": false},
1478 });
1479 assert!(project_verdict_field(&no_severity_anywhere, "severity").is_none());
1480 }
1481
1482 fn verdict_case(tag: &str, field: &str, expected_rows: &str) -> GoldenCase {
1483 let yaml = format!(
1484 "id: v1\nquestion: \"is it fresh?\"\ntags: [{tag}]\nmatch: scalar\n\
1485 result_kind: verdict\nverdict_field: {field}\n\
1486 expected: {{ columns: [{field}], rows: {expected_rows} }}\n"
1487 );
1488 serde_yaml::from_str(&yaml).expect("verdict golden case parses")
1489 }
1490
1491 #[test]
1492 fn golden_case_result_kind_defaults_to_table_when_omitted() {
1493 let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[1]] }\n";
1494 let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
1495 assert_eq!(case.result_kind, ResultKind::Table);
1496 assert!(case.verdict_field.is_none());
1497 }
1498
1499 #[test]
1500 fn score_value_passes_a_matching_verdict_envelope() {
1501 let case = verdict_case("detection", "fresh", "[[false]]");
1502 let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
1503 assert!(
1504 verdict.pass,
1505 "envelope fresh=false matches expected fresh=false"
1506 );
1507 }
1508
1509 #[test]
1510 fn score_value_fails_a_mismatching_verdict_envelope() {
1511 let case = verdict_case("detection", "fresh", "[[true]]");
1512 let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
1513 assert!(
1514 !verdict.pass,
1515 "envelope fresh=false does not match expected fresh=true"
1516 );
1517 }
1518
1519 #[test]
1520 fn score_value_projects_severity_for_the_severity_field() {
1521 let case = verdict_case("severity", "severity", "[[critical]]");
1522 let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
1523 assert!(verdict.pass);
1524 }
1525
1526 #[test]
1527 fn score_value_on_a_table_case_is_unaffected_by_verdict_support() {
1528 let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[42]] }\n";
1529 let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
1530 let result = serde_json::json!({"columns": ["n"], "rows": [[42]]});
1531 let verdict = score_value(&result, &case).expect("table case scores");
1532 assert!(verdict.pass);
1533 }
1534}