use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use warble_claude_code::ModelConfig;
use warble_eval_compare::{compare, CompareRequest, CompareResult, MatchMode, Table, Tolerance};
mod ablation;
mod backend;
mod cache;
mod capture;
mod compliance;
mod context;
mod filter;
mod gate;
mod monitor;
pub use ablation::{
format_ablation, run_ablation, AblationConfig, AblationPoint, AblationReport,
StepRecommendation,
};
pub use backend::Backend;
use backend::{resolve_adapter, BackendAdapter, ModelOverride};
pub use cache::{rescore, CaseKey, Trace, TraceStore};
pub use capture::{build_candidate_yaml, candidates_header, CaptureInput};
pub use compliance::{
format_compliance, score_compliance, Check, CheckStatus, ComplianceIr, ComplianceReport,
ComplianceTrace, TraceEvent,
};
pub use context::{
classify, compute_mdl_sha, parse_context_version, stamp_context_version, verify_context,
Freshness, VerifyResult,
};
pub use filter::{select_cases, CaseFilter, Sample, Selection};
pub use gate::{format_gate, run_gate, FlakyCase, GateResult, Regression};
pub use monitor::{
format_monitor_report, score_monitor_pair, MonitorMetric, MonitorObservation, MonitorReport,
};
#[derive(Debug, serde::Deserialize)]
pub struct Golden {
#[serde(default)]
pub dataset: Option<String>,
#[serde(default)]
pub context_version: Option<String>,
pub cases: Vec<GoldenCase>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResultKind {
#[default]
Table,
Verdict,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct GoldenCase {
pub id: String,
pub question: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(rename = "match")]
pub match_mode: MatchMode,
#[serde(default)]
pub tolerance: Tolerance,
pub expected: Table,
#[serde(default)]
pub result_kind: ResultKind,
#[serde(default)]
pub verdict_field: Option<String>,
}
struct SampleOutcome {
pass: bool,
reason: String,
cost: Option<f64>,
latency_ms: u64,
turns: Option<u64>,
cache_hit: bool,
answer: Option<String>,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct SampleVerdict {
pub pass: bool,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answer: Option<String>,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct CaseResult {
pub id: String,
pub tags: Vec<String>,
#[serde(default)]
pub samples: u32,
#[serde(default)]
pub passes: u32,
#[serde(default)]
pub pass_rate: f64,
pub pass: bool,
#[serde(default)]
pub flaky: bool,
pub reason: String,
#[serde(default)]
pub cost: Option<f64>,
pub latency_ms: u64,
#[serde(default)]
pub turns: Option<u64>,
#[serde(default)]
pub cache_hits: u32,
#[serde(default)]
pub cache_misses: u32,
#[serde(default)]
pub samples_detail: Vec<SampleVerdict>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answer_dist: Option<BTreeMap<String, u32>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputStability {
NotMeasured,
Stable,
Unstable,
Incomplete,
}
impl CaseResult {
pub fn output_stability(&self) -> OutputStability {
let Some(dist) = &self.answer_dist else {
return OutputStability::NotMeasured;
};
if self.samples <= 1 {
return OutputStability::NotMeasured;
}
let observed: u32 = dist.values().sum();
if observed < self.samples {
OutputStability::Incomplete
} else if dist.len() > 1 {
OutputStability::Unstable
} else {
OutputStability::Stable
}
}
}
fn fold_samples(
id: String,
tags: Vec<String>,
outcomes: Vec<SampleOutcome>,
record_answers: bool,
) -> CaseResult {
let samples = outcomes.len() as u32;
let passes = outcomes.iter().filter(|o| o.pass).count() as u32;
let pass_rate = if samples > 0 {
passes as f64 / samples as f64
} else {
0.0
};
let pass = samples > 0 && passes == samples;
let flaky = passes > 0 && passes < samples;
let reason = if passes == samples {
"match".to_string()
} else {
modal_reason(
outcomes
.iter()
.filter(|o| !o.pass)
.map(|o| o.reason.as_str()),
)
};
let cost: Option<f64> = outcomes
.iter()
.try_fold(0.0_f64, |acc, o| o.cost.map(|c| acc + c));
let latency_ms = if samples > 0 {
outcomes.iter().map(|o| o.latency_ms).sum::<u64>() / samples as u64
} else {
0
};
let turns: Option<u64> = if samples > 0 {
outcomes
.iter()
.try_fold(0_u64, |acc, o| o.turns.map(|t| acc + t))
.map(|sum| sum / samples as u64)
} else {
Some(0)
};
let cache_hits = outcomes.iter().filter(|o| o.cache_hit).count() as u32;
let cache_misses = samples - cache_hits;
let answer_dist = record_answers.then(|| {
let mut dist: BTreeMap<String, u32> = BTreeMap::new();
for o in &outcomes {
if let Some(answer) = &o.answer {
*dist.entry(answer.clone()).or_insert(0) += 1;
}
}
dist
});
let samples_detail = outcomes
.into_iter()
.map(|o| SampleVerdict {
pass: o.pass,
reason: o.reason,
answer: if record_answers { o.answer } else { None },
})
.collect();
CaseResult {
id,
tags,
samples,
passes,
pass_rate,
pass,
flaky,
reason,
cost,
latency_ms,
turns,
cache_hits,
cache_misses,
samples_detail,
answer_dist,
}
}
fn modal_reason<'a>(reasons: impl Iterator<Item = &'a str>) -> String {
let mut counts: Vec<(&str, u32)> = Vec::new();
for r in reasons {
match counts.iter_mut().find(|(seen, _)| *seen == r) {
Some(entry) => entry.1 += 1,
None => counts.push((r, 1)),
}
}
let mut best: Option<(&str, u32)> = None;
for (r, c) in counts {
if best.is_none_or(|(_, bc)| c > bc) {
best = Some((r, c));
}
}
best.map(|(r, _)| r.to_string()).unwrap_or_default()
}
fn render_answer(result: &serde_json::Value) -> String {
result.to_string()
}
fn recorded_failure_answer(
record_answers: bool,
extracted: Option<&serde_json::Value>,
raw: &str,
) -> Option<String> {
record_answers.then(|| {
extracted
.map(render_answer)
.unwrap_or_else(|| raw.to_string())
})
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct TagStat {
pub pass: u32,
pub n: u32,
#[serde(default)]
pub pass_rate_sum: f64,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct ConfigReport {
pub model: String,
pub n: usize,
pub accuracy: f64,
#[serde(default)]
pub cost_total_usd: Option<f64>,
pub latency_ms_avg: u64,
#[serde(default)]
pub turns_avg: Option<u64>,
#[serde(default)]
pub cache_hits: usize,
#[serde(default)]
pub cache_misses: usize,
#[serde(default)]
pub flaky_cases: u32,
#[serde(default)]
pub output_unstable_cases: u32,
pub by_tag: BTreeMap<String, TagStat>,
pub cases: Vec<CaseResult>,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct Report {
pub dataset: Option<String>,
pub context_version: Option<String>,
#[serde(default)]
pub context_injection: Option<ContextInjectionReport>,
#[serde(default = "default_parallel")]
pub parallel: usize,
#[serde(default)]
pub selected_cases: usize,
#[serde(default)]
pub total_cases: usize,
pub configs: Vec<ConfigReport>,
#[serde(default)]
pub backend: Backend,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContextInjectionReport {
pub mode: String,
pub schema_digest_fingerprint: String,
pub knowledge_fingerprint: Option<String>,
pub knowledge_chars: usize,
}
impl ContextInjectionReport {
fn validate(self) -> Result<Self, &'static str> {
if !valid_context_fingerprint(&self.schema_digest_fingerprint) {
return Err("invalid schema digest fingerprint");
}
match self.mode.as_str() {
"schema-only" if self.knowledge_fingerprint.is_none() && self.knowledge_chars == 0 => {}
"schema+knowledge"
if self
.knowledge_fingerprint
.as_deref()
.is_some_and(valid_context_fingerprint) => {}
"schema-only" | "schema+knowledge" => return Err("inconsistent knowledge identity"),
_ => return Err("invalid context injection mode"),
}
Ok(self)
}
}
fn valid_context_fingerprint(value: &str) -> bool {
value
.strip_prefix("fnv1a64:")
.is_some_and(|hex| hex.len() == 16 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
}
fn default_parallel() -> usize {
1
}
pub fn effective_record_answers(explicit: bool, samples: usize) -> bool {
explicit || samples > 1
}
pub struct RunConfig {
pub project: PathBuf,
pub agent_dir: Option<PathBuf>,
pub ir_path: Option<PathBuf>,
pub golden_path: PathBuf,
pub models: Vec<String>,
pub out: Option<PathBuf>,
pub parallel: usize,
pub filter: CaseFilter,
pub no_cache: bool,
pub cache_dir: Option<PathBuf>,
pub samples: usize,
pub record_answers: bool,
pub backend: Backend,
pub max_turns: Option<u32>,
pub tier_models: Option<ModelConfig>,
}
pub(crate) struct CaseCtx<'a> {
pub model: &'a str,
pub agent_sha: &'a str,
pub context_sha: &'a str,
pub context_version: Option<&'a str>,
pub store: &'a TraceStore,
pub record_answers: bool,
pub backend: Backend,
pub adapter: &'a dyn BackendAdapter,
pub max_turns: Option<u32>,
pub tier_binding: Option<TierBindingCtx<'a>>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct TierBindingCtx<'a> {
pub strong: &'a str,
pub cheap: &'a str,
pub orchestrator: &'a str,
pub label: &'a str,
}
impl CaseCtx<'_> {
fn model_override(&self) -> Option<ModelOverride<'_>> {
if let Some(tb) = &self.tier_binding {
return Some(ModelOverride::Tiered {
strong: tb.strong,
cheap: tb.cheap,
orchestrator: tb.orchestrator,
});
}
(self.model != cache::FRONTMATTER_MODEL).then_some(ModelOverride::Flat(self.model))
}
}
pub fn extract_result(text: &str) -> Option<Table> {
serde_json::from_value(extract_result_json(text)?).ok()
}
pub fn extract_result_json(text: &str) -> Option<serde_json::Value> {
let candidate = strip_fence(text);
let start = candidate.find('{')?;
let end = candidate.rfind('}')?;
if end <= start {
return None;
}
let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
if !value.get("rows").map(|r| r.is_array()).unwrap_or(false) {
return None;
}
Some(value)
}
pub fn extract_verdict_json(text: &str) -> Option<serde_json::Value> {
let candidate = strip_fence(text);
let start = candidate.find('{')?;
let end = candidate.rfind('}')?;
if end <= start {
return None;
}
let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
let has_verdict = value.get("verdict").is_some();
let has_blocks = value.get("blocks").map(Value::is_array).unwrap_or(false);
if !has_verdict && !has_blocks {
return None;
}
Some(value)
}
pub fn project_verdict_field(envelope: &serde_json::Value, field: &str) -> Option<Table> {
match field {
"fresh" => {
let fresh = envelope.get("verdict")?.get("fresh")?.as_bool()?;
Some(Table {
columns: vec!["fresh".to_string()],
rows: vec![vec![Value::Bool(fresh)]],
})
}
"severity" => {
let status_severity =
envelope
.get("blocks")
.and_then(Value::as_array)
.and_then(|blocks| {
blocks
.iter()
.find(|b| b.get("type").and_then(Value::as_str) == Some("status"))
.and_then(|b| b.get("severity"))
.and_then(Value::as_str)
});
let severity = status_severity.or_else(|| {
envelope
.get("verdict")
.and_then(|v| v.get("severity"))
.and_then(Value::as_str)
})?;
Some(Table {
columns: vec!["severity".to_string()],
rows: vec![vec![Value::String(severity.to_string())]],
})
}
_ => None,
}
}
pub fn score_value(result_value: &Value, case: &GoldenCase) -> Option<CompareResult> {
let actual = match case.result_kind {
ResultKind::Table => serde_json::from_value(result_value.clone()).ok()?,
ResultKind::Verdict => {
let field = case.verdict_field.as_deref().unwrap_or("fresh");
project_verdict_field(result_value, field)?
}
};
Some(compare(&CompareRequest {
match_mode: case.match_mode,
tolerance: case.tolerance,
expected: case.expected.clone(),
actual,
}))
}
fn strip_fence(text: &str) -> &str {
let Some(open) = text.find("```") else {
return text;
};
let after = &text[open + 3..];
let content_start = match after.find('\n') {
Some(i) => open + 3 + i + 1,
None => return text,
};
match text[content_start..].find("```") {
Some(close_rel) => &text[content_start..content_start + close_rel],
None => text,
}
}
pub fn aggregate(model: &str, rows: Vec<CaseResult>) -> ConfigReport {
let n = rows.len();
let pass_rate_sum_total: f64 = rows.iter().map(|r| r.pass_rate).sum();
let accuracy = if n > 0 {
pass_rate_sum_total / n as f64
} else {
0.0
};
let flaky_cases = rows.iter().filter(|r| r.flaky).count() as u32;
let output_unstable_cases = rows
.iter()
.filter(|r| {
matches!(
r.output_stability(),
OutputStability::Unstable | OutputStability::Incomplete
)
})
.count() as u32;
let cost_total_usd: Option<f64> = rows
.iter()
.try_fold(0.0_f64, |acc, r| r.cost.map(|c| acc + c));
let latency_sum: u64 = rows.iter().map(|r| r.latency_ms).sum();
let latency_ms_avg = if n > 0 { latency_sum / n as u64 } else { 0 };
let turns_avg: Option<u64> = if n > 0 {
rows.iter()
.try_fold(0_u64, |acc, r| r.turns.map(|t| acc + t))
.map(|sum| sum / n as u64)
} else {
Some(0)
};
let cache_hits = rows.iter().map(|r| r.cache_hits as usize).sum();
let cache_misses = rows.iter().map(|r| r.cache_misses as usize).sum();
let mut by_tag: BTreeMap<String, TagStat> = BTreeMap::new();
for r in &rows {
for t in &r.tags {
let e = by_tag.entry(t.clone()).or_insert(TagStat {
pass: 0,
n: 0,
pass_rate_sum: 0.0,
});
e.n += 1;
if r.pass {
e.pass += 1;
}
e.pass_rate_sum += r.pass_rate;
}
}
ConfigReport {
model: model.to_string(),
n,
accuracy,
cost_total_usd,
latency_ms_avg,
turns_avg,
cache_hits,
cache_misses,
flaky_cases,
output_unstable_cases,
by_tag,
cases: rows,
}
}
fn binding_label(model: &str) -> String {
if model.contains('=') {
model.to_string()
} else {
format!("strong→{model}")
}
}
pub fn format_pareto(report: &Report) -> String {
let mut out = String::new();
out.push_str("\n=== Warble eval — Pareto (accuracy vs cost vs latency) ===\n");
if report.selected_cases < report.total_cases {
out.push_str(&format!(
" (subset: {}/{} golden cases — smoke run, not a full scoring)\n",
report.selected_cases, report.total_cases
));
}
out.push_str(&format!(
"{:<16} {:<7} {:<10} {:<12} {:<7} by_tag\n",
"binding", "acc", "cost($)", "lat(ms)", "turns"
));
for c in &report.configs {
let tags = c
.by_tag
.iter()
.map(|(t, v)| format!("{t}:{:.2}", v.pass_rate_sum / v.n.max(1) as f64))
.collect::<Vec<_>>()
.join(" ");
let cost = match c.cost_total_usd {
Some(cost) => format!("{cost:.4}"),
None => "n/a".to_string(),
};
let turns = c
.turns_avg
.map(|t| t.to_string())
.unwrap_or_else(|| "n/a".to_string());
out.push_str(&format!(
"{:<16} {:<7} {:<10} {:<12} {:<7} {tags}\n",
binding_label(&c.model),
format!("{:.2}", c.accuracy),
cost,
c.latency_ms_avg,
turns
));
}
let max_samples = report
.configs
.iter()
.flat_map(|c| &c.cases)
.map(|case| case.samples)
.max()
.unwrap_or(0);
if !report.configs.is_empty() && max_samples <= 1 {
out.push_str(
"reproducibility: not measured (samples=1 — pass --samples N>1 to repeat each case \
and check whether its verdict/answer holds).\n",
);
}
if report.configs.iter().any(|c| c.cache_hits > 0) {
out.push_str("cache (miss = LLM call this run):\n");
for c in &report.configs {
let note = if c.cache_misses == 0 {
"re-score only, 0 LLM calls this run".to_string()
} else {
format!("{} LLM calls this run", c.cache_misses)
};
out.push_str(&format!(
" {:<10} {} hit / {} miss — {note}\n",
binding_label(&c.model),
c.cache_hits,
c.cache_misses
));
if c.cache_misses == 0 && c.cases.iter().any(|case| case.samples > 1) {
out.push_str(
" note: every sample replayed its own cached trace — identical output \
here is replay, not evidence of determinism.\n",
);
}
}
}
if report
.configs
.iter()
.any(|c| c.flaky_cases > 0 || c.output_unstable_cases > 0)
{
out.push_str(
"instability across repeated samples (flaky = exact verdict flip; output-unstable = \
textual answer divergence — an upper bound, see OutputStability):\n",
);
for c in &report.configs {
for case in &c.cases {
let stability = case.output_stability();
let output_unstable = matches!(
stability,
OutputStability::Unstable | OutputStability::Incomplete
);
if !case.flaky && !output_unstable {
continue;
}
let label = match (case.flaky, stability) {
(true, OutputStability::Incomplete) => "flaky, incomplete answers",
(true, _) => "flaky",
(false, OutputStability::Unstable) => "output-unstable, verdict stable",
(false, OutputStability::Incomplete) => "incomplete answers, verdict stable",
(false, _) => unreachable!("filtered above: neither flaky nor output-unstable"),
};
out.push_str(&format!(
" {:<10} {} [{label}] pass_rate={:.2} ({}/{})",
binding_label(&c.model),
case.id,
case.pass_rate,
case.passes,
case.samples
));
if let Some(dist) = &case.answer_dist {
let mut answers: Vec<(&String, &u32)> = dist.iter().collect();
answers.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
let top = answers
.into_iter()
.take(3)
.map(|(a, n)| format!("{a}×{n}"))
.collect::<Vec<_>>()
.join(", ");
out.push_str(&format!(" — {top}"));
}
out.push('\n');
}
}
}
out
}
struct InstalledAgents {
files: Vec<PathBuf>,
agents_dir: PathBuf,
claude_dir: PathBuf,
}
impl Drop for InstalledAgents {
fn drop(&mut self) {
for f in &self.files {
let _ = fs::remove_file(f);
}
let _ = fs::remove_dir(&self.agents_dir);
let _ = fs::remove_dir(&self.claude_dir);
}
}
pub(crate) fn install_agents(agent_dir: &Path, project: &Path) -> Result<InstalledAgents, String> {
let claude_dir = project.join(".claude");
let agents_dir = claude_dir.join("agents");
fs::create_dir_all(&agents_dir).map_err(|e| format!("mkdir {}: {e}", agents_dir.display()))?;
let src_agents = agent_dir.join(".claude").join("agents");
let mut files = Vec::new();
for entry in
fs::read_dir(&src_agents).map_err(|e| format!("read {}: {e}", src_agents.display()))?
{
let entry = entry.map_err(|e| e.to_string())?;
let dst = agents_dir.join(entry.file_name());
fs::copy(entry.path(), &dst).map_err(|e| format!("copy agent: {e}"))?;
files.push(dst);
}
let settings_src = {
let split = agent_dir.join(".claude").join("settings.json");
if split.is_file() {
Some(split)
} else {
let flat = agent_dir.join("settings.json");
flat.is_file().then_some(flat)
}
};
if let Some(src) = settings_src {
let dst = claude_dir.join("settings.json");
fs::copy(&src, &dst).map_err(|e| format!("copy settings: {e}"))?;
files.push(dst);
}
Ok(InstalledAgents {
files,
agents_dir,
claude_dir,
})
}
pub(crate) fn agent_name(agent_dir: &Path) -> Result<String, String> {
let dir = agent_dir.join(".claude").join("agents");
let mut names: Vec<String> = fs::read_dir(&dir)
.map_err(|e| format!("read {}: {e}", dir.display()))?
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.filter_map(|f| f.strip_suffix(".md").map(str::to_string))
.collect();
names.sort();
names
.into_iter()
.next()
.ok_or_else(|| format!("no agent files in {}", dir.display()))
}
pub(crate) fn run_path(project: &Path) -> String {
let base = std::env::var("PATH").unwrap_or_default();
let venv = project.join(".venv").join("bin");
if venv.is_dir() {
format!("{}:{base}", venv.display())
} else {
base
}
}
fn run_case(
project: &Path,
agent: &str,
path_env: &str,
case: &GoldenCase,
sample: u32,
ctx: &CaseCtx,
) -> SampleOutcome {
let key = CaseKey {
case_id: &case.id,
question: &case.question,
agent_sha: ctx.agent_sha,
model: ctx.model,
context_sha: ctx.context_sha,
sample,
backend: ctx.backend,
max_turns: ctx.max_turns,
tier_binding: ctx.tier_binding.as_ref().map(|tb| tb.label),
};
if let Some(trace) = ctx.store.load(&key) {
if let Some(verdict) = rescore(&trace, case) {
return SampleOutcome {
pass: verdict.pass,
reason: verdict.reason,
cost: trace.cost,
latency_ms: trace.latency_ms,
turns: trace.turns,
cache_hit: true,
answer: ctx.record_answers.then(|| render_answer(&trace.result)),
};
}
}
let started = Instant::now();
let fail = |reason: &str, cost: Option<f64>, latency_ms: u64| SampleOutcome {
pass: false,
reason: reason.to_string(),
cost,
latency_ms,
turns: None,
cache_hit: false,
answer: None,
};
let invocation = ctx.adapter.invoke(
project,
agent,
path_env,
&case.question,
ctx.model_override(),
ctx.max_turns,
);
if !invocation.ok {
const MAX_REASON_CHARS: usize = 200;
let detail = invocation
.raw
.lines()
.map(str::trim)
.find(|line| !line.is_empty());
let reason = match detail {
Some(line) if line.chars().count() > MAX_REASON_CHARS => {
let clipped: String = line.chars().take(MAX_REASON_CHARS).collect();
format!("backend invocation failed: {clipped}…")
}
Some(line) => format!("backend invocation failed: {line}"),
None => "backend invocation failed".to_string(),
};
return fail(&reason, None, started.elapsed().as_millis() as u64);
}
let raw = invocation.raw;
let result_text = raw.as_str();
let latency_ms = invocation
.latency_ms
.unwrap_or_else(|| started.elapsed().as_millis() as u64);
let cost = invocation.cost;
let turns = invocation.turns;
let extracted = match case.result_kind {
ResultKind::Table => extract_result_json(result_text),
ResultKind::Verdict => extract_verdict_json(result_text),
};
let Some(result_value) = extracted else {
let reason = match case.result_kind {
ResultKind::Table => "no parseable {columns,rows} in output",
ResultKind::Verdict => "no parseable verdict envelope in output",
};
return SampleOutcome {
pass: false,
reason: reason.to_string(),
cost,
latency_ms,
turns,
cache_hit: false,
answer: recorded_failure_answer(ctx.record_answers, None, result_text),
};
};
let Some(verdict) = score_value(&result_value, case) else {
let reason = match case.result_kind {
ResultKind::Table => "no parseable {columns,rows} in output",
ResultKind::Verdict => "verdict envelope missing the expected field",
};
return SampleOutcome {
pass: false,
reason: reason.to_string(),
cost,
latency_ms,
turns,
cache_hit: false,
answer: recorded_failure_answer(ctx.record_answers, Some(&result_value), result_text),
};
};
let trace = Trace {
case_id: case.id.clone(),
agent_sha: ctx.agent_sha.to_string(),
model: ctx.model.to_string(),
context_version: ctx.context_version.map(str::to_string),
context_sha: ctx.context_sha.to_string(),
question: case.question.clone(),
sql_executed: None,
result: result_value,
cost,
latency_ms,
turns,
tool_calls: None,
backend: ctx.backend,
};
if let Err(e) = ctx.store.store(&key, &trace) {
eprintln!(
" note: could not cache trace for {} (sample {sample}): {e}",
case.id
);
}
SampleOutcome {
pass: verdict.pass,
reason: verdict.reason,
cost,
latency_ms,
turns,
cache_hit: false,
answer: ctx.record_answers.then(|| render_answer(&trace.result)),
}
}
#[derive(Clone, Copy)]
enum ArtifactPath<'a> {
AgentDir(&'a Path),
Ir(&'a Path),
}
fn validate_max_turns_backend(backend: Backend, max_turns: Option<u32>) -> Result<(), String> {
if max_turns.is_some() && backend != Backend::ClaudeAgentSdk {
return Err(format!(
"--max-turns is only supported by backend '{}' (the only one whose dispatch CLI has a \
--max-turns knob); backend '{backend}' has none — omit --max-turns or pass --backend {}",
Backend::ClaudeAgentSdk,
Backend::ClaudeAgentSdk
));
}
Ok(())
}
#[cfg(test)]
mod max_turns_backend_validation_tests {
use super::*;
#[test]
fn max_turns_is_rejected_for_a_backend_with_no_turn_budget_knob() {
for backend in [Backend::ClaudeCodeCli, Backend::CodexLocal, Backend::Vercel] {
let err = validate_max_turns_backend(backend, Some(1))
.expect_err("max-turns must be rejected for a backend with no such knob");
assert!(
err.contains(backend.as_str()),
"error must name the offending backend '{backend}': {err}"
);
assert!(
err.contains(Backend::ClaudeAgentSdk.as_str()),
"error must name the backend that does support --max-turns: {err}"
);
}
}
#[test]
fn max_turns_is_accepted_for_the_sdk_backend_and_whenever_absent() {
assert!(validate_max_turns_backend(Backend::ClaudeAgentSdk, Some(1)).is_ok());
for backend in [
Backend::ClaudeCodeCli,
Backend::ClaudeAgentSdk,
Backend::CodexLocal,
Backend::Vercel,
] {
assert!(validate_max_turns_backend(backend, None).is_ok());
}
}
}
fn validate_tier_binding_backend(
backend: Backend,
tier_models: Option<&ModelConfig>,
) -> Result<(), String> {
if tier_models.is_some() && backend != Backend::ClaudeAgentSdk {
return Err(format!(
"a differentiated --strong/--cheap/--orchestrator (or --models-config) binding is only \
supported by backend '{}' (the only one whose dispatch CLI has tier flags); backend '{backend}' has \
none — to run a differentiated binding on '{backend}', dispatch the agent with that binding baked \
in (`warble dispatch --strong/--cheap/--orchestrator ...`) and run this eval with the frontmatter \
sentinel (no tier flags) instead of asking the eval runner to inject one at invocation time",
Backend::ClaudeAgentSdk
));
}
Ok(())
}
#[cfg(test)]
mod tier_binding_backend_validation_tests {
use super::*;
#[test]
fn tiered_binding_is_rejected_for_a_backend_with_no_tier_knob() {
let tier_models = ModelConfig::from_flags(
"sonnet".to_string(),
"haiku".to_string(),
"sonnet".to_string(),
);
for backend in [Backend::ClaudeCodeCli, Backend::CodexLocal, Backend::Vercel] {
let err = validate_tier_binding_backend(backend, Some(&tier_models))
.expect_err("a tiered binding must be rejected for a backend with no tier knob");
assert!(
err.contains(backend.as_str()),
"error must name the offending backend '{backend}': {err}"
);
assert!(
err.contains(Backend::ClaudeAgentSdk.as_str()),
"error must name the backend that does support a differentiated binding: {err}"
);
assert!(
err.contains("frontmatter"),
"error must point at the frontmatter-sentinel workaround: {err}"
);
}
}
#[test]
fn tiered_binding_is_accepted_for_the_sdk_backend_and_whenever_absent() {
let tier_models = ModelConfig::from_flags(
"sonnet".to_string(),
"haiku".to_string(),
"sonnet".to_string(),
);
assert!(validate_tier_binding_backend(Backend::ClaudeAgentSdk, Some(&tier_models)).is_ok());
for backend in [
Backend::ClaudeCodeCli,
Backend::ClaudeAgentSdk,
Backend::CodexLocal,
Backend::Vercel,
] {
assert!(validate_tier_binding_backend(backend, None).is_ok());
}
}
}
pub fn run_eval(cfg: &RunConfig) -> Result<Report, String> {
let golden_text = fs::read_to_string(&cfg.golden_path)
.map_err(|e| format!("read {}: {e}", cfg.golden_path.display()))?;
let golden: Golden =
serde_yaml::from_str(&golden_text).map_err(|e| format!("parse golden: {e}"))?;
let total_cases = golden.cases.len();
let cases = select_and_subset(&golden, &cfg.filter)?;
let selected_cases = cases.len();
let adapter = resolve_adapter(cfg.backend)?;
validate_max_turns_backend(cfg.backend, cfg.max_turns)?;
validate_tier_binding_backend(cfg.backend, cfg.tier_models.as_ref())?;
let artifact = match cfg.backend {
Backend::ClaudeCodeCli => ArtifactPath::AgentDir(
cfg.agent_dir
.as_deref()
.ok_or_else(|| "backend 'claude-code-cli' requires --agent-dir".to_string())?,
),
_ => ArtifactPath::Ir(
cfg.ir_path
.as_deref()
.ok_or_else(|| format!("backend '{}' requires --ir", cfg.backend))?,
),
};
let (agent, context_injection): (String, Option<ContextInjectionReport>) = match artifact {
ArtifactPath::AgentDir(dir) => (agent_name(dir)?, read_context_injection_report(dir)?),
ArtifactPath::Ir(path) => (path.display().to_string(), None),
};
let path_env = run_path(&cfg.project);
let (mut store, context_sha) =
build_store(cfg.no_cache, cfg.cache_dir.as_deref(), &cfg.project);
let agent_sha = if store.is_enabled() {
let sha = match artifact {
ArtifactPath::AgentDir(dir) => context::compute_dir_sha(dir),
ArtifactPath::Ir(path) => context::compute_file_sha(path),
};
match sha {
Ok(s) => s,
Err(e) => {
eprintln!("cache: disabled — cannot hash agent artifact: {e}");
store = TraceStore::disabled();
String::new()
}
}
} else {
String::new()
};
let _installed = match artifact {
ArtifactPath::AgentDir(dir) => Some(install_agents(dir, &cfg.project)?),
ArtifactPath::Ir(_) => None,
};
let parallel = cfg.parallel.max(1);
let par_suffix = if parallel > 1 {
format!(", parallel={parallel}")
} else {
String::new()
};
let mut configs = Vec::new();
if let Some(tier_models) = &cfg.tier_models {
let strong = tier_models.require("strong").map_err(|e| e.to_string())?;
let cheap = tier_models.require("cheap").map_err(|e| e.to_string())?;
let orchestrator = tier_models
.require("orchestrator")
.map_err(|e| e.to_string())?;
let label = format!("strong={strong},cheap={cheap},orchestrator={orchestrator}");
eprintln!("\n### binding: {label} (n={selected_cases}{par_suffix})");
let ctx = CaseCtx {
model: &label,
agent_sha: &agent_sha,
context_sha: &context_sha,
context_version: golden.context_version.as_deref(),
store: &store,
record_answers: cfg.record_answers,
backend: cfg.backend,
adapter: adapter.as_ref(),
max_turns: cfg.max_turns,
tier_binding: Some(TierBindingCtx {
strong,
cheap,
orchestrator,
label: &label,
}),
};
let rows = run_cases(
&cfg.project,
&agent,
&path_env,
&cases,
cfg.samples,
parallel,
&ctx,
);
configs.push(aggregate(&label, rows));
} else {
for model in &cfg.models {
eprintln!("\n### binding: strong→{model} (n={selected_cases}{par_suffix})");
let ctx = CaseCtx {
model,
agent_sha: &agent_sha,
context_sha: &context_sha,
context_version: golden.context_version.as_deref(),
store: &store,
record_answers: cfg.record_answers,
backend: cfg.backend,
adapter: adapter.as_ref(),
max_turns: cfg.max_turns,
tier_binding: None,
};
let rows = run_cases(
&cfg.project,
&agent,
&path_env,
&cases,
cfg.samples,
parallel,
&ctx,
);
configs.push(aggregate(model, rows));
}
}
Ok(Report {
dataset: golden.dataset,
context_version: golden.context_version,
context_injection,
parallel,
selected_cases,
total_cases,
configs,
backend: cfg.backend,
})
}
fn read_context_injection_report(
agent_dir: &Path,
) -> Result<Option<ContextInjectionReport>, String> {
let path = agent_dir.join("context-report.json");
if !path.is_file() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let report: ContextInjectionReport = serde_json::from_str(&raw).map_err(|_| {
format!(
"invalid {}: expected context identity fields only",
path.display()
)
})?;
report
.validate()
.map(Some)
.map_err(|reason| format!("invalid {}: {reason}", path.display()))
}
#[cfg(test)]
mod context_injection_report_tests {
use super::*;
fn write_report(body: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("context-report.json"), body).unwrap();
dir
}
#[test]
fn accepts_only_the_non_sensitive_context_identity_shape() {
let dir = write_report(
r#"{
"mode":"schema+knowledge",
"schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
"knowledge_fingerprint":"fnv1a64:fedcba9876543210",
"knowledge_chars":42
}"#,
);
let report = read_context_injection_report(dir.path())
.unwrap()
.expect("report present");
assert_eq!(report.mode, "schema+knowledge");
assert_eq!(report.knowledge_chars, 42);
}
#[test]
fn rejects_rule_text_paths_and_inconsistent_identity_without_echoing_values() {
let secret = "PRIVATE_RULE_MARKER /Users/example/private-project";
let unknown = write_report(&format!(
r#"{{
"mode":"schema-only",
"schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
"knowledge_fingerprint":null,
"knowledge_chars":0,
"rule_text":{secret:?}
}}"#
));
let error = read_context_injection_report(unknown.path()).unwrap_err();
assert!(!error.contains(secret));
let path_as_fingerprint = write_report(
r#"{
"mode":"schema+knowledge",
"schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
"knowledge_fingerprint":"/Users/example/private-project",
"knowledge_chars":42
}"#,
);
let error = read_context_injection_report(path_as_fingerprint.path()).unwrap_err();
assert!(!error.contains("/Users/example/private-project"));
let inconsistent = write_report(
r#"{
"mode":"schema-only",
"schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
"knowledge_fingerprint":"fnv1a64:fedcba9876543210",
"knowledge_chars":42
}"#,
);
assert!(read_context_injection_report(inconsistent.path()).is_err());
}
}
pub(crate) fn build_store(
no_cache: bool,
cache_dir: Option<&Path>,
project: &Path,
) -> (TraceStore, String) {
if no_cache {
if cache_dir.is_some() {
eprintln!("cache: --cache-dir ignored — --no-cache disables the cache entirely");
}
eprintln!("cache: disabled (--no-cache) — every case re-runs and refreshes its trace");
return (TraceStore::disabled(), String::new());
}
let context_sha = match compute_mdl_sha(project) {
Ok(s) => s,
Err(e) => {
eprintln!("cache: disabled — cannot compute MDL context SHA: {e}");
return (TraceStore::disabled(), String::new());
}
};
let dir = cache_dir
.map(Path::to_path_buf)
.unwrap_or_else(|| project.join(".warble").join("eval-cache"));
eprintln!(
"cache: enabled at {} (context_sha {})",
dir.display(),
&context_sha[..context_sha.len().min(12)]
);
(TraceStore::new(dir, true), context_sha)
}
pub(crate) fn select_and_subset(
golden: &Golden,
filter: &CaseFilter,
) -> Result<Vec<GoldenCase>, String> {
let selection = select_cases(&golden.cases, filter);
if filter.is_active() {
eprintln!("{}", selection.note);
}
if filter.is_active() && selection.indices.is_empty() {
return Err(format!(
"no golden cases match the filter — {} (nothing to run)",
selection.note
));
}
Ok(selection
.indices
.iter()
.map(|&i| golden.cases[i].clone())
.collect())
}
pub(crate) fn run_cases(
project: &Path,
agent: &str,
path_env: &str,
cases: &[GoldenCase],
samples: usize,
parallel: usize,
ctx: &CaseCtx,
) -> Vec<CaseResult> {
let samples = samples.max(1);
let outcomes = run_indexed(cases.len() * samples, parallel, |j| {
let case = &cases[j / samples];
let sample = (j % samples) as u32;
let r = run_case(project, agent, path_env, case, sample, ctx);
let extra = if r.pass {
String::new()
} else {
format!(" — {}", r.reason)
};
let cost = match r.cost {
Some(cost) => format!(", ${cost:.4}"),
None => String::new(),
};
let turns = r
.turns
.map(|t| t.to_string())
.unwrap_or_else(|| "n/a".to_string());
let cache = if r.cache_hit { " [cache]" } else { "" };
if samples == 1 {
eprintln!(
" {} {}{cache} ({:.1}s{cost}, {turns}t){extra}",
if r.pass { "PASS" } else { "FAIL" },
case.id,
r.latency_ms as f64 / 1000.0,
);
} else {
eprintln!(
" {} {} sample {sample}{cache} ({:.1}s{cost}, {turns}t){extra}",
if r.pass { "PASS" } else { "FAIL" },
case.id,
r.latency_ms as f64 / 1000.0,
);
}
r
});
let mut it = outcomes.into_iter();
cases
.iter()
.map(|case| {
let chunk: Vec<SampleOutcome> = (&mut it).take(samples).collect();
let result = fold_samples(
case.id.clone(),
case.tags.clone(),
chunk,
ctx.record_answers,
);
if samples > 1 {
let marker = if result.flaky {
"FLAKY"
} else if result.pass {
"PASS"
} else {
"FAIL"
};
eprintln!(
" {marker} {} pass_rate={:.2} ({}/{})",
result.id, result.pass_rate, result.passes, result.samples
);
}
result
})
.collect()
}
pub(crate) fn run_indexed<T, F>(n: usize, parallel: usize, job: F) -> Vec<T>
where
T: Send,
F: Fn(usize) -> T + Sync,
{
if parallel <= 1 || n <= 1 {
return (0..n).map(job).collect();
}
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
let next = AtomicUsize::new(0);
let slots: Vec<Mutex<Option<T>>> = (0..n).map(|_| Mutex::new(None)).collect();
std::thread::scope(|scope| {
for _ in 0..parallel.min(n) {
scope.spawn(|| loop {
let i = next.fetch_add(1, Ordering::Relaxed);
if i >= n {
break;
}
let r = job(i);
*slots[i].lock().expect("result slot poisoned") = Some(r);
});
}
});
slots
.into_iter()
.map(|slot| {
slot.into_inner()
.expect("result slot poisoned")
.expect("every index 0..n is claimed exactly once by the shared counter")
})
.collect()
}
#[cfg(test)]
mod run_indexed_tests {
use super::run_indexed;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
#[test]
fn serial_path_preserves_order_and_runs_each_once() {
let calls = AtomicUsize::new(0);
let out = run_indexed(5, 1, |i| {
calls.fetch_add(1, Ordering::Relaxed);
i * 10
});
assert_eq!(out, vec![0, 10, 20, 30, 40]);
assert_eq!(calls.load(Ordering::Relaxed), 5);
}
#[test]
fn parallel_results_are_in_index_order_despite_completion_order() {
let out = run_indexed(8, 4, |i| {
std::thread::sleep(Duration::from_millis((8 - i as u64) * 3));
i
});
assert_eq!(out, (0..8).collect::<Vec<_>>());
}
#[test]
fn parallel_runs_each_index_exactly_once() {
let per_index: Vec<AtomicUsize> = (0..32).map(|_| AtomicUsize::new(0)).collect();
let out = run_indexed(32, 6, |i| {
per_index[i].fetch_add(1, Ordering::Relaxed);
i
});
assert_eq!(out.len(), 32);
for (i, c) in per_index.iter().enumerate() {
assert_eq!(c.load(Ordering::Relaxed), 1, "index {i} ran once");
}
}
#[test]
fn parallelism_wider_than_n_and_empty_input_are_fine() {
assert_eq!(run_indexed(2, 16, |i| i), vec![0, 1]);
assert_eq!(run_indexed(0, 4, |i| i), Vec::<usize>::new());
}
#[test]
fn parallel_actually_overlaps_work() {
let in_flight = AtomicUsize::new(0);
let max_in_flight = AtomicUsize::new(0);
run_indexed(4, 4, |_| {
let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
max_in_flight.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(40));
in_flight.fetch_sub(1, Ordering::SeqCst);
});
let max = max_in_flight.load(Ordering::SeqCst);
assert!(
max > 1,
"expected overlapping execution, but max concurrent was {max}"
);
}
}
#[cfg(test)]
mod fold_samples_tests {
use super::*;
fn outcome(pass: bool, reason: &str, cost: f64, latency_ms: u64, turns: u64) -> SampleOutcome {
SampleOutcome {
pass,
reason: reason.to_string(),
cost: Some(cost),
latency_ms,
turns: Some(turns),
cache_hit: false,
answer: None,
}
}
#[test]
fn fully_passing_is_not_flaky_and_reason_is_match() {
let outcomes = vec![
outcome(true, "match", 0.1, 1_000, 2),
outcome(true, "match", 0.1, 1_000, 2),
];
let r = fold_samples("a".into(), vec![], outcomes, false);
assert_eq!(r.samples, 2);
assert_eq!(r.passes, 2);
assert!((r.pass_rate - 1.0).abs() < 1e-9);
assert!(r.pass);
assert!(!r.flaky);
assert_eq!(r.reason, "match");
}
#[test]
fn fully_failing_is_not_flaky() {
let outcomes = vec![
outcome(false, "mismatch", 0.0, 500, 1),
outcome(false, "mismatch", 0.0, 500, 1),
];
let r = fold_samples("a".into(), vec![], outcomes, false);
assert_eq!(r.passes, 0);
assert_eq!(r.pass_rate, 0.0);
assert!(!r.pass);
assert!(!r.flaky, "0 passes out of N is a hard fail, not flaky");
}
#[test]
fn partial_pass_is_flaky_and_reason_is_the_modal_failure() {
let outcomes = vec![
outcome(true, "match", 0.0, 0, 0),
outcome(false, "wrong total", 0.0, 0, 0),
outcome(false, "wrong total", 0.0, 0, 0),
outcome(false, "timeout", 0.0, 0, 0),
];
let r = fold_samples("a".into(), vec![], outcomes, false);
assert_eq!(r.passes, 1);
assert_eq!(r.samples, 4);
assert!((r.pass_rate - 0.25).abs() < 1e-9);
assert!(!r.pass);
assert!(r.flaky);
assert_eq!(r.reason, "wrong total", "modal failure reason wins");
}
#[test]
fn modal_reason_tie_break_is_first_occurrence() {
let outcomes = vec![
outcome(false, "a", 0.0, 0, 0),
outcome(false, "b", 0.0, 0, 0),
outcome(false, "b", 0.0, 0, 0),
outcome(false, "a", 0.0, 0, 0),
];
let r = fold_samples("a".into(), vec![], outcomes, false);
assert_eq!(r.reason, "a");
}
#[test]
fn cost_sums_latency_and_turns_average() {
let outcomes = vec![
outcome(true, "match", 0.10, 1_000, 4),
outcome(true, "match", 0.20, 3_000, 6),
];
let r = fold_samples("a".into(), vec![], outcomes, false);
assert!(
(r.cost.expect("cost present") - 0.30).abs() < 1e-9,
"cost sums across samples"
);
assert_eq!(r.latency_ms, 2_000, "latency averages across samples");
assert_eq!(r.turns, Some(5), "turns averages across samples");
}
#[test]
fn cost_and_turns_are_none_when_any_sample_lacks_them() {
let outcomes = vec![
outcome(true, "match", 0.10, 1_000, 4),
SampleOutcome {
cost: None,
turns: None,
..outcome(true, "match", 0.0, 1_000, 0)
},
];
let r = fold_samples("a".into(), vec![], outcomes, false);
assert!(
r.cost.is_none(),
"one sample's missing cost makes the case's cost absent, not a partial sum"
);
assert!(
r.turns.is_none(),
"one sample's missing turns makes the case's turns absent, not a partial average"
);
}
#[test]
fn cache_hits_and_misses_are_counted_per_sample() {
let outcomes = vec![
SampleOutcome {
cache_hit: true,
..outcome(true, "match", 0.0, 0, 0)
},
SampleOutcome {
cache_hit: false,
..outcome(true, "match", 0.0, 0, 0)
},
SampleOutcome {
cache_hit: true,
..outcome(true, "match", 0.0, 0, 0)
},
];
let r = fold_samples("a".into(), vec![], outcomes, false);
assert_eq!(r.cache_hits, 2);
assert_eq!(r.cache_misses, 1);
}
#[test]
fn answer_dist_is_only_populated_when_record_answers_is_set() {
let build = || {
vec![
SampleOutcome {
answer: Some("42".to_string()),
..outcome(true, "match", 0.0, 0, 0)
},
SampleOutcome {
answer: Some("42".to_string()),
..outcome(true, "match", 0.0, 0, 0)
},
SampleOutcome {
answer: Some("7".to_string()),
..outcome(false, "wrong total", 0.0, 0, 0)
},
]
};
let without = fold_samples("a".into(), vec![], build(), false);
assert!(without.answer_dist.is_none());
assert!(
without.samples_detail.iter().all(|s| s.answer.is_none()),
"no answer strings recorded unless --record-answers"
);
let with = fold_samples("a".into(), vec![], build(), true);
let dist = with.answer_dist.expect("populated when record_answers");
assert_eq!(dist.get("42"), Some(&2));
assert_eq!(dist.get("7"), Some(&1));
assert_eq!(
with.samples_detail
.iter()
.filter(|s| s.answer.is_some())
.count(),
3
);
}
#[test]
fn failed_output_is_recorded_only_when_requested() {
let extracted = serde_json::json!({
"blocks": [{"type": "status", "state": "unknown"}],
"verified": false,
});
assert_eq!(recorded_failure_answer(false, None, "raw refusal"), None);
assert_eq!(
recorded_failure_answer(false, Some(&extracted), "ignored"),
None
);
assert_eq!(
recorded_failure_answer(true, None, "raw refusal").as_deref(),
Some("raw refusal")
);
assert_eq!(
recorded_failure_answer(true, Some(&extracted), "ignored"),
Some(extracted.to_string())
);
}
#[test]
fn modal_reason_of_empty_iterator_is_empty_string() {
assert_eq!(modal_reason(std::iter::empty()), "");
}
}
#[cfg(test)]
mod verdict_tests {
use super::*;
fn envelope(fresh: bool, severity: &str) -> serde_json::Value {
serde_json::json!({
"blocks": [
{ "type": "status", "state": if fresh { "fresh" } else { "stale" },
"label": "orders freshness", "detail": "…", "severity": severity }
],
"verdict": { "type": "freshness_verdict", "fresh": fresh, "observed_lag_hours": 51,
"expected_cadence": "24h" },
"emitted": ["freshness_breach"],
"verified": true,
})
}
fn fenced(value: &serde_json::Value) -> String {
format!("Some prose.\n\n```json\n{value}\n```\n")
}
#[test]
fn extract_verdict_json_accepts_a_real_envelope() {
let text = fenced(&envelope(false, "critical"));
let got = extract_verdict_json(&text).expect("envelope parses");
assert_eq!(got["verdict"]["fresh"], false);
}
#[test]
fn extract_verdict_json_rejects_a_columns_rows_table() {
let text = fenced(&serde_json::json!({"columns": ["n"], "rows": [[42]]}));
assert!(
extract_verdict_json(&text).is_none(),
"a table has neither `verdict` nor `blocks`"
);
}
#[test]
fn extract_verdict_json_rejects_malformed_json() {
assert!(extract_verdict_json("not json at all").is_none());
assert!(extract_verdict_json("```json\n{ not valid\n```").is_none());
}
#[test]
fn extract_result_json_rejects_a_verdict_envelope() {
let text = fenced(&envelope(true, "critical"));
assert!(extract_result_json(&text).is_none());
}
#[test]
fn project_verdict_field_reads_fresh_true_and_false() {
let fresh_table = project_verdict_field(&envelope(true, "critical"), "fresh").unwrap();
assert_eq!(fresh_table.rows, vec![vec![Value::Bool(true)]]);
let stale_table = project_verdict_field(&envelope(false, "critical"), "fresh").unwrap();
assert_eq!(stale_table.rows, vec![vec![Value::Bool(false)]]);
}
#[test]
fn project_verdict_field_reads_severity_warn_and_critical() {
let warn = project_verdict_field(&envelope(false, "warn"), "severity").unwrap();
assert_eq!(warn.rows, vec![vec![Value::String("warn".to_string())]]);
let critical = project_verdict_field(&envelope(false, "critical"), "severity").unwrap();
assert_eq!(
critical.rows,
vec![vec![Value::String("critical".to_string())]]
);
}
#[test]
fn project_verdict_field_falls_back_to_verdict_severity() {
let envelope = serde_json::json!({
"blocks": [{ "type": "status", "state": "stale" }],
"verdict": { "type": "freshness_verdict", "fresh": false, "severity": "warn" },
});
let got = project_verdict_field(&envelope, "severity").unwrap();
assert_eq!(got.rows, vec![vec![Value::String("warn".to_string())]]);
}
#[test]
fn project_verdict_field_is_none_on_missing_or_unknown_field() {
let e = envelope(true, "critical");
assert!(
project_verdict_field(&e, "observed_lag_hours").is_none(),
"unrecognized field fails closed, never silently passes"
);
let no_verdict = serde_json::json!({"blocks": []});
assert!(project_verdict_field(&no_verdict, "fresh").is_none());
let no_severity_anywhere = serde_json::json!({
"blocks": [{"type": "status", "state": "stale"}],
"verdict": {"type": "freshness_verdict", "fresh": false},
});
assert!(project_verdict_field(&no_severity_anywhere, "severity").is_none());
}
fn verdict_case(tag: &str, field: &str, expected_rows: &str) -> GoldenCase {
let yaml = format!(
"id: v1\nquestion: \"is it fresh?\"\ntags: [{tag}]\nmatch: scalar\n\
result_kind: verdict\nverdict_field: {field}\n\
expected: {{ columns: [{field}], rows: {expected_rows} }}\n"
);
serde_yaml::from_str(&yaml).expect("verdict golden case parses")
}
#[test]
fn golden_case_result_kind_defaults_to_table_when_omitted() {
let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[1]] }\n";
let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
assert_eq!(case.result_kind, ResultKind::Table);
assert!(case.verdict_field.is_none());
}
#[test]
fn score_value_passes_a_matching_verdict_envelope() {
let case = verdict_case("detection", "fresh", "[[false]]");
let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
assert!(
verdict.pass,
"envelope fresh=false matches expected fresh=false"
);
}
#[test]
fn score_value_fails_a_mismatching_verdict_envelope() {
let case = verdict_case("detection", "fresh", "[[true]]");
let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
assert!(
!verdict.pass,
"envelope fresh=false does not match expected fresh=true"
);
}
#[test]
fn score_value_projects_severity_for_the_severity_field() {
let case = verdict_case("severity", "severity", "[[critical]]");
let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
assert!(verdict.pass);
}
#[test]
fn score_value_on_a_table_case_is_unaffected_by_verdict_support() {
let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[42]] }\n";
let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
let result = serde_json::json!({"columns": ["n"], "rows": [[42]]});
let verdict = score_value(&result, &case).expect("table case scores");
assert!(verdict.pass);
}
}
#[cfg(test)]
mod output_stability_tests {
use super::*;
fn case(
samples: u32,
passes: u32,
flaky: bool,
answer_dist: Option<BTreeMap<String, u32>>,
) -> CaseResult {
CaseResult {
id: "c1".to_string(),
tags: vec![],
samples,
passes,
pass_rate: if samples == 0 {
0.0
} else {
passes as f64 / samples as f64
},
pass: passes == samples,
flaky,
reason: "match".to_string(),
cost: Some(0.0),
latency_ms: 0,
turns: Some(0),
cache_hits: 0,
cache_misses: samples,
samples_detail: vec![],
answer_dist,
}
}
fn dist(pairs: &[(&str, u32)]) -> BTreeMap<String, u32> {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}
#[test]
fn verdict_stable_but_output_divergent_is_output_unstable_not_flaky() {
let c = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
assert!(!c.flaky, "both samples passed: flaky must stay false");
assert_eq!(c.output_stability(), OutputStability::Unstable);
}
#[test]
fn verdict_flip_is_flaky_and_may_still_be_output_stable() {
let c = case(2, 1, true, Some(dist(&[("42", 2)])));
assert!(c.flaky);
assert_eq!(c.output_stability(), OutputStability::Stable);
}
#[test]
fn fully_stable_case_is_stable_and_not_flaky() {
let c = case(3, 3, false, Some(dist(&[("42", 3)])));
assert!(!c.flaky);
assert_eq!(c.output_stability(), OutputStability::Stable);
}
#[test]
fn over_split_textual_signature_reports_unstable_not_reconciled() {
let c = case(2, 2, false, Some(dist(&[("42", 1), ("42.0", 1)])));
assert!(!c.flaky);
assert_eq!(
c.output_stability(),
OutputStability::Unstable,
"over-split is reported as Unstable (an upper bound), not reconciled to Stable"
);
}
#[test]
fn incomplete_answers_are_never_counted_as_stable() {
let c = case(3, 2, false, Some(dist(&[("42", 2)])));
assert_eq!(c.output_stability(), OutputStability::Incomplete);
}
#[test]
fn no_answer_dist_or_single_sample_is_not_measured() {
assert_eq!(
case(2, 2, false, None).output_stability(),
OutputStability::NotMeasured
);
assert_eq!(
case(1, 1, false, Some(dist(&[("42", 1)]))).output_stability(),
OutputStability::NotMeasured
);
}
#[test]
fn aggregate_counts_flaky_and_output_unstable_from_the_real_function() {
let flaky_only = case(2, 1, true, Some(dist(&[("42", 2)])));
let unstable_only = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
let stable = case(2, 2, false, Some(dist(&[("42", 2)])));
let report = aggregate("m", vec![flaky_only, unstable_only, stable]);
assert_eq!(report.flaky_cases, 1, "one verdict flip");
assert_eq!(
report.output_unstable_cases, 1,
"one output-unstable case, counted independently of the flaky one"
);
assert_eq!(report.n, 3);
}
fn config_report(cases: Vec<CaseResult>) -> ConfigReport {
let flaky_cases = cases.iter().filter(|c| c.flaky).count() as u32;
let output_unstable_cases = cases
.iter()
.filter(|c| {
matches!(
c.output_stability(),
OutputStability::Unstable | OutputStability::Incomplete
)
})
.count() as u32;
ConfigReport {
model: "m".to_string(),
n: cases.len(),
accuracy: 1.0,
cost_total_usd: Some(0.0),
latency_ms_avg: 0,
turns_avg: Some(0),
cache_hits: 0,
cache_misses: 0,
flaky_cases,
output_unstable_cases,
by_tag: BTreeMap::new(),
cases,
}
}
#[test]
fn aggregate_output_unstable_count_is_distinct_from_flaky_count() {
let stable_but_divergent = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
let flaky_but_output_stable = case(2, 1, true, Some(dist(&[("42", 2)])));
let report = config_report(vec![stable_but_divergent, flaky_but_output_stable]);
assert_eq!(report.flaky_cases, 1, "exactly one verdict flip");
assert_eq!(
report.output_unstable_cases, 1,
"exactly one output-unstable case, and it is not the flaky one"
);
}
fn report_with(configs: Vec<ConfigReport>) -> Report {
Report {
dataset: None,
context_version: None,
context_injection: None,
parallel: 1,
selected_cases: configs.iter().map(|c| c.n).sum(),
total_cases: configs.iter().map(|c| c.n).sum(),
configs,
backend: Backend::default(),
}
}
#[test]
fn format_pareto_shows_answer_dist_for_output_unstable_non_flaky_case() {
let c = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
let report = report_with(vec![config_report(vec![c])]);
let table = format_pareto(&report);
assert!(
table.contains("[output-unstable, verdict stable]"),
"expected the per-case output-unstable label in:\n{table}"
);
assert!(
table.contains("42×1") && table.contains("43×1"),
"expected the answer distribution to be shown for the output-unstable case:\n{table}"
);
}
#[test]
fn format_pareto_states_reproducibility_not_measured_at_samples_one() {
let c = case(1, 1, false, None);
let report = report_with(vec![config_report(vec![c])]);
let table = format_pareto(&report);
assert!(
table.contains("not measured"),
"expected an explicit not-measured statement in:\n{table}"
);
}
#[test]
fn format_pareto_warns_when_a_multi_sample_case_was_fully_cache_served() {
let mut c = case(2, 2, false, Some(dist(&[("42", 2)])));
c.cache_hits = 2;
c.cache_misses = 0;
let mut cfg = config_report(vec![c]);
cfg.cache_hits = 2;
cfg.cache_misses = 0;
let report = report_with(vec![cfg]);
let table = format_pareto(&report);
assert!(
table.contains("replay, not evidence of determinism"),
"expected a cache-replay caveat in:\n{table}"
);
}
#[test]
fn effective_record_answers_defaults_on_above_one_sample_only() {
assert!(
!effective_record_answers(false, 1),
"samples == 1 unchanged: still off"
);
assert!(
effective_record_answers(true, 1),
"explicit flag still honored at samples == 1"
);
assert!(
effective_record_answers(false, 2),
"samples > 1 defaults recording on even without the explicit flag"
);
assert!(
effective_record_answers(true, 5),
"explicit flag remains a no-op true at samples > 1"
);
}
}