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_eval_compare::{compare, CompareRequest, CompareResult, MatchMode, Table, Tolerance};
27
28mod ablation;
29mod backend;
30mod cache;
31mod capture;
32mod compliance;
33mod context;
34mod filter;
35mod gate;
36mod monitor;
37
38pub use ablation::{
39 format_ablation, run_ablation, AblationConfig, AblationPoint, AblationReport,
40 StepRecommendation,
41};
42pub use backend::Backend;
43use backend::{resolve_adapter, BackendAdapter};
44pub use cache::{rescore, CaseKey, Trace, TraceStore};
45pub use capture::{build_candidate_yaml, candidates_header, CaptureInput};
46pub use compliance::{
47 format_compliance, score_compliance, Check, CheckStatus, ComplianceIr, ComplianceReport,
48 ComplianceTrace, TraceEvent,
49};
50pub use context::{
51 classify, compute_mdl_sha, parse_context_version, stamp_context_version, verify_context,
52 Freshness, VerifyResult,
53};
54pub use filter::{select_cases, CaseFilter, Sample, Selection};
55pub use gate::{format_gate, run_gate, FlakyCase, GateResult, Regression};
56pub use monitor::{
57 format_monitor_report, score_monitor_pair, MonitorMetric, MonitorObservation, MonitorReport,
58};
59
60#[derive(Debug, serde::Deserialize)]
62pub struct Golden {
63 #[serde(default)]
64 pub dataset: Option<String>,
65 #[serde(default)]
66 pub context_version: Option<String>,
67 pub cases: Vec<GoldenCase>,
68}
69
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum ResultKind {
79 #[default]
80 Table,
81 Verdict,
82}
83
84#[derive(Debug, Clone, serde::Deserialize)]
85pub struct GoldenCase {
86 pub id: String,
87 pub question: String,
88 #[serde(default)]
89 pub tags: Vec<String>,
90 #[serde(rename = "match")]
91 pub match_mode: MatchMode,
92 #[serde(default)]
93 pub tolerance: Tolerance,
94 pub expected: Table,
95 #[serde(default)]
97 pub result_kind: ResultKind,
98 #[serde(default)]
102 pub verdict_field: Option<String>,
103}
104
105struct SampleOutcome {
109 pass: bool,
110 reason: String,
111 cost: Option<f64>,
114 latency_ms: u64,
115 turns: Option<u64>,
117 cache_hit: bool,
118 answer: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, serde::Deserialize)]
127pub struct SampleVerdict {
128 pub pass: bool,
129 pub reason: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub answer: Option<String>,
132}
133
134#[derive(Debug, Clone, Serialize, serde::Deserialize)]
138pub struct CaseResult {
139 pub id: String,
140 pub tags: Vec<String>,
141 #[serde(default)]
145 pub samples: u32,
146 #[serde(default)]
148 pub passes: u32,
149 #[serde(default)]
152 pub pass_rate: f64,
153 pub pass: bool,
156 #[serde(default)]
158 pub flaky: bool,
159 pub reason: String,
162 #[serde(default)]
166 pub cost: Option<f64>,
167 pub latency_ms: u64,
169 #[serde(default)]
172 pub turns: Option<u64>,
173 #[serde(default)]
175 pub cache_hits: u32,
176 #[serde(default)]
177 pub cache_misses: u32,
178 #[serde(default)]
180 pub samples_detail: Vec<SampleVerdict>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub answer_dist: Option<BTreeMap<String, u32>>,
185}
186
187fn fold_samples(
191 id: String,
192 tags: Vec<String>,
193 outcomes: Vec<SampleOutcome>,
194 record_answers: bool,
195) -> CaseResult {
196 let samples = outcomes.len() as u32;
197 let passes = outcomes.iter().filter(|o| o.pass).count() as u32;
198 let pass_rate = if samples > 0 {
199 passes as f64 / samples as f64
200 } else {
201 0.0
202 };
203 let pass = samples > 0 && passes == samples;
204 let flaky = passes > 0 && passes < samples;
205 let reason = if passes == samples {
206 "match".to_string()
207 } else {
208 modal_reason(
209 outcomes
210 .iter()
211 .filter(|o| !o.pass)
212 .map(|o| o.reason.as_str()),
213 )
214 };
215 let cost: Option<f64> = outcomes
218 .iter()
219 .try_fold(0.0_f64, |acc, o| o.cost.map(|c| acc + c));
220 let latency_ms = if samples > 0 {
221 outcomes.iter().map(|o| o.latency_ms).sum::<u64>() / samples as u64
222 } else {
223 0
224 };
225 let turns: Option<u64> = if samples > 0 {
226 outcomes
227 .iter()
228 .try_fold(0_u64, |acc, o| o.turns.map(|t| acc + t))
229 .map(|sum| sum / samples as u64)
230 } else {
231 Some(0)
232 };
233 let cache_hits = outcomes.iter().filter(|o| o.cache_hit).count() as u32;
234 let cache_misses = samples - cache_hits;
235
236 let answer_dist = record_answers.then(|| {
237 let mut dist: BTreeMap<String, u32> = BTreeMap::new();
238 for o in &outcomes {
239 if let Some(answer) = &o.answer {
240 *dist.entry(answer.clone()).or_insert(0) += 1;
241 }
242 }
243 dist
244 });
245 let samples_detail = outcomes
246 .into_iter()
247 .map(|o| SampleVerdict {
248 pass: o.pass,
249 reason: o.reason,
250 answer: if record_answers { o.answer } else { None },
251 })
252 .collect();
253
254 CaseResult {
255 id,
256 tags,
257 samples,
258 passes,
259 pass_rate,
260 pass,
261 flaky,
262 reason,
263 cost,
264 latency_ms,
265 turns,
266 cache_hits,
267 cache_misses,
268 samples_detail,
269 answer_dist,
270 }
271}
272
273fn modal_reason<'a>(reasons: impl Iterator<Item = &'a str>) -> String {
276 let mut counts: Vec<(&str, u32)> = Vec::new();
277 for r in reasons {
278 match counts.iter_mut().find(|(seen, _)| *seen == r) {
279 Some(entry) => entry.1 += 1,
280 None => counts.push((r, 1)),
281 }
282 }
283 let mut best: Option<(&str, u32)> = None;
284 for (r, c) in counts {
285 if best.is_none_or(|(_, bc)| c > bc) {
286 best = Some((r, c));
287 }
288 }
289 best.map(|(r, _)| r.to_string()).unwrap_or_default()
290}
291
292fn render_answer(result: &serde_json::Value) -> String {
304 result.to_string()
305}
306
307fn recorded_failure_answer(
311 record_answers: bool,
312 extracted: Option<&serde_json::Value>,
313 raw: &str,
314) -> Option<String> {
315 record_answers.then(|| {
316 extracted
317 .map(render_answer)
318 .unwrap_or_else(|| raw.to_string())
319 })
320}
321
322#[derive(Debug, Clone, Serialize, serde::Deserialize)]
323pub struct TagStat {
324 pub pass: u32,
325 pub n: u32,
326 #[serde(default)]
330 pub pass_rate_sum: f64,
331}
332
333#[derive(Debug, Clone, Serialize, serde::Deserialize)]
334pub struct ConfigReport {
335 pub model: String,
336 pub n: usize,
337 pub accuracy: f64,
340 #[serde(default)]
343 pub cost_total_usd: Option<f64>,
344 pub latency_ms_avg: u64,
345 #[serde(default)]
349 pub turns_avg: Option<u64>,
350 #[serde(default)]
353 pub cache_hits: usize,
354 #[serde(default)]
355 pub cache_misses: usize,
356 #[serde(default)]
359 pub flaky_cases: u32,
360 pub by_tag: BTreeMap<String, TagStat>,
361 pub cases: Vec<CaseResult>,
362}
363
364#[derive(Debug, Clone, Serialize, serde::Deserialize)]
367pub struct Report {
368 pub dataset: Option<String>,
369 pub context_version: Option<String>,
370 #[serde(default)]
373 pub context_injection: Option<ContextInjectionReport>,
374 #[serde(default = "default_parallel")]
377 pub parallel: usize,
378 #[serde(default)]
383 pub selected_cases: usize,
384 #[serde(default)]
385 pub total_cases: usize,
386 pub configs: Vec<ConfigReport>,
387 #[serde(default)]
391 pub backend: Backend,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
399#[serde(deny_unknown_fields)]
400pub struct ContextInjectionReport {
401 pub mode: String,
402 pub schema_digest_fingerprint: String,
403 pub knowledge_fingerprint: Option<String>,
404 pub knowledge_chars: usize,
405}
406
407impl ContextInjectionReport {
408 fn validate(self) -> Result<Self, &'static str> {
409 if !valid_context_fingerprint(&self.schema_digest_fingerprint) {
410 return Err("invalid schema digest fingerprint");
411 }
412 match self.mode.as_str() {
413 "schema-only" if self.knowledge_fingerprint.is_none() && self.knowledge_chars == 0 => {}
414 "schema+knowledge"
415 if self
416 .knowledge_fingerprint
417 .as_deref()
418 .is_some_and(valid_context_fingerprint) => {}
419 "schema-only" | "schema+knowledge" => return Err("inconsistent knowledge identity"),
420 _ => return Err("invalid context injection mode"),
421 }
422 Ok(self)
423 }
424}
425
426fn valid_context_fingerprint(value: &str) -> bool {
427 value
428 .strip_prefix("fnv1a64:")
429 .is_some_and(|hex| hex.len() == 16 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
430}
431
432fn default_parallel() -> usize {
433 1
434}
435
436pub struct RunConfig {
437 pub project: PathBuf,
438 pub agent_dir: Option<PathBuf>,
443 pub ir_path: Option<PathBuf>,
448 pub golden_path: PathBuf,
449 pub models: Vec<String>,
450 pub out: Option<PathBuf>,
451 pub parallel: usize,
454 pub filter: CaseFilter,
456 pub no_cache: bool,
459 pub cache_dir: Option<PathBuf>,
461 pub samples: usize,
465 pub record_answers: bool,
469 pub backend: Backend,
473}
474
475pub(crate) struct CaseCtx<'a> {
481 pub model: &'a str,
482 pub agent_sha: &'a str,
483 pub context_sha: &'a str,
484 pub context_version: Option<&'a str>,
485 pub store: &'a TraceStore,
486 pub record_answers: bool,
489 pub backend: Backend,
492 pub adapter: &'a dyn BackendAdapter,
496}
497
498impl CaseCtx<'_> {
499 fn model_override(&self) -> Option<&str> {
502 (self.model != cache::FRONTMATTER_MODEL).then_some(self.model)
503 }
504}
505
506pub fn extract_result(text: &str) -> Option<Table> {
511 serde_json::from_value(extract_result_json(text)?).ok()
512}
513
514pub fn extract_result_json(text: &str) -> Option<serde_json::Value> {
517 let candidate = strip_fence(text);
518 let start = candidate.find('{')?;
519 let end = candidate.rfind('}')?;
520 if end <= start {
521 return None;
522 }
523 let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
524 if !value.get("rows").map(|r| r.is_array()).unwrap_or(false) {
526 return None;
527 }
528 Some(value)
529}
530
531pub fn extract_verdict_json(text: &str) -> Option<serde_json::Value> {
537 let candidate = strip_fence(text);
538 let start = candidate.find('{')?;
539 let end = candidate.rfind('}')?;
540 if end <= start {
541 return None;
542 }
543 let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
544 let has_verdict = value.get("verdict").is_some();
545 let has_blocks = value.get("blocks").map(Value::is_array).unwrap_or(false);
546 if !has_verdict && !has_blocks {
547 return None;
548 }
549 Some(value)
550}
551
552pub fn project_verdict_field(envelope: &serde_json::Value, field: &str) -> Option<Table> {
558 match field {
559 "fresh" => {
560 let fresh = envelope.get("verdict")?.get("fresh")?.as_bool()?;
561 Some(Table {
562 columns: vec!["fresh".to_string()],
563 rows: vec![vec![Value::Bool(fresh)]],
564 })
565 }
566 "severity" => {
567 let status_severity =
568 envelope
569 .get("blocks")
570 .and_then(Value::as_array)
571 .and_then(|blocks| {
572 blocks
573 .iter()
574 .find(|b| b.get("type").and_then(Value::as_str) == Some("status"))
575 .and_then(|b| b.get("severity"))
576 .and_then(Value::as_str)
577 });
578 let severity = status_severity.or_else(|| {
579 envelope
580 .get("verdict")
581 .and_then(|v| v.get("severity"))
582 .and_then(Value::as_str)
583 })?;
584 Some(Table {
585 columns: vec!["severity".to_string()],
586 rows: vec![vec![Value::String(severity.to_string())]],
587 })
588 }
589 _ => None,
590 }
591}
592
593pub fn score_value(result_value: &Value, case: &GoldenCase) -> Option<CompareResult> {
599 let actual = match case.result_kind {
600 ResultKind::Table => serde_json::from_value(result_value.clone()).ok()?,
601 ResultKind::Verdict => {
602 let field = case.verdict_field.as_deref().unwrap_or("fresh");
603 project_verdict_field(result_value, field)?
604 }
605 };
606 Some(compare(&CompareRequest {
607 match_mode: case.match_mode,
608 tolerance: case.tolerance,
609 expected: case.expected.clone(),
610 actual,
611 }))
612}
613
614fn strip_fence(text: &str) -> &str {
616 let Some(open) = text.find("```") else {
617 return text;
618 };
619 let after = &text[open + 3..];
620 let content_start = match after.find('\n') {
622 Some(i) => open + 3 + i + 1,
623 None => return text,
624 };
625 match text[content_start..].find("```") {
626 Some(close_rel) => &text[content_start..content_start + close_rel],
627 None => text,
628 }
629}
630
631pub fn aggregate(model: &str, rows: Vec<CaseResult>) -> ConfigReport {
633 let n = rows.len();
634 let pass_rate_sum_total: f64 = rows.iter().map(|r| r.pass_rate).sum();
637 let accuracy = if n > 0 {
638 pass_rate_sum_total / n as f64
639 } else {
640 0.0
641 };
642 let flaky_cases = rows.iter().filter(|r| r.flaky).count() as u32;
643 let cost_total_usd: Option<f64> = rows
646 .iter()
647 .try_fold(0.0_f64, |acc, r| r.cost.map(|c| acc + c));
648 let latency_sum: u64 = rows.iter().map(|r| r.latency_ms).sum();
649 let latency_ms_avg = if n > 0 { latency_sum / n as u64 } else { 0 };
650 let turns_avg: Option<u64> = if n > 0 {
651 rows.iter()
652 .try_fold(0_u64, |acc, r| r.turns.map(|t| acc + t))
653 .map(|sum| sum / n as u64)
654 } else {
655 Some(0)
656 };
657 let cache_hits = rows.iter().map(|r| r.cache_hits as usize).sum();
658 let cache_misses = rows.iter().map(|r| r.cache_misses as usize).sum();
659
660 let mut by_tag: BTreeMap<String, TagStat> = BTreeMap::new();
661 for r in &rows {
662 for t in &r.tags {
663 let e = by_tag.entry(t.clone()).or_insert(TagStat {
664 pass: 0,
665 n: 0,
666 pass_rate_sum: 0.0,
667 });
668 e.n += 1;
669 if r.pass {
670 e.pass += 1;
671 }
672 e.pass_rate_sum += r.pass_rate;
673 }
674 }
675
676 ConfigReport {
677 model: model.to_string(),
678 n,
679 accuracy,
680 cost_total_usd,
681 latency_ms_avg,
682 turns_avg,
683 cache_hits,
684 cache_misses,
685 flaky_cases,
686 by_tag,
687 cases: rows,
688 }
689}
690
691pub fn format_pareto(report: &Report) -> String {
693 let mut out = String::new();
694 out.push_str("\n=== Warble eval — Pareto (accuracy vs cost vs latency) ===\n");
695 if report.selected_cases < report.total_cases {
696 out.push_str(&format!(
697 " (subset: {}/{} golden cases — smoke run, not a full scoring)\n",
698 report.selected_cases, report.total_cases
699 ));
700 }
701 out.push_str(&format!(
702 "{:<16} {:<7} {:<10} {:<12} {:<7} by_tag\n",
703 "binding", "acc", "cost($)", "lat(ms)", "turns"
704 ));
705 for c in &report.configs {
706 let tags = c
707 .by_tag
708 .iter()
709 .map(|(t, v)| format!("{t}:{:.2}", v.pass_rate_sum / v.n.max(1) as f64))
710 .collect::<Vec<_>>()
711 .join(" ");
712 let cost = match c.cost_total_usd {
715 Some(cost) => format!("{cost:.4}"),
716 None => "n/a".to_string(),
717 };
718 let turns = c
719 .turns_avg
720 .map(|t| t.to_string())
721 .unwrap_or_else(|| "n/a".to_string());
722 out.push_str(&format!(
723 "{:<16} {:<7} {:<10} {:<12} {:<7} {tags}\n",
724 format!("strong→{}", c.model),
725 format!("{:.2}", c.accuracy),
726 cost,
727 c.latency_ms_avg,
728 turns
729 ));
730 }
731 if report.configs.iter().any(|c| c.cache_hits > 0) {
734 out.push_str("cache (miss = LLM call this run):\n");
735 for c in &report.configs {
736 let note = if c.cache_misses == 0 {
737 "re-score only, 0 LLM calls this run".to_string()
738 } else {
739 format!("{} LLM calls this run", c.cache_misses)
740 };
741 out.push_str(&format!(
742 " strong→{:<10} {} hit / {} miss — {note}\n",
743 c.model, c.cache_hits, c.cache_misses
744 ));
745 }
746 }
747 if report.configs.iter().any(|c| c.flaky_cases > 0) {
750 out.push_str("flaky cases (inconsistent across samples):\n");
751 for c in &report.configs {
752 for case in c.cases.iter().filter(|case| case.flaky) {
753 out.push_str(&format!(
754 " strong→{:<10} {} pass_rate={:.2} ({}/{})",
755 c.model, case.id, case.pass_rate, case.passes, case.samples
756 ));
757 if let Some(dist) = &case.answer_dist {
758 let mut answers: Vec<(&String, &u32)> = dist.iter().collect();
759 answers.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
760 let top = answers
761 .into_iter()
762 .take(3)
763 .map(|(a, n)| format!("{a}×{n}"))
764 .collect::<Vec<_>>()
765 .join(", ");
766 out.push_str(&format!(" — {top}"));
767 }
768 out.push('\n');
769 }
770 }
771 }
772 out
773}
774
775struct InstalledAgents {
780 files: Vec<PathBuf>,
781 agents_dir: PathBuf,
782 claude_dir: PathBuf,
783}
784
785impl Drop for InstalledAgents {
786 fn drop(&mut self) {
787 for f in &self.files {
788 let _ = fs::remove_file(f);
789 }
790 let _ = fs::remove_dir(&self.agents_dir);
791 let _ = fs::remove_dir(&self.claude_dir);
792 }
793}
794
795pub(crate) fn install_agents(agent_dir: &Path, project: &Path) -> Result<InstalledAgents, String> {
796 let claude_dir = project.join(".claude");
797 let agents_dir = claude_dir.join("agents");
798 fs::create_dir_all(&agents_dir).map_err(|e| format!("mkdir {}: {e}", agents_dir.display()))?;
799
800 let src_agents = agent_dir.join(".claude").join("agents");
801 let mut files = Vec::new();
802 for entry in
803 fs::read_dir(&src_agents).map_err(|e| format!("read {}: {e}", src_agents.display()))?
804 {
805 let entry = entry.map_err(|e| e.to_string())?;
806 let dst = agents_dir.join(entry.file_name());
807 fs::copy(entry.path(), &dst).map_err(|e| format!("copy agent: {e}"))?;
808 files.push(dst);
809 }
810
811 let settings_src = {
813 let split = agent_dir.join(".claude").join("settings.json");
814 if split.is_file() {
815 Some(split)
816 } else {
817 let flat = agent_dir.join("settings.json");
818 flat.is_file().then_some(flat)
819 }
820 };
821 if let Some(src) = settings_src {
822 let dst = claude_dir.join("settings.json");
823 fs::copy(&src, &dst).map_err(|e| format!("copy settings: {e}"))?;
824 files.push(dst);
825 }
826
827 Ok(InstalledAgents {
828 files,
829 agents_dir,
830 claude_dir,
831 })
832}
833
834pub(crate) fn agent_name(agent_dir: &Path) -> Result<String, String> {
836 let dir = agent_dir.join(".claude").join("agents");
837 let mut names: Vec<String> = fs::read_dir(&dir)
838 .map_err(|e| format!("read {}: {e}", dir.display()))?
839 .filter_map(|e| e.ok())
840 .filter_map(|e| e.file_name().to_str().map(str::to_string))
841 .filter_map(|f| f.strip_suffix(".md").map(str::to_string))
842 .collect();
843 names.sort();
844 names
845 .into_iter()
846 .next()
847 .ok_or_else(|| format!("no agent files in {}", dir.display()))
848}
849
850pub(crate) fn run_path(project: &Path) -> String {
852 let base = std::env::var("PATH").unwrap_or_default();
853 let venv = project.join(".venv").join("bin");
854 if venv.is_dir() {
855 format!("{}:{base}", venv.display())
856 } else {
857 base
858 }
859}
860
861fn run_case(
873 project: &Path,
874 agent: &str,
875 path_env: &str,
876 case: &GoldenCase,
877 sample: u32,
878 ctx: &CaseCtx,
879) -> SampleOutcome {
880 let key = CaseKey {
881 case_id: &case.id,
882 question: &case.question,
883 agent_sha: ctx.agent_sha,
884 model: ctx.model,
885 context_sha: ctx.context_sha,
886 sample,
887 backend: ctx.backend,
888 };
889
890 if let Some(trace) = ctx.store.load(&key) {
893 if let Some(verdict) = rescore(&trace, case) {
894 return SampleOutcome {
895 pass: verdict.pass,
896 reason: verdict.reason,
897 cost: trace.cost,
898 latency_ms: trace.latency_ms,
899 turns: trace.turns,
900 cache_hit: true,
901 answer: ctx.record_answers.then(|| render_answer(&trace.result)),
902 };
903 }
904 }
905
906 let started = Instant::now();
907 let fail = |reason: &str, cost: Option<f64>, latency_ms: u64| SampleOutcome {
908 pass: false,
909 reason: reason.to_string(),
910 cost,
911 latency_ms,
912 turns: None,
913 cache_hit: false,
914 answer: None,
915 };
916
917 let invocation = ctx.adapter.invoke(
924 project,
925 agent,
926 path_env,
927 &case.question,
928 ctx.model_override(),
929 );
930
931 if !invocation.ok {
932 const MAX_REASON_CHARS: usize = 200;
941 let detail = invocation
942 .raw
943 .lines()
944 .map(str::trim)
945 .find(|line| !line.is_empty());
946 let reason = match detail {
947 Some(line) if line.chars().count() > MAX_REASON_CHARS => {
948 let clipped: String = line.chars().take(MAX_REASON_CHARS).collect();
949 format!("backend invocation failed: {clipped}…")
950 }
951 Some(line) => format!("backend invocation failed: {line}"),
952 None => "backend invocation failed".to_string(),
953 };
954 return fail(&reason, None, started.elapsed().as_millis() as u64);
955 }
956 let raw = invocation.raw;
957 let result_text = raw.as_str();
958 let latency_ms = invocation
962 .latency_ms
963 .unwrap_or_else(|| started.elapsed().as_millis() as u64);
964 let cost = invocation.cost;
965 let turns = invocation.turns;
968
969 let extracted = match case.result_kind {
974 ResultKind::Table => extract_result_json(result_text),
975 ResultKind::Verdict => extract_verdict_json(result_text),
976 };
977 let Some(result_value) = extracted else {
978 let reason = match case.result_kind {
979 ResultKind::Table => "no parseable {columns,rows} in output",
980 ResultKind::Verdict => "no parseable verdict envelope in output",
981 };
982 return SampleOutcome {
983 pass: false,
984 reason: reason.to_string(),
985 cost,
986 latency_ms,
987 turns,
988 cache_hit: false,
989 answer: recorded_failure_answer(ctx.record_answers, None, result_text),
993 };
994 };
995 let Some(verdict) = score_value(&result_value, case) else {
996 let reason = match case.result_kind {
997 ResultKind::Table => "no parseable {columns,rows} in output",
998 ResultKind::Verdict => "verdict envelope missing the expected field",
999 };
1000 return SampleOutcome {
1001 pass: false,
1002 reason: reason.to_string(),
1003 cost,
1004 latency_ms,
1005 turns,
1006 cache_hit: false,
1007 answer: recorded_failure_answer(ctx.record_answers, Some(&result_value), result_text),
1010 };
1011 };
1012
1013 let trace = Trace {
1015 case_id: case.id.clone(),
1016 agent_sha: ctx.agent_sha.to_string(),
1017 model: ctx.model.to_string(),
1018 context_version: ctx.context_version.map(str::to_string),
1019 context_sha: ctx.context_sha.to_string(),
1020 question: case.question.clone(),
1021 sql_executed: None,
1022 result: result_value,
1023 cost,
1024 latency_ms,
1025 turns,
1026 tool_calls: None,
1027 backend: ctx.backend,
1028 };
1029 if let Err(e) = ctx.store.store(&key, &trace) {
1030 eprintln!(
1031 " note: could not cache trace for {} (sample {sample}): {e}",
1032 case.id
1033 );
1034 }
1035
1036 SampleOutcome {
1037 pass: verdict.pass,
1038 reason: verdict.reason,
1039 cost,
1040 latency_ms,
1041 turns,
1042 cache_hit: false,
1043 answer: ctx.record_answers.then(|| render_answer(&trace.result)),
1044 }
1045}
1046
1047#[derive(Clone, Copy)]
1053enum ArtifactPath<'a> {
1054 AgentDir(&'a Path),
1055 Ir(&'a Path),
1056}
1057
1058pub fn run_eval(cfg: &RunConfig) -> Result<Report, String> {
1059 let golden_text = fs::read_to_string(&cfg.golden_path)
1060 .map_err(|e| format!("read {}: {e}", cfg.golden_path.display()))?;
1061 let golden: Golden =
1062 serde_yaml::from_str(&golden_text).map_err(|e| format!("parse golden: {e}"))?;
1063
1064 let total_cases = golden.cases.len();
1066 let cases = select_and_subset(&golden, &cfg.filter)?;
1067 let selected_cases = cases.len();
1068
1069 let adapter = resolve_adapter(cfg.backend)?;
1073
1074 let artifact = match cfg.backend {
1081 Backend::ClaudeCodeCli => ArtifactPath::AgentDir(
1082 cfg.agent_dir
1083 .as_deref()
1084 .ok_or_else(|| "backend 'claude-code-cli' requires --agent-dir".to_string())?,
1085 ),
1086 _ => ArtifactPath::Ir(
1087 cfg.ir_path
1088 .as_deref()
1089 .ok_or_else(|| format!("backend '{}' requires --ir", cfg.backend))?,
1090 ),
1091 };
1092
1093 let (agent, context_injection): (String, Option<ContextInjectionReport>) = match artifact {
1094 ArtifactPath::AgentDir(dir) => (agent_name(dir)?, read_context_injection_report(dir)?),
1095 ArtifactPath::Ir(path) => (path.display().to_string(), None),
1096 };
1097 let path_env = run_path(&cfg.project);
1098
1099 let (mut store, context_sha) =
1103 build_store(cfg.no_cache, cfg.cache_dir.as_deref(), &cfg.project);
1104 let agent_sha = if store.is_enabled() {
1105 let sha = match artifact {
1106 ArtifactPath::AgentDir(dir) => context::compute_dir_sha(dir),
1107 ArtifactPath::Ir(path) => context::compute_file_sha(path),
1108 };
1109 match sha {
1110 Ok(s) => s,
1111 Err(e) => {
1112 eprintln!("cache: disabled — cannot hash agent artifact: {e}");
1113 store = TraceStore::disabled();
1114 String::new()
1115 }
1116 }
1117 } else {
1118 String::new()
1119 };
1120
1121 let _installed = match artifact {
1124 ArtifactPath::AgentDir(dir) => Some(install_agents(dir, &cfg.project)?),
1125 ArtifactPath::Ir(_) => None,
1126 };
1127
1128 let parallel = cfg.parallel.max(1);
1129 let mut configs = Vec::new();
1130 for model in &cfg.models {
1131 let par = if parallel > 1 {
1132 format!(", parallel={parallel}")
1133 } else {
1134 String::new()
1135 };
1136 eprintln!("\n### binding: strong→{model} (n={selected_cases}{par})");
1137 let ctx = CaseCtx {
1138 model,
1139 agent_sha: &agent_sha,
1140 context_sha: &context_sha,
1141 context_version: golden.context_version.as_deref(),
1142 store: &store,
1143 record_answers: cfg.record_answers,
1144 backend: cfg.backend,
1145 adapter: adapter.as_ref(),
1146 };
1147 let rows = run_cases(
1148 &cfg.project,
1149 &agent,
1150 &path_env,
1151 &cases,
1152 cfg.samples,
1153 parallel,
1154 &ctx,
1155 );
1156 configs.push(aggregate(model, rows));
1157 }
1158
1159 Ok(Report {
1160 dataset: golden.dataset,
1161 context_version: golden.context_version,
1162 context_injection,
1163 parallel,
1164 selected_cases,
1165 total_cases,
1166 configs,
1167 backend: cfg.backend,
1168 })
1169}
1170
1171fn read_context_injection_report(
1172 agent_dir: &Path,
1173) -> Result<Option<ContextInjectionReport>, String> {
1174 let path = agent_dir.join("context-report.json");
1175 if !path.is_file() {
1176 return Ok(None);
1177 }
1178 let raw = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
1179 let report: ContextInjectionReport = serde_json::from_str(&raw).map_err(|_| {
1180 format!(
1181 "invalid {}: expected context identity fields only",
1182 path.display()
1183 )
1184 })?;
1185 report
1186 .validate()
1187 .map(Some)
1188 .map_err(|reason| format!("invalid {}: {reason}", path.display()))
1189}
1190
1191#[cfg(test)]
1192mod context_injection_report_tests {
1193 use super::*;
1194
1195 fn write_report(body: &str) -> tempfile::TempDir {
1196 let dir = tempfile::tempdir().unwrap();
1197 fs::write(dir.path().join("context-report.json"), body).unwrap();
1198 dir
1199 }
1200
1201 #[test]
1202 fn accepts_only_the_non_sensitive_context_identity_shape() {
1203 let dir = write_report(
1204 r#"{
1205 "mode":"schema+knowledge",
1206 "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1207 "knowledge_fingerprint":"fnv1a64:fedcba9876543210",
1208 "knowledge_chars":42
1209 }"#,
1210 );
1211 let report = read_context_injection_report(dir.path())
1212 .unwrap()
1213 .expect("report present");
1214 assert_eq!(report.mode, "schema+knowledge");
1215 assert_eq!(report.knowledge_chars, 42);
1216 }
1217
1218 #[test]
1219 fn rejects_rule_text_paths_and_inconsistent_identity_without_echoing_values() {
1220 let secret = "PRIVATE_RULE_MARKER /Users/example/private-project";
1221 let unknown = write_report(&format!(
1222 r#"{{
1223 "mode":"schema-only",
1224 "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1225 "knowledge_fingerprint":null,
1226 "knowledge_chars":0,
1227 "rule_text":{secret:?}
1228 }}"#
1229 ));
1230 let error = read_context_injection_report(unknown.path()).unwrap_err();
1231 assert!(!error.contains(secret));
1232
1233 let path_as_fingerprint = write_report(
1234 r#"{
1235 "mode":"schema+knowledge",
1236 "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1237 "knowledge_fingerprint":"/Users/example/private-project",
1238 "knowledge_chars":42
1239 }"#,
1240 );
1241 let error = read_context_injection_report(path_as_fingerprint.path()).unwrap_err();
1242 assert!(!error.contains("/Users/example/private-project"));
1243
1244 let inconsistent = write_report(
1245 r#"{
1246 "mode":"schema-only",
1247 "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1248 "knowledge_fingerprint":"fnv1a64:fedcba9876543210",
1249 "knowledge_chars":42
1250 }"#,
1251 );
1252 assert!(read_context_injection_report(inconsistent.path()).is_err());
1253 }
1254}
1255
1256pub(crate) fn build_store(
1262 no_cache: bool,
1263 cache_dir: Option<&Path>,
1264 project: &Path,
1265) -> (TraceStore, String) {
1266 if no_cache {
1267 if cache_dir.is_some() {
1268 eprintln!("cache: --cache-dir ignored — --no-cache disables the cache entirely");
1269 }
1270 eprintln!("cache: disabled (--no-cache) — every case re-runs and refreshes its trace");
1271 return (TraceStore::disabled(), String::new());
1272 }
1273 let context_sha = match compute_mdl_sha(project) {
1274 Ok(s) => s,
1275 Err(e) => {
1276 eprintln!("cache: disabled — cannot compute MDL context SHA: {e}");
1277 return (TraceStore::disabled(), String::new());
1278 }
1279 };
1280 let dir = cache_dir
1281 .map(Path::to_path_buf)
1282 .unwrap_or_else(|| project.join(".warble").join("eval-cache"));
1283 eprintln!(
1284 "cache: enabled at {} (context_sha {})",
1285 dir.display(),
1286 &context_sha[..context_sha.len().min(12)]
1287 );
1288 (TraceStore::new(dir, true), context_sha)
1289}
1290
1291pub(crate) fn select_and_subset(
1295 golden: &Golden,
1296 filter: &CaseFilter,
1297) -> Result<Vec<GoldenCase>, String> {
1298 let selection = select_cases(&golden.cases, filter);
1299 if filter.is_active() {
1300 eprintln!("{}", selection.note);
1301 }
1302 if filter.is_active() && selection.indices.is_empty() {
1303 return Err(format!(
1304 "no golden cases match the filter — {} (nothing to run)",
1305 selection.note
1306 ));
1307 }
1308 Ok(selection
1309 .indices
1310 .iter()
1311 .map(|&i| golden.cases[i].clone())
1312 .collect())
1313}
1314
1315pub(crate) fn run_cases(
1326 project: &Path,
1327 agent: &str,
1328 path_env: &str,
1329 cases: &[GoldenCase],
1330 samples: usize,
1331 parallel: usize,
1332 ctx: &CaseCtx,
1333) -> Vec<CaseResult> {
1334 let samples = samples.max(1);
1335 let outcomes = run_indexed(cases.len() * samples, parallel, |j| {
1336 let case = &cases[j / samples];
1337 let sample = (j % samples) as u32;
1338 let r = run_case(project, agent, path_env, case, sample, ctx);
1339 let extra = if r.pass {
1340 String::new()
1341 } else {
1342 format!(" — {}", r.reason)
1343 };
1344 let cost = match r.cost {
1347 Some(cost) => format!(", ${cost:.4}"),
1348 None => String::new(),
1349 };
1350 let turns = r
1351 .turns
1352 .map(|t| t.to_string())
1353 .unwrap_or_else(|| "n/a".to_string());
1354 let cache = if r.cache_hit { " [cache]" } else { "" };
1356 if samples == 1 {
1357 eprintln!(
1358 " {} {}{cache} ({:.1}s{cost}, {turns}t){extra}",
1359 if r.pass { "PASS" } else { "FAIL" },
1360 case.id,
1361 r.latency_ms as f64 / 1000.0,
1362 );
1363 } else {
1364 eprintln!(
1365 " {} {} sample {sample}{cache} ({:.1}s{cost}, {turns}t){extra}",
1366 if r.pass { "PASS" } else { "FAIL" },
1367 case.id,
1368 r.latency_ms as f64 / 1000.0,
1369 );
1370 }
1371 r
1372 });
1373
1374 let mut it = outcomes.into_iter();
1378 cases
1379 .iter()
1380 .map(|case| {
1381 let chunk: Vec<SampleOutcome> = (&mut it).take(samples).collect();
1382 let result = fold_samples(
1383 case.id.clone(),
1384 case.tags.clone(),
1385 chunk,
1386 ctx.record_answers,
1387 );
1388 if samples > 1 {
1389 let marker = if result.flaky {
1390 "FLAKY"
1391 } else if result.pass {
1392 "PASS"
1393 } else {
1394 "FAIL"
1395 };
1396 eprintln!(
1397 " {marker} {} pass_rate={:.2} ({}/{})",
1398 result.id, result.pass_rate, result.passes, result.samples
1399 );
1400 }
1401 result
1402 })
1403 .collect()
1404}
1405
1406pub(crate) fn run_indexed<T, F>(n: usize, parallel: usize, job: F) -> Vec<T>
1411where
1412 T: Send,
1413 F: Fn(usize) -> T + Sync,
1414{
1415 if parallel <= 1 || n <= 1 {
1416 return (0..n).map(job).collect();
1417 }
1418
1419 use std::sync::atomic::{AtomicUsize, Ordering};
1420 use std::sync::Mutex;
1421
1422 let next = AtomicUsize::new(0);
1423 let slots: Vec<Mutex<Option<T>>> = (0..n).map(|_| Mutex::new(None)).collect();
1424
1425 std::thread::scope(|scope| {
1426 for _ in 0..parallel.min(n) {
1427 scope.spawn(|| loop {
1428 let i = next.fetch_add(1, Ordering::Relaxed);
1429 if i >= n {
1430 break;
1431 }
1432 let r = job(i);
1433 *slots[i].lock().expect("result slot poisoned") = Some(r);
1434 });
1435 }
1436 });
1437
1438 slots
1439 .into_iter()
1440 .map(|slot| {
1441 slot.into_inner()
1442 .expect("result slot poisoned")
1443 .expect("every index 0..n is claimed exactly once by the shared counter")
1444 })
1445 .collect()
1446}
1447
1448#[cfg(test)]
1449mod run_indexed_tests {
1450 use super::run_indexed;
1451 use std::sync::atomic::{AtomicUsize, Ordering};
1452 use std::time::Duration;
1453
1454 #[test]
1455 fn serial_path_preserves_order_and_runs_each_once() {
1456 let calls = AtomicUsize::new(0);
1457 let out = run_indexed(5, 1, |i| {
1458 calls.fetch_add(1, Ordering::Relaxed);
1459 i * 10
1460 });
1461 assert_eq!(out, vec![0, 10, 20, 30, 40]);
1462 assert_eq!(calls.load(Ordering::Relaxed), 5);
1463 }
1464
1465 #[test]
1466 fn parallel_results_are_in_index_order_despite_completion_order() {
1467 let out = run_indexed(8, 4, |i| {
1470 std::thread::sleep(Duration::from_millis((8 - i as u64) * 3));
1471 i
1472 });
1473 assert_eq!(out, (0..8).collect::<Vec<_>>());
1474 }
1475
1476 #[test]
1477 fn parallel_runs_each_index_exactly_once() {
1478 let per_index: Vec<AtomicUsize> = (0..32).map(|_| AtomicUsize::new(0)).collect();
1479 let out = run_indexed(32, 6, |i| {
1480 per_index[i].fetch_add(1, Ordering::Relaxed);
1481 i
1482 });
1483 assert_eq!(out.len(), 32);
1484 for (i, c) in per_index.iter().enumerate() {
1485 assert_eq!(c.load(Ordering::Relaxed), 1, "index {i} ran once");
1486 }
1487 }
1488
1489 #[test]
1490 fn parallelism_wider_than_n_and_empty_input_are_fine() {
1491 assert_eq!(run_indexed(2, 16, |i| i), vec![0, 1]);
1492 assert_eq!(run_indexed(0, 4, |i| i), Vec::<usize>::new());
1493 }
1494
1495 #[test]
1496 fn parallel_actually_overlaps_work() {
1497 let in_flight = AtomicUsize::new(0);
1504 let max_in_flight = AtomicUsize::new(0);
1505 run_indexed(4, 4, |_| {
1506 let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1507 max_in_flight.fetch_max(now, Ordering::SeqCst);
1508 std::thread::sleep(Duration::from_millis(40));
1509 in_flight.fetch_sub(1, Ordering::SeqCst);
1510 });
1511 let max = max_in_flight.load(Ordering::SeqCst);
1512 assert!(
1513 max > 1,
1514 "expected overlapping execution, but max concurrent was {max}"
1515 );
1516 }
1517}
1518
1519#[cfg(test)]
1520mod fold_samples_tests {
1521 use super::*;
1522
1523 fn outcome(pass: bool, reason: &str, cost: f64, latency_ms: u64, turns: u64) -> SampleOutcome {
1524 SampleOutcome {
1525 pass,
1526 reason: reason.to_string(),
1527 cost: Some(cost),
1528 latency_ms,
1529 turns: Some(turns),
1530 cache_hit: false,
1531 answer: None,
1532 }
1533 }
1534
1535 #[test]
1536 fn fully_passing_is_not_flaky_and_reason_is_match() {
1537 let outcomes = vec![
1538 outcome(true, "match", 0.1, 1_000, 2),
1539 outcome(true, "match", 0.1, 1_000, 2),
1540 ];
1541 let r = fold_samples("a".into(), vec![], outcomes, false);
1542 assert_eq!(r.samples, 2);
1543 assert_eq!(r.passes, 2);
1544 assert!((r.pass_rate - 1.0).abs() < 1e-9);
1545 assert!(r.pass);
1546 assert!(!r.flaky);
1547 assert_eq!(r.reason, "match");
1548 }
1549
1550 #[test]
1551 fn fully_failing_is_not_flaky() {
1552 let outcomes = vec![
1553 outcome(false, "mismatch", 0.0, 500, 1),
1554 outcome(false, "mismatch", 0.0, 500, 1),
1555 ];
1556 let r = fold_samples("a".into(), vec![], outcomes, false);
1557 assert_eq!(r.passes, 0);
1558 assert_eq!(r.pass_rate, 0.0);
1559 assert!(!r.pass);
1560 assert!(!r.flaky, "0 passes out of N is a hard fail, not flaky");
1561 }
1562
1563 #[test]
1564 fn partial_pass_is_flaky_and_reason_is_the_modal_failure() {
1565 let outcomes = vec![
1566 outcome(true, "match", 0.0, 0, 0),
1567 outcome(false, "wrong total", 0.0, 0, 0),
1568 outcome(false, "wrong total", 0.0, 0, 0),
1569 outcome(false, "timeout", 0.0, 0, 0),
1570 ];
1571 let r = fold_samples("a".into(), vec![], outcomes, false);
1572 assert_eq!(r.passes, 1);
1573 assert_eq!(r.samples, 4);
1574 assert!((r.pass_rate - 0.25).abs() < 1e-9);
1575 assert!(!r.pass);
1576 assert!(r.flaky);
1577 assert_eq!(r.reason, "wrong total", "modal failure reason wins");
1578 }
1579
1580 #[test]
1581 fn modal_reason_tie_break_is_first_occurrence() {
1582 let outcomes = vec![
1584 outcome(false, "a", 0.0, 0, 0),
1585 outcome(false, "b", 0.0, 0, 0),
1586 outcome(false, "b", 0.0, 0, 0),
1587 outcome(false, "a", 0.0, 0, 0),
1588 ];
1589 let r = fold_samples("a".into(), vec![], outcomes, false);
1590 assert_eq!(r.reason, "a");
1591 }
1592
1593 #[test]
1594 fn cost_sums_latency_and_turns_average() {
1595 let outcomes = vec![
1596 outcome(true, "match", 0.10, 1_000, 4),
1597 outcome(true, "match", 0.20, 3_000, 6),
1598 ];
1599 let r = fold_samples("a".into(), vec![], outcomes, false);
1600 assert!(
1601 (r.cost.expect("cost present") - 0.30).abs() < 1e-9,
1602 "cost sums across samples"
1603 );
1604 assert_eq!(r.latency_ms, 2_000, "latency averages across samples");
1605 assert_eq!(r.turns, Some(5), "turns averages across samples");
1606 }
1607
1608 #[test]
1609 fn cost_and_turns_are_none_when_any_sample_lacks_them() {
1610 let outcomes = vec![
1611 outcome(true, "match", 0.10, 1_000, 4),
1612 SampleOutcome {
1613 cost: None,
1614 turns: None,
1615 ..outcome(true, "match", 0.0, 1_000, 0)
1616 },
1617 ];
1618 let r = fold_samples("a".into(), vec![], outcomes, false);
1619 assert!(
1620 r.cost.is_none(),
1621 "one sample's missing cost makes the case's cost absent, not a partial sum"
1622 );
1623 assert!(
1624 r.turns.is_none(),
1625 "one sample's missing turns makes the case's turns absent, not a partial average"
1626 );
1627 }
1628
1629 #[test]
1630 fn cache_hits_and_misses_are_counted_per_sample() {
1631 let outcomes = vec![
1632 SampleOutcome {
1633 cache_hit: true,
1634 ..outcome(true, "match", 0.0, 0, 0)
1635 },
1636 SampleOutcome {
1637 cache_hit: false,
1638 ..outcome(true, "match", 0.0, 0, 0)
1639 },
1640 SampleOutcome {
1641 cache_hit: true,
1642 ..outcome(true, "match", 0.0, 0, 0)
1643 },
1644 ];
1645 let r = fold_samples("a".into(), vec![], outcomes, false);
1646 assert_eq!(r.cache_hits, 2);
1647 assert_eq!(r.cache_misses, 1);
1648 }
1649
1650 #[test]
1651 fn answer_dist_is_only_populated_when_record_answers_is_set() {
1652 let build = || {
1653 vec![
1654 SampleOutcome {
1655 answer: Some("42".to_string()),
1656 ..outcome(true, "match", 0.0, 0, 0)
1657 },
1658 SampleOutcome {
1659 answer: Some("42".to_string()),
1660 ..outcome(true, "match", 0.0, 0, 0)
1661 },
1662 SampleOutcome {
1663 answer: Some("7".to_string()),
1664 ..outcome(false, "wrong total", 0.0, 0, 0)
1665 },
1666 ]
1667 };
1668
1669 let without = fold_samples("a".into(), vec![], build(), false);
1670 assert!(without.answer_dist.is_none());
1671 assert!(
1672 without.samples_detail.iter().all(|s| s.answer.is_none()),
1673 "no answer strings recorded unless --record-answers"
1674 );
1675
1676 let with = fold_samples("a".into(), vec![], build(), true);
1677 let dist = with.answer_dist.expect("populated when record_answers");
1678 assert_eq!(dist.get("42"), Some(&2));
1679 assert_eq!(dist.get("7"), Some(&1));
1680 assert_eq!(
1681 with.samples_detail
1682 .iter()
1683 .filter(|s| s.answer.is_some())
1684 .count(),
1685 3
1686 );
1687 }
1688
1689 #[test]
1690 fn failed_output_is_recorded_only_when_requested() {
1691 let extracted = serde_json::json!({
1692 "blocks": [{"type": "status", "state": "unknown"}],
1693 "verified": false,
1694 });
1695
1696 assert_eq!(recorded_failure_answer(false, None, "raw refusal"), None);
1697 assert_eq!(
1698 recorded_failure_answer(false, Some(&extracted), "ignored"),
1699 None
1700 );
1701 assert_eq!(
1702 recorded_failure_answer(true, None, "raw refusal").as_deref(),
1703 Some("raw refusal")
1704 );
1705 assert_eq!(
1706 recorded_failure_answer(true, Some(&extracted), "ignored"),
1707 Some(extracted.to_string())
1708 );
1709 }
1710
1711 #[test]
1712 fn modal_reason_of_empty_iterator_is_empty_string() {
1713 assert_eq!(modal_reason(std::iter::empty()), "");
1714 }
1715}
1716
1717#[cfg(test)]
1718mod verdict_tests {
1719 use super::*;
1720
1721 fn envelope(fresh: bool, severity: &str) -> serde_json::Value {
1724 serde_json::json!({
1725 "blocks": [
1726 { "type": "status", "state": if fresh { "fresh" } else { "stale" },
1727 "label": "orders freshness", "detail": "…", "severity": severity }
1728 ],
1729 "verdict": { "type": "freshness_verdict", "fresh": fresh, "observed_lag_hours": 51,
1730 "expected_cadence": "24h" },
1731 "emitted": ["freshness_breach"],
1732 "verified": true,
1733 })
1734 }
1735
1736 fn fenced(value: &serde_json::Value) -> String {
1737 format!("Some prose.\n\n```json\n{value}\n```\n")
1738 }
1739
1740 #[test]
1741 fn extract_verdict_json_accepts_a_real_envelope() {
1742 let text = fenced(&envelope(false, "critical"));
1743 let got = extract_verdict_json(&text).expect("envelope parses");
1744 assert_eq!(got["verdict"]["fresh"], false);
1745 }
1746
1747 #[test]
1748 fn extract_verdict_json_rejects_a_columns_rows_table() {
1749 let text = fenced(&serde_json::json!({"columns": ["n"], "rows": [[42]]}));
1750 assert!(
1751 extract_verdict_json(&text).is_none(),
1752 "a table has neither `verdict` nor `blocks`"
1753 );
1754 }
1755
1756 #[test]
1757 fn extract_verdict_json_rejects_malformed_json() {
1758 assert!(extract_verdict_json("not json at all").is_none());
1759 assert!(extract_verdict_json("```json\n{ not valid\n```").is_none());
1760 }
1761
1762 #[test]
1763 fn extract_result_json_rejects_a_verdict_envelope() {
1764 let text = fenced(&envelope(true, "critical"));
1767 assert!(extract_result_json(&text).is_none());
1768 }
1769
1770 #[test]
1771 fn project_verdict_field_reads_fresh_true_and_false() {
1772 let fresh_table = project_verdict_field(&envelope(true, "critical"), "fresh").unwrap();
1773 assert_eq!(fresh_table.rows, vec![vec![Value::Bool(true)]]);
1774
1775 let stale_table = project_verdict_field(&envelope(false, "critical"), "fresh").unwrap();
1776 assert_eq!(stale_table.rows, vec![vec![Value::Bool(false)]]);
1777 }
1778
1779 #[test]
1780 fn project_verdict_field_reads_severity_warn_and_critical() {
1781 let warn = project_verdict_field(&envelope(false, "warn"), "severity").unwrap();
1782 assert_eq!(warn.rows, vec![vec![Value::String("warn".to_string())]]);
1783
1784 let critical = project_verdict_field(&envelope(false, "critical"), "severity").unwrap();
1785 assert_eq!(
1786 critical.rows,
1787 vec![vec![Value::String("critical".to_string())]]
1788 );
1789 }
1790
1791 #[test]
1792 fn project_verdict_field_falls_back_to_verdict_severity() {
1793 let envelope = serde_json::json!({
1795 "blocks": [{ "type": "status", "state": "stale" }],
1796 "verdict": { "type": "freshness_verdict", "fresh": false, "severity": "warn" },
1797 });
1798 let got = project_verdict_field(&envelope, "severity").unwrap();
1799 assert_eq!(got.rows, vec![vec![Value::String("warn".to_string())]]);
1800 }
1801
1802 #[test]
1803 fn project_verdict_field_is_none_on_missing_or_unknown_field() {
1804 let e = envelope(true, "critical");
1805 assert!(
1806 project_verdict_field(&e, "observed_lag_hours").is_none(),
1807 "unrecognized field fails closed, never silently passes"
1808 );
1809 let no_verdict = serde_json::json!({"blocks": []});
1810 assert!(project_verdict_field(&no_verdict, "fresh").is_none());
1811 let no_severity_anywhere = serde_json::json!({
1812 "blocks": [{"type": "status", "state": "stale"}],
1813 "verdict": {"type": "freshness_verdict", "fresh": false},
1814 });
1815 assert!(project_verdict_field(&no_severity_anywhere, "severity").is_none());
1816 }
1817
1818 fn verdict_case(tag: &str, field: &str, expected_rows: &str) -> GoldenCase {
1819 let yaml = format!(
1820 "id: v1\nquestion: \"is it fresh?\"\ntags: [{tag}]\nmatch: scalar\n\
1821 result_kind: verdict\nverdict_field: {field}\n\
1822 expected: {{ columns: [{field}], rows: {expected_rows} }}\n"
1823 );
1824 serde_yaml::from_str(&yaml).expect("verdict golden case parses")
1825 }
1826
1827 #[test]
1828 fn golden_case_result_kind_defaults_to_table_when_omitted() {
1829 let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[1]] }\n";
1830 let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
1831 assert_eq!(case.result_kind, ResultKind::Table);
1832 assert!(case.verdict_field.is_none());
1833 }
1834
1835 #[test]
1836 fn score_value_passes_a_matching_verdict_envelope() {
1837 let case = verdict_case("detection", "fresh", "[[false]]");
1838 let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
1839 assert!(
1840 verdict.pass,
1841 "envelope fresh=false matches expected fresh=false"
1842 );
1843 }
1844
1845 #[test]
1846 fn score_value_fails_a_mismatching_verdict_envelope() {
1847 let case = verdict_case("detection", "fresh", "[[true]]");
1848 let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
1849 assert!(
1850 !verdict.pass,
1851 "envelope fresh=false does not match expected fresh=true"
1852 );
1853 }
1854
1855 #[test]
1856 fn score_value_projects_severity_for_the_severity_field() {
1857 let case = verdict_case("severity", "severity", "[[critical]]");
1858 let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
1859 assert!(verdict.pass);
1860 }
1861
1862 #[test]
1863 fn score_value_on_a_table_case_is_unaffected_by_verdict_support() {
1864 let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[42]] }\n";
1865 let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
1866 let result = serde_json::json!({"columns": ["n"], "rows": [[42]]});
1867 let verdict = score_value(&result, &case).expect("table case scores");
1868 assert!(verdict.pass);
1869 }
1870}