Skip to main content

warble_eval_runner/
lib.rs

1//! Warble eval runner.
2//!
3//! Replays golden questions through a dispatched Warble agent under several tier→model bindings,
4//! scores each result set with `warble-eval-compare`, and produces a Pareto report
5//! (accuracy vs cost vs latency). The whole-model binding is runtime-injected via `claude --model`
6//! — same IR/agent, different binding, which is exactly what [`run_eval`] varies. The queryable
7//! project (connection + data) is injected via `project`; the agent files are installed into
8//! `<project>/.claude` for the run and removed afterward.
9//!
10//! On top of that measurement, the **closed loop** (roadmap Phase 1.4) lives in sibling modules:
11//! - `ablation` — per-step tier ablation: re-dispatch the IR binding one named step at a time.
12//! - `gate` — CI gate: fail on accuracy regression vs a committed baseline.
13//! - `context` — MDL-version reverify: flag goldens whose pinned `context_version` went stale.
14//! - `capture` — capture-confirmed: mint a candidate golden from one confirmed run.
15//!
16//! Pure helpers (`extract_result`, `aggregate`, `format_pareto`, golden parsing) and the closed-loop
17//! modules' logic are unit-tested; `run_eval` / `run_ablation` additionally spawn `claude` and are
18//! exercised end-to-end against a live project.
19
20use serde::Serialize;
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::time::Instant;
27use warble_eval_compare::{compare, CompareRequest, CompareResult, MatchMode, Table, Tolerance};
28
29mod ablation;
30mod cache;
31mod capture;
32mod compliance;
33mod context;
34mod filter;
35mod gate;
36
37pub use ablation::{
38    format_ablation, run_ablation, AblationConfig, AblationPoint, AblationReport,
39    StepRecommendation,
40};
41pub use cache::{rescore, CaseKey, Trace, TraceStore};
42pub use capture::{build_candidate_yaml, candidates_header, CaptureInput};
43pub use compliance::{
44    format_compliance, score_compliance, Check, CheckStatus, ComplianceIr, ComplianceReport,
45    ComplianceTrace, TraceEvent,
46};
47pub use context::{
48    classify, compute_mdl_sha, parse_context_version, stamp_context_version, verify_context,
49    Freshness, VerifyResult,
50};
51pub use filter::{select_cases, CaseFilter, Sample, Selection};
52pub use gate::{format_gate, run_gate, FlakyCase, GateResult, Regression};
53
54/// A golden eval file: dataset metadata + cases with expected result sets.
55#[derive(Debug, serde::Deserialize)]
56pub struct Golden {
57    #[serde(default)]
58    pub dataset: Option<String>,
59    #[serde(default)]
60    pub context_version: Option<String>,
61    pub cases: Vec<GoldenCase>,
62}
63
64/// Which shape a case's `expected` compares against — additive so every existing (all-`Table`)
65/// golden stays byte-compatible: an omitted `result_kind` defaults to `Table`, the pre-existing
66/// behavior. `Verdict` is the +Assertive twin: the agent's final message is a `{blocks, verdict,
67/// emitted, verified}` envelope (see `dispatcher/claude-code-cli`'s assertion output contract), not
68/// a `{columns, rows}` table, and one of its fields is projected down to `expected`'s scalar shape
69/// before the usual comparator runs — see [`project_verdict_field`].
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum ResultKind {
73    #[default]
74    Table,
75    Verdict,
76}
77
78#[derive(Debug, Clone, serde::Deserialize)]
79pub struct GoldenCase {
80    pub id: String,
81    pub question: String,
82    #[serde(default)]
83    pub tags: Vec<String>,
84    #[serde(rename = "match")]
85    pub match_mode: MatchMode,
86    #[serde(default)]
87    pub tolerance: Tolerance,
88    pub expected: Table,
89    /// See [`ResultKind`]. Defaulted to `Table` so every pre-existing golden is unaffected.
90    #[serde(default)]
91    pub result_kind: ResultKind,
92    /// For `ResultKind::Verdict` cases, which envelope field [`project_verdict_field`] projects onto
93    /// `expected`'s single cell: `"fresh"` (`verdict.fresh`) or `"severity"` (the breach severity).
94    /// Ignored for `ResultKind::Table` cases; `None` on a verdict case defaults to `"fresh"`.
95    #[serde(default)]
96    pub verdict_field: Option<String>,
97}
98
99/// One repeated-sample invocation of a case — the direct product of one [`run_case`] call, folded
100/// by [`fold_samples`] into the case's aggregate [`CaseResult`]. Not serialized itself; only the
101/// fold's output (`CaseResult`/`SampleVerdict`) is.
102struct SampleOutcome {
103    pass: bool,
104    reason: String,
105    cost: f64,
106    latency_ms: u64,
107    turns: u64,
108    cache_hit: bool,
109    /// The sample's rendered result-set value. `Some` only when the run records answers
110    /// (`--record-answers`) — the distinct-answer distribution is opt-in, since it means keeping
111    /// one more string around per sample.
112    answer: Option<String>,
113}
114
115/// One sample's verdict, kept in [`CaseResult::samples_detail`] so a flaky case's individual
116/// sample-by-sample results (which ones failed, and why) survive past the aggregate.
117#[derive(Debug, Clone, Serialize, serde::Deserialize)]
118pub struct SampleVerdict {
119    pub pass: bool,
120    pub reason: String,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub answer: Option<String>,
123}
124
125/// A case's aggregate result over `samples` repeated runs (pass-rate methodology). At `samples ==
126/// 1` every field is bit-identical to the pre-repeated-sampling report shape — that identity is
127/// the whole back-compat story, both for the gate's case-level comparison and for the Pareto table.
128#[derive(Debug, Clone, Serialize, serde::Deserialize)]
129pub struct CaseResult {
130    pub id: String,
131    pub tags: Vec<String>,
132    /// How many times this case was sampled. `0` only on a report predating repeated sampling —
133    /// the sentinel [`crate::Report::backfill_legacy`] detects and migrates forward. Every
134    /// freshly-produced result sets this to `samples.max(1)`.
135    #[serde(default)]
136    pub samples: u32,
137    /// How many of `samples` passed.
138    #[serde(default)]
139    pub passes: u32,
140    /// `passes as f64 / samples as f64`. Kept as a stored field (not recomputed on read) so a
141    /// legacy report's backfilled value round-trips identically to a freshly-computed one.
142    #[serde(default)]
143    pub pass_rate: f64,
144    /// Fully passing: every sample passed. At `samples == 1` this is exactly today's single-run
145    /// `pass` flag.
146    pub pass: bool,
147    /// Some samples passed, some failed (`0 < passes < samples`). Always `false` at `samples <= 1`.
148    #[serde(default)]
149    pub flaky: bool,
150    /// A representative reason: the modal failure reason among failing samples, or `"match"` when
151    /// every sample passed.
152    pub reason: String,
153    /// Summed over samples — repeated sampling multiplies spend by `samples`, so the total is what
154    /// a run's cost column should reflect, not a per-sample average.
155    pub cost: f64,
156    /// Averaged over samples.
157    pub latency_ms: u64,
158    /// Averaged over samples (`num_turns`) — the round-trip diagnostic.
159    #[serde(default)]
160    pub turns: u64,
161    /// Samples served from the trace cache (re-scored, 0 LLM) vs freshly re-run this invocation.
162    #[serde(default)]
163    pub cache_hits: u32,
164    #[serde(default)]
165    pub cache_misses: u32,
166    /// Per-sample verdicts. Empty on a legacy report (there is nothing to backfill this from).
167    #[serde(default)]
168    pub samples_detail: Vec<SampleVerdict>,
169    /// Distinct-answer distribution across samples. `Some` only when the run recorded answers
170    /// (`--record-answers`) — absence is unambiguous (not just an empty map).
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub answer_dist: Option<BTreeMap<String, u32>>,
173}
174
175/// Fold one case's repeated-sample outcomes into its aggregate [`CaseResult`]. Pure and
176/// unit-tested directly: no I/O, no cache, no process spawn. `samples.len() == 1` degenerates
177/// bit-identically to the pre-repeated-sampling shape.
178fn fold_samples(
179    id: String,
180    tags: Vec<String>,
181    outcomes: Vec<SampleOutcome>,
182    record_answers: bool,
183) -> CaseResult {
184    let samples = outcomes.len() as u32;
185    let passes = outcomes.iter().filter(|o| o.pass).count() as u32;
186    let pass_rate = if samples > 0 {
187        passes as f64 / samples as f64
188    } else {
189        0.0
190    };
191    let pass = samples > 0 && passes == samples;
192    let flaky = passes > 0 && passes < samples;
193    let reason = if passes == samples {
194        "match".to_string()
195    } else {
196        modal_reason(
197            outcomes
198                .iter()
199                .filter(|o| !o.pass)
200                .map(|o| o.reason.as_str()),
201        )
202    };
203    let cost = outcomes.iter().map(|o| o.cost).sum();
204    let latency_ms = if samples > 0 {
205        outcomes.iter().map(|o| o.latency_ms).sum::<u64>() / samples as u64
206    } else {
207        0
208    };
209    let turns = if samples > 0 {
210        outcomes.iter().map(|o| o.turns).sum::<u64>() / samples as u64
211    } else {
212        0
213    };
214    let cache_hits = outcomes.iter().filter(|o| o.cache_hit).count() as u32;
215    let cache_misses = samples - cache_hits;
216
217    let answer_dist = record_answers.then(|| {
218        let mut dist: BTreeMap<String, u32> = BTreeMap::new();
219        for o in &outcomes {
220            if let Some(answer) = &o.answer {
221                *dist.entry(answer.clone()).or_insert(0) += 1;
222            }
223        }
224        dist
225    });
226    let samples_detail = outcomes
227        .into_iter()
228        .map(|o| SampleVerdict {
229            pass: o.pass,
230            reason: o.reason,
231            answer: if record_answers { o.answer } else { None },
232        })
233        .collect();
234
235    CaseResult {
236        id,
237        tags,
238        samples,
239        passes,
240        pass_rate,
241        pass,
242        flaky,
243        reason,
244        cost,
245        latency_ms,
246        turns,
247        cache_hits,
248        cache_misses,
249        samples_detail,
250        answer_dist,
251    }
252}
253
254/// The most common reason among `reasons`, breaking ties by first occurrence — a stable
255/// "representative reason" rather than whatever order a hash-based tally would land on for a tie.
256fn modal_reason<'a>(reasons: impl Iterator<Item = &'a str>) -> String {
257    let mut counts: Vec<(&str, u32)> = Vec::new();
258    for r in reasons {
259        match counts.iter_mut().find(|(seen, _)| *seen == r) {
260            Some(entry) => entry.1 += 1,
261            None => counts.push((r, 1)),
262        }
263    }
264    let mut best: Option<(&str, u32)> = None;
265    for (r, c) in counts {
266        if best.is_none_or(|(_, bc)| c > bc) {
267            best = Some((r, c));
268        }
269    }
270    best.map(|(r, _)| r.to_string()).unwrap_or_default()
271}
272
273/// Render a case's raw `{columns, rows}` result as a comparable string for the answer-distribution
274/// feature (`--record-answers`). `Table` derives `Deserialize` only, not `Serialize`, so this
275/// renders the raw JSON value's canonical text form rather than serializing a `Table` instance.
276///
277/// This is a *textual* signature, not the comparator's semantic one: it fixes object-key order and
278/// whitespace but not row order or number scale (`42` vs `42.0`). So for set/unordered match modes
279/// two results the comparator deems equal can land in different `answer_dist` buckets — the
280/// distribution can over-split. That only affects this diagnostic view; pass/fail and the gate run
281/// through the comparator, never through this string. Unparseable/failed samples contribute no
282/// answer (see the `fail` closure), so a case's bucket counts can sum to fewer than its samples.
283fn render_answer(result: &serde_json::Value) -> String {
284    result.to_string()
285}
286
287#[derive(Debug, Clone, Serialize, serde::Deserialize)]
288pub struct TagStat {
289    pub pass: u32,
290    pub n: u32,
291    /// Sum of each case's `pass_rate` for this tag — `pass_rate_sum / n` is the tag's mean
292    /// pass-rate accuracy, consistent with the gate's per-tag comparison. `0.0` in a pre-repeated-
293    /// sampling report; [`crate::Report::backfill_legacy`] backfills it from the legacy `pass` count.
294    #[serde(default)]
295    pub pass_rate_sum: f64,
296}
297
298#[derive(Debug, Clone, Serialize, serde::Deserialize)]
299pub struct ConfigReport {
300    pub model: String,
301    pub n: usize,
302    /// Mean of each case's `pass_rate`. At `samples == 1` this is exactly the old passes/n
303    /// accuracy — the field name is kept for gate continuity.
304    pub accuracy: f64,
305    pub cost_total_usd: f64,
306    pub latency_ms_avg: u64,
307    /// Average conversation turns per case (`num_turns`) — a context-quality diagnostic
308    /// (fewer turns ⇒ less exploration ⇒ cheaper). `0` in a pre-P4 report.
309    #[serde(default)]
310    pub turns_avg: u64,
311    /// Samples (not cases) served from the trace cache (re-scored, 0 LLM) vs freshly re-run this
312    /// invocation. `cache_misses` is the count of actual LLM calls made; both `0` in a pre-P4 report.
313    #[serde(default)]
314    pub cache_hits: usize,
315    #[serde(default)]
316    pub cache_misses: usize,
317    /// Cases with `0 < passes < samples` — inconsistent across samples, not a hard regression.
318    /// `0` in a pre-repeated-sampling report (no case could be flaky at samples == 1).
319    #[serde(default)]
320    pub flaky_cases: u32,
321    pub by_tag: BTreeMap<String, TagStat>,
322    pub cases: Vec<CaseResult>,
323}
324
325/// A full eval report (one config per tier→model binding). Serialized by `warble eval run --out`
326/// and consumed as the baseline / candidate by `warble eval gate`.
327#[derive(Debug, Clone, Serialize, serde::Deserialize)]
328pub struct Report {
329    pub dataset: Option<String>,
330    pub context_version: Option<String>,
331    /// Concurrency the cases ran at (1 = serial). Recorded because parallelism can inflate
332    /// per-case latency (queueing), so latency columns are only comparable at equal levels.
333    #[serde(default = "default_parallel")]
334    pub parallel: usize,
335    /// Golden cases scored vs the golden file's total. `selected_cases < total_cases` means a
336    /// `--tags`/`--sample` subset (a smoke run) — recorded so a partial run is never mistaken for
337    /// a full one and `eval gate` can see the comparison was over fewer cases (no silent caps).
338    /// Both 0 in a pre-P3 report (the field was absent).
339    #[serde(default)]
340    pub selected_cases: usize,
341    #[serde(default)]
342    pub total_cases: usize,
343    pub configs: Vec<ConfigReport>,
344}
345
346fn default_parallel() -> usize {
347    1
348}
349
350pub struct RunConfig {
351    pub project: PathBuf,
352    pub agent_dir: PathBuf,
353    pub golden_path: PathBuf,
354    pub models: Vec<String>,
355    pub out: Option<PathBuf>,
356    /// Concurrent cases per binding (1 = serial). Under contention the per-case latency
357    /// column measures queueing too, so the report records the level it ran at.
358    pub parallel: usize,
359    /// Golden case selection (`--tags` / `--sample`). Default = full run (every case).
360    pub filter: CaseFilter,
361    /// Bypass the trace cache (`--no-cache`): re-run every case and refresh its cached result
362    /// instead of reusing a content-addressed hit.
363    pub no_cache: bool,
364    /// Where per-case traces are read/written. `None` = `<project>/.warble/eval-cache`.
365    pub cache_dir: Option<PathBuf>,
366    /// Repeated runs per case (pass-rate methodology). `1` is today's single-run behavior;
367    /// use more to distinguish model capability from run-to-run noise (flaky vs consistently
368    /// passing/failing).
369    pub samples: usize,
370    /// Record each sample's rendered result value into `CaseResult::answer_dist` (a distinct-
371    /// answer distribution). Off by default — it means keeping the actual answer strings around,
372    /// not just pass/fail.
373    pub record_answers: bool,
374}
375
376/// The per-run inputs that key the trace cache, threaded through [`run_cases`]/`run_case` so a case
377/// can be re-scored from cache instead of re-run. `model` is the whole-run binding (run path) or
378/// [`cache::FRONTMATTER_MODEL`] (ablation path); `agent_sha`/`context_sha` are content SHAs of the
379/// dispatched agent dir and the bound MDL. A `disabled` store makes every case a miss (`--no-cache`
380/// or when the SHAs can't be computed).
381pub(crate) struct CaseCtx<'a> {
382    pub model: &'a str,
383    pub agent_sha: &'a str,
384    pub context_sha: &'a str,
385    pub context_version: Option<&'a str>,
386    pub store: &'a TraceStore,
387    /// Populate `SampleOutcome::answer` from this run's result — the answer-distribution feature
388    /// (`--record-answers`) is opt-in per run, not always-on.
389    pub record_answers: bool,
390}
391
392impl CaseCtx<'_> {
393    /// The `--model` override to pass `claude`, or `None` on the ablation/frontmatter path (where
394    /// the tier→model binding is baked into the emitted agent and must not be overridden).
395    fn model_override(&self) -> Option<&str> {
396        (self.model != cache::FRONTMATTER_MODEL).then_some(self.model)
397    }
398}
399
400// --- pure helpers ---------------------------------------------------------------------------------
401
402/// Extract the `{columns, rows}` object from an agent's free-form final text: prefer a fenced code
403/// block, else the whole text; then take the first `{` … last `}` and parse it as a table.
404pub fn extract_result(text: &str) -> Option<Table> {
405    serde_json::from_value(extract_result_json(text)?).ok()
406}
407
408/// Like [`extract_result`] but returns the raw `{columns, rows}` JSON object (kept serializable so
409/// `warble eval capture` can embed it verbatim as a golden's `expected`).
410pub fn extract_result_json(text: &str) -> Option<serde_json::Value> {
411    let candidate = strip_fence(text);
412    let start = candidate.find('{')?;
413    let end = candidate.rfind('}')?;
414    if end <= start {
415        return None;
416    }
417    let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
418    // Only accept objects that actually carry a `rows` array (matches the JS guard).
419    if !value.get("rows").map(|r| r.is_array()).unwrap_or(false) {
420        return None;
421    }
422    Some(value)
423}
424
425/// Extract a `{blocks, verdict, emitted, verified}` verdict envelope from an agent's free-form final
426/// text — the +Assertive twin of [`extract_result_json`]. Same fence-stripping and outermost-brace
427/// slicing, but the acceptance guard checks for `verdict` or `blocks` instead of `rows`, since a
428/// verdict envelope carries neither of the table's fields. Rejects anything else (including a plain
429/// `{columns,rows}` table) so a verdict case never accidentally scores a table-shaped answer.
430pub fn extract_verdict_json(text: &str) -> Option<serde_json::Value> {
431    let candidate = strip_fence(text);
432    let start = candidate.find('{')?;
433    let end = candidate.rfind('}')?;
434    if end <= start {
435        return None;
436    }
437    let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
438    let has_verdict = value.get("verdict").is_some();
439    let has_blocks = value.get("blocks").map(Value::is_array).unwrap_or(false);
440    if !has_verdict && !has_blocks {
441        return None;
442    }
443    Some(value)
444}
445
446/// Project one field of a verdict envelope down to the 1x1 scalar [`Table`] a case's `expected`
447/// compares against. `"fresh"` reads `verdict.fresh`; `"severity"` reads the breach severity off the
448/// envelope's `status` block (falling back to `verdict.severity`, in case a future envelope carries
449/// it there instead). Fails closed: an absent, wrong-typed, or unrecognized field returns `None` —
450/// the case then fails outright rather than silently comparing something else.
451pub fn project_verdict_field(envelope: &serde_json::Value, field: &str) -> Option<Table> {
452    match field {
453        "fresh" => {
454            let fresh = envelope.get("verdict")?.get("fresh")?.as_bool()?;
455            Some(Table {
456                columns: vec!["fresh".to_string()],
457                rows: vec![vec![Value::Bool(fresh)]],
458            })
459        }
460        "severity" => {
461            let status_severity =
462                envelope
463                    .get("blocks")
464                    .and_then(Value::as_array)
465                    .and_then(|blocks| {
466                        blocks
467                            .iter()
468                            .find(|b| b.get("type").and_then(Value::as_str) == Some("status"))
469                            .and_then(|b| b.get("severity"))
470                            .and_then(Value::as_str)
471                    });
472            let severity = status_severity.or_else(|| {
473                envelope
474                    .get("verdict")
475                    .and_then(|v| v.get("severity"))
476                    .and_then(Value::as_str)
477            })?;
478            Some(Table {
479                columns: vec!["severity".to_string()],
480                rows: vec![vec![Value::String(severity.to_string())]],
481            })
482        }
483        _ => None,
484    }
485}
486
487/// Score a raw result value against a case's expectation, dispatching on [`ResultKind`] so the
488/// fresh-run path (`run_case`) and the cache re-score path ([`cache::rescore`]) project the same
489/// way from the same stored shape: a `Table` value for `ResultKind::Table`, a verdict envelope for
490/// `ResultKind::Verdict`. Returns `None` when `result_value` doesn't have the shape the case's kind
491/// expects — the caller treats that as a hard fail, never a false pass.
492pub fn score_value(result_value: &Value, case: &GoldenCase) -> Option<CompareResult> {
493    let actual = match case.result_kind {
494        ResultKind::Table => serde_json::from_value(result_value.clone()).ok()?,
495        ResultKind::Verdict => {
496            let field = case.verdict_field.as_deref().unwrap_or("fresh");
497            project_verdict_field(result_value, field)?
498        }
499    };
500    Some(compare(&CompareRequest {
501        match_mode: case.match_mode,
502        tolerance: case.tolerance,
503        expected: case.expected.clone(),
504        actual,
505    }))
506}
507
508/// Return the content of the first ```/```json fenced block, or the whole text if none.
509fn strip_fence(text: &str) -> &str {
510    let Some(open) = text.find("```") else {
511        return text;
512    };
513    let after = &text[open + 3..];
514    // Skip an optional language tag up to the newline that ends the opening fence line.
515    let content_start = match after.find('\n') {
516        Some(i) => open + 3 + i + 1,
517        None => return text,
518    };
519    match text[content_start..].find("```") {
520        Some(close_rel) => &text[content_start..content_start + close_rel],
521        None => text,
522    }
523}
524
525/// Aggregate per-case results for one model binding into a config report.
526pub fn aggregate(model: &str, rows: Vec<CaseResult>) -> ConfigReport {
527    let n = rows.len();
528    // Accuracy is the mean of each case's pass_rate — at samples == 1 this is numerically
529    // identical to the old passes/n (pass_rate is exactly 0.0 or 1.0 there).
530    let pass_rate_sum_total: f64 = rows.iter().map(|r| r.pass_rate).sum();
531    let accuracy = if n > 0 {
532        pass_rate_sum_total / n as f64
533    } else {
534        0.0
535    };
536    let flaky_cases = rows.iter().filter(|r| r.flaky).count() as u32;
537    let cost_total_usd = rows.iter().map(|r| r.cost).sum();
538    let latency_sum: u64 = rows.iter().map(|r| r.latency_ms).sum();
539    let latency_ms_avg = if n > 0 { latency_sum / n as u64 } else { 0 };
540    let turns_sum: u64 = rows.iter().map(|r| r.turns).sum();
541    let turns_avg = if n > 0 { turns_sum / n as u64 } else { 0 };
542    let cache_hits = rows.iter().map(|r| r.cache_hits as usize).sum();
543    let cache_misses = rows.iter().map(|r| r.cache_misses as usize).sum();
544
545    let mut by_tag: BTreeMap<String, TagStat> = BTreeMap::new();
546    for r in &rows {
547        for t in &r.tags {
548            let e = by_tag.entry(t.clone()).or_insert(TagStat {
549                pass: 0,
550                n: 0,
551                pass_rate_sum: 0.0,
552            });
553            e.n += 1;
554            if r.pass {
555                e.pass += 1;
556            }
557            e.pass_rate_sum += r.pass_rate;
558        }
559    }
560
561    ConfigReport {
562        model: model.to_string(),
563        n,
564        accuracy,
565        cost_total_usd,
566        latency_ms_avg,
567        turns_avg,
568        cache_hits,
569        cache_misses,
570        flaky_cases,
571        by_tag,
572        cases: rows,
573    }
574}
575
576/// Render the Pareto table (accuracy vs cost vs latency, with per-tag accuracy).
577pub fn format_pareto(report: &Report) -> String {
578    let mut out = String::new();
579    out.push_str("\n=== Warble eval — Pareto (accuracy vs cost vs latency) ===\n");
580    if report.selected_cases < report.total_cases {
581        out.push_str(&format!(
582            "  (subset: {}/{} golden cases — smoke run, not a full scoring)\n",
583            report.selected_cases, report.total_cases
584        ));
585    }
586    out.push_str(&format!(
587        "{:<16} {:<7} {:<10} {:<12} {:<7} by_tag\n",
588        "binding", "acc", "cost($)", "lat(ms)", "turns"
589    ));
590    for c in &report.configs {
591        let tags = c
592            .by_tag
593            .iter()
594            .map(|(t, v)| format!("{t}:{:.2}", v.pass_rate_sum / v.n.max(1) as f64))
595            .collect::<Vec<_>>()
596            .join(" ");
597        let cost = if c.cost_total_usd > 0.0 {
598            format!("{:.4}", c.cost_total_usd)
599        } else {
600            "n/a".to_string()
601        };
602        out.push_str(&format!(
603            "{:<16} {:<7} {:<10} {:<12} {:<7} {tags}\n",
604            format!("strong→{}", c.model),
605            format!("{:.2}", c.accuracy),
606            cost,
607            c.latency_ms_avg,
608            c.turns_avg
609        ));
610    }
611    // Cache visibility (no silent caps): show what was re-scored from cache vs freshly re-run, so a
612    // 0-LLM re-score is never mistaken for a fresh run. `cache_misses` == the LLM calls this run.
613    if report.configs.iter().any(|c| c.cache_hits > 0) {
614        out.push_str("cache (miss = LLM call this run):\n");
615        for c in &report.configs {
616            let note = if c.cache_misses == 0 {
617                "re-score only, 0 LLM calls this run".to_string()
618            } else {
619                format!("{} LLM calls this run", c.cache_misses)
620            };
621            out.push_str(&format!(
622                "  strong→{:<10} {} hit / {} miss — {note}\n",
623                c.model, c.cache_hits, c.cache_misses
624            ));
625        }
626    }
627    // Flaky cases (inconsistent across samples) are surfaced, never silently folded into a hard
628    // failure — the gate treats them separately too (see gate::run_gate).
629    if report.configs.iter().any(|c| c.flaky_cases > 0) {
630        out.push_str("flaky cases (inconsistent across samples):\n");
631        for c in &report.configs {
632            for case in c.cases.iter().filter(|case| case.flaky) {
633                out.push_str(&format!(
634                    "  strong→{:<10} {}  pass_rate={:.2} ({}/{})",
635                    c.model, case.id, case.pass_rate, case.passes, case.samples
636                ));
637                if let Some(dist) = &case.answer_dist {
638                    let mut answers: Vec<(&String, &u32)> = dist.iter().collect();
639                    answers.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
640                    let top = answers
641                        .into_iter()
642                        .take(3)
643                        .map(|(a, n)| format!("{a}×{n}"))
644                        .collect::<Vec<_>>()
645                        .join(", ");
646                    out.push_str(&format!("  — {top}"));
647                }
648                out.push('\n');
649            }
650        }
651    }
652    out
653}
654
655// --- orchestration --------------------------------------------------------------------------------
656
657/// Installs agent files into `<project>/.claude` and removes them on drop (the project may have had
658/// no `.claude` at all, so cleanup is best-effort and non-recursive, matching the reference runner).
659struct InstalledAgents {
660    files: Vec<PathBuf>,
661    agents_dir: PathBuf,
662    claude_dir: PathBuf,
663}
664
665impl Drop for InstalledAgents {
666    fn drop(&mut self) {
667        for f in &self.files {
668            let _ = fs::remove_file(f);
669        }
670        let _ = fs::remove_dir(&self.agents_dir);
671        let _ = fs::remove_dir(&self.claude_dir);
672    }
673}
674
675pub(crate) fn install_agents(agent_dir: &Path, project: &Path) -> Result<InstalledAgents, String> {
676    let claude_dir = project.join(".claude");
677    let agents_dir = claude_dir.join("agents");
678    fs::create_dir_all(&agents_dir).map_err(|e| format!("mkdir {}: {e}", agents_dir.display()))?;
679
680    let src_agents = agent_dir.join(".claude").join("agents");
681    let mut files = Vec::new();
682    for entry in
683        fs::read_dir(&src_agents).map_err(|e| format!("read {}: {e}", src_agents.display()))?
684    {
685        let entry = entry.map_err(|e| e.to_string())?;
686        let dst = agents_dir.join(entry.file_name());
687        fs::copy(entry.path(), &dst).map_err(|e| format!("copy agent: {e}"))?;
688        files.push(dst);
689    }
690
691    // settings.json lives at .claude/settings.json (split path) or <out>/settings.json (single path).
692    let settings_src = {
693        let split = agent_dir.join(".claude").join("settings.json");
694        if split.is_file() {
695            Some(split)
696        } else {
697            let flat = agent_dir.join("settings.json");
698            flat.is_file().then_some(flat)
699        }
700    };
701    if let Some(src) = settings_src {
702        let dst = claude_dir.join("settings.json");
703        fs::copy(&src, &dst).map_err(|e| format!("copy settings: {e}"))?;
704        files.push(dst);
705    }
706
707    Ok(InstalledAgents {
708        files,
709        agents_dir,
710        claude_dir,
711    })
712}
713
714/// The agent name is the basename of the first agent file (the driver, for split components).
715pub(crate) fn agent_name(agent_dir: &Path) -> Result<String, String> {
716    let dir = agent_dir.join(".claude").join("agents");
717    let mut names: Vec<String> = fs::read_dir(&dir)
718        .map_err(|e| format!("read {}: {e}", dir.display()))?
719        .filter_map(|e| e.ok())
720        .filter_map(|e| e.file_name().to_str().map(str::to_string))
721        .filter_map(|f| f.strip_suffix(".md").map(str::to_string))
722        .collect();
723    names.sort();
724    names
725        .into_iter()
726        .next()
727        .ok_or_else(|| format!("no agent files in {}", dir.display()))
728}
729
730/// Build a PATH that prepends `<project>/.venv/bin` when present, so the agent's `wren` is found.
731pub(crate) fn run_path(project: &Path) -> String {
732    let base = std::env::var("PATH").unwrap_or_default();
733    let venv = project.join(".venv").join("bin");
734    if venv.is_dir() {
735        format!("{}:{base}", venv.display())
736    } else {
737        base
738    }
739}
740
741/// Run one repeated sample of one golden case through the dispatched `agent`, consulting the trace
742/// cache in `ctx`.
743///
744/// **Cache hit** — identical `(case, agent_sha, model, context_sha, sample)` — re-scores the cached
745/// result against the case's *current* expectation with **no LLM call** (`cache_hit = true`). This is
746/// both the content-addressed skip and the "only the golden's `expected` changed" 0-LLM re-score: the
747/// expectation is deliberately outside the key, so such a change still hits and only the comparison
748/// is redone. **Miss** invokes the agent (`ctx` supplies the `--model` override on the run path, or
749/// the frontmatter binding on the ablation path) and writes a fresh trace back to the cache. Each
750/// `sample` is its own cache entry — the pass-rate methodology depends on every sample actually
751/// invoking the agent (or replaying its own prior trace), never silently collapsing onto sample 0.
752fn run_case(
753    project: &Path,
754    agent: &str,
755    path_env: &str,
756    case: &GoldenCase,
757    sample: u32,
758    ctx: &CaseCtx,
759) -> SampleOutcome {
760    let key = CaseKey {
761        case_id: &case.id,
762        question: &case.question,
763        agent_sha: ctx.agent_sha,
764        model: ctx.model,
765        context_sha: ctx.context_sha,
766        sample,
767    };
768
769    // Cache hit → re-score the cached result against the current expectation (0 LLM). A corrupt
770    // entry (result no longer reads back as a table) falls through and re-runs the case.
771    if let Some(trace) = ctx.store.load(&key) {
772        if let Some(verdict) = rescore(&trace, case) {
773            return SampleOutcome {
774                pass: verdict.pass,
775                reason: verdict.reason,
776                cost: trace.cost,
777                latency_ms: trace.latency_ms,
778                turns: trace.turns,
779                cache_hit: true,
780                answer: ctx.record_answers.then(|| render_answer(&trace.result)),
781            };
782        }
783    }
784
785    let started = Instant::now();
786    let fail = |reason: &str, cost: f64, latency_ms: u64| SampleOutcome {
787        pass: false,
788        reason: reason.to_string(),
789        cost,
790        latency_ms,
791        turns: 0,
792        cache_hit: false,
793        answer: None,
794    };
795
796    let mut args: Vec<&str> = vec!["-p", &case.question, "--agent", agent];
797    if let Some(model) = ctx.model_override() {
798        args.extend_from_slice(&["--model", model]);
799    }
800    args.extend_from_slice(&[
801        "--output-format",
802        "json",
803        "--allowedTools",
804        "Read",
805        "Bash(wren:*)",
806    ]);
807    let output = Command::new("claude")
808        .args(&args)
809        .current_dir(project)
810        .env("PATH", path_env)
811        .output();
812
813    let raw = match output {
814        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
815        _ => {
816            return fail(
817                "claude invocation failed",
818                0.0,
819                started.elapsed().as_millis() as u64,
820            )
821        }
822    };
823
824    let meta: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
825    let result_text = meta.get("result").and_then(|v| v.as_str()).unwrap_or(&raw);
826    let latency_ms = meta
827        .get("duration_ms")
828        .and_then(|v| v.as_u64())
829        .unwrap_or_else(|| started.elapsed().as_millis() as u64);
830    let cost = meta
831        .get("total_cost_usd")
832        .and_then(|v| v.as_f64())
833        .unwrap_or(0.0);
834    // `num_turns` is the round-trip count the whole codebase treats as the turn diagnostic.
835    let turns = meta.get("num_turns").and_then(|v| v.as_u64()).unwrap_or(0);
836
837    // Extraction dispatches on the case's kind: a Table case still reads `{columns,rows}`; a
838    // Verdict case reads the +Assertive `{blocks,verdict,emitted,verified}` envelope instead. Either
839    // way the raw value (not the projected scalar) is what gets cached in `Trace.result`, so a later
840    // re-score ([`cache::rescore`]) can re-project it against a possibly-changed expectation/field.
841    let extracted = match case.result_kind {
842        ResultKind::Table => extract_result_json(result_text),
843        ResultKind::Verdict => extract_verdict_json(result_text),
844    };
845    let Some(result_value) = extracted else {
846        let reason = match case.result_kind {
847            ResultKind::Table => "no parseable {columns,rows} in output",
848            ResultKind::Verdict => "no parseable verdict envelope in output",
849        };
850        return fail(reason, cost, latency_ms);
851    };
852    let Some(verdict) = score_value(&result_value, case) else {
853        let reason = match case.result_kind {
854            ResultKind::Table => "no parseable {columns,rows} in output",
855            ResultKind::Verdict => "verdict envelope missing the expected field",
856        };
857        return fail(reason, cost, latency_ms);
858    };
859
860    // Cache the result (best-effort; a write failure degrades to "not cached", never fails the run).
861    let trace = Trace {
862        case_id: case.id.clone(),
863        agent_sha: ctx.agent_sha.to_string(),
864        model: ctx.model.to_string(),
865        context_version: ctx.context_version.map(str::to_string),
866        context_sha: ctx.context_sha.to_string(),
867        question: case.question.clone(),
868        sql_executed: None,
869        result: result_value,
870        cost,
871        latency_ms,
872        turns,
873        tool_calls: None,
874    };
875    if let Err(e) = ctx.store.store(&key, &trace) {
876        eprintln!(
877            "  note: could not cache trace for {} (sample {sample}): {e}",
878            case.id
879        );
880    }
881
882    SampleOutcome {
883        pass: verdict.pass,
884        reason: verdict.reason,
885        cost,
886        latency_ms,
887        turns,
888        cache_hit: false,
889        answer: ctx.record_answers.then(|| render_answer(&trace.result)),
890    }
891}
892
893/// Load the golden file, install the agent, run every case under every model binding, and return
894/// the aggregated report. Progress is streamed to stderr. The agent install is cleaned up on return
895/// (including on error) via the `InstalledAgents` drop guard.
896pub fn run_eval(cfg: &RunConfig) -> Result<Report, String> {
897    let golden_text = fs::read_to_string(&cfg.golden_path)
898        .map_err(|e| format!("read {}: {e}", cfg.golden_path.display()))?;
899    let golden: Golden =
900        serde_yaml::from_str(&golden_text).map_err(|e| format!("parse golden: {e}"))?;
901
902    // Stratified selection (`--tags` / `--sample`): pick the smoke/full subset before any spend.
903    let total_cases = golden.cases.len();
904    let cases = select_and_subset(&golden, &cfg.filter)?;
905    let selected_cases = cases.len();
906
907    let agent = agent_name(&cfg.agent_dir)?;
908    let path_env = run_path(&cfg.project);
909
910    // Trace cache: the MDL SHA (context_sha) + the dispatched-agent SHA (agent_sha) are the run's
911    // content-addressed key material. Computed before install (install copies agents into the
912    // project's .claude, which compute_mdl_sha skips). Either failing disables the cache visibly.
913    let (mut store, context_sha) =
914        build_store(cfg.no_cache, cfg.cache_dir.as_deref(), &cfg.project);
915    let agent_sha = if store.is_enabled() {
916        match context::compute_dir_sha(&cfg.agent_dir) {
917            Ok(s) => s,
918            Err(e) => {
919                eprintln!("cache: disabled — cannot hash agent dir: {e}");
920                store = TraceStore::disabled();
921                String::new()
922            }
923        }
924    } else {
925        String::new()
926    };
927
928    let _installed = install_agents(&cfg.agent_dir, &cfg.project)?;
929
930    let parallel = cfg.parallel.max(1);
931    let mut configs = Vec::new();
932    for model in &cfg.models {
933        let par = if parallel > 1 {
934            format!(", parallel={parallel}")
935        } else {
936            String::new()
937        };
938        eprintln!("\n### binding: strong→{model}  (n={selected_cases}{par})");
939        let ctx = CaseCtx {
940            model,
941            agent_sha: &agent_sha,
942            context_sha: &context_sha,
943            context_version: golden.context_version.as_deref(),
944            store: &store,
945            record_answers: cfg.record_answers,
946        };
947        let rows = run_cases(
948            &cfg.project,
949            &agent,
950            &path_env,
951            &cases,
952            cfg.samples,
953            parallel,
954            &ctx,
955        );
956        configs.push(aggregate(model, rows));
957    }
958
959    Ok(Report {
960        dataset: golden.dataset,
961        context_version: golden.context_version,
962        parallel,
963        selected_cases,
964        total_cases,
965        configs,
966    })
967}
968
969/// Build the trace store and compute the `context_sha` (MDL SHA) key component shared by every case
970/// of a run/ablation. Returns a **disabled** store (every case re-runs, nothing is cached) with an
971/// empty `context_sha` when `--no-cache` is set or the MDL SHA can't be computed — the reason is
972/// printed to stderr so a disabled cache is never a silent surprise. The default cache dir is
973/// `<project>/.warble/eval-cache`.
974pub(crate) fn build_store(
975    no_cache: bool,
976    cache_dir: Option<&Path>,
977    project: &Path,
978) -> (TraceStore, String) {
979    if no_cache {
980        if cache_dir.is_some() {
981            eprintln!("cache: --cache-dir ignored — --no-cache disables the cache entirely");
982        }
983        eprintln!("cache: disabled (--no-cache) — every case re-runs and refreshes its trace");
984        return (TraceStore::disabled(), String::new());
985    }
986    let context_sha = match compute_mdl_sha(project) {
987        Ok(s) => s,
988        Err(e) => {
989            eprintln!("cache: disabled — cannot compute MDL context SHA: {e}");
990            return (TraceStore::disabled(), String::new());
991        }
992    };
993    let dir = cache_dir
994        .map(Path::to_path_buf)
995        .unwrap_or_else(|| project.join(".warble").join("eval-cache"));
996    eprintln!(
997        "cache: enabled at {} (context_sha {})",
998        dir.display(),
999        &context_sha[..context_sha.len().min(12)]
1000    );
1001    (TraceStore::new(dir, true), context_sha)
1002}
1003
1004/// Apply `filter` to `golden.cases`, print the no-silent-caps selection note, and return the
1005/// selected cases. Errors if an active filter selects nothing (a typo'd tag shouldn't quietly run
1006/// zero cases). Shared by the run and ablation paths.
1007pub(crate) fn select_and_subset(
1008    golden: &Golden,
1009    filter: &CaseFilter,
1010) -> Result<Vec<GoldenCase>, String> {
1011    let selection = select_cases(&golden.cases, filter);
1012    if filter.is_active() {
1013        eprintln!("{}", selection.note);
1014    }
1015    if filter.is_active() && selection.indices.is_empty() {
1016        return Err(format!(
1017            "no golden cases match the filter — {} (nothing to run)",
1018            selection.note
1019        ));
1020    }
1021    Ok(selection
1022        .indices
1023        .iter()
1024        .map(|&i| golden.cases[i].clone())
1025        .collect())
1026}
1027
1028/// Run every golden case, `samples` times each, through the installed `agent`, streaming progress
1029/// to stderr. `ctx` carries the model binding and trace-cache key material (see [`CaseCtx`]); a
1030/// cache hit re-scores without invoking the agent. The agent must already be installed into
1031/// `project`. `parallel` jobs (case × sample) run concurrently (1 = serial); jobs are independent
1032/// and the installed agent files are only read, so the shared state is safe.
1033///
1034/// At `samples == 1` the per-case progress line is byte-for-byte what it was before repeated
1035/// sampling existed. At `samples > 1` each sample additionally prints its own compact line, and
1036/// once every sample of a case is in, a pass-rate summary line follows (marked `FLAKY` when the
1037/// case's samples disagree). The returned rows are always in golden-file order.
1038pub(crate) fn run_cases(
1039    project: &Path,
1040    agent: &str,
1041    path_env: &str,
1042    cases: &[GoldenCase],
1043    samples: usize,
1044    parallel: usize,
1045    ctx: &CaseCtx,
1046) -> Vec<CaseResult> {
1047    let samples = samples.max(1);
1048    let outcomes = run_indexed(cases.len() * samples, parallel, |j| {
1049        let case = &cases[j / samples];
1050        let sample = (j % samples) as u32;
1051        let r = run_case(project, agent, path_env, case, sample, ctx);
1052        let extra = if r.pass {
1053            String::new()
1054        } else {
1055            format!("  — {}", r.reason)
1056        };
1057        let cost = if r.cost > 0.0 {
1058            format!(", ${:.4}", r.cost)
1059        } else {
1060            String::new()
1061        };
1062        // Mark re-scored (cached) samples so a 0-LLM run is never read as a fresh one.
1063        let cache = if r.cache_hit { " [cache]" } else { "" };
1064        if samples == 1 {
1065            eprintln!(
1066                "  {}  {}{cache}  ({:.1}s{cost}, {}t){extra}",
1067                if r.pass { "PASS" } else { "FAIL" },
1068                case.id,
1069                r.latency_ms as f64 / 1000.0,
1070                r.turns
1071            );
1072        } else {
1073            eprintln!(
1074                "    {}  {} sample {sample}{cache}  ({:.1}s{cost}, {}t){extra}",
1075                if r.pass { "PASS" } else { "FAIL" },
1076                case.id,
1077                r.latency_ms as f64 / 1000.0,
1078                r.turns
1079            );
1080        }
1081        r
1082    });
1083
1084    // Group the flat (case*samples + sample) outcomes back into per-case chunks, in golden order,
1085    // and fold each into its aggregate. `SampleOutcome` isn't `Clone`, so a shared mutable iterator
1086    // — not indexing/slicing — is what lets each case take exactly its `samples` items.
1087    let mut it = outcomes.into_iter();
1088    cases
1089        .iter()
1090        .map(|case| {
1091            let chunk: Vec<SampleOutcome> = (&mut it).take(samples).collect();
1092            let result = fold_samples(
1093                case.id.clone(),
1094                case.tags.clone(),
1095                chunk,
1096                ctx.record_answers,
1097            );
1098            if samples > 1 {
1099                let marker = if result.flaky {
1100                    "FLAKY"
1101                } else if result.pass {
1102                    "PASS"
1103                } else {
1104                    "FAIL"
1105                };
1106                eprintln!(
1107                    "  {marker}  {}  pass_rate={:.2} ({}/{})",
1108                    result.id, result.pass_rate, result.passes, result.samples
1109                );
1110            }
1111            result
1112        })
1113        .collect()
1114}
1115
1116/// Run jobs `0..n` through `job`, at most `parallel` concurrently, and return the results in
1117/// index order regardless of completion order. `parallel <= 1` degenerates to a plain serial
1118/// loop. Workers pull the next index from a shared counter, so long jobs don't starve the
1119/// queue behind a fixed pre-partition.
1120pub(crate) fn run_indexed<T, F>(n: usize, parallel: usize, job: F) -> Vec<T>
1121where
1122    T: Send,
1123    F: Fn(usize) -> T + Sync,
1124{
1125    if parallel <= 1 || n <= 1 {
1126        return (0..n).map(job).collect();
1127    }
1128
1129    use std::sync::atomic::{AtomicUsize, Ordering};
1130    use std::sync::Mutex;
1131
1132    let next = AtomicUsize::new(0);
1133    let slots: Vec<Mutex<Option<T>>> = (0..n).map(|_| Mutex::new(None)).collect();
1134
1135    std::thread::scope(|scope| {
1136        for _ in 0..parallel.min(n) {
1137            scope.spawn(|| loop {
1138                let i = next.fetch_add(1, Ordering::Relaxed);
1139                if i >= n {
1140                    break;
1141                }
1142                let r = job(i);
1143                *slots[i].lock().expect("result slot poisoned") = Some(r);
1144            });
1145        }
1146    });
1147
1148    slots
1149        .into_iter()
1150        .map(|slot| {
1151            slot.into_inner()
1152                .expect("result slot poisoned")
1153                .expect("every index 0..n is claimed exactly once by the shared counter")
1154        })
1155        .collect()
1156}
1157
1158#[cfg(test)]
1159mod run_indexed_tests {
1160    use super::run_indexed;
1161    use std::sync::atomic::{AtomicUsize, Ordering};
1162    use std::time::Duration;
1163
1164    #[test]
1165    fn serial_path_preserves_order_and_runs_each_once() {
1166        let calls = AtomicUsize::new(0);
1167        let out = run_indexed(5, 1, |i| {
1168            calls.fetch_add(1, Ordering::Relaxed);
1169            i * 10
1170        });
1171        assert_eq!(out, vec![0, 10, 20, 30, 40]);
1172        assert_eq!(calls.load(Ordering::Relaxed), 5);
1173    }
1174
1175    #[test]
1176    fn parallel_results_are_in_index_order_despite_completion_order() {
1177        // Earlier indices sleep longer, so completion order is roughly reversed —
1178        // the returned Vec must still be in index order.
1179        let out = run_indexed(8, 4, |i| {
1180            std::thread::sleep(Duration::from_millis((8 - i as u64) * 3));
1181            i
1182        });
1183        assert_eq!(out, (0..8).collect::<Vec<_>>());
1184    }
1185
1186    #[test]
1187    fn parallel_runs_each_index_exactly_once() {
1188        let per_index: Vec<AtomicUsize> = (0..32).map(|_| AtomicUsize::new(0)).collect();
1189        let out = run_indexed(32, 6, |i| {
1190            per_index[i].fetch_add(1, Ordering::Relaxed);
1191            i
1192        });
1193        assert_eq!(out.len(), 32);
1194        for (i, c) in per_index.iter().enumerate() {
1195            assert_eq!(c.load(Ordering::Relaxed), 1, "index {i} ran once");
1196        }
1197    }
1198
1199    #[test]
1200    fn parallelism_wider_than_n_and_empty_input_are_fine() {
1201        assert_eq!(run_indexed(2, 16, |i| i), vec![0, 1]);
1202        assert_eq!(run_indexed(0, 4, |i| i), Vec::<usize>::new());
1203    }
1204
1205    #[test]
1206    fn parallel_actually_overlaps_work() {
1207        // Track concurrency directly instead of asserting on wall-clock duration: a shared/
1208        // contended CI runner can make even a generous timing threshold flaky, since the claim
1209        // under test ("did the pool hand out overlapping work") isn't really about elapsed time.
1210        // Two sleeping threads are both genuinely inside the closure at once, so overlap is
1211        // observable this way even when the runner has fewer cores than the requested
1212        // parallelism — which is also why we assert `> 1`, not `== 4`.
1213        let in_flight = AtomicUsize::new(0);
1214        let max_in_flight = AtomicUsize::new(0);
1215        run_indexed(4, 4, |_| {
1216            let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1217            max_in_flight.fetch_max(now, Ordering::SeqCst);
1218            std::thread::sleep(Duration::from_millis(40));
1219            in_flight.fetch_sub(1, Ordering::SeqCst);
1220        });
1221        let max = max_in_flight.load(Ordering::SeqCst);
1222        assert!(
1223            max > 1,
1224            "expected overlapping execution, but max concurrent was {max}"
1225        );
1226    }
1227}
1228
1229#[cfg(test)]
1230mod fold_samples_tests {
1231    use super::*;
1232
1233    fn outcome(pass: bool, reason: &str, cost: f64, latency_ms: u64, turns: u64) -> SampleOutcome {
1234        SampleOutcome {
1235            pass,
1236            reason: reason.to_string(),
1237            cost,
1238            latency_ms,
1239            turns,
1240            cache_hit: false,
1241            answer: None,
1242        }
1243    }
1244
1245    #[test]
1246    fn fully_passing_is_not_flaky_and_reason_is_match() {
1247        let outcomes = vec![
1248            outcome(true, "match", 0.1, 1_000, 2),
1249            outcome(true, "match", 0.1, 1_000, 2),
1250        ];
1251        let r = fold_samples("a".into(), vec![], outcomes, false);
1252        assert_eq!(r.samples, 2);
1253        assert_eq!(r.passes, 2);
1254        assert!((r.pass_rate - 1.0).abs() < 1e-9);
1255        assert!(r.pass);
1256        assert!(!r.flaky);
1257        assert_eq!(r.reason, "match");
1258    }
1259
1260    #[test]
1261    fn fully_failing_is_not_flaky() {
1262        let outcomes = vec![
1263            outcome(false, "mismatch", 0.0, 500, 1),
1264            outcome(false, "mismatch", 0.0, 500, 1),
1265        ];
1266        let r = fold_samples("a".into(), vec![], outcomes, false);
1267        assert_eq!(r.passes, 0);
1268        assert_eq!(r.pass_rate, 0.0);
1269        assert!(!r.pass);
1270        assert!(!r.flaky, "0 passes out of N is a hard fail, not flaky");
1271    }
1272
1273    #[test]
1274    fn partial_pass_is_flaky_and_reason_is_the_modal_failure() {
1275        let outcomes = vec![
1276            outcome(true, "match", 0.0, 0, 0),
1277            outcome(false, "wrong total", 0.0, 0, 0),
1278            outcome(false, "wrong total", 0.0, 0, 0),
1279            outcome(false, "timeout", 0.0, 0, 0),
1280        ];
1281        let r = fold_samples("a".into(), vec![], outcomes, false);
1282        assert_eq!(r.passes, 1);
1283        assert_eq!(r.samples, 4);
1284        assert!((r.pass_rate - 0.25).abs() < 1e-9);
1285        assert!(!r.pass);
1286        assert!(r.flaky);
1287        assert_eq!(r.reason, "wrong total", "modal failure reason wins");
1288    }
1289
1290    #[test]
1291    fn modal_reason_tie_break_is_first_occurrence() {
1292        // "b" and "a" both fail twice — "a" was seen first among the failures, so it wins the tie.
1293        let outcomes = vec![
1294            outcome(false, "a", 0.0, 0, 0),
1295            outcome(false, "b", 0.0, 0, 0),
1296            outcome(false, "b", 0.0, 0, 0),
1297            outcome(false, "a", 0.0, 0, 0),
1298        ];
1299        let r = fold_samples("a".into(), vec![], outcomes, false);
1300        assert_eq!(r.reason, "a");
1301    }
1302
1303    #[test]
1304    fn cost_sums_latency_and_turns_average() {
1305        let outcomes = vec![
1306            outcome(true, "match", 0.10, 1_000, 4),
1307            outcome(true, "match", 0.20, 3_000, 6),
1308        ];
1309        let r = fold_samples("a".into(), vec![], outcomes, false);
1310        assert!((r.cost - 0.30).abs() < 1e-9, "cost sums across samples");
1311        assert_eq!(r.latency_ms, 2_000, "latency averages across samples");
1312        assert_eq!(r.turns, 5, "turns averages across samples");
1313    }
1314
1315    #[test]
1316    fn cache_hits_and_misses_are_counted_per_sample() {
1317        let outcomes = vec![
1318            SampleOutcome {
1319                cache_hit: true,
1320                ..outcome(true, "match", 0.0, 0, 0)
1321            },
1322            SampleOutcome {
1323                cache_hit: false,
1324                ..outcome(true, "match", 0.0, 0, 0)
1325            },
1326            SampleOutcome {
1327                cache_hit: true,
1328                ..outcome(true, "match", 0.0, 0, 0)
1329            },
1330        ];
1331        let r = fold_samples("a".into(), vec![], outcomes, false);
1332        assert_eq!(r.cache_hits, 2);
1333        assert_eq!(r.cache_misses, 1);
1334    }
1335
1336    #[test]
1337    fn answer_dist_is_only_populated_when_record_answers_is_set() {
1338        let build = || {
1339            vec![
1340                SampleOutcome {
1341                    answer: Some("42".to_string()),
1342                    ..outcome(true, "match", 0.0, 0, 0)
1343                },
1344                SampleOutcome {
1345                    answer: Some("42".to_string()),
1346                    ..outcome(true, "match", 0.0, 0, 0)
1347                },
1348                SampleOutcome {
1349                    answer: Some("7".to_string()),
1350                    ..outcome(false, "wrong total", 0.0, 0, 0)
1351                },
1352            ]
1353        };
1354
1355        let without = fold_samples("a".into(), vec![], build(), false);
1356        assert!(without.answer_dist.is_none());
1357        assert!(
1358            without.samples_detail.iter().all(|s| s.answer.is_none()),
1359            "no answer strings recorded unless --record-answers"
1360        );
1361
1362        let with = fold_samples("a".into(), vec![], build(), true);
1363        let dist = with.answer_dist.expect("populated when record_answers");
1364        assert_eq!(dist.get("42"), Some(&2));
1365        assert_eq!(dist.get("7"), Some(&1));
1366        assert_eq!(
1367            with.samples_detail
1368                .iter()
1369                .filter(|s| s.answer.is_some())
1370                .count(),
1371            3
1372        );
1373    }
1374
1375    #[test]
1376    fn modal_reason_of_empty_iterator_is_empty_string() {
1377        assert_eq!(modal_reason(std::iter::empty()), "");
1378    }
1379}
1380
1381#[cfg(test)]
1382mod verdict_tests {
1383    use super::*;
1384
1385    /// A representative +Assertive envelope, shaped exactly like
1386    /// `dispatcher/claude-code-cli`'s `VERDICT_ENVELOPE_EXAMPLE`.
1387    fn envelope(fresh: bool, severity: &str) -> serde_json::Value {
1388        serde_json::json!({
1389            "blocks": [
1390                { "type": "status", "state": if fresh { "fresh" } else { "stale" },
1391                  "label": "orders freshness", "detail": "…", "severity": severity }
1392            ],
1393            "verdict": { "type": "freshness_verdict", "fresh": fresh, "observed_lag_hours": 51,
1394                         "expected_cadence": "24h" },
1395            "emitted": ["freshness_breach"],
1396            "verified": true,
1397        })
1398    }
1399
1400    fn fenced(value: &serde_json::Value) -> String {
1401        format!("Some prose.\n\n```json\n{value}\n```\n")
1402    }
1403
1404    #[test]
1405    fn extract_verdict_json_accepts_a_real_envelope() {
1406        let text = fenced(&envelope(false, "critical"));
1407        let got = extract_verdict_json(&text).expect("envelope parses");
1408        assert_eq!(got["verdict"]["fresh"], false);
1409    }
1410
1411    #[test]
1412    fn extract_verdict_json_rejects_a_columns_rows_table() {
1413        let text = fenced(&serde_json::json!({"columns": ["n"], "rows": [[42]]}));
1414        assert!(
1415            extract_verdict_json(&text).is_none(),
1416            "a table has neither `verdict` nor `blocks`"
1417        );
1418    }
1419
1420    #[test]
1421    fn extract_verdict_json_rejects_malformed_json() {
1422        assert!(extract_verdict_json("not json at all").is_none());
1423        assert!(extract_verdict_json("```json\n{ not valid\n```").is_none());
1424    }
1425
1426    #[test]
1427    fn extract_result_json_rejects_a_verdict_envelope() {
1428        // The inverse guard: a verdict envelope has no `rows` array, so the Table extractor must
1429        // not accidentally accept it either.
1430        let text = fenced(&envelope(true, "critical"));
1431        assert!(extract_result_json(&text).is_none());
1432    }
1433
1434    #[test]
1435    fn project_verdict_field_reads_fresh_true_and_false() {
1436        let fresh_table = project_verdict_field(&envelope(true, "critical"), "fresh").unwrap();
1437        assert_eq!(fresh_table.rows, vec![vec![Value::Bool(true)]]);
1438
1439        let stale_table = project_verdict_field(&envelope(false, "critical"), "fresh").unwrap();
1440        assert_eq!(stale_table.rows, vec![vec![Value::Bool(false)]]);
1441    }
1442
1443    #[test]
1444    fn project_verdict_field_reads_severity_warn_and_critical() {
1445        let warn = project_verdict_field(&envelope(false, "warn"), "severity").unwrap();
1446        assert_eq!(warn.rows, vec![vec![Value::String("warn".to_string())]]);
1447
1448        let critical = project_verdict_field(&envelope(false, "critical"), "severity").unwrap();
1449        assert_eq!(
1450            critical.rows,
1451            vec![vec![Value::String("critical".to_string())]]
1452        );
1453    }
1454
1455    #[test]
1456    fn project_verdict_field_falls_back_to_verdict_severity() {
1457        // A `status` block without a `severity` key: fall back to `verdict.severity`.
1458        let envelope = serde_json::json!({
1459            "blocks": [{ "type": "status", "state": "stale" }],
1460            "verdict": { "type": "freshness_verdict", "fresh": false, "severity": "warn" },
1461        });
1462        let got = project_verdict_field(&envelope, "severity").unwrap();
1463        assert_eq!(got.rows, vec![vec![Value::String("warn".to_string())]]);
1464    }
1465
1466    #[test]
1467    fn project_verdict_field_is_none_on_missing_or_unknown_field() {
1468        let e = envelope(true, "critical");
1469        assert!(
1470            project_verdict_field(&e, "observed_lag_hours").is_none(),
1471            "unrecognized field fails closed, never silently passes"
1472        );
1473        let no_verdict = serde_json::json!({"blocks": []});
1474        assert!(project_verdict_field(&no_verdict, "fresh").is_none());
1475        let no_severity_anywhere = serde_json::json!({
1476            "blocks": [{"type": "status", "state": "stale"}],
1477            "verdict": {"type": "freshness_verdict", "fresh": false},
1478        });
1479        assert!(project_verdict_field(&no_severity_anywhere, "severity").is_none());
1480    }
1481
1482    fn verdict_case(tag: &str, field: &str, expected_rows: &str) -> GoldenCase {
1483        let yaml = format!(
1484            "id: v1\nquestion: \"is it fresh?\"\ntags: [{tag}]\nmatch: scalar\n\
1485             result_kind: verdict\nverdict_field: {field}\n\
1486             expected: {{ columns: [{field}], rows: {expected_rows} }}\n"
1487        );
1488        serde_yaml::from_str(&yaml).expect("verdict golden case parses")
1489    }
1490
1491    #[test]
1492    fn golden_case_result_kind_defaults_to_table_when_omitted() {
1493        let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[1]] }\n";
1494        let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
1495        assert_eq!(case.result_kind, ResultKind::Table);
1496        assert!(case.verdict_field.is_none());
1497    }
1498
1499    #[test]
1500    fn score_value_passes_a_matching_verdict_envelope() {
1501        let case = verdict_case("detection", "fresh", "[[false]]");
1502        let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
1503        assert!(
1504            verdict.pass,
1505            "envelope fresh=false matches expected fresh=false"
1506        );
1507    }
1508
1509    #[test]
1510    fn score_value_fails_a_mismatching_verdict_envelope() {
1511        let case = verdict_case("detection", "fresh", "[[true]]");
1512        let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
1513        assert!(
1514            !verdict.pass,
1515            "envelope fresh=false does not match expected fresh=true"
1516        );
1517    }
1518
1519    #[test]
1520    fn score_value_projects_severity_for_the_severity_field() {
1521        let case = verdict_case("severity", "severity", "[[critical]]");
1522        let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
1523        assert!(verdict.pass);
1524    }
1525
1526    #[test]
1527    fn score_value_on_a_table_case_is_unaffected_by_verdict_support() {
1528        let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[42]] }\n";
1529        let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
1530        let result = serde_json::json!({"columns": ["n"], "rows": [[42]]});
1531        let verdict = score_value(&result, &case).expect("table case scores");
1532        assert!(verdict.pass);
1533    }
1534}