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