Skip to main content

warble_eval_runner/
ablation.rs

1//! Per-step tier ablation — the closed loop's core (roadmap Phase 1.4 step 8).
2//!
3//! Where [`crate::run_eval`] ablates the *whole* model (one `--model` for the entire run), this
4//! module ablates **one named step at a time**: it holds every step at a `base_tier` and, for each
5//! `llm_calls[].name`, re-binds just that step to each swept tier, re-dispatches, and re-runs the
6//! goldens. The per-step binding is realized the same way the runtime realizes it — by re-dispatching
7//! the IR with a tier→model config so each (sub)agent's frontmatter carries its own model (no
8//! `--model` override) — which is exactly the axis the closed loop tunes: measure accuracy Δ vs
9//! cost Δ per step, then keep the cheapest tier that stays at/above the accuracy floor.
10//!
11//! Combinatorial discipline (roadmap risk #1): the full grid is Mᴺ (N steps × M tiers). We do **not**
12//! sweep it — one step moves at a time while the rest stay at `base_tier`, so the run is
13//! `1 + N·(M−1)` dispatches. What is swept and what is skipped is logged (no silent caps).
14
15use serde::Serialize;
16use std::path::PathBuf;
17use warble_claude_code::{
18    emit_claude_code_with_models, ir::WarbleIr, ModelConfig, DEFAULT_RENDER_FLAVOR,
19};
20
21use crate::backend::{resolve_adapter, BackendAdapter};
22use crate::cache::FRONTMATTER_MODEL;
23use crate::context::compute_dir_sha;
24use crate::{
25    agent_name, aggregate, build_store, install_agents, run_cases, run_path, select_and_subset,
26    Backend, CaseCtx, CaseFilter, ConfigReport, Golden, GoldenCase, TraceStore,
27};
28
29/// Inputs for a per-step ablation sweep.
30pub struct AblationConfig {
31    /// A queryable wren project (connection + data); agent files are installed here per dispatch.
32    pub project: PathBuf,
33    /// Compiled IR JSON (the same artifact `warble dispatch` consumes). Re-dispatched per point.
34    pub ir_path: PathBuf,
35    /// Golden cases YAML.
36    pub golden_path: PathBuf,
37    /// Dispatch target (e.g. `claude-code:headless`).
38    pub target: String,
39    /// Optional tier→model config YAML; defaults to the standard `strong/cheap/orchestrator` map.
40    pub models_config_path: Option<PathBuf>,
41    /// Tiers to try per step (e.g. `[cheap, strong]`).
42    pub sweep_tiers: Vec<String>,
43    /// Tier every non-ablated step is pinned to (the reference point; conventionally `strong`).
44    pub base_tier: String,
45    /// A tier qualifies for the recommendation if its accuracy is within this much of the baseline.
46    pub accuracy_drop_tolerance: f64,
47    /// Write the full JSON report here.
48    pub out: Option<PathBuf>,
49    /// Concurrent cases per dispatched point (1 = serial); see `RunConfig::parallel`.
50    pub parallel: usize,
51    /// Golden case selection (`--tags` / `--sample`). Default = full run (every case).
52    pub filter: CaseFilter,
53    /// Bypass the trace cache (`--no-cache`): re-run every case at every point.
54    pub no_cache: bool,
55    /// Where per-case traces are read/written. `None` = `<project>/.warble/eval-cache`.
56    pub cache_dir: Option<PathBuf>,
57    /// Which back-end/runtime to run the ablation through. Distinct from `target` (the existing
58    /// per-back-end capability target this crate has always had): today the ablation loop only
59    /// works with [`Backend::ClaudeCodeCli`] (it re-dispatches IR via that back-end's own Rust
60    /// emitter directly), so `run_ablation` validates this at entry and fails loudly otherwise
61    /// rather than silently ignoring it.
62    pub backend: Backend,
63}
64
65/// A single named step in the IR (`verb.step_name`) with its authored tier.
66#[derive(Debug, Clone)]
67struct StepRef {
68    verb: String,
69    step_name: String,
70    component_idx: usize,
71    call_idx: usize,
72}
73
74impl StepRef {
75    fn label(&self) -> String {
76        format!("{}.{}", self.verb, self.step_name)
77    }
78}
79
80/// One ablation measurement: step `label` bound to `tier`, scored against the goldens.
81#[derive(Debug, Clone, Serialize)]
82pub struct AblationPoint {
83    pub step: String,
84    pub tier: String,
85    pub accuracy: f64,
86    pub cost_total_usd: f64,
87    pub latency_ms_avg: u64,
88    /// Accuracy minus the baseline accuracy (negative = regression from moving this step).
89    pub delta_accuracy: f64,
90    /// Cost minus the baseline cost (negative = cheaper than baseline).
91    pub delta_cost: f64,
92}
93
94/// The cheapest tier that holds accuracy for one step (the closed loop's per-step verdict).
95#[derive(Debug, Clone, Serialize)]
96pub struct StepRecommendation {
97    pub step: String,
98    pub recommended_tier: String,
99    pub accuracy: f64,
100    pub note: String,
101}
102
103#[derive(Debug, Clone, Serialize)]
104pub struct AblationReport {
105    pub dataset: Option<String>,
106    pub context_version: Option<String>,
107    /// Concurrency the cases ran at (1 = serial); recorded because parallelism affects the
108    /// per-case latency column (queueing), mirroring `Report::parallel` on the run path.
109    pub parallel: usize,
110    /// Golden cases scored vs the golden file's total; `selected_cases < total_cases` marks a
111    /// `--tags`/`--sample` subset (a smoke ablation). Mirrors `Report` — no silent caps.
112    pub selected_cases: usize,
113    pub total_cases: usize,
114    pub base_tier: String,
115    pub sweep_tiers: Vec<String>,
116    /// Every step at `base_tier` — the reference the per-step deltas are measured against.
117    pub baseline: ConfigReport,
118    pub points: Vec<AblationPoint>,
119    pub recommendations: Vec<StepRecommendation>,
120}
121
122/// Enumerate every named step across all components, in a stable order.
123fn enumerate_steps(ir: &WarbleIr) -> Vec<StepRef> {
124    let mut steps = Vec::new();
125    for (component_idx, node) in ir.components.iter().enumerate() {
126        for (call_idx, call) in node.llm_calls.iter().enumerate() {
127            steps.push(StepRef {
128                verb: node.verb.clone(),
129                step_name: call.name.clone(),
130                component_idx,
131                call_idx,
132            });
133        }
134    }
135    steps
136}
137
138/// Clone `ir` and set every step to `base_tier`, except `focus` (when given) which is set to
139/// `focus_tier`. This is the per-step binding: one step moves, the rest are pinned.
140fn rebind_tiers(
141    ir: &WarbleIr,
142    base_tier: &str,
143    focus: Option<&StepRef>,
144    focus_tier: &str,
145) -> WarbleIr {
146    let mut ir = ir.clone();
147    for (ci, node) in ir.components.iter_mut().enumerate() {
148        for (li, call) in node.llm_calls.iter_mut().enumerate() {
149            let is_focus = focus
150                .map(|f| f.component_idx == ci && f.call_idx == li)
151                .unwrap_or(false);
152            call.tier = if is_focus {
153                focus_tier.to_string()
154            } else {
155                base_tier.to_string()
156            };
157        }
158    }
159    ir
160}
161
162/// Dispatch `ir` under `models` into a fresh temp dir, install it into `project`, run every golden
163/// case (frontmatter models — no `--model` override), and aggregate under `label`.
164///
165/// The trace cache is keyed on this point's **agent SHA** (the emitted dir), so two dispatches that
166/// emit byte-identical agent files share cached results, while a genuinely different per-step binding
167/// emits different files and correctly misses. `context_sha` is constant across points (one bound
168/// MDL). If the emitted dir can't be hashed, this one point runs uncached.
169#[allow(clippy::too_many_arguments)]
170fn dispatch_and_run(
171    ir: &WarbleIr,
172    models: &ModelConfig,
173    target: &str,
174    project: &std::path::Path,
175    cases: &[GoldenCase],
176    label: &str,
177    parallel: usize,
178    store: &TraceStore,
179    context_sha: &str,
180    context_version: Option<&str>,
181    backend: Backend,
182    adapter: &dyn BackendAdapter,
183) -> Result<ConfigReport, String> {
184    let tmp = tempfile::tempdir().map_err(|e| format!("tempdir: {e}"))?;
185    emit_claude_code_with_models(ir, tmp.path(), target, DEFAULT_RENDER_FLAVOR, models)
186        .map_err(|e| format!("dispatch {label}: {e}"))?;
187
188    let agent = agent_name(tmp.path())?;
189    let path_env = run_path(project);
190
191    // Per-point agent SHA; fall back to an uncached run for this point if it can't be computed.
192    let local_disabled = TraceStore::disabled();
193    let (store_ref, agent_sha): (&TraceStore, String) = if !store.is_enabled() {
194        (store, String::new())
195    } else {
196        match compute_dir_sha(tmp.path()) {
197            Ok(sha) => (store, sha),
198            Err(e) => {
199                eprintln!("  cache: point '{label}' uncached — cannot hash agent dir: {e}");
200                (&local_disabled, String::new())
201            }
202        }
203    };
204
205    let _installed = install_agents(tmp.path(), project)?;
206    let ctx = CaseCtx {
207        model: FRONTMATTER_MODEL,
208        agent_sha: &agent_sha,
209        context_sha,
210        context_version,
211        store: store_ref,
212        record_answers: false,
213        backend,
214        adapter,
215        // Ablation has no `--max-turns` flag of its own — leave the back-end's own default turn
216        // budget in place, same rationale as `verify-context --reverify`'s `RunConfig`.
217        max_turns: None,
218        // Ablation never carries a differentiated `--strong`/`--cheap`/`--orchestrator` binding of
219        // its own — it re-dispatches the IR one step at a time with the frontmatter sentinel above,
220        // which is exactly the escape hatch `validate_tier_binding_backend`'s error message points
221        // at for backends with no tier knob. `models_config_path` on `AblationConfig` is a distinct,
222        // untouched mechanism (per-point IR re-dispatch), not this per-invoke override.
223        tier_binding: None,
224    };
225    let rows = run_cases(project, &agent, &path_env, cases, 1, parallel, &ctx);
226    Ok(aggregate(label, rows))
227}
228
229/// Run the per-step ablation: baseline (all steps at `base_tier`) plus, for each named step, one
230/// re-dispatch per swept tier. Streams progress to stderr and returns the aggregated report.
231pub fn run_ablation(cfg: &AblationConfig) -> Result<AblationReport, String> {
232    // The ablation loop re-dispatches IR via `claude-code-cli`'s own Rust emitter directly (see
233    // `dispatch_and_run`), so it only works for that one backend today — fail loudly here rather
234    // than silently ignoring a `--backend` that names a real dispatcher with no ablation support.
235    if cfg.backend != Backend::ClaudeCodeCli {
236        return Err(format!(
237            "eval ablate only supports backend '{}' today; got '{}'",
238            Backend::ClaudeCodeCli,
239            cfg.backend
240        ));
241    }
242    let adapter = resolve_adapter(cfg.backend)?;
243
244    let golden_text = std::fs::read_to_string(&cfg.golden_path)
245        .map_err(|e| format!("read {}: {e}", cfg.golden_path.display()))?;
246    let golden: Golden =
247        serde_yaml::from_str(&golden_text).map_err(|e| format!("parse golden: {e}"))?;
248
249    // Stratified selection (`--tags` / `--sample`): pick the smoke/full subset before any dispatch.
250    let total_cases = golden.cases.len();
251    let cases = select_and_subset(&golden, &cfg.filter)?;
252    let selected_cases = cases.len();
253
254    // Trace cache: context_sha is constant across points (one bound MDL); each dispatch keys on its
255    // own agent SHA. Disabled visibly on --no-cache or if the MDL SHA can't be computed.
256    let (store, context_sha) = build_store(cfg.no_cache, cfg.cache_dir.as_deref(), &cfg.project);
257    let context_version = golden.context_version.as_deref();
258
259    let ir_text = std::fs::read_to_string(&cfg.ir_path)
260        .map_err(|e| format!("read {}: {e}", cfg.ir_path.display()))?;
261    let ir: WarbleIr = serde_json::from_str(&ir_text).map_err(|e| format!("parse IR: {e}"))?;
262
263    let models = match &cfg.models_config_path {
264        Some(path) => {
265            let text = std::fs::read_to_string(path)
266                .map_err(|e| format!("read {}: {e}", path.display()))?;
267            ModelConfig::from_yaml(&text).map_err(|e| e.to_string())?
268        }
269        None => ModelConfig::default(),
270    };
271    // Front-load the tier→model checks so a bad config fails before any dispatch/claude spend.
272    models.require(&cfg.base_tier).map_err(|e| e.to_string())?;
273    for tier in &cfg.sweep_tiers {
274        models.require(tier).map_err(|e| e.to_string())?;
275    }
276
277    let steps = enumerate_steps(&ir);
278    if steps.is_empty() {
279        return Err("IR has no llm_calls to ablate".to_string());
280    }
281
282    // Log the plan (no silent caps): what we sweep vs the full grid we deliberately skip.
283    let per_step_tiers: Vec<&String> = cfg
284        .sweep_tiers
285        .iter()
286        .filter(|t| *t != &cfg.base_tier)
287        .collect();
288    let planned = 1 + steps.len() * per_step_tiers.len();
289    let full_grid = (cfg.sweep_tiers.len().max(1)).pow(steps.len() as u32);
290    let parallel = cfg.parallel.max(1);
291    let par = if parallel > 1 {
292        format!(", parallel={parallel}")
293    } else {
294        String::new()
295    };
296    eprintln!(
297        "### per-step ablation: {} step(s), base_tier={}, sweep={:?}{par}",
298        steps.len(),
299        cfg.base_tier,
300        cfg.sweep_tiers
301    );
302    eprintln!(
303        "    steps: {}",
304        steps
305            .iter()
306            .map(StepRef::label)
307            .collect::<Vec<_>>()
308            .join(", ")
309    );
310    eprintln!(
311        "    dispatches: {planned} (1 baseline + {} steps × {} swept tier(s)); \
312skipping the full {full_grid}-combo grid (one step moves at a time)",
313        steps.len(),
314        per_step_tiers.len()
315    );
316
317    // Baseline: every step at base_tier.
318    eprintln!("\n## baseline: all steps → {}", cfg.base_tier);
319    let baseline_ir = rebind_tiers(&ir, &cfg.base_tier, None, &cfg.base_tier);
320    let baseline = dispatch_and_run(
321        &baseline_ir,
322        &models,
323        &cfg.target,
324        &cfg.project,
325        &cases,
326        &format!("baseline:all→{}", cfg.base_tier),
327        cfg.parallel.max(1),
328        &store,
329        &context_sha,
330        context_version,
331        cfg.backend,
332        adapter.as_ref(),
333    )?;
334
335    // The baseline's own cost anchors every per-step delta below. A backend can genuinely omit
336    // `cost_total_usd` (see `ClaudeCodeCliAdapter::invoke`'s `.and_then(|v| v.as_f64())`, which
337    // yields `None` whenever the CLI's JSON envelope omits or renames the cost field) — silently
338    // treating that as $0 would price a real run at zero and could steer `recommend()` into
339    // picking a tier on a fabricated cost. Fail loudly instead.
340    let baseline_cost = baseline.cost_total_usd.ok_or_else(|| {
341        format!(
342            "baseline (all steps at '{}') reported no cost_total_usd from backend '{}' — \
343refusing to default it to $0; ablation cost deltas require a real cost from every dispatch",
344            cfg.base_tier, cfg.backend
345        )
346    })?;
347
348    // Per-step sweep: move one step to each swept tier, hold the rest at base_tier.
349    let mut points = Vec::new();
350    for step in &steps {
351        for tier in &per_step_tiers {
352            eprintln!(
353                "\n## {} → {} (others → {})",
354                step.label(),
355                tier,
356                cfg.base_tier
357            );
358            let point_ir = rebind_tiers(&ir, &cfg.base_tier, Some(step), tier);
359            let report = dispatch_and_run(
360                &point_ir,
361                &models,
362                &cfg.target,
363                &cfg.project,
364                &cases,
365                &format!("{}→{}", step.label(), tier),
366                cfg.parallel.max(1),
367                &store,
368                &context_sha,
369                context_version,
370                cfg.backend,
371                adapter.as_ref(),
372            )?;
373            // Same "absent stays absent, or fail loud" rule as the baseline check above — a
374            // missing cost here must not silently become $0 for this point.
375            let report_cost = report.cost_total_usd.ok_or_else(|| {
376                format!(
377                    "point '{}→{}' reported no cost_total_usd from backend '{}' — \
378refusing to default it to $0",
379                    step.label(),
380                    tier,
381                    cfg.backend
382                )
383            })?;
384            points.push(AblationPoint {
385                step: step.label(),
386                tier: (*tier).clone(),
387                accuracy: report.accuracy,
388                cost_total_usd: report_cost,
389                latency_ms_avg: report.latency_ms_avg,
390                delta_accuracy: report.accuracy - baseline.accuracy,
391                delta_cost: report_cost - baseline_cost,
392            });
393        }
394    }
395
396    let recommendations = recommend(
397        &steps,
398        &cfg.base_tier,
399        &baseline,
400        &points,
401        cfg.accuracy_drop_tolerance,
402    );
403
404    Ok(AblationReport {
405        dataset: golden.dataset,
406        context_version: golden.context_version,
407        parallel,
408        selected_cases,
409        total_cases,
410        base_tier: cfg.base_tier.clone(),
411        sweep_tiers: cfg.sweep_tiers.clone(),
412        baseline,
413        points,
414        recommendations,
415    })
416}
417
418/// For each step, pick the cheapest tier whose accuracy stays within `tolerance` of the baseline.
419/// Candidates are the swept points for that step plus the baseline (which represents that step at
420/// `base_tier`). "Cheapest" is lowest measured cost; when costs are all zero (subscription mode) we
421/// fall back to preferring the swept tier and say so in the note.
422fn recommend(
423    steps: &[StepRef],
424    base_tier: &str,
425    baseline: &ConfigReport,
426    points: &[AblationPoint],
427    tolerance: f64,
428) -> Vec<StepRecommendation> {
429    let floor = baseline.accuracy - tolerance;
430    let mut out = Vec::new();
431    for step in steps {
432        let label = step.label();
433        // Candidate: (tier, accuracy, cost). Start with the baseline (this step at base_tier).
434        // `baseline.cost_total_usd` is `Option` at the `ConfigReport` level (a backend can lack cost
435        // reporting in general), but `run_ablation` already fails loudly before ever calling
436        // `recommend` if the baseline (or any point) came back with no cost — see its
437        // `baseline_cost`/`report_cost` checks. By the time we're here the value is guaranteed
438        // present, so `.expect` documents that invariant instead of defaulting a possibly-missing
439        // value to $0.
440        let mut candidates: Vec<(String, f64, f64)> = vec![(
441            base_tier.to_string(),
442            baseline.accuracy,
443            baseline
444                .cost_total_usd
445                .expect("run_ablation validates baseline cost before calling recommend"),
446        )];
447        for p in points.iter().filter(|p| p.step == label) {
448            candidates.push((p.tier.clone(), p.accuracy, p.cost_total_usd));
449        }
450
451        let qualifying: Vec<&(String, f64, f64)> = candidates
452            .iter()
453            .filter(|(_, acc, _)| *acc >= floor)
454            .collect();
455        let costs_all_zero = qualifying.iter().all(|(_, _, cost)| *cost <= 0.0);
456
457        // Cheapest qualifying tier: lowest cost, ties → non-base (the ablation's candidate) wins.
458        let best = qualifying.iter().min_by(|a, b| {
459            a.2.partial_cmp(&b.2)
460                .unwrap_or(std::cmp::Ordering::Equal)
461                .then_with(|| {
462                    // prefer a non-base tier on ties so a cost-neutral downgrade is surfaced
463                    let a_base = a.0 == base_tier;
464                    let b_base = b.0 == base_tier;
465                    a_base.cmp(&b_base)
466                })
467        });
468
469        match best {
470            Some((tier, acc, _)) => {
471                let note = if costs_all_zero {
472                    format!(
473                        "costs are zero (subscription-computed); recommending the cheapest tier by \
474config order at/above the {floor:.2} accuracy floor"
475                    )
476                } else if tier == base_tier {
477                    format!("no swept tier held the {floor:.2} accuracy floor more cheaply")
478                } else {
479                    format!("holds the {floor:.2} accuracy floor at lower cost than {base_tier}")
480                };
481                out.push(StepRecommendation {
482                    step: label,
483                    recommended_tier: tier.clone(),
484                    accuracy: *acc,
485                    note,
486                });
487            }
488            None => out.push(StepRecommendation {
489                step: label,
490                recommended_tier: base_tier.to_string(),
491                accuracy: baseline.accuracy,
492                note: format!("no tier met the {floor:.2} accuracy floor; keeping {base_tier}"),
493            }),
494        }
495    }
496    out
497}
498
499/// Render the per-step ablation as a table: baseline first, then each (step, tier) with Δacc / Δcost,
500/// followed by the per-step recommendation.
501pub fn format_ablation(report: &AblationReport) -> String {
502    let mut out = String::new();
503    out.push_str("\n=== Warble eval — per-step tier ablation (closed loop) ===\n");
504    if report.selected_cases < report.total_cases {
505        out.push_str(&format!(
506            "  (subset: {}/{} golden cases — smoke run, not a full scoring)\n",
507            report.selected_cases, report.total_cases
508        ));
509    }
510    out.push_str(&format!(
511        "baseline: all steps → {}   acc={:.2}  cost={}  lat={}ms\n\n",
512        report.base_tier,
513        report.baseline.accuracy,
514        fmt_cost(report.baseline.cost_total_usd),
515        report.baseline.latency_ms_avg
516    ));
517    out.push_str(&format!(
518        "{:<28} {:<8} {:<7} {:<9} {:<9} {:<9}\n",
519        "step", "tier", "acc", "Δacc", "cost($)", "Δcost($)"
520    ));
521    for p in &report.points {
522        out.push_str(&format!(
523            "{:<28} {:<8} {:<7} {:<+9.2} {:<9} {:<+9.4}\n",
524            p.step,
525            p.tier,
526            format!("{:.2}", p.accuracy),
527            p.delta_accuracy,
528            fmt_cost(Some(p.cost_total_usd)),
529            p.delta_cost,
530        ));
531    }
532    out.push_str("\n--- per-step recommendation (cheapest tier at/above accuracy floor) ---\n");
533    for r in &report.recommendations {
534        out.push_str(&format!(
535            "{:<28} → {:<8} (acc {:.2})  {}\n",
536            r.step, r.recommended_tier, r.accuracy, r.note
537        ));
538    }
539    out
540}
541
542/// Absent is not zero: a backend that reported no cost renders as `n/a`, never a misleading
543/// `$0.0000` — mirrors the same principle enforced on `AdapterResult`'s fields in `backend.rs`.
544fn fmt_cost(cost: Option<f64>) -> String {
545    match cost {
546        Some(c) => format!("{c:.4}"),
547        None => "n/a".to_string(),
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::{CaseResult, ConfigReport};
555    use std::collections::BTreeMap;
556
557    /// A two-step, single-component IR (steps `plan`@strong, `draft`@cheap) for enumerate/rebind.
558    fn two_step_ir() -> WarbleIr {
559        let json = r#"{
560          "warble_ir_version": "0.7",
561          "profile": "t",
562          "context_binding": {"project": "p", "binding_mode": "runtime_selected"},
563          "config": {},
564          "components": [{
565            "id": "build_thing", "verb": "build_thing", "type": "analytical",
566            "realization_kind": "skill",
567            "context_binding": {"project": "p", "binding_mode": "runtime_selected"},
568            "precondition_result": {"status": "ok", "checks": []},
569            "prompt_fragment": "",
570            "llm_calls": [
571              {"name": "plan", "tier": "strong", "prompt": "plan it"},
572              {"name": "draft", "tier": "cheap", "prompt": "draft it"}
573            ],
574            "guardrails": [], "trigger": {"kind": "one_shot"},
575            "eval_ref": "eval/",
576            "effect": {"render_blocks": [], "outcome": {"kind": "none"}}
577          }]
578        }"#;
579        serde_json::from_str(json).expect("fixture IR parses")
580    }
581
582    fn config(model: &str, accuracy: f64, cost: f64) -> ConfigReport {
583        ConfigReport {
584            model: model.to_string(),
585            n: 10,
586            accuracy,
587            cost_total_usd: Some(cost),
588            latency_ms_avg: 1000,
589            turns_avg: Some(0),
590            cache_hits: 0,
591            cache_misses: 0,
592            flaky_cases: 0,
593            output_unstable_cases: 0,
594            by_tag: BTreeMap::new(),
595            cases: vec![CaseResult {
596                id: "c".into(),
597                tags: vec![],
598                samples: 1,
599                passes: 1,
600                pass_rate: 1.0,
601                pass: true,
602                flaky: false,
603                reason: "match".into(),
604                cost: Some(cost),
605                latency_ms: 1000,
606                turns: Some(0),
607                cache_hits: 0,
608                cache_misses: 1,
609                samples_detail: Vec::new(),
610                answer_dist: None,
611            }],
612        }
613    }
614
615    #[test]
616    fn enumerate_lists_every_named_step_in_order() {
617        let ir = two_step_ir();
618        let steps = enumerate_steps(&ir);
619        let labels: Vec<String> = steps.iter().map(StepRef::label).collect();
620        assert_eq!(labels, vec!["build_thing.plan", "build_thing.draft"]);
621    }
622
623    #[test]
624    fn rebind_pins_others_to_base_and_moves_only_the_focus_step() {
625        let ir = two_step_ir();
626        let steps = enumerate_steps(&ir);
627        // Move `draft` (index 1) to strong, everything else to base=cheap.
628        let rebound = rebind_tiers(&ir, "cheap", Some(&steps[1]), "strong");
629        let calls = &rebound.components[0].llm_calls;
630        assert_eq!(calls[0].tier, "cheap", "non-focus step pinned to base");
631        assert_eq!(calls[1].tier, "strong", "focus step moved to swept tier");
632    }
633
634    #[test]
635    fn rebind_baseline_sets_all_steps_to_base() {
636        let ir = two_step_ir();
637        let baseline = rebind_tiers(&ir, "strong", None, "strong");
638        for call in &baseline.components[0].llm_calls {
639            assert_eq!(call.tier, "strong");
640        }
641    }
642
643    #[test]
644    fn recommend_downgrades_a_step_that_holds_accuracy_more_cheaply() {
645        // baseline: all strong, acc 1.0, cost 0.30. Moving `answer` to cheap keeps acc 1.0 at 0.10.
646        let steps = enumerate_steps(&two_step_ir());
647        let baseline = config("all-strong", 1.0, 0.30);
648        let points = vec![
649            AblationPoint {
650                step: "build_thing.plan".into(),
651                tier: "cheap".into(),
652                accuracy: 1.0,
653                cost_total_usd: 0.10,
654                latency_ms_avg: 900,
655                delta_accuracy: 0.0,
656                delta_cost: -0.20,
657            },
658            AblationPoint {
659                step: "build_thing.draft".into(),
660                tier: "cheap".into(),
661                accuracy: 0.80, // regresses — must NOT be recommended
662                cost_total_usd: 0.10,
663                latency_ms_avg: 900,
664                delta_accuracy: -0.20,
665                delta_cost: -0.20,
666            },
667        ];
668        let recs = recommend(&steps, "strong", &baseline, &points, 0.0);
669        let plan = recs.iter().find(|r| r.step == "build_thing.plan").unwrap();
670        let draft = recs.iter().find(|r| r.step == "build_thing.draft").unwrap();
671        assert_eq!(
672            plan.recommended_tier, "cheap",
673            "cost-neutral downgrade taken"
674        );
675        assert_eq!(
676            draft.recommended_tier, "strong",
677            "accuracy regression kept at base"
678        );
679    }
680
681    #[test]
682    fn recommend_respects_accuracy_drop_tolerance() {
683        let steps = enumerate_steps(&two_step_ir());
684        let baseline = config("all-strong", 1.0, 0.30);
685        let points = vec![AblationPoint {
686            step: "build_thing.plan".into(),
687            tier: "cheap".into(),
688            accuracy: 0.95,
689            cost_total_usd: 0.10,
690            latency_ms_avg: 900,
691            delta_accuracy: -0.05,
692            delta_cost: -0.20,
693        }];
694        // With a 0.10 tolerance, a 0.05 drop qualifies → downgrade.
695        let lenient = recommend(&steps, "strong", &baseline, &points, 0.10);
696        assert_eq!(
697            lenient
698                .iter()
699                .find(|r| r.step == "build_thing.plan")
700                .unwrap()
701                .recommended_tier,
702            "cheap"
703        );
704        // With zero tolerance, any drop disqualifies → keep base.
705        let strict = recommend(&steps, "strong", &baseline, &points, 0.0);
706        assert_eq!(
707            strict
708                .iter()
709                .find(|r| r.step == "build_thing.plan")
710                .unwrap()
711                .recommended_tier,
712            "strong"
713        );
714    }
715
716    #[test]
717    fn format_ablation_shows_baseline_points_and_recommendations() {
718        let report = AblationReport {
719            dataset: Some("jaffle".into()),
720            context_version: None,
721            parallel: 1,
722            selected_cases: 10,
723            total_cases: 10,
724            base_tier: "strong".into(),
725            sweep_tiers: vec!["cheap".into(), "strong".into()],
726            baseline: config("all-strong", 1.0, 0.30),
727            points: vec![AblationPoint {
728                step: "answer_query.answer".into(),
729                tier: "cheap".into(),
730                accuracy: 1.0,
731                cost_total_usd: 0.10,
732                latency_ms_avg: 900,
733                delta_accuracy: 0.0,
734                delta_cost: -0.20,
735            }],
736            recommendations: vec![StepRecommendation {
737                step: "answer_query.answer".into(),
738                recommended_tier: "cheap".into(),
739                accuracy: 1.0,
740                note: "cheaper".into(),
741            }],
742        };
743        let out = format_ablation(&report);
744        assert!(out.contains("per-step tier ablation"));
745        assert!(out.contains("answer_query.answer"));
746        assert!(out.contains("cheap"));
747        assert!(out.contains("recommendation"));
748    }
749}