1use serde::Serialize;
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::time::Instant;
26use warble_claude_code::ModelConfig;
27use warble_eval_compare::{compare, CompareRequest, CompareResult, MatchMode, Table, Tolerance};
28
29mod ablation;
30mod backend;
31mod cache;
32mod capture;
33mod compliance;
34mod context;
35mod filter;
36mod gate;
37mod monitor;
38
39pub use ablation::{
40 format_ablation, run_ablation, AblationConfig, AblationPoint, AblationReport,
41 StepRecommendation,
42};
43pub use backend::Backend;
44use backend::{resolve_adapter, BackendAdapter, ModelOverride};
45pub use cache::{rescore, CaseKey, Trace, TraceStore};
46pub use capture::{build_candidate_yaml, candidates_header, CaptureInput};
47pub use compliance::{
48 format_compliance, score_compliance, Check, CheckStatus, ComplianceIr, ComplianceReport,
49 ComplianceTrace, TraceEvent,
50};
51pub use context::{
52 classify, compute_mdl_sha, parse_context_version, stamp_context_version, verify_context,
53 Freshness, VerifyResult,
54};
55pub use filter::{select_cases, CaseFilter, Sample, Selection};
56pub use gate::{format_gate, run_gate, FlakyCase, GateResult, Regression};
57pub use monitor::{
58 format_monitor_report, score_monitor_pair, MonitorMetric, MonitorObservation, MonitorReport,
59};
60
61#[derive(Debug, serde::Deserialize)]
63pub struct Golden {
64 #[serde(default)]
65 pub dataset: Option<String>,
66 #[serde(default)]
67 pub context_version: Option<String>,
68 pub cases: Vec<GoldenCase>,
69}
70
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum ResultKind {
80 #[default]
81 Table,
82 Verdict,
83}
84
85#[derive(Debug, Clone, serde::Deserialize)]
86pub struct GoldenCase {
87 pub id: String,
88 pub question: String,
89 #[serde(default)]
90 pub tags: Vec<String>,
91 #[serde(rename = "match")]
92 pub match_mode: MatchMode,
93 #[serde(default)]
94 pub tolerance: Tolerance,
95 pub expected: Table,
96 #[serde(default)]
98 pub result_kind: ResultKind,
99 #[serde(default)]
103 pub verdict_field: Option<String>,
104}
105
106struct SampleOutcome {
110 pass: bool,
111 reason: String,
112 cost: Option<f64>,
115 latency_ms: u64,
116 turns: Option<u64>,
118 cache_hit: bool,
119 answer: Option<String>,
123}
124
125#[derive(Debug, Clone, Serialize, serde::Deserialize)]
128pub struct SampleVerdict {
129 pub pass: bool,
130 pub reason: String,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub answer: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize, serde::Deserialize)]
139pub struct CaseResult {
140 pub id: String,
141 pub tags: Vec<String>,
142 #[serde(default)]
146 pub samples: u32,
147 #[serde(default)]
149 pub passes: u32,
150 #[serde(default)]
153 pub pass_rate: f64,
154 pub pass: bool,
157 #[serde(default)]
159 pub flaky: bool,
160 pub reason: String,
163 #[serde(default)]
167 pub cost: Option<f64>,
168 pub latency_ms: u64,
170 #[serde(default)]
173 pub turns: Option<u64>,
174 #[serde(default)]
176 pub cache_hits: u32,
177 #[serde(default)]
178 pub cache_misses: u32,
179 #[serde(default)]
181 pub samples_detail: Vec<SampleVerdict>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub answer_dist: Option<BTreeMap<String, u32>>,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum OutputStability {
195 NotMeasured,
197 Stable,
199 Unstable,
208 Incomplete,
212}
213
214impl CaseResult {
215 pub fn output_stability(&self) -> OutputStability {
219 let Some(dist) = &self.answer_dist else {
220 return OutputStability::NotMeasured;
221 };
222 if self.samples <= 1 {
223 return OutputStability::NotMeasured;
224 }
225 let observed: u32 = dist.values().sum();
226 if observed < self.samples {
227 OutputStability::Incomplete
228 } else if dist.len() > 1 {
229 OutputStability::Unstable
230 } else {
231 OutputStability::Stable
232 }
233 }
234}
235
236fn fold_samples(
240 id: String,
241 tags: Vec<String>,
242 outcomes: Vec<SampleOutcome>,
243 record_answers: bool,
244) -> CaseResult {
245 let samples = outcomes.len() as u32;
246 let passes = outcomes.iter().filter(|o| o.pass).count() as u32;
247 let pass_rate = if samples > 0 {
248 passes as f64 / samples as f64
249 } else {
250 0.0
251 };
252 let pass = samples > 0 && passes == samples;
253 let flaky = passes > 0 && passes < samples;
254 let reason = if passes == samples {
255 "match".to_string()
256 } else {
257 modal_reason(
258 outcomes
259 .iter()
260 .filter(|o| !o.pass)
261 .map(|o| o.reason.as_str()),
262 )
263 };
264 let cost: Option<f64> = outcomes
267 .iter()
268 .try_fold(0.0_f64, |acc, o| o.cost.map(|c| acc + c));
269 let latency_ms = if samples > 0 {
270 outcomes.iter().map(|o| o.latency_ms).sum::<u64>() / samples as u64
271 } else {
272 0
273 };
274 let turns: Option<u64> = if samples > 0 {
275 outcomes
276 .iter()
277 .try_fold(0_u64, |acc, o| o.turns.map(|t| acc + t))
278 .map(|sum| sum / samples as u64)
279 } else {
280 Some(0)
281 };
282 let cache_hits = outcomes.iter().filter(|o| o.cache_hit).count() as u32;
283 let cache_misses = samples - cache_hits;
284
285 let answer_dist = record_answers.then(|| {
286 let mut dist: BTreeMap<String, u32> = BTreeMap::new();
287 for o in &outcomes {
288 if let Some(answer) = &o.answer {
289 *dist.entry(answer.clone()).or_insert(0) += 1;
290 }
291 }
292 dist
293 });
294 let samples_detail = outcomes
295 .into_iter()
296 .map(|o| SampleVerdict {
297 pass: o.pass,
298 reason: o.reason,
299 answer: if record_answers { o.answer } else { None },
300 })
301 .collect();
302
303 CaseResult {
304 id,
305 tags,
306 samples,
307 passes,
308 pass_rate,
309 pass,
310 flaky,
311 reason,
312 cost,
313 latency_ms,
314 turns,
315 cache_hits,
316 cache_misses,
317 samples_detail,
318 answer_dist,
319 }
320}
321
322fn modal_reason<'a>(reasons: impl Iterator<Item = &'a str>) -> String {
325 let mut counts: Vec<(&str, u32)> = Vec::new();
326 for r in reasons {
327 match counts.iter_mut().find(|(seen, _)| *seen == r) {
328 Some(entry) => entry.1 += 1,
329 None => counts.push((r, 1)),
330 }
331 }
332 let mut best: Option<(&str, u32)> = None;
333 for (r, c) in counts {
334 if best.is_none_or(|(_, bc)| c > bc) {
335 best = Some((r, c));
336 }
337 }
338 best.map(|(r, _)| r.to_string()).unwrap_or_default()
339}
340
341fn render_answer(result: &serde_json::Value) -> String {
353 result.to_string()
354}
355
356fn recorded_failure_answer(
360 record_answers: bool,
361 extracted: Option<&serde_json::Value>,
362 raw: &str,
363) -> Option<String> {
364 record_answers.then(|| {
365 extracted
366 .map(render_answer)
367 .unwrap_or_else(|| raw.to_string())
368 })
369}
370
371#[derive(Debug, Clone, Serialize, serde::Deserialize)]
372pub struct TagStat {
373 pub pass: u32,
374 pub n: u32,
375 #[serde(default)]
379 pub pass_rate_sum: f64,
380}
381
382#[derive(Debug, Clone, Serialize, serde::Deserialize)]
383pub struct ConfigReport {
384 pub model: String,
385 pub n: usize,
386 pub accuracy: f64,
389 #[serde(default)]
392 pub cost_total_usd: Option<f64>,
393 pub latency_ms_avg: u64,
394 #[serde(default)]
398 pub turns_avg: Option<u64>,
399 #[serde(default)]
402 pub cache_hits: usize,
403 #[serde(default)]
404 pub cache_misses: usize,
405 #[serde(default)]
408 pub flaky_cases: u32,
409 #[serde(default)]
416 pub output_unstable_cases: u32,
417 pub by_tag: BTreeMap<String, TagStat>,
418 pub cases: Vec<CaseResult>,
419}
420
421#[derive(Debug, Clone, Serialize, serde::Deserialize)]
424pub struct Report {
425 pub dataset: Option<String>,
426 pub context_version: Option<String>,
427 #[serde(default)]
430 pub context_injection: Option<ContextInjectionReport>,
431 #[serde(default = "default_parallel")]
434 pub parallel: usize,
435 #[serde(default)]
440 pub selected_cases: usize,
441 #[serde(default)]
442 pub total_cases: usize,
443 pub configs: Vec<ConfigReport>,
444 #[serde(default)]
448 pub backend: Backend,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
456#[serde(deny_unknown_fields)]
457pub struct ContextInjectionReport {
458 pub mode: String,
459 pub schema_digest_fingerprint: String,
460 pub knowledge_fingerprint: Option<String>,
461 pub knowledge_chars: usize,
462}
463
464impl ContextInjectionReport {
465 fn validate(self) -> Result<Self, &'static str> {
466 if !valid_context_fingerprint(&self.schema_digest_fingerprint) {
467 return Err("invalid schema digest fingerprint");
468 }
469 match self.mode.as_str() {
470 "schema-only" if self.knowledge_fingerprint.is_none() && self.knowledge_chars == 0 => {}
471 "schema+knowledge"
472 if self
473 .knowledge_fingerprint
474 .as_deref()
475 .is_some_and(valid_context_fingerprint) => {}
476 "schema-only" | "schema+knowledge" => return Err("inconsistent knowledge identity"),
477 _ => return Err("invalid context injection mode"),
478 }
479 Ok(self)
480 }
481}
482
483fn valid_context_fingerprint(value: &str) -> bool {
484 value
485 .strip_prefix("fnv1a64:")
486 .is_some_and(|hex| hex.len() == 16 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
487}
488
489fn default_parallel() -> usize {
490 1
491}
492
493pub fn effective_record_answers(explicit: bool, samples: usize) -> bool {
505 explicit || samples > 1
506}
507
508pub struct RunConfig {
509 pub project: PathBuf,
510 pub agent_dir: Option<PathBuf>,
515 pub ir_path: Option<PathBuf>,
520 pub golden_path: PathBuf,
521 pub models: Vec<String>,
522 pub out: Option<PathBuf>,
523 pub parallel: usize,
526 pub filter: CaseFilter,
528 pub no_cache: bool,
531 pub cache_dir: Option<PathBuf>,
533 pub samples: usize,
537 pub record_answers: bool,
541 pub backend: Backend,
545 pub max_turns: Option<u32>,
552 pub tier_models: Option<ModelConfig>,
559}
560
561pub(crate) struct CaseCtx<'a> {
567 pub model: &'a str,
568 pub agent_sha: &'a str,
569 pub context_sha: &'a str,
570 pub context_version: Option<&'a str>,
571 pub store: &'a TraceStore,
572 pub record_answers: bool,
575 pub backend: Backend,
578 pub adapter: &'a dyn BackendAdapter,
582 pub max_turns: Option<u32>,
586 pub tier_binding: Option<TierBindingCtx<'a>>,
591}
592
593#[derive(Debug, Clone, Copy)]
599pub(crate) struct TierBindingCtx<'a> {
600 pub strong: &'a str,
601 pub cheap: &'a str,
602 pub orchestrator: &'a str,
603 pub label: &'a str,
604}
605
606impl CaseCtx<'_> {
607 fn model_override(&self) -> Option<ModelOverride<'_>> {
612 if let Some(tb) = &self.tier_binding {
613 return Some(ModelOverride::Tiered {
614 strong: tb.strong,
615 cheap: tb.cheap,
616 orchestrator: tb.orchestrator,
617 });
618 }
619 (self.model != cache::FRONTMATTER_MODEL).then_some(ModelOverride::Flat(self.model))
620 }
621}
622
623pub fn extract_result(text: &str) -> Option<Table> {
628 serde_json::from_value(extract_result_json(text)?).ok()
629}
630
631pub fn extract_result_json(text: &str) -> Option<serde_json::Value> {
634 let candidate = strip_fence(text);
635 let start = candidate.find('{')?;
636 let end = candidate.rfind('}')?;
637 if end <= start {
638 return None;
639 }
640 let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
641 if !value.get("rows").map(|r| r.is_array()).unwrap_or(false) {
643 return None;
644 }
645 Some(value)
646}
647
648pub fn extract_verdict_json(text: &str) -> Option<serde_json::Value> {
654 let candidate = strip_fence(text);
655 let start = candidate.find('{')?;
656 let end = candidate.rfind('}')?;
657 if end <= start {
658 return None;
659 }
660 let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
661 let has_verdict = value.get("verdict").is_some();
662 let has_blocks = value.get("blocks").map(Value::is_array).unwrap_or(false);
663 if !has_verdict && !has_blocks {
664 return None;
665 }
666 Some(value)
667}
668
669pub fn project_verdict_field(envelope: &serde_json::Value, field: &str) -> Option<Table> {
675 match field {
676 "fresh" => {
677 let fresh = envelope.get("verdict")?.get("fresh")?.as_bool()?;
678 Some(Table {
679 columns: vec!["fresh".to_string()],
680 rows: vec![vec![Value::Bool(fresh)]],
681 })
682 }
683 "severity" => {
684 let status_severity =
685 envelope
686 .get("blocks")
687 .and_then(Value::as_array)
688 .and_then(|blocks| {
689 blocks
690 .iter()
691 .find(|b| b.get("type").and_then(Value::as_str) == Some("status"))
692 .and_then(|b| b.get("severity"))
693 .and_then(Value::as_str)
694 });
695 let severity = status_severity.or_else(|| {
696 envelope
697 .get("verdict")
698 .and_then(|v| v.get("severity"))
699 .and_then(Value::as_str)
700 })?;
701 Some(Table {
702 columns: vec!["severity".to_string()],
703 rows: vec![vec![Value::String(severity.to_string())]],
704 })
705 }
706 _ => None,
707 }
708}
709
710pub fn score_value(result_value: &Value, case: &GoldenCase) -> Option<CompareResult> {
716 let actual = match case.result_kind {
717 ResultKind::Table => serde_json::from_value(result_value.clone()).ok()?,
718 ResultKind::Verdict => {
719 let field = case.verdict_field.as_deref().unwrap_or("fresh");
720 project_verdict_field(result_value, field)?
721 }
722 };
723 Some(compare(&CompareRequest {
724 match_mode: case.match_mode,
725 tolerance: case.tolerance,
726 expected: case.expected.clone(),
727 actual,
728 }))
729}
730
731fn strip_fence(text: &str) -> &str {
733 let Some(open) = text.find("```") else {
734 return text;
735 };
736 let after = &text[open + 3..];
737 let content_start = match after.find('\n') {
739 Some(i) => open + 3 + i + 1,
740 None => return text,
741 };
742 match text[content_start..].find("```") {
743 Some(close_rel) => &text[content_start..content_start + close_rel],
744 None => text,
745 }
746}
747
748pub fn aggregate(model: &str, rows: Vec<CaseResult>) -> ConfigReport {
750 let n = rows.len();
751 let pass_rate_sum_total: f64 = rows.iter().map(|r| r.pass_rate).sum();
754 let accuracy = if n > 0 {
755 pass_rate_sum_total / n as f64
756 } else {
757 0.0
758 };
759 let flaky_cases = rows.iter().filter(|r| r.flaky).count() as u32;
760 let output_unstable_cases = rows
761 .iter()
762 .filter(|r| {
763 matches!(
764 r.output_stability(),
765 OutputStability::Unstable | OutputStability::Incomplete
766 )
767 })
768 .count() as u32;
769 let cost_total_usd: Option<f64> = rows
772 .iter()
773 .try_fold(0.0_f64, |acc, r| r.cost.map(|c| acc + c));
774 let latency_sum: u64 = rows.iter().map(|r| r.latency_ms).sum();
775 let latency_ms_avg = if n > 0 { latency_sum / n as u64 } else { 0 };
776 let turns_avg: Option<u64> = if n > 0 {
777 rows.iter()
778 .try_fold(0_u64, |acc, r| r.turns.map(|t| acc + t))
779 .map(|sum| sum / n as u64)
780 } else {
781 Some(0)
782 };
783 let cache_hits = rows.iter().map(|r| r.cache_hits as usize).sum();
784 let cache_misses = rows.iter().map(|r| r.cache_misses as usize).sum();
785
786 let mut by_tag: BTreeMap<String, TagStat> = BTreeMap::new();
787 for r in &rows {
788 for t in &r.tags {
789 let e = by_tag.entry(t.clone()).or_insert(TagStat {
790 pass: 0,
791 n: 0,
792 pass_rate_sum: 0.0,
793 });
794 e.n += 1;
795 if r.pass {
796 e.pass += 1;
797 }
798 e.pass_rate_sum += r.pass_rate;
799 }
800 }
801
802 ConfigReport {
803 model: model.to_string(),
804 n,
805 accuracy,
806 cost_total_usd,
807 latency_ms_avg,
808 turns_avg,
809 cache_hits,
810 cache_misses,
811 flaky_cases,
812 output_unstable_cases,
813 by_tag,
814 cases: rows,
815 }
816}
817
818fn binding_label(model: &str) -> String {
829 if model.contains('=') {
830 model.to_string()
831 } else {
832 format!("strong→{model}")
833 }
834}
835
836pub fn format_pareto(report: &Report) -> String {
838 let mut out = String::new();
839 out.push_str("\n=== Warble eval — Pareto (accuracy vs cost vs latency) ===\n");
840 if report.selected_cases < report.total_cases {
841 out.push_str(&format!(
842 " (subset: {}/{} golden cases — smoke run, not a full scoring)\n",
843 report.selected_cases, report.total_cases
844 ));
845 }
846 out.push_str(&format!(
847 "{:<16} {:<7} {:<10} {:<12} {:<7} by_tag\n",
848 "binding", "acc", "cost($)", "lat(ms)", "turns"
849 ));
850 for c in &report.configs {
851 let tags = c
852 .by_tag
853 .iter()
854 .map(|(t, v)| format!("{t}:{:.2}", v.pass_rate_sum / v.n.max(1) as f64))
855 .collect::<Vec<_>>()
856 .join(" ");
857 let cost = match c.cost_total_usd {
860 Some(cost) => format!("{cost:.4}"),
861 None => "n/a".to_string(),
862 };
863 let turns = c
864 .turns_avg
865 .map(|t| t.to_string())
866 .unwrap_or_else(|| "n/a".to_string());
867 out.push_str(&format!(
868 "{:<16} {:<7} {:<10} {:<12} {:<7} {tags}\n",
869 binding_label(&c.model),
870 format!("{:.2}", c.accuracy),
871 cost,
872 c.latency_ms_avg,
873 turns
874 ));
875 }
876 let max_samples = report
880 .configs
881 .iter()
882 .flat_map(|c| &c.cases)
883 .map(|case| case.samples)
884 .max()
885 .unwrap_or(0);
886 if !report.configs.is_empty() && max_samples <= 1 {
887 out.push_str(
888 "reproducibility: not measured (samples=1 — pass --samples N>1 to repeat each case \
889and check whether its verdict/answer holds).\n",
890 );
891 }
892 if report.configs.iter().any(|c| c.cache_hits > 0) {
895 out.push_str("cache (miss = LLM call this run):\n");
896 for c in &report.configs {
897 let note = if c.cache_misses == 0 {
898 "re-score only, 0 LLM calls this run".to_string()
899 } else {
900 format!("{} LLM calls this run", c.cache_misses)
901 };
902 out.push_str(&format!(
903 " {:<10} {} hit / {} miss — {note}\n",
904 binding_label(&c.model),
905 c.cache_hits,
906 c.cache_misses
907 ));
908 if c.cache_misses == 0 && c.cases.iter().any(|case| case.samples > 1) {
912 out.push_str(
913 " note: every sample replayed its own cached trace — identical output \
914here is replay, not evidence of determinism.\n",
915 );
916 }
917 }
918 }
919 if report
928 .configs
929 .iter()
930 .any(|c| c.flaky_cases > 0 || c.output_unstable_cases > 0)
931 {
932 out.push_str(
933 "instability across repeated samples (flaky = exact verdict flip; output-unstable = \
934textual answer divergence — an upper bound, see OutputStability):\n",
935 );
936 for c in &report.configs {
937 for case in &c.cases {
938 let stability = case.output_stability();
939 let output_unstable = matches!(
940 stability,
941 OutputStability::Unstable | OutputStability::Incomplete
942 );
943 if !case.flaky && !output_unstable {
944 continue;
945 }
946 let label = match (case.flaky, stability) {
947 (true, OutputStability::Incomplete) => "flaky, incomplete answers",
948 (true, _) => "flaky",
949 (false, OutputStability::Unstable) => "output-unstable, verdict stable",
950 (false, OutputStability::Incomplete) => "incomplete answers, verdict stable",
951 (false, _) => unreachable!("filtered above: neither flaky nor output-unstable"),
952 };
953 out.push_str(&format!(
954 " {:<10} {} [{label}] pass_rate={:.2} ({}/{})",
955 binding_label(&c.model),
956 case.id,
957 case.pass_rate,
958 case.passes,
959 case.samples
960 ));
961 if let Some(dist) = &case.answer_dist {
962 let mut answers: Vec<(&String, &u32)> = dist.iter().collect();
963 answers.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
964 let top = answers
965 .into_iter()
966 .take(3)
967 .map(|(a, n)| format!("{a}×{n}"))
968 .collect::<Vec<_>>()
969 .join(", ");
970 out.push_str(&format!(" — {top}"));
971 }
972 out.push('\n');
973 }
974 }
975 }
976 out
977}
978
979struct InstalledAgents {
984 files: Vec<PathBuf>,
985 agents_dir: PathBuf,
986 claude_dir: PathBuf,
987}
988
989impl Drop for InstalledAgents {
990 fn drop(&mut self) {
991 for f in &self.files {
992 let _ = fs::remove_file(f);
993 }
994 let _ = fs::remove_dir(&self.agents_dir);
995 let _ = fs::remove_dir(&self.claude_dir);
996 }
997}
998
999pub(crate) fn install_agents(agent_dir: &Path, project: &Path) -> Result<InstalledAgents, String> {
1000 let claude_dir = project.join(".claude");
1001 let agents_dir = claude_dir.join("agents");
1002 fs::create_dir_all(&agents_dir).map_err(|e| format!("mkdir {}: {e}", agents_dir.display()))?;
1003
1004 let src_agents = agent_dir.join(".claude").join("agents");
1005 let mut files = Vec::new();
1006 for entry in
1007 fs::read_dir(&src_agents).map_err(|e| format!("read {}: {e}", src_agents.display()))?
1008 {
1009 let entry = entry.map_err(|e| e.to_string())?;
1010 let dst = agents_dir.join(entry.file_name());
1011 fs::copy(entry.path(), &dst).map_err(|e| format!("copy agent: {e}"))?;
1012 files.push(dst);
1013 }
1014
1015 let settings_src = {
1017 let split = agent_dir.join(".claude").join("settings.json");
1018 if split.is_file() {
1019 Some(split)
1020 } else {
1021 let flat = agent_dir.join("settings.json");
1022 flat.is_file().then_some(flat)
1023 }
1024 };
1025 if let Some(src) = settings_src {
1026 let dst = claude_dir.join("settings.json");
1027 fs::copy(&src, &dst).map_err(|e| format!("copy settings: {e}"))?;
1028 files.push(dst);
1029 }
1030
1031 Ok(InstalledAgents {
1032 files,
1033 agents_dir,
1034 claude_dir,
1035 })
1036}
1037
1038pub(crate) fn agent_name(agent_dir: &Path) -> Result<String, String> {
1040 let dir = agent_dir.join(".claude").join("agents");
1041 let mut names: Vec<String> = fs::read_dir(&dir)
1042 .map_err(|e| format!("read {}: {e}", dir.display()))?
1043 .filter_map(|e| e.ok())
1044 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1045 .filter_map(|f| f.strip_suffix(".md").map(str::to_string))
1046 .collect();
1047 names.sort();
1048 names
1049 .into_iter()
1050 .next()
1051 .ok_or_else(|| format!("no agent files in {}", dir.display()))
1052}
1053
1054pub(crate) fn run_path(project: &Path) -> String {
1056 let base = std::env::var("PATH").unwrap_or_default();
1057 let venv = project.join(".venv").join("bin");
1058 if venv.is_dir() {
1059 format!("{}:{base}", venv.display())
1060 } else {
1061 base
1062 }
1063}
1064
1065fn run_case(
1077 project: &Path,
1078 agent: &str,
1079 path_env: &str,
1080 case: &GoldenCase,
1081 sample: u32,
1082 ctx: &CaseCtx,
1083) -> SampleOutcome {
1084 let key = CaseKey {
1085 case_id: &case.id,
1086 question: &case.question,
1087 agent_sha: ctx.agent_sha,
1088 model: ctx.model,
1089 context_sha: ctx.context_sha,
1090 sample,
1091 backend: ctx.backend,
1092 max_turns: ctx.max_turns,
1093 tier_binding: ctx.tier_binding.as_ref().map(|tb| tb.label),
1094 };
1095
1096 if let Some(trace) = ctx.store.load(&key) {
1099 if let Some(verdict) = rescore(&trace, case) {
1100 return SampleOutcome {
1101 pass: verdict.pass,
1102 reason: verdict.reason,
1103 cost: trace.cost,
1104 latency_ms: trace.latency_ms,
1105 turns: trace.turns,
1106 cache_hit: true,
1107 answer: ctx.record_answers.then(|| render_answer(&trace.result)),
1108 };
1109 }
1110 }
1111
1112 let started = Instant::now();
1113 let fail = |reason: &str, cost: Option<f64>, latency_ms: u64| SampleOutcome {
1114 pass: false,
1115 reason: reason.to_string(),
1116 cost,
1117 latency_ms,
1118 turns: None,
1119 cache_hit: false,
1120 answer: None,
1121 };
1122
1123 let invocation = ctx.adapter.invoke(
1130 project,
1131 agent,
1132 path_env,
1133 &case.question,
1134 ctx.model_override(),
1135 ctx.max_turns,
1136 );
1137
1138 if !invocation.ok {
1139 const MAX_REASON_CHARS: usize = 200;
1148 let detail = invocation
1149 .raw
1150 .lines()
1151 .map(str::trim)
1152 .find(|line| !line.is_empty());
1153 let reason = match detail {
1154 Some(line) if line.chars().count() > MAX_REASON_CHARS => {
1155 let clipped: String = line.chars().take(MAX_REASON_CHARS).collect();
1156 format!("backend invocation failed: {clipped}…")
1157 }
1158 Some(line) => format!("backend invocation failed: {line}"),
1159 None => "backend invocation failed".to_string(),
1160 };
1161 return fail(&reason, None, started.elapsed().as_millis() as u64);
1162 }
1163 let raw = invocation.raw;
1164 let result_text = raw.as_str();
1165 let latency_ms = invocation
1169 .latency_ms
1170 .unwrap_or_else(|| started.elapsed().as_millis() as u64);
1171 let cost = invocation.cost;
1172 let turns = invocation.turns;
1175
1176 let extracted = match case.result_kind {
1181 ResultKind::Table => extract_result_json(result_text),
1182 ResultKind::Verdict => extract_verdict_json(result_text),
1183 };
1184 let Some(result_value) = extracted else {
1185 let reason = match case.result_kind {
1186 ResultKind::Table => "no parseable {columns,rows} in output",
1187 ResultKind::Verdict => "no parseable verdict envelope in output",
1188 };
1189 return SampleOutcome {
1190 pass: false,
1191 reason: reason.to_string(),
1192 cost,
1193 latency_ms,
1194 turns,
1195 cache_hit: false,
1196 answer: recorded_failure_answer(ctx.record_answers, None, result_text),
1200 };
1201 };
1202 let Some(verdict) = score_value(&result_value, case) else {
1203 let reason = match case.result_kind {
1204 ResultKind::Table => "no parseable {columns,rows} in output",
1205 ResultKind::Verdict => "verdict envelope missing the expected field",
1206 };
1207 return SampleOutcome {
1208 pass: false,
1209 reason: reason.to_string(),
1210 cost,
1211 latency_ms,
1212 turns,
1213 cache_hit: false,
1214 answer: recorded_failure_answer(ctx.record_answers, Some(&result_value), result_text),
1217 };
1218 };
1219
1220 let trace = Trace {
1222 case_id: case.id.clone(),
1223 agent_sha: ctx.agent_sha.to_string(),
1224 model: ctx.model.to_string(),
1225 context_version: ctx.context_version.map(str::to_string),
1226 context_sha: ctx.context_sha.to_string(),
1227 question: case.question.clone(),
1228 sql_executed: None,
1229 result: result_value,
1230 cost,
1231 latency_ms,
1232 turns,
1233 tool_calls: None,
1234 backend: ctx.backend,
1235 };
1236 if let Err(e) = ctx.store.store(&key, &trace) {
1237 eprintln!(
1238 " note: could not cache trace for {} (sample {sample}): {e}",
1239 case.id
1240 );
1241 }
1242
1243 SampleOutcome {
1244 pass: verdict.pass,
1245 reason: verdict.reason,
1246 cost,
1247 latency_ms,
1248 turns,
1249 cache_hit: false,
1250 answer: ctx.record_answers.then(|| render_answer(&trace.result)),
1251 }
1252}
1253
1254#[derive(Clone, Copy)]
1260enum ArtifactPath<'a> {
1261 AgentDir(&'a Path),
1262 Ir(&'a Path),
1263}
1264
1265fn validate_max_turns_backend(backend: Backend, max_turns: Option<u32>) -> Result<(), String> {
1272 if max_turns.is_some() && backend != Backend::ClaudeAgentSdk {
1273 return Err(format!(
1274 "--max-turns is only supported by backend '{}' (the only one whose dispatch CLI has a \
1275--max-turns knob); backend '{backend}' has none — omit --max-turns or pass --backend {}",
1276 Backend::ClaudeAgentSdk,
1277 Backend::ClaudeAgentSdk
1278 ));
1279 }
1280 Ok(())
1281}
1282
1283#[cfg(test)]
1284mod max_turns_backend_validation_tests {
1285 use super::*;
1286
1287 #[test]
1291 fn max_turns_is_rejected_for_a_backend_with_no_turn_budget_knob() {
1292 for backend in [Backend::ClaudeCodeCli, Backend::CodexLocal, Backend::Vercel] {
1293 let err = validate_max_turns_backend(backend, Some(1))
1294 .expect_err("max-turns must be rejected for a backend with no such knob");
1295 assert!(
1296 err.contains(backend.as_str()),
1297 "error must name the offending backend '{backend}': {err}"
1298 );
1299 assert!(
1300 err.contains(Backend::ClaudeAgentSdk.as_str()),
1301 "error must name the backend that does support --max-turns: {err}"
1302 );
1303 }
1304 }
1305
1306 #[test]
1309 fn max_turns_is_accepted_for_the_sdk_backend_and_whenever_absent() {
1310 assert!(validate_max_turns_backend(Backend::ClaudeAgentSdk, Some(1)).is_ok());
1311 for backend in [
1312 Backend::ClaudeCodeCli,
1313 Backend::ClaudeAgentSdk,
1314 Backend::CodexLocal,
1315 Backend::Vercel,
1316 ] {
1317 assert!(validate_max_turns_backend(backend, None).is_ok());
1318 }
1319 }
1320}
1321
1322fn validate_tier_binding_backend(
1332 backend: Backend,
1333 tier_models: Option<&ModelConfig>,
1334) -> Result<(), String> {
1335 if tier_models.is_some() && backend != Backend::ClaudeAgentSdk {
1336 return Err(format!(
1337 "a differentiated --strong/--cheap/--orchestrator (or --models-config) binding is only \
1338supported by backend '{}' (the only one whose dispatch CLI has tier flags); backend '{backend}' has \
1339none — to run a differentiated binding on '{backend}', dispatch the agent with that binding baked \
1340in (`warble dispatch --strong/--cheap/--orchestrator ...`) and run this eval with the frontmatter \
1341sentinel (no tier flags) instead of asking the eval runner to inject one at invocation time",
1342 Backend::ClaudeAgentSdk
1343 ));
1344 }
1345 Ok(())
1346}
1347
1348#[cfg(test)]
1349mod tier_binding_backend_validation_tests {
1350 use super::*;
1351
1352 #[test]
1356 fn tiered_binding_is_rejected_for_a_backend_with_no_tier_knob() {
1357 let tier_models = ModelConfig::from_flags(
1358 "sonnet".to_string(),
1359 "haiku".to_string(),
1360 "sonnet".to_string(),
1361 );
1362 for backend in [Backend::ClaudeCodeCli, Backend::CodexLocal, Backend::Vercel] {
1363 let err = validate_tier_binding_backend(backend, Some(&tier_models))
1364 .expect_err("a tiered binding must be rejected for a backend with no tier knob");
1365 assert!(
1366 err.contains(backend.as_str()),
1367 "error must name the offending backend '{backend}': {err}"
1368 );
1369 assert!(
1370 err.contains(Backend::ClaudeAgentSdk.as_str()),
1371 "error must name the backend that does support a differentiated binding: {err}"
1372 );
1373 assert!(
1374 err.contains("frontmatter"),
1375 "error must point at the frontmatter-sentinel workaround: {err}"
1376 );
1377 }
1378 }
1379
1380 #[test]
1384 fn tiered_binding_is_accepted_for_the_sdk_backend_and_whenever_absent() {
1385 let tier_models = ModelConfig::from_flags(
1386 "sonnet".to_string(),
1387 "haiku".to_string(),
1388 "sonnet".to_string(),
1389 );
1390 assert!(validate_tier_binding_backend(Backend::ClaudeAgentSdk, Some(&tier_models)).is_ok());
1391 for backend in [
1392 Backend::ClaudeCodeCli,
1393 Backend::ClaudeAgentSdk,
1394 Backend::CodexLocal,
1395 Backend::Vercel,
1396 ] {
1397 assert!(validate_tier_binding_backend(backend, None).is_ok());
1398 }
1399 }
1400}
1401
1402pub fn run_eval(cfg: &RunConfig) -> Result<Report, String> {
1403 let golden_text = fs::read_to_string(&cfg.golden_path)
1404 .map_err(|e| format!("read {}: {e}", cfg.golden_path.display()))?;
1405 let golden: Golden =
1406 serde_yaml::from_str(&golden_text).map_err(|e| format!("parse golden: {e}"))?;
1407
1408 let total_cases = golden.cases.len();
1410 let cases = select_and_subset(&golden, &cfg.filter)?;
1411 let selected_cases = cases.len();
1412
1413 let adapter = resolve_adapter(cfg.backend)?;
1417
1418 validate_max_turns_backend(cfg.backend, cfg.max_turns)?;
1423
1424 validate_tier_binding_backend(cfg.backend, cfg.tier_models.as_ref())?;
1429
1430 let artifact = match cfg.backend {
1437 Backend::ClaudeCodeCli => ArtifactPath::AgentDir(
1438 cfg.agent_dir
1439 .as_deref()
1440 .ok_or_else(|| "backend 'claude-code-cli' requires --agent-dir".to_string())?,
1441 ),
1442 _ => ArtifactPath::Ir(
1443 cfg.ir_path
1444 .as_deref()
1445 .ok_or_else(|| format!("backend '{}' requires --ir", cfg.backend))?,
1446 ),
1447 };
1448
1449 let (agent, context_injection): (String, Option<ContextInjectionReport>) = match artifact {
1450 ArtifactPath::AgentDir(dir) => (agent_name(dir)?, read_context_injection_report(dir)?),
1451 ArtifactPath::Ir(path) => (path.display().to_string(), None),
1452 };
1453 let path_env = run_path(&cfg.project);
1454
1455 let (mut store, context_sha) =
1459 build_store(cfg.no_cache, cfg.cache_dir.as_deref(), &cfg.project);
1460 let agent_sha = if store.is_enabled() {
1461 let sha = match artifact {
1462 ArtifactPath::AgentDir(dir) => context::compute_dir_sha(dir),
1463 ArtifactPath::Ir(path) => context::compute_file_sha(path),
1464 };
1465 match sha {
1466 Ok(s) => s,
1467 Err(e) => {
1468 eprintln!("cache: disabled — cannot hash agent artifact: {e}");
1469 store = TraceStore::disabled();
1470 String::new()
1471 }
1472 }
1473 } else {
1474 String::new()
1475 };
1476
1477 let _installed = match artifact {
1480 ArtifactPath::AgentDir(dir) => Some(install_agents(dir, &cfg.project)?),
1481 ArtifactPath::Ir(_) => None,
1482 };
1483
1484 let parallel = cfg.parallel.max(1);
1485 let par_suffix = if parallel > 1 {
1486 format!(", parallel={parallel}")
1487 } else {
1488 String::new()
1489 };
1490 let mut configs = Vec::new();
1491 if let Some(tier_models) = &cfg.tier_models {
1498 let strong = tier_models.require("strong").map_err(|e| e.to_string())?;
1499 let cheap = tier_models.require("cheap").map_err(|e| e.to_string())?;
1500 let orchestrator = tier_models
1501 .require("orchestrator")
1502 .map_err(|e| e.to_string())?;
1503 let label = format!("strong={strong},cheap={cheap},orchestrator={orchestrator}");
1507 eprintln!("\n### binding: {label} (n={selected_cases}{par_suffix})");
1508 let ctx = CaseCtx {
1509 model: &label,
1510 agent_sha: &agent_sha,
1511 context_sha: &context_sha,
1512 context_version: golden.context_version.as_deref(),
1513 store: &store,
1514 record_answers: cfg.record_answers,
1515 backend: cfg.backend,
1516 adapter: adapter.as_ref(),
1517 max_turns: cfg.max_turns,
1518 tier_binding: Some(TierBindingCtx {
1519 strong,
1520 cheap,
1521 orchestrator,
1522 label: &label,
1523 }),
1524 };
1525 let rows = run_cases(
1526 &cfg.project,
1527 &agent,
1528 &path_env,
1529 &cases,
1530 cfg.samples,
1531 parallel,
1532 &ctx,
1533 );
1534 configs.push(aggregate(&label, rows));
1535 } else {
1536 for model in &cfg.models {
1537 eprintln!("\n### binding: strong→{model} (n={selected_cases}{par_suffix})");
1538 let ctx = CaseCtx {
1539 model,
1540 agent_sha: &agent_sha,
1541 context_sha: &context_sha,
1542 context_version: golden.context_version.as_deref(),
1543 store: &store,
1544 record_answers: cfg.record_answers,
1545 backend: cfg.backend,
1546 adapter: adapter.as_ref(),
1547 max_turns: cfg.max_turns,
1548 tier_binding: None,
1549 };
1550 let rows = run_cases(
1551 &cfg.project,
1552 &agent,
1553 &path_env,
1554 &cases,
1555 cfg.samples,
1556 parallel,
1557 &ctx,
1558 );
1559 configs.push(aggregate(model, rows));
1560 }
1561 }
1562
1563 Ok(Report {
1564 dataset: golden.dataset,
1565 context_version: golden.context_version,
1566 context_injection,
1567 parallel,
1568 selected_cases,
1569 total_cases,
1570 configs,
1571 backend: cfg.backend,
1572 })
1573}
1574
1575fn read_context_injection_report(
1576 agent_dir: &Path,
1577) -> Result<Option<ContextInjectionReport>, String> {
1578 let path = agent_dir.join("context-report.json");
1579 if !path.is_file() {
1580 return Ok(None);
1581 }
1582 let raw = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
1583 let report: ContextInjectionReport = serde_json::from_str(&raw).map_err(|_| {
1584 format!(
1585 "invalid {}: expected context identity fields only",
1586 path.display()
1587 )
1588 })?;
1589 report
1590 .validate()
1591 .map(Some)
1592 .map_err(|reason| format!("invalid {}: {reason}", path.display()))
1593}
1594
1595#[cfg(test)]
1596mod context_injection_report_tests {
1597 use super::*;
1598
1599 fn write_report(body: &str) -> tempfile::TempDir {
1600 let dir = tempfile::tempdir().unwrap();
1601 fs::write(dir.path().join("context-report.json"), body).unwrap();
1602 dir
1603 }
1604
1605 #[test]
1606 fn accepts_only_the_non_sensitive_context_identity_shape() {
1607 let dir = write_report(
1608 r#"{
1609 "mode":"schema+knowledge",
1610 "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1611 "knowledge_fingerprint":"fnv1a64:fedcba9876543210",
1612 "knowledge_chars":42
1613 }"#,
1614 );
1615 let report = read_context_injection_report(dir.path())
1616 .unwrap()
1617 .expect("report present");
1618 assert_eq!(report.mode, "schema+knowledge");
1619 assert_eq!(report.knowledge_chars, 42);
1620 }
1621
1622 #[test]
1623 fn rejects_rule_text_paths_and_inconsistent_identity_without_echoing_values() {
1624 let secret = "PRIVATE_RULE_MARKER /Users/example/private-project";
1625 let unknown = write_report(&format!(
1626 r#"{{
1627 "mode":"schema-only",
1628 "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1629 "knowledge_fingerprint":null,
1630 "knowledge_chars":0,
1631 "rule_text":{secret:?}
1632 }}"#
1633 ));
1634 let error = read_context_injection_report(unknown.path()).unwrap_err();
1635 assert!(!error.contains(secret));
1636
1637 let path_as_fingerprint = write_report(
1638 r#"{
1639 "mode":"schema+knowledge",
1640 "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1641 "knowledge_fingerprint":"/Users/example/private-project",
1642 "knowledge_chars":42
1643 }"#,
1644 );
1645 let error = read_context_injection_report(path_as_fingerprint.path()).unwrap_err();
1646 assert!(!error.contains("/Users/example/private-project"));
1647
1648 let inconsistent = write_report(
1649 r#"{
1650 "mode":"schema-only",
1651 "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1652 "knowledge_fingerprint":"fnv1a64:fedcba9876543210",
1653 "knowledge_chars":42
1654 }"#,
1655 );
1656 assert!(read_context_injection_report(inconsistent.path()).is_err());
1657 }
1658}
1659
1660pub(crate) fn build_store(
1666 no_cache: bool,
1667 cache_dir: Option<&Path>,
1668 project: &Path,
1669) -> (TraceStore, String) {
1670 if no_cache {
1671 if cache_dir.is_some() {
1672 eprintln!("cache: --cache-dir ignored — --no-cache disables the cache entirely");
1673 }
1674 eprintln!("cache: disabled (--no-cache) — every case re-runs and refreshes its trace");
1675 return (TraceStore::disabled(), String::new());
1676 }
1677 let context_sha = match compute_mdl_sha(project) {
1678 Ok(s) => s,
1679 Err(e) => {
1680 eprintln!("cache: disabled — cannot compute MDL context SHA: {e}");
1681 return (TraceStore::disabled(), String::new());
1682 }
1683 };
1684 let dir = cache_dir
1685 .map(Path::to_path_buf)
1686 .unwrap_or_else(|| project.join(".warble").join("eval-cache"));
1687 eprintln!(
1688 "cache: enabled at {} (context_sha {})",
1689 dir.display(),
1690 &context_sha[..context_sha.len().min(12)]
1691 );
1692 (TraceStore::new(dir, true), context_sha)
1693}
1694
1695pub(crate) fn select_and_subset(
1699 golden: &Golden,
1700 filter: &CaseFilter,
1701) -> Result<Vec<GoldenCase>, String> {
1702 let selection = select_cases(&golden.cases, filter);
1703 if filter.is_active() {
1704 eprintln!("{}", selection.note);
1705 }
1706 if filter.is_active() && selection.indices.is_empty() {
1707 return Err(format!(
1708 "no golden cases match the filter — {} (nothing to run)",
1709 selection.note
1710 ));
1711 }
1712 Ok(selection
1713 .indices
1714 .iter()
1715 .map(|&i| golden.cases[i].clone())
1716 .collect())
1717}
1718
1719pub(crate) fn run_cases(
1730 project: &Path,
1731 agent: &str,
1732 path_env: &str,
1733 cases: &[GoldenCase],
1734 samples: usize,
1735 parallel: usize,
1736 ctx: &CaseCtx,
1737) -> Vec<CaseResult> {
1738 let samples = samples.max(1);
1739 let outcomes = run_indexed(cases.len() * samples, parallel, |j| {
1740 let case = &cases[j / samples];
1741 let sample = (j % samples) as u32;
1742 let r = run_case(project, agent, path_env, case, sample, ctx);
1743 let extra = if r.pass {
1744 String::new()
1745 } else {
1746 format!(" — {}", r.reason)
1747 };
1748 let cost = match r.cost {
1751 Some(cost) => format!(", ${cost:.4}"),
1752 None => String::new(),
1753 };
1754 let turns = r
1755 .turns
1756 .map(|t| t.to_string())
1757 .unwrap_or_else(|| "n/a".to_string());
1758 let cache = if r.cache_hit { " [cache]" } else { "" };
1760 if samples == 1 {
1761 eprintln!(
1762 " {} {}{cache} ({:.1}s{cost}, {turns}t){extra}",
1763 if r.pass { "PASS" } else { "FAIL" },
1764 case.id,
1765 r.latency_ms as f64 / 1000.0,
1766 );
1767 } else {
1768 eprintln!(
1769 " {} {} sample {sample}{cache} ({:.1}s{cost}, {turns}t){extra}",
1770 if r.pass { "PASS" } else { "FAIL" },
1771 case.id,
1772 r.latency_ms as f64 / 1000.0,
1773 );
1774 }
1775 r
1776 });
1777
1778 let mut it = outcomes.into_iter();
1782 cases
1783 .iter()
1784 .map(|case| {
1785 let chunk: Vec<SampleOutcome> = (&mut it).take(samples).collect();
1786 let result = fold_samples(
1787 case.id.clone(),
1788 case.tags.clone(),
1789 chunk,
1790 ctx.record_answers,
1791 );
1792 if samples > 1 {
1793 let marker = if result.flaky {
1794 "FLAKY"
1795 } else if result.pass {
1796 "PASS"
1797 } else {
1798 "FAIL"
1799 };
1800 eprintln!(
1801 " {marker} {} pass_rate={:.2} ({}/{})",
1802 result.id, result.pass_rate, result.passes, result.samples
1803 );
1804 }
1805 result
1806 })
1807 .collect()
1808}
1809
1810pub(crate) fn run_indexed<T, F>(n: usize, parallel: usize, job: F) -> Vec<T>
1815where
1816 T: Send,
1817 F: Fn(usize) -> T + Sync,
1818{
1819 if parallel <= 1 || n <= 1 {
1820 return (0..n).map(job).collect();
1821 }
1822
1823 use std::sync::atomic::{AtomicUsize, Ordering};
1824 use std::sync::Mutex;
1825
1826 let next = AtomicUsize::new(0);
1827 let slots: Vec<Mutex<Option<T>>> = (0..n).map(|_| Mutex::new(None)).collect();
1828
1829 std::thread::scope(|scope| {
1830 for _ in 0..parallel.min(n) {
1831 scope.spawn(|| loop {
1832 let i = next.fetch_add(1, Ordering::Relaxed);
1833 if i >= n {
1834 break;
1835 }
1836 let r = job(i);
1837 *slots[i].lock().expect("result slot poisoned") = Some(r);
1838 });
1839 }
1840 });
1841
1842 slots
1843 .into_iter()
1844 .map(|slot| {
1845 slot.into_inner()
1846 .expect("result slot poisoned")
1847 .expect("every index 0..n is claimed exactly once by the shared counter")
1848 })
1849 .collect()
1850}
1851
1852#[cfg(test)]
1853mod run_indexed_tests {
1854 use super::run_indexed;
1855 use std::sync::atomic::{AtomicUsize, Ordering};
1856 use std::time::Duration;
1857
1858 #[test]
1859 fn serial_path_preserves_order_and_runs_each_once() {
1860 let calls = AtomicUsize::new(0);
1861 let out = run_indexed(5, 1, |i| {
1862 calls.fetch_add(1, Ordering::Relaxed);
1863 i * 10
1864 });
1865 assert_eq!(out, vec![0, 10, 20, 30, 40]);
1866 assert_eq!(calls.load(Ordering::Relaxed), 5);
1867 }
1868
1869 #[test]
1870 fn parallel_results_are_in_index_order_despite_completion_order() {
1871 let out = run_indexed(8, 4, |i| {
1874 std::thread::sleep(Duration::from_millis((8 - i as u64) * 3));
1875 i
1876 });
1877 assert_eq!(out, (0..8).collect::<Vec<_>>());
1878 }
1879
1880 #[test]
1881 fn parallel_runs_each_index_exactly_once() {
1882 let per_index: Vec<AtomicUsize> = (0..32).map(|_| AtomicUsize::new(0)).collect();
1883 let out = run_indexed(32, 6, |i| {
1884 per_index[i].fetch_add(1, Ordering::Relaxed);
1885 i
1886 });
1887 assert_eq!(out.len(), 32);
1888 for (i, c) in per_index.iter().enumerate() {
1889 assert_eq!(c.load(Ordering::Relaxed), 1, "index {i} ran once");
1890 }
1891 }
1892
1893 #[test]
1894 fn parallelism_wider_than_n_and_empty_input_are_fine() {
1895 assert_eq!(run_indexed(2, 16, |i| i), vec![0, 1]);
1896 assert_eq!(run_indexed(0, 4, |i| i), Vec::<usize>::new());
1897 }
1898
1899 #[test]
1900 fn parallel_actually_overlaps_work() {
1901 let in_flight = AtomicUsize::new(0);
1908 let max_in_flight = AtomicUsize::new(0);
1909 run_indexed(4, 4, |_| {
1910 let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1911 max_in_flight.fetch_max(now, Ordering::SeqCst);
1912 std::thread::sleep(Duration::from_millis(40));
1913 in_flight.fetch_sub(1, Ordering::SeqCst);
1914 });
1915 let max = max_in_flight.load(Ordering::SeqCst);
1916 assert!(
1917 max > 1,
1918 "expected overlapping execution, but max concurrent was {max}"
1919 );
1920 }
1921}
1922
1923#[cfg(test)]
1924mod fold_samples_tests {
1925 use super::*;
1926
1927 fn outcome(pass: bool, reason: &str, cost: f64, latency_ms: u64, turns: u64) -> SampleOutcome {
1928 SampleOutcome {
1929 pass,
1930 reason: reason.to_string(),
1931 cost: Some(cost),
1932 latency_ms,
1933 turns: Some(turns),
1934 cache_hit: false,
1935 answer: None,
1936 }
1937 }
1938
1939 #[test]
1940 fn fully_passing_is_not_flaky_and_reason_is_match() {
1941 let outcomes = vec![
1942 outcome(true, "match", 0.1, 1_000, 2),
1943 outcome(true, "match", 0.1, 1_000, 2),
1944 ];
1945 let r = fold_samples("a".into(), vec![], outcomes, false);
1946 assert_eq!(r.samples, 2);
1947 assert_eq!(r.passes, 2);
1948 assert!((r.pass_rate - 1.0).abs() < 1e-9);
1949 assert!(r.pass);
1950 assert!(!r.flaky);
1951 assert_eq!(r.reason, "match");
1952 }
1953
1954 #[test]
1955 fn fully_failing_is_not_flaky() {
1956 let outcomes = vec![
1957 outcome(false, "mismatch", 0.0, 500, 1),
1958 outcome(false, "mismatch", 0.0, 500, 1),
1959 ];
1960 let r = fold_samples("a".into(), vec![], outcomes, false);
1961 assert_eq!(r.passes, 0);
1962 assert_eq!(r.pass_rate, 0.0);
1963 assert!(!r.pass);
1964 assert!(!r.flaky, "0 passes out of N is a hard fail, not flaky");
1965 }
1966
1967 #[test]
1968 fn partial_pass_is_flaky_and_reason_is_the_modal_failure() {
1969 let outcomes = vec![
1970 outcome(true, "match", 0.0, 0, 0),
1971 outcome(false, "wrong total", 0.0, 0, 0),
1972 outcome(false, "wrong total", 0.0, 0, 0),
1973 outcome(false, "timeout", 0.0, 0, 0),
1974 ];
1975 let r = fold_samples("a".into(), vec![], outcomes, false);
1976 assert_eq!(r.passes, 1);
1977 assert_eq!(r.samples, 4);
1978 assert!((r.pass_rate - 0.25).abs() < 1e-9);
1979 assert!(!r.pass);
1980 assert!(r.flaky);
1981 assert_eq!(r.reason, "wrong total", "modal failure reason wins");
1982 }
1983
1984 #[test]
1985 fn modal_reason_tie_break_is_first_occurrence() {
1986 let outcomes = vec![
1988 outcome(false, "a", 0.0, 0, 0),
1989 outcome(false, "b", 0.0, 0, 0),
1990 outcome(false, "b", 0.0, 0, 0),
1991 outcome(false, "a", 0.0, 0, 0),
1992 ];
1993 let r = fold_samples("a".into(), vec![], outcomes, false);
1994 assert_eq!(r.reason, "a");
1995 }
1996
1997 #[test]
1998 fn cost_sums_latency_and_turns_average() {
1999 let outcomes = vec![
2000 outcome(true, "match", 0.10, 1_000, 4),
2001 outcome(true, "match", 0.20, 3_000, 6),
2002 ];
2003 let r = fold_samples("a".into(), vec![], outcomes, false);
2004 assert!(
2005 (r.cost.expect("cost present") - 0.30).abs() < 1e-9,
2006 "cost sums across samples"
2007 );
2008 assert_eq!(r.latency_ms, 2_000, "latency averages across samples");
2009 assert_eq!(r.turns, Some(5), "turns averages across samples");
2010 }
2011
2012 #[test]
2013 fn cost_and_turns_are_none_when_any_sample_lacks_them() {
2014 let outcomes = vec![
2015 outcome(true, "match", 0.10, 1_000, 4),
2016 SampleOutcome {
2017 cost: None,
2018 turns: None,
2019 ..outcome(true, "match", 0.0, 1_000, 0)
2020 },
2021 ];
2022 let r = fold_samples("a".into(), vec![], outcomes, false);
2023 assert!(
2024 r.cost.is_none(),
2025 "one sample's missing cost makes the case's cost absent, not a partial sum"
2026 );
2027 assert!(
2028 r.turns.is_none(),
2029 "one sample's missing turns makes the case's turns absent, not a partial average"
2030 );
2031 }
2032
2033 #[test]
2034 fn cache_hits_and_misses_are_counted_per_sample() {
2035 let outcomes = vec![
2036 SampleOutcome {
2037 cache_hit: true,
2038 ..outcome(true, "match", 0.0, 0, 0)
2039 },
2040 SampleOutcome {
2041 cache_hit: false,
2042 ..outcome(true, "match", 0.0, 0, 0)
2043 },
2044 SampleOutcome {
2045 cache_hit: true,
2046 ..outcome(true, "match", 0.0, 0, 0)
2047 },
2048 ];
2049 let r = fold_samples("a".into(), vec![], outcomes, false);
2050 assert_eq!(r.cache_hits, 2);
2051 assert_eq!(r.cache_misses, 1);
2052 }
2053
2054 #[test]
2055 fn answer_dist_is_only_populated_when_record_answers_is_set() {
2056 let build = || {
2057 vec![
2058 SampleOutcome {
2059 answer: Some("42".to_string()),
2060 ..outcome(true, "match", 0.0, 0, 0)
2061 },
2062 SampleOutcome {
2063 answer: Some("42".to_string()),
2064 ..outcome(true, "match", 0.0, 0, 0)
2065 },
2066 SampleOutcome {
2067 answer: Some("7".to_string()),
2068 ..outcome(false, "wrong total", 0.0, 0, 0)
2069 },
2070 ]
2071 };
2072
2073 let without = fold_samples("a".into(), vec![], build(), false);
2074 assert!(without.answer_dist.is_none());
2075 assert!(
2076 without.samples_detail.iter().all(|s| s.answer.is_none()),
2077 "no answer strings recorded unless --record-answers"
2078 );
2079
2080 let with = fold_samples("a".into(), vec![], build(), true);
2081 let dist = with.answer_dist.expect("populated when record_answers");
2082 assert_eq!(dist.get("42"), Some(&2));
2083 assert_eq!(dist.get("7"), Some(&1));
2084 assert_eq!(
2085 with.samples_detail
2086 .iter()
2087 .filter(|s| s.answer.is_some())
2088 .count(),
2089 3
2090 );
2091 }
2092
2093 #[test]
2094 fn failed_output_is_recorded_only_when_requested() {
2095 let extracted = serde_json::json!({
2096 "blocks": [{"type": "status", "state": "unknown"}],
2097 "verified": false,
2098 });
2099
2100 assert_eq!(recorded_failure_answer(false, None, "raw refusal"), None);
2101 assert_eq!(
2102 recorded_failure_answer(false, Some(&extracted), "ignored"),
2103 None
2104 );
2105 assert_eq!(
2106 recorded_failure_answer(true, None, "raw refusal").as_deref(),
2107 Some("raw refusal")
2108 );
2109 assert_eq!(
2110 recorded_failure_answer(true, Some(&extracted), "ignored"),
2111 Some(extracted.to_string())
2112 );
2113 }
2114
2115 #[test]
2116 fn modal_reason_of_empty_iterator_is_empty_string() {
2117 assert_eq!(modal_reason(std::iter::empty()), "");
2118 }
2119}
2120
2121#[cfg(test)]
2122mod verdict_tests {
2123 use super::*;
2124
2125 fn envelope(fresh: bool, severity: &str) -> serde_json::Value {
2128 serde_json::json!({
2129 "blocks": [
2130 { "type": "status", "state": if fresh { "fresh" } else { "stale" },
2131 "label": "orders freshness", "detail": "…", "severity": severity }
2132 ],
2133 "verdict": { "type": "freshness_verdict", "fresh": fresh, "observed_lag_hours": 51,
2134 "expected_cadence": "24h" },
2135 "emitted": ["freshness_breach"],
2136 "verified": true,
2137 })
2138 }
2139
2140 fn fenced(value: &serde_json::Value) -> String {
2141 format!("Some prose.\n\n```json\n{value}\n```\n")
2142 }
2143
2144 #[test]
2145 fn extract_verdict_json_accepts_a_real_envelope() {
2146 let text = fenced(&envelope(false, "critical"));
2147 let got = extract_verdict_json(&text).expect("envelope parses");
2148 assert_eq!(got["verdict"]["fresh"], false);
2149 }
2150
2151 #[test]
2152 fn extract_verdict_json_rejects_a_columns_rows_table() {
2153 let text = fenced(&serde_json::json!({"columns": ["n"], "rows": [[42]]}));
2154 assert!(
2155 extract_verdict_json(&text).is_none(),
2156 "a table has neither `verdict` nor `blocks`"
2157 );
2158 }
2159
2160 #[test]
2161 fn extract_verdict_json_rejects_malformed_json() {
2162 assert!(extract_verdict_json("not json at all").is_none());
2163 assert!(extract_verdict_json("```json\n{ not valid\n```").is_none());
2164 }
2165
2166 #[test]
2167 fn extract_result_json_rejects_a_verdict_envelope() {
2168 let text = fenced(&envelope(true, "critical"));
2171 assert!(extract_result_json(&text).is_none());
2172 }
2173
2174 #[test]
2175 fn project_verdict_field_reads_fresh_true_and_false() {
2176 let fresh_table = project_verdict_field(&envelope(true, "critical"), "fresh").unwrap();
2177 assert_eq!(fresh_table.rows, vec![vec![Value::Bool(true)]]);
2178
2179 let stale_table = project_verdict_field(&envelope(false, "critical"), "fresh").unwrap();
2180 assert_eq!(stale_table.rows, vec![vec![Value::Bool(false)]]);
2181 }
2182
2183 #[test]
2184 fn project_verdict_field_reads_severity_warn_and_critical() {
2185 let warn = project_verdict_field(&envelope(false, "warn"), "severity").unwrap();
2186 assert_eq!(warn.rows, vec![vec![Value::String("warn".to_string())]]);
2187
2188 let critical = project_verdict_field(&envelope(false, "critical"), "severity").unwrap();
2189 assert_eq!(
2190 critical.rows,
2191 vec![vec![Value::String("critical".to_string())]]
2192 );
2193 }
2194
2195 #[test]
2196 fn project_verdict_field_falls_back_to_verdict_severity() {
2197 let envelope = serde_json::json!({
2199 "blocks": [{ "type": "status", "state": "stale" }],
2200 "verdict": { "type": "freshness_verdict", "fresh": false, "severity": "warn" },
2201 });
2202 let got = project_verdict_field(&envelope, "severity").unwrap();
2203 assert_eq!(got.rows, vec![vec![Value::String("warn".to_string())]]);
2204 }
2205
2206 #[test]
2207 fn project_verdict_field_is_none_on_missing_or_unknown_field() {
2208 let e = envelope(true, "critical");
2209 assert!(
2210 project_verdict_field(&e, "observed_lag_hours").is_none(),
2211 "unrecognized field fails closed, never silently passes"
2212 );
2213 let no_verdict = serde_json::json!({"blocks": []});
2214 assert!(project_verdict_field(&no_verdict, "fresh").is_none());
2215 let no_severity_anywhere = serde_json::json!({
2216 "blocks": [{"type": "status", "state": "stale"}],
2217 "verdict": {"type": "freshness_verdict", "fresh": false},
2218 });
2219 assert!(project_verdict_field(&no_severity_anywhere, "severity").is_none());
2220 }
2221
2222 fn verdict_case(tag: &str, field: &str, expected_rows: &str) -> GoldenCase {
2223 let yaml = format!(
2224 "id: v1\nquestion: \"is it fresh?\"\ntags: [{tag}]\nmatch: scalar\n\
2225 result_kind: verdict\nverdict_field: {field}\n\
2226 expected: {{ columns: [{field}], rows: {expected_rows} }}\n"
2227 );
2228 serde_yaml::from_str(&yaml).expect("verdict golden case parses")
2229 }
2230
2231 #[test]
2232 fn golden_case_result_kind_defaults_to_table_when_omitted() {
2233 let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[1]] }\n";
2234 let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
2235 assert_eq!(case.result_kind, ResultKind::Table);
2236 assert!(case.verdict_field.is_none());
2237 }
2238
2239 #[test]
2240 fn score_value_passes_a_matching_verdict_envelope() {
2241 let case = verdict_case("detection", "fresh", "[[false]]");
2242 let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
2243 assert!(
2244 verdict.pass,
2245 "envelope fresh=false matches expected fresh=false"
2246 );
2247 }
2248
2249 #[test]
2250 fn score_value_fails_a_mismatching_verdict_envelope() {
2251 let case = verdict_case("detection", "fresh", "[[true]]");
2252 let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
2253 assert!(
2254 !verdict.pass,
2255 "envelope fresh=false does not match expected fresh=true"
2256 );
2257 }
2258
2259 #[test]
2260 fn score_value_projects_severity_for_the_severity_field() {
2261 let case = verdict_case("severity", "severity", "[[critical]]");
2262 let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
2263 assert!(verdict.pass);
2264 }
2265
2266 #[test]
2267 fn score_value_on_a_table_case_is_unaffected_by_verdict_support() {
2268 let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[42]] }\n";
2269 let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
2270 let result = serde_json::json!({"columns": ["n"], "rows": [[42]]});
2271 let verdict = score_value(&result, &case).expect("table case scores");
2272 assert!(verdict.pass);
2273 }
2274}
2275
2276#[cfg(test)]
2277mod output_stability_tests {
2278 use super::*;
2279
2280 fn case(
2283 samples: u32,
2284 passes: u32,
2285 flaky: bool,
2286 answer_dist: Option<BTreeMap<String, u32>>,
2287 ) -> CaseResult {
2288 CaseResult {
2289 id: "c1".to_string(),
2290 tags: vec![],
2291 samples,
2292 passes,
2293 pass_rate: if samples == 0 {
2294 0.0
2295 } else {
2296 passes as f64 / samples as f64
2297 },
2298 pass: passes == samples,
2299 flaky,
2300 reason: "match".to_string(),
2301 cost: Some(0.0),
2302 latency_ms: 0,
2303 turns: Some(0),
2304 cache_hits: 0,
2305 cache_misses: samples,
2306 samples_detail: vec![],
2307 answer_dist,
2308 }
2309 }
2310
2311 fn dist(pairs: &[(&str, u32)]) -> BTreeMap<String, u32> {
2312 pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
2313 }
2314
2315 #[test]
2318 fn verdict_stable_but_output_divergent_is_output_unstable_not_flaky() {
2319 let c = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
2322 assert!(!c.flaky, "both samples passed: flaky must stay false");
2323 assert_eq!(c.output_stability(), OutputStability::Unstable);
2324 }
2325
2326 #[test]
2328 fn verdict_flip_is_flaky_and_may_still_be_output_stable() {
2329 let c = case(2, 1, true, Some(dist(&[("42", 2)])));
2331 assert!(c.flaky);
2332 assert_eq!(c.output_stability(), OutputStability::Stable);
2333 }
2334
2335 #[test]
2337 fn fully_stable_case_is_stable_and_not_flaky() {
2338 let c = case(3, 3, false, Some(dist(&[("42", 3)])));
2339 assert!(!c.flaky);
2340 assert_eq!(c.output_stability(), OutputStability::Stable);
2341 }
2342
2343 #[test]
2348 fn over_split_textual_signature_reports_unstable_not_reconciled() {
2349 let c = case(2, 2, false, Some(dist(&[("42", 1), ("42.0", 1)])));
2350 assert!(!c.flaky);
2351 assert_eq!(
2352 c.output_stability(),
2353 OutputStability::Unstable,
2354 "over-split is reported as Unstable (an upper bound), not reconciled to Stable"
2355 );
2356 }
2357
2358 #[test]
2361 fn incomplete_answers_are_never_counted_as_stable() {
2362 let c = case(3, 2, false, Some(dist(&[("42", 2)])));
2364 assert_eq!(c.output_stability(), OutputStability::Incomplete);
2365 }
2366
2367 #[test]
2368 fn no_answer_dist_or_single_sample_is_not_measured() {
2369 assert_eq!(
2370 case(2, 2, false, None).output_stability(),
2371 OutputStability::NotMeasured
2372 );
2373 assert_eq!(
2374 case(1, 1, false, Some(dist(&[("42", 1)]))).output_stability(),
2375 OutputStability::NotMeasured
2376 );
2377 }
2378
2379 #[test]
2383 fn aggregate_counts_flaky_and_output_unstable_from_the_real_function() {
2384 let flaky_only = case(2, 1, true, Some(dist(&[("42", 2)])));
2385 let unstable_only = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
2386 let stable = case(2, 2, false, Some(dist(&[("42", 2)])));
2387 let report = aggregate("m", vec![flaky_only, unstable_only, stable]);
2388 assert_eq!(report.flaky_cases, 1, "one verdict flip");
2389 assert_eq!(
2390 report.output_unstable_cases, 1,
2391 "one output-unstable case, counted independently of the flaky one"
2392 );
2393 assert_eq!(report.n, 3);
2394 }
2395
2396 fn config_report(cases: Vec<CaseResult>) -> ConfigReport {
2397 let flaky_cases = cases.iter().filter(|c| c.flaky).count() as u32;
2398 let output_unstable_cases = cases
2399 .iter()
2400 .filter(|c| {
2401 matches!(
2402 c.output_stability(),
2403 OutputStability::Unstable | OutputStability::Incomplete
2404 )
2405 })
2406 .count() as u32;
2407 ConfigReport {
2408 model: "m".to_string(),
2409 n: cases.len(),
2410 accuracy: 1.0,
2411 cost_total_usd: Some(0.0),
2412 latency_ms_avg: 0,
2413 turns_avg: Some(0),
2414 cache_hits: 0,
2415 cache_misses: 0,
2416 flaky_cases,
2417 output_unstable_cases,
2418 by_tag: BTreeMap::new(),
2419 cases,
2420 }
2421 }
2422
2423 #[test]
2426 fn aggregate_output_unstable_count_is_distinct_from_flaky_count() {
2427 let stable_but_divergent = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
2428 let flaky_but_output_stable = case(2, 1, true, Some(dist(&[("42", 2)])));
2429 let report = config_report(vec![stable_but_divergent, flaky_but_output_stable]);
2430 assert_eq!(report.flaky_cases, 1, "exactly one verdict flip");
2431 assert_eq!(
2432 report.output_unstable_cases, 1,
2433 "exactly one output-unstable case, and it is not the flaky one"
2434 );
2435 }
2436
2437 fn report_with(configs: Vec<ConfigReport>) -> Report {
2438 Report {
2439 dataset: None,
2440 context_version: None,
2441 context_injection: None,
2442 parallel: 1,
2443 selected_cases: configs.iter().map(|c| c.n).sum(),
2444 total_cases: configs.iter().map(|c| c.n).sum(),
2445 configs,
2446 backend: Backend::default(),
2447 }
2448 }
2449
2450 #[test]
2453 fn format_pareto_shows_answer_dist_for_output_unstable_non_flaky_case() {
2454 let c = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
2455 let report = report_with(vec![config_report(vec![c])]);
2456 let table = format_pareto(&report);
2457 assert!(
2458 table.contains("[output-unstable, verdict stable]"),
2459 "expected the per-case output-unstable label in:\n{table}"
2460 );
2461 assert!(
2462 table.contains("42×1") && table.contains("43×1"),
2463 "expected the answer distribution to be shown for the output-unstable case:\n{table}"
2464 );
2465 }
2466
2467 #[test]
2470 fn format_pareto_states_reproducibility_not_measured_at_samples_one() {
2471 let c = case(1, 1, false, None);
2472 let report = report_with(vec![config_report(vec![c])]);
2473 let table = format_pareto(&report);
2474 assert!(
2475 table.contains("not measured"),
2476 "expected an explicit not-measured statement in:\n{table}"
2477 );
2478 }
2479
2480 #[test]
2482 fn format_pareto_warns_when_a_multi_sample_case_was_fully_cache_served() {
2483 let mut c = case(2, 2, false, Some(dist(&[("42", 2)])));
2484 c.cache_hits = 2;
2485 c.cache_misses = 0;
2486 let mut cfg = config_report(vec![c]);
2487 cfg.cache_hits = 2;
2488 cfg.cache_misses = 0;
2489 let report = report_with(vec![cfg]);
2490 let table = format_pareto(&report);
2491 assert!(
2492 table.contains("replay, not evidence of determinism"),
2493 "expected a cache-replay caveat in:\n{table}"
2494 );
2495 }
2496
2497 #[test]
2500 fn effective_record_answers_defaults_on_above_one_sample_only() {
2501 assert!(
2502 !effective_record_answers(false, 1),
2503 "samples == 1 unchanged: still off"
2504 );
2505 assert!(
2506 effective_record_answers(true, 1),
2507 "explicit flag still honored at samples == 1"
2508 );
2509 assert!(
2510 effective_record_answers(false, 2),
2511 "samples > 1 defaults recording on even without the explicit flag"
2512 );
2513 assert!(
2514 effective_record_answers(true, 5),
2515 "explicit flag remains a no-op true at samples > 1"
2516 );
2517 }
2518}