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::time::Instant;
26use warble_claude_code::ModelConfig;
27use warble_eval_compare::{compare, CompareRequest, CompareResult, MatchMode, Table, Tolerance};
28
29mod ablation;
30mod backend;
31mod cache;
32mod capture;
33mod compliance;
34mod context;
35mod filter;
36mod gate;
37mod monitor;
38
39pub use ablation::{
40    format_ablation, run_ablation, AblationConfig, AblationPoint, AblationReport,
41    StepRecommendation,
42};
43pub use backend::Backend;
44use backend::{resolve_adapter, BackendAdapter, ModelOverride};
45pub use cache::{rescore, CaseKey, Trace, TraceStore};
46pub use capture::{build_candidate_yaml, candidates_header, CaptureInput};
47pub use compliance::{
48    format_compliance, score_compliance, Check, CheckStatus, ComplianceIr, ComplianceReport,
49    ComplianceTrace, TraceEvent,
50};
51pub use context::{
52    classify, compute_mdl_sha, parse_context_version, stamp_context_version, verify_context,
53    Freshness, VerifyResult,
54};
55pub use filter::{select_cases, CaseFilter, Sample, Selection};
56pub use gate::{format_gate, run_gate, FlakyCase, GateResult, Regression};
57pub use monitor::{
58    format_monitor_report, score_monitor_pair, MonitorMetric, MonitorObservation, MonitorReport,
59};
60
61/// A golden eval file: dataset metadata + cases with expected result sets.
62#[derive(Debug, serde::Deserialize)]
63pub struct Golden {
64    #[serde(default)]
65    pub dataset: Option<String>,
66    #[serde(default)]
67    pub context_version: Option<String>,
68    pub cases: Vec<GoldenCase>,
69}
70
71/// Which shape a case's `expected` compares against — additive so every existing (all-`Table`)
72/// golden stays byte-compatible: an omitted `result_kind` defaults to `Table`, the pre-existing
73/// behavior. `Verdict` is the +Assertive twin: the agent's final message is a `{blocks, verdict,
74/// emitted, verified}` envelope (see `dispatcher/claude-code-cli`'s assertion output contract), not
75/// a `{columns, rows}` table, and one of its fields is projected down to `expected`'s scalar shape
76/// before the usual comparator runs — see [`project_verdict_field`].
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum ResultKind {
80    #[default]
81    Table,
82    Verdict,
83}
84
85#[derive(Debug, Clone, serde::Deserialize)]
86pub struct GoldenCase {
87    pub id: String,
88    pub question: String,
89    #[serde(default)]
90    pub tags: Vec<String>,
91    #[serde(rename = "match")]
92    pub match_mode: MatchMode,
93    #[serde(default)]
94    pub tolerance: Tolerance,
95    pub expected: Table,
96    /// See [`ResultKind`]. Defaulted to `Table` so every pre-existing golden is unaffected.
97    #[serde(default)]
98    pub result_kind: ResultKind,
99    /// For `ResultKind::Verdict` cases, which envelope field [`project_verdict_field`] projects onto
100    /// `expected`'s single cell: `"fresh"` (`verdict.fresh`) or `"severity"` (the breach severity).
101    /// Ignored for `ResultKind::Table` cases; `None` on a verdict case defaults to `"fresh"`.
102    #[serde(default)]
103    pub verdict_field: Option<String>,
104}
105
106/// One repeated-sample invocation of a case — the direct product of one [`run_case`] call, folded
107/// by [`fold_samples`] into the case's aggregate [`CaseResult`]. Not serialized itself; only the
108/// fold's output (`CaseResult`/`SampleVerdict`) is.
109struct SampleOutcome {
110    pass: bool,
111    reason: String,
112    /// `None` when this back-end genuinely cannot report cost (missing is not free — never
113    /// defaulted to `0.0`).
114    cost: Option<f64>,
115    latency_ms: u64,
116    /// `None` when this back-end genuinely cannot report a turn count.
117    turns: Option<u64>,
118    cache_hit: bool,
119    /// The sample's rendered result value, or its raw final output when extraction fails. `Some`
120    /// only when the run records answers (`--record-answers`) — the distinct-answer distribution
121    /// is opt-in, since it means keeping one more string around per sample.
122    answer: Option<String>,
123}
124
125/// One sample's verdict, kept in [`CaseResult::samples_detail`] so a flaky case's individual
126/// sample-by-sample results (which ones failed, and why) survive past the aggregate.
127#[derive(Debug, Clone, Serialize, serde::Deserialize)]
128pub struct SampleVerdict {
129    pub pass: bool,
130    pub reason: String,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub answer: Option<String>,
133}
134
135/// A case's aggregate result over `samples` repeated runs (pass-rate methodology). At `samples ==
136/// 1` every field is bit-identical to the pre-repeated-sampling report shape — that identity is
137/// the whole back-compat story, both for the gate's case-level comparison and for the Pareto table.
138#[derive(Debug, Clone, Serialize, serde::Deserialize)]
139pub struct CaseResult {
140    pub id: String,
141    pub tags: Vec<String>,
142    /// How many times this case was sampled. `0` only on a report predating repeated sampling —
143    /// the sentinel [`crate::Report::backfill_legacy`] detects and migrates forward. Every
144    /// freshly-produced result sets this to `samples.max(1)`.
145    #[serde(default)]
146    pub samples: u32,
147    /// How many of `samples` passed.
148    #[serde(default)]
149    pub passes: u32,
150    /// `passes as f64 / samples as f64`. Kept as a stored field (not recomputed on read) so a
151    /// legacy report's backfilled value round-trips identically to a freshly-computed one.
152    #[serde(default)]
153    pub pass_rate: f64,
154    /// Fully passing: every sample passed. At `samples == 1` this is exactly today's single-run
155    /// `pass` flag.
156    pub pass: bool,
157    /// Some samples passed, some failed (`0 < passes < samples`). Always `false` at `samples <= 1`.
158    #[serde(default)]
159    pub flaky: bool,
160    /// A representative reason: the modal failure reason among failing samples, or `"match"` when
161    /// every sample passed.
162    pub reason: String,
163    /// Summed over samples — repeated sampling multiplies spend by `samples`, so the total is what
164    /// a run's cost column should reflect, not a per-sample average. `None` when any sample's
165    /// backend couldn't report cost — absent is not zero, so the sum doesn't quietly understate.
166    #[serde(default)]
167    pub cost: Option<f64>,
168    /// Averaged over samples.
169    pub latency_ms: u64,
170    /// Averaged over samples (`num_turns`) — the round-trip diagnostic. `None` when any sample's
171    /// backend couldn't report a turn count.
172    #[serde(default)]
173    pub turns: Option<u64>,
174    /// Samples served from the trace cache (re-scored, 0 LLM) vs freshly re-run this invocation.
175    #[serde(default)]
176    pub cache_hits: u32,
177    #[serde(default)]
178    pub cache_misses: u32,
179    /// Per-sample verdicts. Empty on a legacy report (there is nothing to backfill this from).
180    #[serde(default)]
181    pub samples_detail: Vec<SampleVerdict>,
182    /// Distinct-answer distribution across samples. `Some` only when the run recorded answers
183    /// (`--record-answers`) — absence is unambiguous (not just an empty map).
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub answer_dist: Option<BTreeMap<String, u32>>,
186}
187
188/// A case's output-level stability across repeated samples — independent of pass/fail. `flaky`
189/// only tells you the *verdict* flipped; a case can fail every sample with a *different* wrong
190/// answer each time and `flaky` never notices, because `pass` never changes. This classification
191/// is derived on read from `samples`/`answer_dist` rather than stored, so it can never drift out
192/// of sync with the data it summarizes and needs no new field or legacy-report migration.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum OutputStability {
195    /// `--record-answers` was off, or `samples <= 1` (nothing to compare across samples).
196    NotMeasured,
197    /// Every sample produced an answer, and every answer had the same textual signature.
198    Stable,
199    /// Two or more distinct textual answer signatures were seen across samples. This is an
200    /// **upper bound** on real (semantic) instability, not an exact count: `render_answer` is a
201    /// textual signature, not the comparator's semantic one, so set/unordered match modes or a
202    /// bare number-scale difference (`42` vs `42.0`) can over-split a comparator-equal result into
203    /// separate buckets here. `flaky_cases`/`flaky` is the complementary **exact lower bound** —
204    /// it only ever fires on an actual verdict flip, never on textual noise — so the two numbers
205    /// are reported side by side, both labelled, rather than folded into one bare count that would
206    /// read as precise.
207    Unstable,
208    /// Answer buckets summed to fewer than `samples` — at least one sample produced no answer at
209    /// all (an outright invocation failure, not a parseable-but-wrong result). Never classified as
210    /// `Stable`: a missing sample is not agreement, it's missing data.
211    Incomplete,
212}
213
214impl CaseResult {
215    /// Classify this case's output-level stability from its own `samples`/`answer_dist` — see
216    /// [`OutputStability`] for what each variant means and why `Unstable` is a labelled upper
217    /// bound rather than an exact count.
218    pub fn output_stability(&self) -> OutputStability {
219        let Some(dist) = &self.answer_dist else {
220            return OutputStability::NotMeasured;
221        };
222        if self.samples <= 1 {
223            return OutputStability::NotMeasured;
224        }
225        let observed: u32 = dist.values().sum();
226        if observed < self.samples {
227            OutputStability::Incomplete
228        } else if dist.len() > 1 {
229            OutputStability::Unstable
230        } else {
231            OutputStability::Stable
232        }
233    }
234}
235
236/// Fold one case's repeated-sample outcomes into its aggregate [`CaseResult`]. Pure and
237/// unit-tested directly: no I/O, no cache, no process spawn. `samples.len() == 1` degenerates
238/// bit-identically to the pre-repeated-sampling shape.
239fn fold_samples(
240    id: String,
241    tags: Vec<String>,
242    outcomes: Vec<SampleOutcome>,
243    record_answers: bool,
244) -> CaseResult {
245    let samples = outcomes.len() as u32;
246    let passes = outcomes.iter().filter(|o| o.pass).count() as u32;
247    let pass_rate = if samples > 0 {
248        passes as f64 / samples as f64
249    } else {
250        0.0
251    };
252    let pass = samples > 0 && passes == samples;
253    let flaky = passes > 0 && passes < samples;
254    let reason = if passes == samples {
255        "match".to_string()
256    } else {
257        modal_reason(
258            outcomes
259                .iter()
260                .filter(|o| !o.pass)
261                .map(|o| o.reason.as_str()),
262        )
263    };
264    // `try_fold` short-circuits to `None` the moment any one sample's backend didn't report the
265    // metric — a partial sum would misrepresent "absent" as "zero" (see `SampleOutcome::cost`).
266    let cost: Option<f64> = outcomes
267        .iter()
268        .try_fold(0.0_f64, |acc, o| o.cost.map(|c| acc + c));
269    let latency_ms = if samples > 0 {
270        outcomes.iter().map(|o| o.latency_ms).sum::<u64>() / samples as u64
271    } else {
272        0
273    };
274    let turns: Option<u64> = if samples > 0 {
275        outcomes
276            .iter()
277            .try_fold(0_u64, |acc, o| o.turns.map(|t| acc + t))
278            .map(|sum| sum / samples as u64)
279    } else {
280        Some(0)
281    };
282    let cache_hits = outcomes.iter().filter(|o| o.cache_hit).count() as u32;
283    let cache_misses = samples - cache_hits;
284
285    let answer_dist = record_answers.then(|| {
286        let mut dist: BTreeMap<String, u32> = BTreeMap::new();
287        for o in &outcomes {
288            if let Some(answer) = &o.answer {
289                *dist.entry(answer.clone()).or_insert(0) += 1;
290            }
291        }
292        dist
293    });
294    let samples_detail = outcomes
295        .into_iter()
296        .map(|o| SampleVerdict {
297            pass: o.pass,
298            reason: o.reason,
299            answer: if record_answers { o.answer } else { None },
300        })
301        .collect();
302
303    CaseResult {
304        id,
305        tags,
306        samples,
307        passes,
308        pass_rate,
309        pass,
310        flaky,
311        reason,
312        cost,
313        latency_ms,
314        turns,
315        cache_hits,
316        cache_misses,
317        samples_detail,
318        answer_dist,
319    }
320}
321
322/// The most common reason among `reasons`, breaking ties by first occurrence — a stable
323/// "representative reason" rather than whatever order a hash-based tally would land on for a tie.
324fn modal_reason<'a>(reasons: impl Iterator<Item = &'a str>) -> String {
325    let mut counts: Vec<(&str, u32)> = Vec::new();
326    for r in reasons {
327        match counts.iter_mut().find(|(seen, _)| *seen == r) {
328            Some(entry) => entry.1 += 1,
329            None => counts.push((r, 1)),
330        }
331    }
332    let mut best: Option<(&str, u32)> = None;
333    for (r, c) in counts {
334        if best.is_none_or(|(_, bc)| c > bc) {
335            best = Some((r, c));
336        }
337    }
338    best.map(|(r, _)| r.to_string()).unwrap_or_default()
339}
340
341/// Render a case's raw `{columns, rows}` result as a comparable string for the answer-distribution
342/// feature (`--record-answers`). `Table` derives `Deserialize` only, not `Serialize`, so this
343/// renders the raw JSON value's canonical text form rather than serializing a `Table` instance.
344///
345/// This is a *textual* signature, not the comparator's semantic one: it fixes object-key order and
346/// whitespace but not row order or number scale (`42` vs `42.0`). So for set/unordered match modes
347/// two results the comparator deems equal can land in different `answer_dist` buckets — the
348/// distribution can over-split. That only affects this diagnostic view; pass/fail and the gate run
349/// through the comparator, never through this string. Invocation failures have no final output and
350/// therefore contribute no answer, so a case's bucket counts can still sum to fewer than its
351/// samples.
352fn render_answer(result: &serde_json::Value) -> String {
353    result.to_string()
354}
355
356/// Preserve the best diagnostic representation of a completed model response when answer
357/// recording is enabled: canonical JSON after extraction, otherwise the raw final output that
358/// explains why extraction failed.
359fn recorded_failure_answer(
360    record_answers: bool,
361    extracted: Option<&serde_json::Value>,
362    raw: &str,
363) -> Option<String> {
364    record_answers.then(|| {
365        extracted
366            .map(render_answer)
367            .unwrap_or_else(|| raw.to_string())
368    })
369}
370
371#[derive(Debug, Clone, Serialize, serde::Deserialize)]
372pub struct TagStat {
373    pub pass: u32,
374    pub n: u32,
375    /// Sum of each case's `pass_rate` for this tag — `pass_rate_sum / n` is the tag's mean
376    /// pass-rate accuracy, consistent with the gate's per-tag comparison. `0.0` in a pre-repeated-
377    /// sampling report; [`crate::Report::backfill_legacy`] backfills it from the legacy `pass` count.
378    #[serde(default)]
379    pub pass_rate_sum: f64,
380}
381
382#[derive(Debug, Clone, Serialize, serde::Deserialize)]
383pub struct ConfigReport {
384    pub model: String,
385    pub n: usize,
386    /// Mean of each case's `pass_rate`. At `samples == 1` this is exactly the old passes/n
387    /// accuracy — the field name is kept for gate continuity.
388    pub accuracy: f64,
389    /// `None` when any case's backend couldn't report cost — absent is not zero, so a back-end
390    /// that genuinely can't supply cost never shows up as "free".
391    #[serde(default)]
392    pub cost_total_usd: Option<f64>,
393    pub latency_ms_avg: u64,
394    /// Average conversation turns per case (`num_turns`) — a context-quality diagnostic
395    /// (fewer turns ⇒ less exploration ⇒ cheaper). `0` in a pre-P4 report. `None` when any
396    /// case's backend couldn't report a turn count.
397    #[serde(default)]
398    pub turns_avg: Option<u64>,
399    /// Samples (not cases) served from the trace cache (re-scored, 0 LLM) vs freshly re-run this
400    /// invocation. `cache_misses` is the count of actual LLM calls made; both `0` in a pre-P4 report.
401    #[serde(default)]
402    pub cache_hits: usize,
403    #[serde(default)]
404    pub cache_misses: usize,
405    /// Cases with `0 < passes < samples` — inconsistent across samples, not a hard regression.
406    /// `0` in a pre-repeated-sampling report (no case could be flaky at samples == 1).
407    #[serde(default)]
408    pub flaky_cases: u32,
409    /// Cases whose [`CaseResult::output_stability`] is `Unstable` or `Incomplete` — distinct from
410    /// `flaky_cases`, which only tracks verdict flips. A case can be output-unstable while passing
411    /// (or failing) every sample, which `flaky_cases` can never see. This is a **labelled upper
412    /// bound**, not an exact count — see [`OutputStability::Unstable`] for why. `0` when
413    /// `--record-answers` was off or `samples <= 1` (nothing was measured), same as a
414    /// pre-repeated-sampling report.
415    #[serde(default)]
416    pub output_unstable_cases: u32,
417    pub by_tag: BTreeMap<String, TagStat>,
418    pub cases: Vec<CaseResult>,
419}
420
421/// A full eval report (one config per tier→model binding). Serialized by `warble eval run --out`
422/// and consumed as the baseline / candidate by `warble eval gate`.
423#[derive(Debug, Clone, Serialize, serde::Deserialize)]
424pub struct Report {
425    pub dataset: Option<String>,
426    pub context_version: Option<String>,
427    /// Dispatch-time context identity copied from `context-report.json`. Fingerprints/counts only: the
428    /// report never embeds private business-rule text. `None` for legacy agents.
429    #[serde(default)]
430    pub context_injection: Option<ContextInjectionReport>,
431    /// Concurrency the cases ran at (1 = serial). Recorded because parallelism can inflate
432    /// per-case latency (queueing), so latency columns are only comparable at equal levels.
433    #[serde(default = "default_parallel")]
434    pub parallel: usize,
435    /// Golden cases scored vs the golden file's total. `selected_cases < total_cases` means a
436    /// `--tags`/`--sample` subset (a smoke run) — recorded so a partial run is never mistaken for
437    /// a full one and `eval gate` can see the comparison was over fewer cases (no silent caps).
438    /// Both 0 in a pre-P3 report (the field was absent).
439    #[serde(default)]
440    pub selected_cases: usize,
441    #[serde(default)]
442    pub total_cases: usize,
443    pub configs: Vec<ConfigReport>,
444    /// Which back-end/runtime produced this report. `#[serde(default)]` so a legacy report (no
445    /// field at all) deserializes to [`Backend::default`] — the back-end that behavior always
446    /// meant before this dimension existed — never a hard parse failure.
447    #[serde(default)]
448    pub backend: Backend,
449}
450
451/// Strict, non-sensitive projection of a dispatched agent's context identity.
452///
453/// `deny_unknown_fields` is a privacy boundary: an arbitrary agent directory cannot smuggle rule
454/// text or local paths into an exported eval report through `context-report.json`.
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
456#[serde(deny_unknown_fields)]
457pub struct ContextInjectionReport {
458    pub mode: String,
459    pub schema_digest_fingerprint: String,
460    pub knowledge_fingerprint: Option<String>,
461    pub knowledge_chars: usize,
462}
463
464impl ContextInjectionReport {
465    fn validate(self) -> Result<Self, &'static str> {
466        if !valid_context_fingerprint(&self.schema_digest_fingerprint) {
467            return Err("invalid schema digest fingerprint");
468        }
469        match self.mode.as_str() {
470            "schema-only" if self.knowledge_fingerprint.is_none() && self.knowledge_chars == 0 => {}
471            "schema+knowledge"
472                if self
473                    .knowledge_fingerprint
474                    .as_deref()
475                    .is_some_and(valid_context_fingerprint) => {}
476            "schema-only" | "schema+knowledge" => return Err("inconsistent knowledge identity"),
477            _ => return Err("invalid context injection mode"),
478        }
479        Ok(self)
480    }
481}
482
483fn valid_context_fingerprint(value: &str) -> bool {
484    value
485        .strip_prefix("fnv1a64:")
486        .is_some_and(|hex| hex.len() == 16 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
487}
488
489fn default_parallel() -> usize {
490    1
491}
492
493/// Whether a run should record each sample's answer value (`CaseResult::answer_dist`), given the
494/// explicit `--record-answers` flag and the resolved `--samples` count.
495///
496/// At `samples == 1` this always returns `explicit` unchanged — there is nothing to distinguish
497/// across repeated samples, so a single-sample run's behavior must not change just because this
498/// function exists. At `samples > 1`, recording defaults **on**: repeated sampling exists to check
499/// reproducibility, and the answer distribution is the only way to see output-level instability
500/// (a case whose *verdict* never flips can still return a different wrong answer every sample —
501/// see `OutputStability`). An explicit `--record-answers` still works and is a no-op here since
502/// `true || _ == true`; there is no flag to force recording *off* at `samples > 1` because turning
503/// off the one signal repeated sampling exists to produce would defeat the point of asking for it.
504pub fn effective_record_answers(explicit: bool, samples: usize) -> bool {
505    explicit || samples > 1
506}
507
508pub struct RunConfig {
509    pub project: PathBuf,
510    /// A dispatched agent output dir (contains `.claude/agents/…`) — the artifact `Backend::ClaudeCodeCli`
511    /// installs and runs against. Required when `backend` is `ClaudeCodeCli` (the default); `run_eval`
512    /// fails loudly if it's missing then. Mutually exclusive with `ir_path` in practice — exactly one
513    /// artifact shape applies per backend.
514    pub agent_dir: Option<PathBuf>,
515    /// Compiled IR JSON (the same artifact `warble dispatch` consumes) — the artifact
516    /// `Backend::ClaudeAgentSdk` dispatches directly at invocation time, since that back-end has no
517    /// pre-installed agent dir to point at. Required when `backend` needs it; `run_eval` fails loudly
518    /// if it's missing then.
519    pub ir_path: Option<PathBuf>,
520    pub golden_path: PathBuf,
521    pub models: Vec<String>,
522    pub out: Option<PathBuf>,
523    /// Concurrent cases per binding (1 = serial). Under contention the per-case latency
524    /// column measures queueing too, so the report records the level it ran at.
525    pub parallel: usize,
526    /// Golden case selection (`--tags` / `--sample`). Default = full run (every case).
527    pub filter: CaseFilter,
528    /// Bypass the trace cache (`--no-cache`): re-run every case and refresh its cached result
529    /// instead of reusing a content-addressed hit.
530    pub no_cache: bool,
531    /// Where per-case traces are read/written. `None` = `<project>/.warble/eval-cache`.
532    pub cache_dir: Option<PathBuf>,
533    /// Repeated runs per case (pass-rate methodology). `1` is today's single-run behavior;
534    /// use more to distinguish model capability from run-to-run noise (flaky vs consistently
535    /// passing/failing).
536    pub samples: usize,
537    /// Record each sample's rendered result value into `CaseResult::answer_dist` (a distinct-
538    /// answer distribution). Off by default — it means keeping the actual answer strings around,
539    /// not just pass/fail.
540    pub record_answers: bool,
541    /// Which back-end/runtime to dispatch the run through. Defaults to [`Backend::default`]
542    /// (`claude-code-cli`) so an invocation that never mentions `--backend` measures exactly
543    /// what it always measured.
544    pub backend: Backend,
545    /// The `--max-turns` cap to invoke the back-end's own dispatch CLI with, or `None` to leave its
546    /// default turn budget in place. Only [`Backend::ClaudeAgentSdk`] has a `--max-turns` knob at
547    /// all (see `ClaudeAgentSdkAdapter::invoke`); `run_eval` fails loudly, before any spend, if this
548    /// is `Some` for any other backend rather than silently ignoring it — see
549    /// `validate_max_turns_backend`. Also part of the trace cache key (`CaseKey::max_turns`) so a
550    /// capped run can never hit a differently-capped (or uncapped) run's cached trace.
551    pub max_turns: Option<u32>,
552    /// A differentiated `--strong`/`--cheap`/`--orchestrator` (or `--models-config`) binding for this
553    /// run, or `None` to keep the pre-existing single-model `--models` sweep (`models` above) exactly
554    /// as it always behaved. `Some` bypasses the `models` sweep entirely and runs one pass with this
555    /// resolved binding instead — see [`run_eval`]. Only [`Backend::ClaudeAgentSdk`] can honour a
556    /// differentiated binding; `run_eval` fails loudly before any spend otherwise (see
557    /// `validate_tier_binding_backend`).
558    pub tier_models: Option<ModelConfig>,
559}
560
561/// The per-run inputs that key the trace cache, threaded through [`run_cases`]/`run_case` so a case
562/// can be re-scored from cache instead of re-run. `model` is the whole-run binding (run path) or
563/// [`cache::FRONTMATTER_MODEL`] (ablation path); `agent_sha`/`context_sha` are content SHAs of the
564/// dispatched agent dir and the bound MDL. A `disabled` store makes every case a miss (`--no-cache`
565/// or when the SHAs can't be computed).
566pub(crate) struct CaseCtx<'a> {
567    pub model: &'a str,
568    pub agent_sha: &'a str,
569    pub context_sha: &'a str,
570    pub context_version: Option<&'a str>,
571    pub store: &'a TraceStore,
572    /// Populate `SampleOutcome::answer` from this run's result — the answer-distribution feature
573    /// (`--record-answers`) is opt-in per run, not always-on.
574    pub record_answers: bool,
575    /// Which back-end/runtime this run is measuring — part of the trace cache key so runs on
576    /// different backends can never hit each other's cache, and copied onto the report/trace.
577    pub backend: Backend,
578    /// The resolved runner adapter for `backend` — owns launch mechanism, question-passing, and
579    /// trace/metadata extraction (and, implicitly, the capability envelope: the adapter no longer
580    /// receives a hard-coded tool allowlist from the eval loop).
581    pub adapter: &'a dyn BackendAdapter,
582    /// The `--max-turns` cap for this run, or `None` for the back-end's own default. Copied onto
583    /// every [`CaseKey`] and forwarded to [`BackendAdapter::invoke`] — see [`RunConfig::max_turns`]
584    /// for why only `claude-agent-sdk` ever sees a non-`None` value here.
585    pub max_turns: Option<u32>,
586    /// A resolved differentiated tier binding for this run (see [`RunConfig::tier_models`]), or
587    /// `None` on every pre-existing path (the single-model `--models` sweep and the
588    /// ablation/frontmatter path). When `Some`, [`Self::model_override`] returns
589    /// [`ModelOverride::Tiered`] instead of consulting `model`/[`cache::FRONTMATTER_MODEL`] at all.
590    pub tier_binding: Option<TierBindingCtx<'a>>,
591}
592
593/// A resolved `--strong`/`--cheap`/`--orchestrator` binding, plus the single canonical `label`
594/// string derived from it once (e.g. `"strong=sonnet,cheap=haiku,orchestrator=sonnet"`). Threading
595/// the same pre-synthesized label into both [`CaseKey::tier_binding`] and the report's
596/// `ConfigReport::model` guarantees the cache key and the human-visible label can never drift apart
597/// — there is exactly one place (`run_eval`) that ever constructs this label.
598#[derive(Debug, Clone, Copy)]
599pub(crate) struct TierBindingCtx<'a> {
600    pub strong: &'a str,
601    pub cheap: &'a str,
602    pub orchestrator: &'a str,
603    pub label: &'a str,
604}
605
606impl CaseCtx<'_> {
607    /// The model override to pass the adapter: [`ModelOverride::Tiered`] when this run carries a
608    /// differentiated binding, [`ModelOverride::Flat`] for the pre-existing single-model `--models`
609    /// sweep, or `None` on the ablation/frontmatter path (where the tier→model binding is baked into
610    /// the emitted agent and must not be overridden).
611    fn model_override(&self) -> Option<ModelOverride<'_>> {
612        if let Some(tb) = &self.tier_binding {
613            return Some(ModelOverride::Tiered {
614                strong: tb.strong,
615                cheap: tb.cheap,
616                orchestrator: tb.orchestrator,
617            });
618        }
619        (self.model != cache::FRONTMATTER_MODEL).then_some(ModelOverride::Flat(self.model))
620    }
621}
622
623// --- pure helpers ---------------------------------------------------------------------------------
624
625/// Extract the `{columns, rows}` object from an agent's free-form final text: prefer a fenced code
626/// block, else the whole text; then take the first `{` … last `}` and parse it as a table.
627pub fn extract_result(text: &str) -> Option<Table> {
628    serde_json::from_value(extract_result_json(text)?).ok()
629}
630
631/// Like [`extract_result`] but returns the raw `{columns, rows}` JSON object (kept serializable so
632/// `warble eval capture` can embed it verbatim as a golden's `expected`).
633pub fn extract_result_json(text: &str) -> Option<serde_json::Value> {
634    let candidate = strip_fence(text);
635    let start = candidate.find('{')?;
636    let end = candidate.rfind('}')?;
637    if end <= start {
638        return None;
639    }
640    let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
641    // Only accept objects that actually carry a `rows` array (matches the JS guard).
642    if !value.get("rows").map(|r| r.is_array()).unwrap_or(false) {
643        return None;
644    }
645    Some(value)
646}
647
648/// Extract a `{blocks, verdict, emitted, verified}` verdict envelope from an agent's free-form final
649/// text — the +Assertive twin of [`extract_result_json`]. Same fence-stripping and outermost-brace
650/// slicing, but the acceptance guard checks for `verdict` or `blocks` instead of `rows`, since a
651/// verdict envelope carries neither of the table's fields. Rejects anything else (including a plain
652/// `{columns,rows}` table) so a verdict case never accidentally scores a table-shaped answer.
653pub fn extract_verdict_json(text: &str) -> Option<serde_json::Value> {
654    let candidate = strip_fence(text);
655    let start = candidate.find('{')?;
656    let end = candidate.rfind('}')?;
657    if end <= start {
658        return None;
659    }
660    let value: serde_json::Value = serde_json::from_str(&candidate[start..=end]).ok()?;
661    let has_verdict = value.get("verdict").is_some();
662    let has_blocks = value.get("blocks").map(Value::is_array).unwrap_or(false);
663    if !has_verdict && !has_blocks {
664        return None;
665    }
666    Some(value)
667}
668
669/// Project one field of a verdict envelope down to the 1x1 scalar [`Table`] a case's `expected`
670/// compares against. `"fresh"` reads `verdict.fresh`; `"severity"` reads the breach severity off the
671/// envelope's `status` block (falling back to `verdict.severity`, in case a future envelope carries
672/// it there instead). Fails closed: an absent, wrong-typed, or unrecognized field returns `None` —
673/// the case then fails outright rather than silently comparing something else.
674pub fn project_verdict_field(envelope: &serde_json::Value, field: &str) -> Option<Table> {
675    match field {
676        "fresh" => {
677            let fresh = envelope.get("verdict")?.get("fresh")?.as_bool()?;
678            Some(Table {
679                columns: vec!["fresh".to_string()],
680                rows: vec![vec![Value::Bool(fresh)]],
681            })
682        }
683        "severity" => {
684            let status_severity =
685                envelope
686                    .get("blocks")
687                    .and_then(Value::as_array)
688                    .and_then(|blocks| {
689                        blocks
690                            .iter()
691                            .find(|b| b.get("type").and_then(Value::as_str) == Some("status"))
692                            .and_then(|b| b.get("severity"))
693                            .and_then(Value::as_str)
694                    });
695            let severity = status_severity.or_else(|| {
696                envelope
697                    .get("verdict")
698                    .and_then(|v| v.get("severity"))
699                    .and_then(Value::as_str)
700            })?;
701            Some(Table {
702                columns: vec!["severity".to_string()],
703                rows: vec![vec![Value::String(severity.to_string())]],
704            })
705        }
706        _ => None,
707    }
708}
709
710/// Score a raw result value against a case's expectation, dispatching on [`ResultKind`] so the
711/// fresh-run path (`run_case`) and the cache re-score path ([`cache::rescore`]) project the same
712/// way from the same stored shape: a `Table` value for `ResultKind::Table`, a verdict envelope for
713/// `ResultKind::Verdict`. Returns `None` when `result_value` doesn't have the shape the case's kind
714/// expects — the caller treats that as a hard fail, never a false pass.
715pub fn score_value(result_value: &Value, case: &GoldenCase) -> Option<CompareResult> {
716    let actual = match case.result_kind {
717        ResultKind::Table => serde_json::from_value(result_value.clone()).ok()?,
718        ResultKind::Verdict => {
719            let field = case.verdict_field.as_deref().unwrap_or("fresh");
720            project_verdict_field(result_value, field)?
721        }
722    };
723    Some(compare(&CompareRequest {
724        match_mode: case.match_mode,
725        tolerance: case.tolerance,
726        expected: case.expected.clone(),
727        actual,
728    }))
729}
730
731/// Return the content of the first ```/```json fenced block, or the whole text if none.
732fn strip_fence(text: &str) -> &str {
733    let Some(open) = text.find("```") else {
734        return text;
735    };
736    let after = &text[open + 3..];
737    // Skip an optional language tag up to the newline that ends the opening fence line.
738    let content_start = match after.find('\n') {
739        Some(i) => open + 3 + i + 1,
740        None => return text,
741    };
742    match text[content_start..].find("```") {
743        Some(close_rel) => &text[content_start..content_start + close_rel],
744        None => text,
745    }
746}
747
748/// Aggregate per-case results for one model binding into a config report.
749pub fn aggregate(model: &str, rows: Vec<CaseResult>) -> ConfigReport {
750    let n = rows.len();
751    // Accuracy is the mean of each case's pass_rate — at samples == 1 this is numerically
752    // identical to the old passes/n (pass_rate is exactly 0.0 or 1.0 there).
753    let pass_rate_sum_total: f64 = rows.iter().map(|r| r.pass_rate).sum();
754    let accuracy = if n > 0 {
755        pass_rate_sum_total / n as f64
756    } else {
757        0.0
758    };
759    let flaky_cases = rows.iter().filter(|r| r.flaky).count() as u32;
760    let output_unstable_cases = rows
761        .iter()
762        .filter(|r| {
763            matches!(
764                r.output_stability(),
765                OutputStability::Unstable | OutputStability::Incomplete
766            )
767        })
768        .count() as u32;
769    // `try_fold` short-circuits to `None` the moment any one case's backend didn't report the
770    // metric — absent is not zero (binding decision: never let a defaulted 0 look like "free").
771    let cost_total_usd: Option<f64> = rows
772        .iter()
773        .try_fold(0.0_f64, |acc, r| r.cost.map(|c| acc + c));
774    let latency_sum: u64 = rows.iter().map(|r| r.latency_ms).sum();
775    let latency_ms_avg = if n > 0 { latency_sum / n as u64 } else { 0 };
776    let turns_avg: Option<u64> = if n > 0 {
777        rows.iter()
778            .try_fold(0_u64, |acc, r| r.turns.map(|t| acc + t))
779            .map(|sum| sum / n as u64)
780    } else {
781        Some(0)
782    };
783    let cache_hits = rows.iter().map(|r| r.cache_hits as usize).sum();
784    let cache_misses = rows.iter().map(|r| r.cache_misses as usize).sum();
785
786    let mut by_tag: BTreeMap<String, TagStat> = BTreeMap::new();
787    for r in &rows {
788        for t in &r.tags {
789            let e = by_tag.entry(t.clone()).or_insert(TagStat {
790                pass: 0,
791                n: 0,
792                pass_rate_sum: 0.0,
793            });
794            e.n += 1;
795            if r.pass {
796                e.pass += 1;
797            }
798            e.pass_rate_sum += r.pass_rate;
799        }
800    }
801
802    ConfigReport {
803        model: model.to_string(),
804        n,
805        accuracy,
806        cost_total_usd,
807        latency_ms_avg,
808        turns_avg,
809        cache_hits,
810        cache_misses,
811        flaky_cases,
812        output_unstable_cases,
813        by_tag,
814        cases: rows,
815    }
816}
817
818/// Render a [`ConfigReport::model`] value as the human-readable binding label used throughout
819/// `format_pareto`. For the pre-existing single-model path (`--models` sweep or the
820/// ablation/frontmatter path), `model` is a bare model name (e.g. `"sonnet"`) and gets the
821/// `strong→` prefix that label has always carried. For a differentiated `--strong`/`--cheap`/
822/// `--orchestrator` binding, `model` is already the full `"strong=..,cheap=..,orchestrator=.."`
823/// label constructed once in `run_eval` (see [`TierBindingCtx`]) — prefixing that with another
824/// `strong→` would render `"strong→strong=..."` and overrun the table's `{:<16}` column, so it is
825/// used verbatim instead. Distinguishing on `contains('=')` mirrors that label's own construction
826/// (`format!("strong={strong},cheap={cheap},orchestrator={orchestrator}")`), which a bare model
827/// name never contains.
828fn binding_label(model: &str) -> String {
829    if model.contains('=') {
830        model.to_string()
831    } else {
832        format!("strong→{model}")
833    }
834}
835
836/// Render the Pareto table (accuracy vs cost vs latency, with per-tag accuracy).
837pub fn format_pareto(report: &Report) -> String {
838    let mut out = String::new();
839    out.push_str("\n=== Warble eval — Pareto (accuracy vs cost vs latency) ===\n");
840    if report.selected_cases < report.total_cases {
841        out.push_str(&format!(
842            "  (subset: {}/{} golden cases — smoke run, not a full scoring)\n",
843            report.selected_cases, report.total_cases
844        ));
845    }
846    out.push_str(&format!(
847        "{:<16} {:<7} {:<10} {:<12} {:<7} by_tag\n",
848        "binding", "acc", "cost($)", "lat(ms)", "turns"
849    ));
850    for c in &report.configs {
851        let tags = c
852            .by_tag
853            .iter()
854            .map(|(t, v)| format!("{t}:{:.2}", v.pass_rate_sum / v.n.max(1) as f64))
855            .collect::<Vec<_>>()
856            .join(" ");
857        // Absent is not zero: a backend that reported no cost renders as `n/a`, never conflated
858        // with a genuine `$0.00` — mirrors `ablation.rs::fmt_cost`.
859        let cost = match c.cost_total_usd {
860            Some(cost) => format!("{cost:.4}"),
861            None => "n/a".to_string(),
862        };
863        let turns = c
864            .turns_avg
865            .map(|t| t.to_string())
866            .unwrap_or_else(|| "n/a".to_string());
867        out.push_str(&format!(
868            "{:<16} {:<7} {:<10} {:<12} {:<7} {tags}\n",
869            binding_label(&c.model),
870            format!("{:.2}", c.accuracy),
871            cost,
872            c.latency_ms_avg,
873            turns
874        ));
875    }
876    // Reproducibility visibility: say plainly when repeated-sampling wasn't measured at all,
877    // rather than silently omitting the instability section below — an absent section otherwise
878    // reads as "measured stable", which is not the same claim as "not measured".
879    let max_samples = report
880        .configs
881        .iter()
882        .flat_map(|c| &c.cases)
883        .map(|case| case.samples)
884        .max()
885        .unwrap_or(0);
886    if !report.configs.is_empty() && max_samples <= 1 {
887        out.push_str(
888            "reproducibility: not measured (samples=1 — pass --samples N>1 to repeat each case \
889and check whether its verdict/answer holds).\n",
890        );
891    }
892    // Cache visibility (no silent caps): show what was re-scored from cache vs freshly re-run, so a
893    // 0-LLM re-score is never mistaken for a fresh run. `cache_misses` == the LLM calls this run.
894    if report.configs.iter().any(|c| c.cache_hits > 0) {
895        out.push_str("cache (miss = LLM call this run):\n");
896        for c in &report.configs {
897            let note = if c.cache_misses == 0 {
898                "re-score only, 0 LLM calls this run".to_string()
899            } else {
900                format!("{} LLM calls this run", c.cache_misses)
901            };
902            out.push_str(&format!(
903                "  {:<10} {} hit / {} miss — {note}\n",
904                binding_label(&c.model),
905                c.cache_hits,
906                c.cache_misses
907            ));
908            // A fully cache-served run with samples > 1 replayed each sample from its own stored
909            // trace — identical output across samples there is the cache doing its job, not the
910            // model behaving deterministically. Say so, or this reads as evidence it isn't.
911            if c.cache_misses == 0 && c.cases.iter().any(|case| case.samples > 1) {
912                out.push_str(
913                    "    note: every sample replayed its own cached trace — identical output \
914here is replay, not evidence of determinism.\n",
915                );
916            }
917        }
918    }
919    // Instability across repeated samples, reported as two distinct, labelled signals:
920    //   - flaky: the verdict (pass/fail) itself flipped across samples — exact, never over-counts.
921    //   - output-unstable / incomplete: the answer *value* differed (or was missing) across
922    //     samples, independent of pass/fail — `flaky` alone misses this entirely (see the crux
923    //     this closes: a case can return a different wrong answer on every sample and never flip
924    //     its failing verdict). This is a labelled **upper bound**, not an exact count — see
925    //     `OutputStability::Unstable`'s doc comment for why a textual answer signature can
926    //     over-split a comparator-equal result. Never folded into the exact flaky count.
927    if report
928        .configs
929        .iter()
930        .any(|c| c.flaky_cases > 0 || c.output_unstable_cases > 0)
931    {
932        out.push_str(
933            "instability across repeated samples (flaky = exact verdict flip; output-unstable = \
934textual answer divergence — an upper bound, see OutputStability):\n",
935        );
936        for c in &report.configs {
937            for case in &c.cases {
938                let stability = case.output_stability();
939                let output_unstable = matches!(
940                    stability,
941                    OutputStability::Unstable | OutputStability::Incomplete
942                );
943                if !case.flaky && !output_unstable {
944                    continue;
945                }
946                let label = match (case.flaky, stability) {
947                    (true, OutputStability::Incomplete) => "flaky, incomplete answers",
948                    (true, _) => "flaky",
949                    (false, OutputStability::Unstable) => "output-unstable, verdict stable",
950                    (false, OutputStability::Incomplete) => "incomplete answers, verdict stable",
951                    (false, _) => unreachable!("filtered above: neither flaky nor output-unstable"),
952                };
953                out.push_str(&format!(
954                    "  {:<10} {}  [{label}]  pass_rate={:.2} ({}/{})",
955                    binding_label(&c.model),
956                    case.id,
957                    case.pass_rate,
958                    case.passes,
959                    case.samples
960                ));
961                if let Some(dist) = &case.answer_dist {
962                    let mut answers: Vec<(&String, &u32)> = dist.iter().collect();
963                    answers.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
964                    let top = answers
965                        .into_iter()
966                        .take(3)
967                        .map(|(a, n)| format!("{a}×{n}"))
968                        .collect::<Vec<_>>()
969                        .join(", ");
970                    out.push_str(&format!("  — {top}"));
971                }
972                out.push('\n');
973            }
974        }
975    }
976    out
977}
978
979// --- orchestration --------------------------------------------------------------------------------
980
981/// Installs agent files into `<project>/.claude` and removes them on drop (the project may have had
982/// no `.claude` at all, so cleanup is best-effort and non-recursive, matching the reference runner).
983struct InstalledAgents {
984    files: Vec<PathBuf>,
985    agents_dir: PathBuf,
986    claude_dir: PathBuf,
987}
988
989impl Drop for InstalledAgents {
990    fn drop(&mut self) {
991        for f in &self.files {
992            let _ = fs::remove_file(f);
993        }
994        let _ = fs::remove_dir(&self.agents_dir);
995        let _ = fs::remove_dir(&self.claude_dir);
996    }
997}
998
999pub(crate) fn install_agents(agent_dir: &Path, project: &Path) -> Result<InstalledAgents, String> {
1000    let claude_dir = project.join(".claude");
1001    let agents_dir = claude_dir.join("agents");
1002    fs::create_dir_all(&agents_dir).map_err(|e| format!("mkdir {}: {e}", agents_dir.display()))?;
1003
1004    let src_agents = agent_dir.join(".claude").join("agents");
1005    let mut files = Vec::new();
1006    for entry in
1007        fs::read_dir(&src_agents).map_err(|e| format!("read {}: {e}", src_agents.display()))?
1008    {
1009        let entry = entry.map_err(|e| e.to_string())?;
1010        let dst = agents_dir.join(entry.file_name());
1011        fs::copy(entry.path(), &dst).map_err(|e| format!("copy agent: {e}"))?;
1012        files.push(dst);
1013    }
1014
1015    // settings.json lives at .claude/settings.json (split path) or <out>/settings.json (single path).
1016    let settings_src = {
1017        let split = agent_dir.join(".claude").join("settings.json");
1018        if split.is_file() {
1019            Some(split)
1020        } else {
1021            let flat = agent_dir.join("settings.json");
1022            flat.is_file().then_some(flat)
1023        }
1024    };
1025    if let Some(src) = settings_src {
1026        let dst = claude_dir.join("settings.json");
1027        fs::copy(&src, &dst).map_err(|e| format!("copy settings: {e}"))?;
1028        files.push(dst);
1029    }
1030
1031    Ok(InstalledAgents {
1032        files,
1033        agents_dir,
1034        claude_dir,
1035    })
1036}
1037
1038/// The agent name is the basename of the first agent file (the driver, for split components).
1039pub(crate) fn agent_name(agent_dir: &Path) -> Result<String, String> {
1040    let dir = agent_dir.join(".claude").join("agents");
1041    let mut names: Vec<String> = fs::read_dir(&dir)
1042        .map_err(|e| format!("read {}: {e}", dir.display()))?
1043        .filter_map(|e| e.ok())
1044        .filter_map(|e| e.file_name().to_str().map(str::to_string))
1045        .filter_map(|f| f.strip_suffix(".md").map(str::to_string))
1046        .collect();
1047    names.sort();
1048    names
1049        .into_iter()
1050        .next()
1051        .ok_or_else(|| format!("no agent files in {}", dir.display()))
1052}
1053
1054/// Build a PATH that prepends `<project>/.venv/bin` when present, so the agent's `wren` is found.
1055pub(crate) fn run_path(project: &Path) -> String {
1056    let base = std::env::var("PATH").unwrap_or_default();
1057    let venv = project.join(".venv").join("bin");
1058    if venv.is_dir() {
1059        format!("{}:{base}", venv.display())
1060    } else {
1061        base
1062    }
1063}
1064
1065/// Run one repeated sample of one golden case through the dispatched `agent`, consulting the trace
1066/// cache in `ctx`.
1067///
1068/// **Cache hit** — identical `(case, agent_sha, model, context_sha, sample)` — re-scores the cached
1069/// result against the case's *current* expectation with **no LLM call** (`cache_hit = true`). This is
1070/// both the content-addressed skip and the "only the golden's `expected` changed" 0-LLM re-score: the
1071/// expectation is deliberately outside the key, so such a change still hits and only the comparison
1072/// is redone. **Miss** invokes the agent (`ctx` supplies the `--model` override on the run path, or
1073/// the frontmatter binding on the ablation path) and writes a fresh trace back to the cache. Each
1074/// `sample` is its own cache entry — the pass-rate methodology depends on every sample actually
1075/// invoking the agent (or replaying its own prior trace), never silently collapsing onto sample 0.
1076fn run_case(
1077    project: &Path,
1078    agent: &str,
1079    path_env: &str,
1080    case: &GoldenCase,
1081    sample: u32,
1082    ctx: &CaseCtx,
1083) -> SampleOutcome {
1084    let key = CaseKey {
1085        case_id: &case.id,
1086        question: &case.question,
1087        agent_sha: ctx.agent_sha,
1088        model: ctx.model,
1089        context_sha: ctx.context_sha,
1090        sample,
1091        backend: ctx.backend,
1092        max_turns: ctx.max_turns,
1093        tier_binding: ctx.tier_binding.as_ref().map(|tb| tb.label),
1094    };
1095
1096    // Cache hit → re-score the cached result against the current expectation (0 LLM). A corrupt
1097    // entry (result no longer reads back as a table) falls through and re-runs the case.
1098    if let Some(trace) = ctx.store.load(&key) {
1099        if let Some(verdict) = rescore(&trace, case) {
1100            return SampleOutcome {
1101                pass: verdict.pass,
1102                reason: verdict.reason,
1103                cost: trace.cost,
1104                latency_ms: trace.latency_ms,
1105                turns: trace.turns,
1106                cache_hit: true,
1107                answer: ctx.record_answers.then(|| render_answer(&trace.result)),
1108            };
1109        }
1110    }
1111
1112    let started = Instant::now();
1113    let fail = |reason: &str, cost: Option<f64>, latency_ms: u64| SampleOutcome {
1114        pass: false,
1115        reason: reason.to_string(),
1116        cost,
1117        latency_ms,
1118        turns: None,
1119        cache_hit: false,
1120        answer: None,
1121    };
1122
1123    // The target decides how the run is launched, how the question is passed, and how the
1124    // trace/metadata come back — including the capability envelope (which tools the agent may
1125    // use). That is no longer decided here: `claude-code-cli`'s own dispatch already wrote a
1126    // per-component `.claude/settings.json` with the computed tool allowlist, and `install_agents`
1127    // copied it into `project` before this call — so the envelope is the adapter's to apply from
1128    // that file, never a literal chosen here.
1129    let invocation = ctx.adapter.invoke(
1130        project,
1131        agent,
1132        path_env,
1133        &case.question,
1134        ctx.model_override(),
1135        ctx.max_turns,
1136    );
1137
1138    if !invocation.ok {
1139        // Carry the adapter's own first line of explanation instead of a fixed string: a failure
1140        // whose cause is a misconfiguration (e.g. an unreadable capability envelope) is otherwise
1141        // indistinguishable in the report from the agent simply erroring out.
1142        //
1143        // Bounded on purpose: adapters are expected to put a short diagnostic here, but `raw` is a
1144        // free-form field and a future back-end could populate it with captured stdout. The report
1145        // is a committed artifact, so take one line and cap its length rather than letting whatever
1146        // a process printed flow into it verbatim.
1147        const MAX_REASON_CHARS: usize = 200;
1148        let detail = invocation
1149            .raw
1150            .lines()
1151            .map(str::trim)
1152            .find(|line| !line.is_empty());
1153        let reason = match detail {
1154            Some(line) if line.chars().count() > MAX_REASON_CHARS => {
1155                let clipped: String = line.chars().take(MAX_REASON_CHARS).collect();
1156                format!("backend invocation failed: {clipped}…")
1157            }
1158            Some(line) => format!("backend invocation failed: {line}"),
1159            None => "backend invocation failed".to_string(),
1160        };
1161        return fail(&reason, None, started.elapsed().as_millis() as u64);
1162    }
1163    let raw = invocation.raw;
1164    let result_text = raw.as_str();
1165    // `latency_ms` is the one metric every backend can be given for free (we always know when we
1166    // started), so it stays non-Option throughout: fall back to wall-clock elapsed time only when
1167    // the adapter itself couldn't report a more precise duration.
1168    let latency_ms = invocation
1169        .latency_ms
1170        .unwrap_or_else(|| started.elapsed().as_millis() as u64);
1171    let cost = invocation.cost;
1172    // `num_turns` is the round-trip count the whole codebase treats as the turn diagnostic. `None`
1173    // when this backend genuinely can't report it — absent is not zero.
1174    let turns = invocation.turns;
1175
1176    // Extraction dispatches on the case's kind: a Table case still reads `{columns,rows}`; a
1177    // Verdict case reads the +Assertive `{blocks,verdict,emitted,verified}` envelope instead. Either
1178    // way the raw value (not the projected scalar) is what gets cached in `Trace.result`, so a later
1179    // re-score ([`cache::rescore`]) can re-project it against a possibly-changed expectation/field.
1180    let extracted = match case.result_kind {
1181        ResultKind::Table => extract_result_json(result_text),
1182        ResultKind::Verdict => extract_verdict_json(result_text),
1183    };
1184    let Some(result_value) = extracted else {
1185        let reason = match case.result_kind {
1186            ResultKind::Table => "no parseable {columns,rows} in output",
1187            ResultKind::Verdict => "no parseable verdict envelope in output",
1188        };
1189        return SampleOutcome {
1190            pass: false,
1191            reason: reason.to_string(),
1192            cost,
1193            latency_ms,
1194            turns,
1195            cache_hit: false,
1196            // A failed extraction is exactly when the raw final message is most useful. Keep it
1197            // when answer recording was explicitly requested so a live eval artifact explains
1198            // the model-contract failure instead of exposing only an empty distribution.
1199            answer: recorded_failure_answer(ctx.record_answers, None, result_text),
1200        };
1201    };
1202    let Some(verdict) = score_value(&result_value, case) else {
1203        let reason = match case.result_kind {
1204            ResultKind::Table => "no parseable {columns,rows} in output",
1205            ResultKind::Verdict => "verdict envelope missing the expected field",
1206        };
1207        return SampleOutcome {
1208            pass: false,
1209            reason: reason.to_string(),
1210            cost,
1211            latency_ms,
1212            turns,
1213            cache_hit: false,
1214            // Extraction succeeded, so retain the structured value even though projection did
1215            // not. This makes --record-answers cover failed samples as its CLI contract promises.
1216            answer: recorded_failure_answer(ctx.record_answers, Some(&result_value), result_text),
1217        };
1218    };
1219
1220    // Cache the result (best-effort; a write failure degrades to "not cached", never fails the run).
1221    let trace = Trace {
1222        case_id: case.id.clone(),
1223        agent_sha: ctx.agent_sha.to_string(),
1224        model: ctx.model.to_string(),
1225        context_version: ctx.context_version.map(str::to_string),
1226        context_sha: ctx.context_sha.to_string(),
1227        question: case.question.clone(),
1228        sql_executed: None,
1229        result: result_value,
1230        cost,
1231        latency_ms,
1232        turns,
1233        tool_calls: None,
1234        backend: ctx.backend,
1235    };
1236    if let Err(e) = ctx.store.store(&key, &trace) {
1237        eprintln!(
1238            "  note: could not cache trace for {} (sample {sample}): {e}",
1239            case.id
1240        );
1241    }
1242
1243    SampleOutcome {
1244        pass: verdict.pass,
1245        reason: verdict.reason,
1246        cost,
1247        latency_ms,
1248        turns,
1249        cache_hit: false,
1250        answer: ctx.record_answers.then(|| render_answer(&trace.result)),
1251    }
1252}
1253
1254/// Load the golden file, install the agent, run every case under every model binding, and return
1255/// the aggregated report. Progress is streamed to stderr. The agent install is cleaned up on return
1256/// (including on error) via the `InstalledAgents` drop guard.
1257/// Which artifact shape `run_eval` resolved for this invocation's backend — see the "Two distinct
1258/// artifact shapes" comment at the top of `run_eval` for what each variant means and skips.
1259#[derive(Clone, Copy)]
1260enum ArtifactPath<'a> {
1261    AgentDir(&'a Path),
1262    Ir(&'a Path),
1263}
1264
1265/// `--max-turns` only has somewhere to go for [`Backend::ClaudeAgentSdk`] — its own dispatch CLI is
1266/// the only one with a `--max-turns` knob (see `ClaudeAgentSdkAdapter::invoke`). `claude-code-cli`
1267/// (`claude -p`) has no such flag at all, and `codex-local`'s sandboxed `codex exec` call is already
1268/// single-turn with no turn-budget knob either. Rather than silently dropping a `--max-turns` the
1269/// chosen backend cannot honor — which would make a capped experiment quietly run uncapped — a
1270/// mismatch fails loudly here, naming both the offending backend and the one that does support it.
1271fn validate_max_turns_backend(backend: Backend, max_turns: Option<u32>) -> Result<(), String> {
1272    if max_turns.is_some() && backend != Backend::ClaudeAgentSdk {
1273        return Err(format!(
1274            "--max-turns is only supported by backend '{}' (the only one whose dispatch CLI has a \
1275--max-turns knob); backend '{backend}' has none — omit --max-turns or pass --backend {}",
1276            Backend::ClaudeAgentSdk,
1277            Backend::ClaudeAgentSdk
1278        ));
1279    }
1280    Ok(())
1281}
1282
1283#[cfg(test)]
1284mod max_turns_backend_validation_tests {
1285    use super::*;
1286
1287    /// The back-end-mismatch guard: `--max-turns` on any backend other than `claude-agent-sdk`
1288    /// fails loudly, before any dispatch/claude spend, naming both the offending backend and the
1289    /// one that does support the flag — so the caller knows exactly what to change.
1290    #[test]
1291    fn max_turns_is_rejected_for_a_backend_with_no_turn_budget_knob() {
1292        for backend in [Backend::ClaudeCodeCli, Backend::CodexLocal, Backend::Vercel] {
1293            let err = validate_max_turns_backend(backend, Some(1))
1294                .expect_err("max-turns must be rejected for a backend with no such knob");
1295            assert!(
1296                err.contains(backend.as_str()),
1297                "error must name the offending backend '{backend}': {err}"
1298            );
1299            assert!(
1300                err.contains(Backend::ClaudeAgentSdk.as_str()),
1301                "error must name the backend that does support --max-turns: {err}"
1302            );
1303        }
1304    }
1305
1306    /// The two paths that must never error: the one backend that does have a `--max-turns` knob,
1307    /// and `max_turns: None` (no flag passed) on every backend regardless of knob support.
1308    #[test]
1309    fn max_turns_is_accepted_for_the_sdk_backend_and_whenever_absent() {
1310        assert!(validate_max_turns_backend(Backend::ClaudeAgentSdk, Some(1)).is_ok());
1311        for backend in [
1312            Backend::ClaudeCodeCli,
1313            Backend::ClaudeAgentSdk,
1314            Backend::CodexLocal,
1315            Backend::Vercel,
1316        ] {
1317            assert!(validate_max_turns_backend(backend, None).is_ok());
1318        }
1319    }
1320}
1321
1322/// A differentiated `--strong`/`--cheap`/`--orchestrator` (or `--models-config`) binding only has
1323/// somewhere to go for [`Backend::ClaudeAgentSdk`] — it is the only back-end whose own dispatch CLI
1324/// has tier flags at all (see `ClaudeAgentSdkAdapter::invoke`). `claude-code-cli` (`claude -p`) takes
1325/// a single `--model`; `codex-local`'s sandboxed `codex exec` has no tier knob whatsoever. Rather
1326/// than silently flattening a differentiated binding to one model (or dropping two of the three
1327/// values) — which would make a "differentiated" experiment quietly run as a single-model one — a
1328/// mismatch fails loudly here, before any dispatch/claude spend, and says how to actually get
1329/// differentiated tiers on the offending backend: dispatch the agent with the binding baked in, then
1330/// run with the frontmatter sentinel.
1331fn validate_tier_binding_backend(
1332    backend: Backend,
1333    tier_models: Option<&ModelConfig>,
1334) -> Result<(), String> {
1335    if tier_models.is_some() && backend != Backend::ClaudeAgentSdk {
1336        return Err(format!(
1337            "a differentiated --strong/--cheap/--orchestrator (or --models-config) binding is only \
1338supported by backend '{}' (the only one whose dispatch CLI has tier flags); backend '{backend}' has \
1339none — to run a differentiated binding on '{backend}', dispatch the agent with that binding baked \
1340in (`warble dispatch --strong/--cheap/--orchestrator ...`) and run this eval with the frontmatter \
1341sentinel (no tier flags) instead of asking the eval runner to inject one at invocation time",
1342            Backend::ClaudeAgentSdk
1343        ));
1344    }
1345    Ok(())
1346}
1347
1348#[cfg(test)]
1349mod tier_binding_backend_validation_tests {
1350    use super::*;
1351
1352    /// The back-end-mismatch guard: a differentiated binding on any backend other than
1353    /// `claude-agent-sdk` fails loudly, before any dispatch/claude spend, naming the offending
1354    /// backend and pointing at the dispatch-time-baked-binding + frontmatter-sentinel workaround.
1355    #[test]
1356    fn tiered_binding_is_rejected_for_a_backend_with_no_tier_knob() {
1357        let tier_models = ModelConfig::from_flags(
1358            "sonnet".to_string(),
1359            "haiku".to_string(),
1360            "sonnet".to_string(),
1361        );
1362        for backend in [Backend::ClaudeCodeCli, Backend::CodexLocal, Backend::Vercel] {
1363            let err = validate_tier_binding_backend(backend, Some(&tier_models))
1364                .expect_err("a tiered binding must be rejected for a backend with no tier knob");
1365            assert!(
1366                err.contains(backend.as_str()),
1367                "error must name the offending backend '{backend}': {err}"
1368            );
1369            assert!(
1370                err.contains(Backend::ClaudeAgentSdk.as_str()),
1371                "error must name the backend that does support a differentiated binding: {err}"
1372            );
1373            assert!(
1374                err.contains("frontmatter"),
1375                "error must point at the frontmatter-sentinel workaround: {err}"
1376            );
1377        }
1378    }
1379
1380    /// The two paths that must never error: the one backend that does have a tier knob, and
1381    /// `tier_models: None` (no differentiated binding requested) on every backend regardless of
1382    /// tier-knob support.
1383    #[test]
1384    fn tiered_binding_is_accepted_for_the_sdk_backend_and_whenever_absent() {
1385        let tier_models = ModelConfig::from_flags(
1386            "sonnet".to_string(),
1387            "haiku".to_string(),
1388            "sonnet".to_string(),
1389        );
1390        assert!(validate_tier_binding_backend(Backend::ClaudeAgentSdk, Some(&tier_models)).is_ok());
1391        for backend in [
1392            Backend::ClaudeCodeCli,
1393            Backend::ClaudeAgentSdk,
1394            Backend::CodexLocal,
1395            Backend::Vercel,
1396        ] {
1397            assert!(validate_tier_binding_backend(backend, None).is_ok());
1398        }
1399    }
1400}
1401
1402pub fn run_eval(cfg: &RunConfig) -> Result<Report, String> {
1403    let golden_text = fs::read_to_string(&cfg.golden_path)
1404        .map_err(|e| format!("read {}: {e}", cfg.golden_path.display()))?;
1405    let golden: Golden =
1406        serde_yaml::from_str(&golden_text).map_err(|e| format!("parse golden: {e}"))?;
1407
1408    // Stratified selection (`--tags` / `--sample`): pick the smoke/full subset before any spend.
1409    let total_cases = golden.cases.len();
1410    let cases = select_and_subset(&golden, &cfg.filter)?;
1411    let selected_cases = cases.len();
1412
1413    // Resolved once for the whole run (not per model/case), and before either artifact path below is
1414    // even validated — every binding under this invocation measures the same backend, and a backend
1415    // with no adapter fails loudly here, before ever reaching a `Report`/`CaseKey`/`Trace`.
1416    let adapter = resolve_adapter(cfg.backend)?;
1417
1418    // `--max-turns` only means something to `claude-agent-sdk` — validated once, up front, for the
1419    // same reason the artifact-shape check below is: a mismatched `--max-turns`/`--backend`
1420    // combination fails loudly before any dispatch/claude spend, rather than being silently ignored
1421    // per case.
1422    validate_max_turns_backend(cfg.backend, cfg.max_turns)?;
1423
1424    // A differentiated `--strong`/`--cheap`/`--orchestrator` (or `--models-config`) binding only
1425    // means something to `claude-agent-sdk` — validated once, up front, for the same reason
1426    // `--max-turns` is above: a backend that cannot honour it fails loudly before any dispatch/claude
1427    // spend rather than silently flattening it to one model.
1428    validate_tier_binding_backend(cfg.backend, cfg.tier_models.as_ref())?;
1429
1430    // Two distinct artifact shapes, keyed on backend: `claude-code-cli` points at a pre-installed
1431    // dispatched agent dir (`agent_name`/`context-report.json`/`install_agents` all assume this
1432    // shape); `claude-agent-sdk` has no such dir — it dispatches straight from the compiled IR at
1433    // invocation time (see `ClaudeAgentSdkAdapter`), so `agent` becomes the IR path itself and there
1434    // is no context-injection report or install step. Validated once, up front, so a mismatched
1435    // `--agent-dir`/`--ir` vs `--backend` combination fails loudly before any dispatch/claude spend.
1436    let artifact = match cfg.backend {
1437        Backend::ClaudeCodeCli => ArtifactPath::AgentDir(
1438            cfg.agent_dir
1439                .as_deref()
1440                .ok_or_else(|| "backend 'claude-code-cli' requires --agent-dir".to_string())?,
1441        ),
1442        _ => ArtifactPath::Ir(
1443            cfg.ir_path
1444                .as_deref()
1445                .ok_or_else(|| format!("backend '{}' requires --ir", cfg.backend))?,
1446        ),
1447    };
1448
1449    let (agent, context_injection): (String, Option<ContextInjectionReport>) = match artifact {
1450        ArtifactPath::AgentDir(dir) => (agent_name(dir)?, read_context_injection_report(dir)?),
1451        ArtifactPath::Ir(path) => (path.display().to_string(), None),
1452    };
1453    let path_env = run_path(&cfg.project);
1454
1455    // Trace cache: the MDL SHA (context_sha) + the agent-artifact SHA (agent_sha) are the run's
1456    // content-addressed key material. Computed before install (install copies agents into the
1457    // project's .claude, which compute_mdl_sha skips). Either failing disables the cache visibly.
1458    let (mut store, context_sha) =
1459        build_store(cfg.no_cache, cfg.cache_dir.as_deref(), &cfg.project);
1460    let agent_sha = if store.is_enabled() {
1461        let sha = match artifact {
1462            ArtifactPath::AgentDir(dir) => context::compute_dir_sha(dir),
1463            ArtifactPath::Ir(path) => context::compute_file_sha(path),
1464        };
1465        match sha {
1466            Ok(s) => s,
1467            Err(e) => {
1468                eprintln!("cache: disabled — cannot hash agent artifact: {e}");
1469                store = TraceStore::disabled();
1470                String::new()
1471            }
1472        }
1473    } else {
1474        String::new()
1475    };
1476
1477    // Only `claude-code-cli` installs agent files into the project; `claude-agent-sdk`'s CLI reads
1478    // the IR directly (via its own `--project`) and needs no installation step.
1479    let _installed = match artifact {
1480        ArtifactPath::AgentDir(dir) => Some(install_agents(dir, &cfg.project)?),
1481        ArtifactPath::Ir(_) => None,
1482    };
1483
1484    let parallel = cfg.parallel.max(1);
1485    let par_suffix = if parallel > 1 {
1486        format!(", parallel={parallel}")
1487    } else {
1488        String::new()
1489    };
1490    let mut configs = Vec::new();
1491    // A differentiated binding (`cfg.tier_models`) bypasses the `--models` sweep entirely and runs
1492    // exactly one pass with the resolved binding — see `RunConfig::tier_models`. This does not
1493    // disturb the existing `--models` sweep below: `run_eval` with no tier flags and no
1494    // `--models-config` never has `tier_models: Some`, so that path is unchanged (see
1495    // `validate_tier_binding_backend` and `RunConfig::tier_models` for why the two are mutually
1496    // exclusive per run).
1497    if let Some(tier_models) = &cfg.tier_models {
1498        let strong = tier_models.require("strong").map_err(|e| e.to_string())?;
1499        let cheap = tier_models.require("cheap").map_err(|e| e.to_string())?;
1500        let orchestrator = tier_models
1501            .require("orchestrator")
1502            .map_err(|e| e.to_string())?;
1503        // The single canonical rendering of this binding — reused verbatim for the progress line,
1504        // the report's `ConfigReport::model` label (AC5), and `CaseKey::tier_binding` (decision 68)
1505        // so all three can never drift apart.
1506        let label = format!("strong={strong},cheap={cheap},orchestrator={orchestrator}");
1507        eprintln!("\n### binding: {label}  (n={selected_cases}{par_suffix})");
1508        let ctx = CaseCtx {
1509            model: &label,
1510            agent_sha: &agent_sha,
1511            context_sha: &context_sha,
1512            context_version: golden.context_version.as_deref(),
1513            store: &store,
1514            record_answers: cfg.record_answers,
1515            backend: cfg.backend,
1516            adapter: adapter.as_ref(),
1517            max_turns: cfg.max_turns,
1518            tier_binding: Some(TierBindingCtx {
1519                strong,
1520                cheap,
1521                orchestrator,
1522                label: &label,
1523            }),
1524        };
1525        let rows = run_cases(
1526            &cfg.project,
1527            &agent,
1528            &path_env,
1529            &cases,
1530            cfg.samples,
1531            parallel,
1532            &ctx,
1533        );
1534        configs.push(aggregate(&label, rows));
1535    } else {
1536        for model in &cfg.models {
1537            eprintln!("\n### binding: strong→{model}  (n={selected_cases}{par_suffix})");
1538            let ctx = CaseCtx {
1539                model,
1540                agent_sha: &agent_sha,
1541                context_sha: &context_sha,
1542                context_version: golden.context_version.as_deref(),
1543                store: &store,
1544                record_answers: cfg.record_answers,
1545                backend: cfg.backend,
1546                adapter: adapter.as_ref(),
1547                max_turns: cfg.max_turns,
1548                tier_binding: None,
1549            };
1550            let rows = run_cases(
1551                &cfg.project,
1552                &agent,
1553                &path_env,
1554                &cases,
1555                cfg.samples,
1556                parallel,
1557                &ctx,
1558            );
1559            configs.push(aggregate(model, rows));
1560        }
1561    }
1562
1563    Ok(Report {
1564        dataset: golden.dataset,
1565        context_version: golden.context_version,
1566        context_injection,
1567        parallel,
1568        selected_cases,
1569        total_cases,
1570        configs,
1571        backend: cfg.backend,
1572    })
1573}
1574
1575fn read_context_injection_report(
1576    agent_dir: &Path,
1577) -> Result<Option<ContextInjectionReport>, String> {
1578    let path = agent_dir.join("context-report.json");
1579    if !path.is_file() {
1580        return Ok(None);
1581    }
1582    let raw = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
1583    let report: ContextInjectionReport = serde_json::from_str(&raw).map_err(|_| {
1584        format!(
1585            "invalid {}: expected context identity fields only",
1586            path.display()
1587        )
1588    })?;
1589    report
1590        .validate()
1591        .map(Some)
1592        .map_err(|reason| format!("invalid {}: {reason}", path.display()))
1593}
1594
1595#[cfg(test)]
1596mod context_injection_report_tests {
1597    use super::*;
1598
1599    fn write_report(body: &str) -> tempfile::TempDir {
1600        let dir = tempfile::tempdir().unwrap();
1601        fs::write(dir.path().join("context-report.json"), body).unwrap();
1602        dir
1603    }
1604
1605    #[test]
1606    fn accepts_only_the_non_sensitive_context_identity_shape() {
1607        let dir = write_report(
1608            r#"{
1609                "mode":"schema+knowledge",
1610                "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1611                "knowledge_fingerprint":"fnv1a64:fedcba9876543210",
1612                "knowledge_chars":42
1613            }"#,
1614        );
1615        let report = read_context_injection_report(dir.path())
1616            .unwrap()
1617            .expect("report present");
1618        assert_eq!(report.mode, "schema+knowledge");
1619        assert_eq!(report.knowledge_chars, 42);
1620    }
1621
1622    #[test]
1623    fn rejects_rule_text_paths_and_inconsistent_identity_without_echoing_values() {
1624        let secret = "PRIVATE_RULE_MARKER /Users/example/private-project";
1625        let unknown = write_report(&format!(
1626            r#"{{
1627                "mode":"schema-only",
1628                "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1629                "knowledge_fingerprint":null,
1630                "knowledge_chars":0,
1631                "rule_text":{secret:?}
1632            }}"#
1633        ));
1634        let error = read_context_injection_report(unknown.path()).unwrap_err();
1635        assert!(!error.contains(secret));
1636
1637        let path_as_fingerprint = write_report(
1638            r#"{
1639                "mode":"schema+knowledge",
1640                "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1641                "knowledge_fingerprint":"/Users/example/private-project",
1642                "knowledge_chars":42
1643            }"#,
1644        );
1645        let error = read_context_injection_report(path_as_fingerprint.path()).unwrap_err();
1646        assert!(!error.contains("/Users/example/private-project"));
1647
1648        let inconsistent = write_report(
1649            r#"{
1650                "mode":"schema-only",
1651                "schema_digest_fingerprint":"fnv1a64:0123456789abcdef",
1652                "knowledge_fingerprint":"fnv1a64:fedcba9876543210",
1653                "knowledge_chars":42
1654            }"#,
1655        );
1656        assert!(read_context_injection_report(inconsistent.path()).is_err());
1657    }
1658}
1659
1660/// Build the trace store and compute the `context_sha` (MDL SHA) key component shared by every case
1661/// of a run/ablation. Returns a **disabled** store (every case re-runs, nothing is cached) with an
1662/// empty `context_sha` when `--no-cache` is set or the MDL SHA can't be computed — the reason is
1663/// printed to stderr so a disabled cache is never a silent surprise. The default cache dir is
1664/// `<project>/.warble/eval-cache`.
1665pub(crate) fn build_store(
1666    no_cache: bool,
1667    cache_dir: Option<&Path>,
1668    project: &Path,
1669) -> (TraceStore, String) {
1670    if no_cache {
1671        if cache_dir.is_some() {
1672            eprintln!("cache: --cache-dir ignored — --no-cache disables the cache entirely");
1673        }
1674        eprintln!("cache: disabled (--no-cache) — every case re-runs and refreshes its trace");
1675        return (TraceStore::disabled(), String::new());
1676    }
1677    let context_sha = match compute_mdl_sha(project) {
1678        Ok(s) => s,
1679        Err(e) => {
1680            eprintln!("cache: disabled — cannot compute MDL context SHA: {e}");
1681            return (TraceStore::disabled(), String::new());
1682        }
1683    };
1684    let dir = cache_dir
1685        .map(Path::to_path_buf)
1686        .unwrap_or_else(|| project.join(".warble").join("eval-cache"));
1687    eprintln!(
1688        "cache: enabled at {} (context_sha {})",
1689        dir.display(),
1690        &context_sha[..context_sha.len().min(12)]
1691    );
1692    (TraceStore::new(dir, true), context_sha)
1693}
1694
1695/// Apply `filter` to `golden.cases`, print the no-silent-caps selection note, and return the
1696/// selected cases. Errors if an active filter selects nothing (a typo'd tag shouldn't quietly run
1697/// zero cases). Shared by the run and ablation paths.
1698pub(crate) fn select_and_subset(
1699    golden: &Golden,
1700    filter: &CaseFilter,
1701) -> Result<Vec<GoldenCase>, String> {
1702    let selection = select_cases(&golden.cases, filter);
1703    if filter.is_active() {
1704        eprintln!("{}", selection.note);
1705    }
1706    if filter.is_active() && selection.indices.is_empty() {
1707        return Err(format!(
1708            "no golden cases match the filter — {} (nothing to run)",
1709            selection.note
1710        ));
1711    }
1712    Ok(selection
1713        .indices
1714        .iter()
1715        .map(|&i| golden.cases[i].clone())
1716        .collect())
1717}
1718
1719/// Run every golden case, `samples` times each, through the installed `agent`, streaming progress
1720/// to stderr. `ctx` carries the model binding and trace-cache key material (see [`CaseCtx`]); a
1721/// cache hit re-scores without invoking the agent. The agent must already be installed into
1722/// `project`. `parallel` jobs (case × sample) run concurrently (1 = serial); jobs are independent
1723/// and the installed agent files are only read, so the shared state is safe.
1724///
1725/// At `samples == 1` the per-case progress line is byte-for-byte what it was before repeated
1726/// sampling existed. At `samples > 1` each sample additionally prints its own compact line, and
1727/// once every sample of a case is in, a pass-rate summary line follows (marked `FLAKY` when the
1728/// case's samples disagree). The returned rows are always in golden-file order.
1729pub(crate) fn run_cases(
1730    project: &Path,
1731    agent: &str,
1732    path_env: &str,
1733    cases: &[GoldenCase],
1734    samples: usize,
1735    parallel: usize,
1736    ctx: &CaseCtx,
1737) -> Vec<CaseResult> {
1738    let samples = samples.max(1);
1739    let outcomes = run_indexed(cases.len() * samples, parallel, |j| {
1740        let case = &cases[j / samples];
1741        let sample = (j % samples) as u32;
1742        let r = run_case(project, agent, path_env, case, sample, ctx);
1743        let extra = if r.pass {
1744            String::new()
1745        } else {
1746            format!("  — {}", r.reason)
1747        };
1748        // Absent is not zero: a backend that reported no cost renders as no cost segment at all,
1749        // never conflated with a genuine `$0.00` — mirrors `ablation.rs::fmt_cost`.
1750        let cost = match r.cost {
1751            Some(cost) => format!(", ${cost:.4}"),
1752            None => String::new(),
1753        };
1754        let turns = r
1755            .turns
1756            .map(|t| t.to_string())
1757            .unwrap_or_else(|| "n/a".to_string());
1758        // Mark re-scored (cached) samples so a 0-LLM run is never read as a fresh one.
1759        let cache = if r.cache_hit { " [cache]" } else { "" };
1760        if samples == 1 {
1761            eprintln!(
1762                "  {}  {}{cache}  ({:.1}s{cost}, {turns}t){extra}",
1763                if r.pass { "PASS" } else { "FAIL" },
1764                case.id,
1765                r.latency_ms as f64 / 1000.0,
1766            );
1767        } else {
1768            eprintln!(
1769                "    {}  {} sample {sample}{cache}  ({:.1}s{cost}, {turns}t){extra}",
1770                if r.pass { "PASS" } else { "FAIL" },
1771                case.id,
1772                r.latency_ms as f64 / 1000.0,
1773            );
1774        }
1775        r
1776    });
1777
1778    // Group the flat (case*samples + sample) outcomes back into per-case chunks, in golden order,
1779    // and fold each into its aggregate. `SampleOutcome` isn't `Clone`, so a shared mutable iterator
1780    // — not indexing/slicing — is what lets each case take exactly its `samples` items.
1781    let mut it = outcomes.into_iter();
1782    cases
1783        .iter()
1784        .map(|case| {
1785            let chunk: Vec<SampleOutcome> = (&mut it).take(samples).collect();
1786            let result = fold_samples(
1787                case.id.clone(),
1788                case.tags.clone(),
1789                chunk,
1790                ctx.record_answers,
1791            );
1792            if samples > 1 {
1793                let marker = if result.flaky {
1794                    "FLAKY"
1795                } else if result.pass {
1796                    "PASS"
1797                } else {
1798                    "FAIL"
1799                };
1800                eprintln!(
1801                    "  {marker}  {}  pass_rate={:.2} ({}/{})",
1802                    result.id, result.pass_rate, result.passes, result.samples
1803                );
1804            }
1805            result
1806        })
1807        .collect()
1808}
1809
1810/// Run jobs `0..n` through `job`, at most `parallel` concurrently, and return the results in
1811/// index order regardless of completion order. `parallel <= 1` degenerates to a plain serial
1812/// loop. Workers pull the next index from a shared counter, so long jobs don't starve the
1813/// queue behind a fixed pre-partition.
1814pub(crate) fn run_indexed<T, F>(n: usize, parallel: usize, job: F) -> Vec<T>
1815where
1816    T: Send,
1817    F: Fn(usize) -> T + Sync,
1818{
1819    if parallel <= 1 || n <= 1 {
1820        return (0..n).map(job).collect();
1821    }
1822
1823    use std::sync::atomic::{AtomicUsize, Ordering};
1824    use std::sync::Mutex;
1825
1826    let next = AtomicUsize::new(0);
1827    let slots: Vec<Mutex<Option<T>>> = (0..n).map(|_| Mutex::new(None)).collect();
1828
1829    std::thread::scope(|scope| {
1830        for _ in 0..parallel.min(n) {
1831            scope.spawn(|| loop {
1832                let i = next.fetch_add(1, Ordering::Relaxed);
1833                if i >= n {
1834                    break;
1835                }
1836                let r = job(i);
1837                *slots[i].lock().expect("result slot poisoned") = Some(r);
1838            });
1839        }
1840    });
1841
1842    slots
1843        .into_iter()
1844        .map(|slot| {
1845            slot.into_inner()
1846                .expect("result slot poisoned")
1847                .expect("every index 0..n is claimed exactly once by the shared counter")
1848        })
1849        .collect()
1850}
1851
1852#[cfg(test)]
1853mod run_indexed_tests {
1854    use super::run_indexed;
1855    use std::sync::atomic::{AtomicUsize, Ordering};
1856    use std::time::Duration;
1857
1858    #[test]
1859    fn serial_path_preserves_order_and_runs_each_once() {
1860        let calls = AtomicUsize::new(0);
1861        let out = run_indexed(5, 1, |i| {
1862            calls.fetch_add(1, Ordering::Relaxed);
1863            i * 10
1864        });
1865        assert_eq!(out, vec![0, 10, 20, 30, 40]);
1866        assert_eq!(calls.load(Ordering::Relaxed), 5);
1867    }
1868
1869    #[test]
1870    fn parallel_results_are_in_index_order_despite_completion_order() {
1871        // Earlier indices sleep longer, so completion order is roughly reversed —
1872        // the returned Vec must still be in index order.
1873        let out = run_indexed(8, 4, |i| {
1874            std::thread::sleep(Duration::from_millis((8 - i as u64) * 3));
1875            i
1876        });
1877        assert_eq!(out, (0..8).collect::<Vec<_>>());
1878    }
1879
1880    #[test]
1881    fn parallel_runs_each_index_exactly_once() {
1882        let per_index: Vec<AtomicUsize> = (0..32).map(|_| AtomicUsize::new(0)).collect();
1883        let out = run_indexed(32, 6, |i| {
1884            per_index[i].fetch_add(1, Ordering::Relaxed);
1885            i
1886        });
1887        assert_eq!(out.len(), 32);
1888        for (i, c) in per_index.iter().enumerate() {
1889            assert_eq!(c.load(Ordering::Relaxed), 1, "index {i} ran once");
1890        }
1891    }
1892
1893    #[test]
1894    fn parallelism_wider_than_n_and_empty_input_are_fine() {
1895        assert_eq!(run_indexed(2, 16, |i| i), vec![0, 1]);
1896        assert_eq!(run_indexed(0, 4, |i| i), Vec::<usize>::new());
1897    }
1898
1899    #[test]
1900    fn parallel_actually_overlaps_work() {
1901        // Track concurrency directly instead of asserting on wall-clock duration: a shared/
1902        // contended CI runner can make even a generous timing threshold flaky, since the claim
1903        // under test ("did the pool hand out overlapping work") isn't really about elapsed time.
1904        // Two sleeping threads are both genuinely inside the closure at once, so overlap is
1905        // observable this way even when the runner has fewer cores than the requested
1906        // parallelism — which is also why we assert `> 1`, not `== 4`.
1907        let in_flight = AtomicUsize::new(0);
1908        let max_in_flight = AtomicUsize::new(0);
1909        run_indexed(4, 4, |_| {
1910            let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1911            max_in_flight.fetch_max(now, Ordering::SeqCst);
1912            std::thread::sleep(Duration::from_millis(40));
1913            in_flight.fetch_sub(1, Ordering::SeqCst);
1914        });
1915        let max = max_in_flight.load(Ordering::SeqCst);
1916        assert!(
1917            max > 1,
1918            "expected overlapping execution, but max concurrent was {max}"
1919        );
1920    }
1921}
1922
1923#[cfg(test)]
1924mod fold_samples_tests {
1925    use super::*;
1926
1927    fn outcome(pass: bool, reason: &str, cost: f64, latency_ms: u64, turns: u64) -> SampleOutcome {
1928        SampleOutcome {
1929            pass,
1930            reason: reason.to_string(),
1931            cost: Some(cost),
1932            latency_ms,
1933            turns: Some(turns),
1934            cache_hit: false,
1935            answer: None,
1936        }
1937    }
1938
1939    #[test]
1940    fn fully_passing_is_not_flaky_and_reason_is_match() {
1941        let outcomes = vec![
1942            outcome(true, "match", 0.1, 1_000, 2),
1943            outcome(true, "match", 0.1, 1_000, 2),
1944        ];
1945        let r = fold_samples("a".into(), vec![], outcomes, false);
1946        assert_eq!(r.samples, 2);
1947        assert_eq!(r.passes, 2);
1948        assert!((r.pass_rate - 1.0).abs() < 1e-9);
1949        assert!(r.pass);
1950        assert!(!r.flaky);
1951        assert_eq!(r.reason, "match");
1952    }
1953
1954    #[test]
1955    fn fully_failing_is_not_flaky() {
1956        let outcomes = vec![
1957            outcome(false, "mismatch", 0.0, 500, 1),
1958            outcome(false, "mismatch", 0.0, 500, 1),
1959        ];
1960        let r = fold_samples("a".into(), vec![], outcomes, false);
1961        assert_eq!(r.passes, 0);
1962        assert_eq!(r.pass_rate, 0.0);
1963        assert!(!r.pass);
1964        assert!(!r.flaky, "0 passes out of N is a hard fail, not flaky");
1965    }
1966
1967    #[test]
1968    fn partial_pass_is_flaky_and_reason_is_the_modal_failure() {
1969        let outcomes = vec![
1970            outcome(true, "match", 0.0, 0, 0),
1971            outcome(false, "wrong total", 0.0, 0, 0),
1972            outcome(false, "wrong total", 0.0, 0, 0),
1973            outcome(false, "timeout", 0.0, 0, 0),
1974        ];
1975        let r = fold_samples("a".into(), vec![], outcomes, false);
1976        assert_eq!(r.passes, 1);
1977        assert_eq!(r.samples, 4);
1978        assert!((r.pass_rate - 0.25).abs() < 1e-9);
1979        assert!(!r.pass);
1980        assert!(r.flaky);
1981        assert_eq!(r.reason, "wrong total", "modal failure reason wins");
1982    }
1983
1984    #[test]
1985    fn modal_reason_tie_break_is_first_occurrence() {
1986        // "b" and "a" both fail twice — "a" was seen first among the failures, so it wins the tie.
1987        let outcomes = vec![
1988            outcome(false, "a", 0.0, 0, 0),
1989            outcome(false, "b", 0.0, 0, 0),
1990            outcome(false, "b", 0.0, 0, 0),
1991            outcome(false, "a", 0.0, 0, 0),
1992        ];
1993        let r = fold_samples("a".into(), vec![], outcomes, false);
1994        assert_eq!(r.reason, "a");
1995    }
1996
1997    #[test]
1998    fn cost_sums_latency_and_turns_average() {
1999        let outcomes = vec![
2000            outcome(true, "match", 0.10, 1_000, 4),
2001            outcome(true, "match", 0.20, 3_000, 6),
2002        ];
2003        let r = fold_samples("a".into(), vec![], outcomes, false);
2004        assert!(
2005            (r.cost.expect("cost present") - 0.30).abs() < 1e-9,
2006            "cost sums across samples"
2007        );
2008        assert_eq!(r.latency_ms, 2_000, "latency averages across samples");
2009        assert_eq!(r.turns, Some(5), "turns averages across samples");
2010    }
2011
2012    #[test]
2013    fn cost_and_turns_are_none_when_any_sample_lacks_them() {
2014        let outcomes = vec![
2015            outcome(true, "match", 0.10, 1_000, 4),
2016            SampleOutcome {
2017                cost: None,
2018                turns: None,
2019                ..outcome(true, "match", 0.0, 1_000, 0)
2020            },
2021        ];
2022        let r = fold_samples("a".into(), vec![], outcomes, false);
2023        assert!(
2024            r.cost.is_none(),
2025            "one sample's missing cost makes the case's cost absent, not a partial sum"
2026        );
2027        assert!(
2028            r.turns.is_none(),
2029            "one sample's missing turns makes the case's turns absent, not a partial average"
2030        );
2031    }
2032
2033    #[test]
2034    fn cache_hits_and_misses_are_counted_per_sample() {
2035        let outcomes = vec![
2036            SampleOutcome {
2037                cache_hit: true,
2038                ..outcome(true, "match", 0.0, 0, 0)
2039            },
2040            SampleOutcome {
2041                cache_hit: false,
2042                ..outcome(true, "match", 0.0, 0, 0)
2043            },
2044            SampleOutcome {
2045                cache_hit: true,
2046                ..outcome(true, "match", 0.0, 0, 0)
2047            },
2048        ];
2049        let r = fold_samples("a".into(), vec![], outcomes, false);
2050        assert_eq!(r.cache_hits, 2);
2051        assert_eq!(r.cache_misses, 1);
2052    }
2053
2054    #[test]
2055    fn answer_dist_is_only_populated_when_record_answers_is_set() {
2056        let build = || {
2057            vec![
2058                SampleOutcome {
2059                    answer: Some("42".to_string()),
2060                    ..outcome(true, "match", 0.0, 0, 0)
2061                },
2062                SampleOutcome {
2063                    answer: Some("42".to_string()),
2064                    ..outcome(true, "match", 0.0, 0, 0)
2065                },
2066                SampleOutcome {
2067                    answer: Some("7".to_string()),
2068                    ..outcome(false, "wrong total", 0.0, 0, 0)
2069                },
2070            ]
2071        };
2072
2073        let without = fold_samples("a".into(), vec![], build(), false);
2074        assert!(without.answer_dist.is_none());
2075        assert!(
2076            without.samples_detail.iter().all(|s| s.answer.is_none()),
2077            "no answer strings recorded unless --record-answers"
2078        );
2079
2080        let with = fold_samples("a".into(), vec![], build(), true);
2081        let dist = with.answer_dist.expect("populated when record_answers");
2082        assert_eq!(dist.get("42"), Some(&2));
2083        assert_eq!(dist.get("7"), Some(&1));
2084        assert_eq!(
2085            with.samples_detail
2086                .iter()
2087                .filter(|s| s.answer.is_some())
2088                .count(),
2089            3
2090        );
2091    }
2092
2093    #[test]
2094    fn failed_output_is_recorded_only_when_requested() {
2095        let extracted = serde_json::json!({
2096            "blocks": [{"type": "status", "state": "unknown"}],
2097            "verified": false,
2098        });
2099
2100        assert_eq!(recorded_failure_answer(false, None, "raw refusal"), None);
2101        assert_eq!(
2102            recorded_failure_answer(false, Some(&extracted), "ignored"),
2103            None
2104        );
2105        assert_eq!(
2106            recorded_failure_answer(true, None, "raw refusal").as_deref(),
2107            Some("raw refusal")
2108        );
2109        assert_eq!(
2110            recorded_failure_answer(true, Some(&extracted), "ignored"),
2111            Some(extracted.to_string())
2112        );
2113    }
2114
2115    #[test]
2116    fn modal_reason_of_empty_iterator_is_empty_string() {
2117        assert_eq!(modal_reason(std::iter::empty()), "");
2118    }
2119}
2120
2121#[cfg(test)]
2122mod verdict_tests {
2123    use super::*;
2124
2125    /// A representative +Assertive envelope, shaped exactly like
2126    /// `dispatcher/claude-code-cli`'s `VERDICT_ENVELOPE_EXAMPLE`.
2127    fn envelope(fresh: bool, severity: &str) -> serde_json::Value {
2128        serde_json::json!({
2129            "blocks": [
2130                { "type": "status", "state": if fresh { "fresh" } else { "stale" },
2131                  "label": "orders freshness", "detail": "…", "severity": severity }
2132            ],
2133            "verdict": { "type": "freshness_verdict", "fresh": fresh, "observed_lag_hours": 51,
2134                         "expected_cadence": "24h" },
2135            "emitted": ["freshness_breach"],
2136            "verified": true,
2137        })
2138    }
2139
2140    fn fenced(value: &serde_json::Value) -> String {
2141        format!("Some prose.\n\n```json\n{value}\n```\n")
2142    }
2143
2144    #[test]
2145    fn extract_verdict_json_accepts_a_real_envelope() {
2146        let text = fenced(&envelope(false, "critical"));
2147        let got = extract_verdict_json(&text).expect("envelope parses");
2148        assert_eq!(got["verdict"]["fresh"], false);
2149    }
2150
2151    #[test]
2152    fn extract_verdict_json_rejects_a_columns_rows_table() {
2153        let text = fenced(&serde_json::json!({"columns": ["n"], "rows": [[42]]}));
2154        assert!(
2155            extract_verdict_json(&text).is_none(),
2156            "a table has neither `verdict` nor `blocks`"
2157        );
2158    }
2159
2160    #[test]
2161    fn extract_verdict_json_rejects_malformed_json() {
2162        assert!(extract_verdict_json("not json at all").is_none());
2163        assert!(extract_verdict_json("```json\n{ not valid\n```").is_none());
2164    }
2165
2166    #[test]
2167    fn extract_result_json_rejects_a_verdict_envelope() {
2168        // The inverse guard: a verdict envelope has no `rows` array, so the Table extractor must
2169        // not accidentally accept it either.
2170        let text = fenced(&envelope(true, "critical"));
2171        assert!(extract_result_json(&text).is_none());
2172    }
2173
2174    #[test]
2175    fn project_verdict_field_reads_fresh_true_and_false() {
2176        let fresh_table = project_verdict_field(&envelope(true, "critical"), "fresh").unwrap();
2177        assert_eq!(fresh_table.rows, vec![vec![Value::Bool(true)]]);
2178
2179        let stale_table = project_verdict_field(&envelope(false, "critical"), "fresh").unwrap();
2180        assert_eq!(stale_table.rows, vec![vec![Value::Bool(false)]]);
2181    }
2182
2183    #[test]
2184    fn project_verdict_field_reads_severity_warn_and_critical() {
2185        let warn = project_verdict_field(&envelope(false, "warn"), "severity").unwrap();
2186        assert_eq!(warn.rows, vec![vec![Value::String("warn".to_string())]]);
2187
2188        let critical = project_verdict_field(&envelope(false, "critical"), "severity").unwrap();
2189        assert_eq!(
2190            critical.rows,
2191            vec![vec![Value::String("critical".to_string())]]
2192        );
2193    }
2194
2195    #[test]
2196    fn project_verdict_field_falls_back_to_verdict_severity() {
2197        // A `status` block without a `severity` key: fall back to `verdict.severity`.
2198        let envelope = serde_json::json!({
2199            "blocks": [{ "type": "status", "state": "stale" }],
2200            "verdict": { "type": "freshness_verdict", "fresh": false, "severity": "warn" },
2201        });
2202        let got = project_verdict_field(&envelope, "severity").unwrap();
2203        assert_eq!(got.rows, vec![vec![Value::String("warn".to_string())]]);
2204    }
2205
2206    #[test]
2207    fn project_verdict_field_is_none_on_missing_or_unknown_field() {
2208        let e = envelope(true, "critical");
2209        assert!(
2210            project_verdict_field(&e, "observed_lag_hours").is_none(),
2211            "unrecognized field fails closed, never silently passes"
2212        );
2213        let no_verdict = serde_json::json!({"blocks": []});
2214        assert!(project_verdict_field(&no_verdict, "fresh").is_none());
2215        let no_severity_anywhere = serde_json::json!({
2216            "blocks": [{"type": "status", "state": "stale"}],
2217            "verdict": {"type": "freshness_verdict", "fresh": false},
2218        });
2219        assert!(project_verdict_field(&no_severity_anywhere, "severity").is_none());
2220    }
2221
2222    fn verdict_case(tag: &str, field: &str, expected_rows: &str) -> GoldenCase {
2223        let yaml = format!(
2224            "id: v1\nquestion: \"is it fresh?\"\ntags: [{tag}]\nmatch: scalar\n\
2225             result_kind: verdict\nverdict_field: {field}\n\
2226             expected: {{ columns: [{field}], rows: {expected_rows} }}\n"
2227        );
2228        serde_yaml::from_str(&yaml).expect("verdict golden case parses")
2229    }
2230
2231    #[test]
2232    fn golden_case_result_kind_defaults_to_table_when_omitted() {
2233        let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[1]] }\n";
2234        let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
2235        assert_eq!(case.result_kind, ResultKind::Table);
2236        assert!(case.verdict_field.is_none());
2237    }
2238
2239    #[test]
2240    fn score_value_passes_a_matching_verdict_envelope() {
2241        let case = verdict_case("detection", "fresh", "[[false]]");
2242        let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
2243        assert!(
2244            verdict.pass,
2245            "envelope fresh=false matches expected fresh=false"
2246        );
2247    }
2248
2249    #[test]
2250    fn score_value_fails_a_mismatching_verdict_envelope() {
2251        let case = verdict_case("detection", "fresh", "[[true]]");
2252        let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
2253        assert!(
2254            !verdict.pass,
2255            "envelope fresh=false does not match expected fresh=true"
2256        );
2257    }
2258
2259    #[test]
2260    fn score_value_projects_severity_for_the_severity_field() {
2261        let case = verdict_case("severity", "severity", "[[critical]]");
2262        let verdict = score_value(&envelope(false, "critical"), &case).expect("scores");
2263        assert!(verdict.pass);
2264    }
2265
2266    #[test]
2267    fn score_value_on_a_table_case_is_unaffected_by_verdict_support() {
2268        let yaml = "id: t1\nquestion: \"how many?\"\nmatch: scalar\nexpected: { columns: [n], rows: [[42]] }\n";
2269        let case: GoldenCase = serde_yaml::from_str(yaml).expect("legacy case parses");
2270        let result = serde_json::json!({"columns": ["n"], "rows": [[42]]});
2271        let verdict = score_value(&result, &case).expect("table case scores");
2272        assert!(verdict.pass);
2273    }
2274}
2275
2276#[cfg(test)]
2277mod output_stability_tests {
2278    use super::*;
2279
2280    /// A minimal, fully-specified `CaseResult` — every test below overrides only the fields the
2281    /// shape under test cares about (`samples`, `flaky`, `pass`, `passes`, `answer_dist`).
2282    fn case(
2283        samples: u32,
2284        passes: u32,
2285        flaky: bool,
2286        answer_dist: Option<BTreeMap<String, u32>>,
2287    ) -> CaseResult {
2288        CaseResult {
2289            id: "c1".to_string(),
2290            tags: vec![],
2291            samples,
2292            passes,
2293            pass_rate: if samples == 0 {
2294                0.0
2295            } else {
2296                passes as f64 / samples as f64
2297            },
2298            pass: passes == samples,
2299            flaky,
2300            reason: "match".to_string(),
2301            cost: Some(0.0),
2302            latency_ms: 0,
2303            turns: Some(0),
2304            cache_hits: 0,
2305            cache_misses: samples,
2306            samples_detail: vec![],
2307            answer_dist,
2308        }
2309    }
2310
2311    fn dist(pairs: &[(&str, u32)]) -> BTreeMap<String, u32> {
2312        pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
2313    }
2314
2315    // --- AC8 shape 1: verdict-stable but output-divergent — this is exactly the crux the packet
2316    // exists to fix: `flaky` never sees it, because every sample passed (or every sample failed).
2317    #[test]
2318    fn verdict_stable_but_output_divergent_is_output_unstable_not_flaky() {
2319        // Both samples "pass" (say, a fuzzy/verdict-style case whose grader is lenient) but return
2320        // two different answer values — flaky is false, output_stability must not be Stable.
2321        let c = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
2322        assert!(!c.flaky, "both samples passed: flaky must stay false");
2323        assert_eq!(c.output_stability(), OutputStability::Unstable);
2324    }
2325
2326    // --- AC8 shape 2: verdict-flip (the pre-existing `flaky` signal) — unaffected by this change.
2327    #[test]
2328    fn verdict_flip_is_flaky_and_may_still_be_output_stable() {
2329        // 1 of 2 passed (flaky), but both samples happened to render the same answer text.
2330        let c = case(2, 1, true, Some(dist(&[("42", 2)])));
2331        assert!(c.flaky);
2332        assert_eq!(c.output_stability(), OutputStability::Stable);
2333    }
2334
2335    // --- AC8 shape 3: fully stable — same verdict, same answer, every sample.
2336    #[test]
2337    fn fully_stable_case_is_stable_and_not_flaky() {
2338        let c = case(3, 3, false, Some(dist(&[("42", 3)])));
2339        assert!(!c.flaky);
2340        assert_eq!(c.output_stability(), OutputStability::Stable);
2341    }
2342
2343    // --- AC8 shape 4: over-split — same *semantic* result, different *textual* signature
2344    // (`42` vs `42.0`). `render_answer`'s textual signature is documented as over-splitting this;
2345    // `output_stability` reports it as `Unstable` (the labelled upper bound), not silently
2346    // reconciled — reconciling it would require going through the comparator (out of scope; see
2347    // the packet's stop-and-report condition).
2348    #[test]
2349    fn over_split_textual_signature_reports_unstable_not_reconciled() {
2350        let c = case(2, 2, false, Some(dist(&[("42", 1), ("42.0", 1)])));
2351        assert!(!c.flaky);
2352        assert_eq!(
2353            c.output_stability(),
2354            OutputStability::Unstable,
2355            "over-split is reported as Unstable (an upper bound), not reconciled to Stable"
2356        );
2357    }
2358
2359    // --- AC7: a case whose answer buckets sum to fewer than `samples` (an invocation failure,
2360    // e.g. a crash with no final output) must never be miscounted as stable.
2361    #[test]
2362    fn incomplete_answers_are_never_counted_as_stable() {
2363        // 3 samples, but only 2 answers were ever recorded (the third produced none).
2364        let c = case(3, 2, false, Some(dist(&[("42", 2)])));
2365        assert_eq!(c.output_stability(), OutputStability::Incomplete);
2366    }
2367
2368    #[test]
2369    fn no_answer_dist_or_single_sample_is_not_measured() {
2370        assert_eq!(
2371            case(2, 2, false, None).output_stability(),
2372            OutputStability::NotMeasured
2373        );
2374        assert_eq!(
2375            case(1, 1, false, Some(dist(&[("42", 1)]))).output_stability(),
2376            OutputStability::NotMeasured
2377        );
2378    }
2379
2380    // AC1/AC9: exercise the real `aggregate()`, not a hand-rolled copy of its filter. Without
2381    // this, hardcoding either count to a constant passes the whole suite — the field would be
2382    // reporting-only and silently wrong. Covers `flaky_cases` too, which had the same gap.
2383    #[test]
2384    fn aggregate_counts_flaky_and_output_unstable_from_the_real_function() {
2385        let flaky_only = case(2, 1, true, Some(dist(&[("42", 2)])));
2386        let unstable_only = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
2387        let stable = case(2, 2, false, Some(dist(&[("42", 2)])));
2388        let report = aggregate("m", vec![flaky_only, unstable_only, stable]);
2389        assert_eq!(report.flaky_cases, 1, "one verdict flip");
2390        assert_eq!(
2391            report.output_unstable_cases, 1,
2392            "one output-unstable case, counted independently of the flaky one"
2393        );
2394        assert_eq!(report.n, 3);
2395    }
2396
2397    fn config_report(cases: Vec<CaseResult>) -> ConfigReport {
2398        let flaky_cases = cases.iter().filter(|c| c.flaky).count() as u32;
2399        let output_unstable_cases = cases
2400            .iter()
2401            .filter(|c| {
2402                matches!(
2403                    c.output_stability(),
2404                    OutputStability::Unstable | OutputStability::Incomplete
2405                )
2406            })
2407            .count() as u32;
2408        ConfigReport {
2409            model: "m".to_string(),
2410            n: cases.len(),
2411            accuracy: 1.0,
2412            cost_total_usd: Some(0.0),
2413            latency_ms_avg: 0,
2414            turns_avg: Some(0),
2415            cache_hits: 0,
2416            cache_misses: 0,
2417            flaky_cases,
2418            output_unstable_cases,
2419            by_tag: BTreeMap::new(),
2420            cases,
2421        }
2422    }
2423
2424    // AC1: the aggregate is a distinct counter from `flaky_cases` — a config with one
2425    // verdict-stable-but-output-divergent case and zero flaky cases still counts it.
2426    #[test]
2427    fn aggregate_output_unstable_count_is_distinct_from_flaky_count() {
2428        let stable_but_divergent = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
2429        let flaky_but_output_stable = case(2, 1, true, Some(dist(&[("42", 2)])));
2430        let report = config_report(vec![stable_but_divergent, flaky_but_output_stable]);
2431        assert_eq!(report.flaky_cases, 1, "exactly one verdict flip");
2432        assert_eq!(
2433            report.output_unstable_cases, 1,
2434            "exactly one output-unstable case, and it is not the flaky one"
2435        );
2436    }
2437
2438    fn report_with(configs: Vec<ConfigReport>) -> Report {
2439        Report {
2440            dataset: None,
2441            context_version: None,
2442            context_injection: None,
2443            parallel: 1,
2444            selected_cases: configs.iter().map(|c| c.n).sum(),
2445            total_cases: configs.iter().map(|c| c.n).sum(),
2446            configs,
2447            backend: Backend::default(),
2448        }
2449    }
2450
2451    // AC2: `answer_dist` must be surfaced for an output-unstable case even though it never went
2452    // `flaky` (the whole point — this is the case the old "flaky cases" block would have hidden).
2453    #[test]
2454    fn format_pareto_shows_answer_dist_for_output_unstable_non_flaky_case() {
2455        let c = case(2, 2, false, Some(dist(&[("42", 1), ("43", 1)])));
2456        let report = report_with(vec![config_report(vec![c])]);
2457        let table = format_pareto(&report);
2458        assert!(
2459            table.contains("[output-unstable, verdict stable]"),
2460            "expected the per-case output-unstable label in:\n{table}"
2461        );
2462        assert!(
2463            table.contains("42×1") && table.contains("43×1"),
2464            "expected the answer distribution to be shown for the output-unstable case:\n{table}"
2465        );
2466    }
2467
2468    // AC5: at samples == 1, the report says reproducibility was not measured, rather than
2469    // silently omitting the section.
2470    #[test]
2471    fn format_pareto_states_reproducibility_not_measured_at_samples_one() {
2472        let c = case(1, 1, false, None);
2473        let report = report_with(vec![config_report(vec![c])]);
2474        let table = format_pareto(&report);
2475        assert!(
2476            table.contains("not measured"),
2477            "expected an explicit not-measured statement in:\n{table}"
2478        );
2479    }
2480
2481    // AC6: a fully cache-served run with samples > 1 warns that identical output there is replay.
2482    #[test]
2483    fn format_pareto_warns_when_a_multi_sample_case_was_fully_cache_served() {
2484        let mut c = case(2, 2, false, Some(dist(&[("42", 2)])));
2485        c.cache_hits = 2;
2486        c.cache_misses = 0;
2487        let mut cfg = config_report(vec![c]);
2488        cfg.cache_hits = 2;
2489        cfg.cache_misses = 0;
2490        let report = report_with(vec![cfg]);
2491        let table = format_pareto(&report);
2492        assert!(
2493            table.contains("replay, not evidence of determinism"),
2494            "expected a cache-replay caveat in:\n{table}"
2495        );
2496    }
2497
2498    // AC4: `record_answers` defaults on once `samples > 1`; an explicit `false` at `samples == 1`
2499    // is left completely unchanged (no new behavior at samples == 1).
2500    #[test]
2501    fn effective_record_answers_defaults_on_above_one_sample_only() {
2502        assert!(
2503            !effective_record_answers(false, 1),
2504            "samples == 1 unchanged: still off"
2505        );
2506        assert!(
2507            effective_record_answers(true, 1),
2508            "explicit flag still honored at samples == 1"
2509        );
2510        assert!(
2511            effective_record_answers(false, 2),
2512            "samples > 1 defaults recording on even without the explicit flag"
2513        );
2514        assert!(
2515            effective_record_answers(true, 5),
2516            "explicit flag remains a no-op true at samples > 1"
2517        );
2518    }
2519}