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    };
216    let rows = run_cases(project, &agent, &path_env, cases, 1, parallel, &ctx);
217    Ok(aggregate(label, rows))
218}
219
220/// Run the per-step ablation: baseline (all steps at `base_tier`) plus, for each named step, one
221/// re-dispatch per swept tier. Streams progress to stderr and returns the aggregated report.
222pub fn run_ablation(cfg: &AblationConfig) -> Result<AblationReport, String> {
223    // The ablation loop re-dispatches IR via `claude-code-cli`'s own Rust emitter directly (see
224    // `dispatch_and_run`), so it only works for that one backend today — fail loudly here rather
225    // than silently ignoring a `--backend` that names a real dispatcher with no ablation support.
226    if cfg.backend != Backend::ClaudeCodeCli {
227        return Err(format!(
228            "eval ablate only supports backend '{}' today; got '{}'",
229            Backend::ClaudeCodeCli,
230            cfg.backend
231        ));
232    }
233    let adapter = resolve_adapter(cfg.backend)?;
234
235    let golden_text = std::fs::read_to_string(&cfg.golden_path)
236        .map_err(|e| format!("read {}: {e}", cfg.golden_path.display()))?;
237    let golden: Golden =
238        serde_yaml::from_str(&golden_text).map_err(|e| format!("parse golden: {e}"))?;
239
240    // Stratified selection (`--tags` / `--sample`): pick the smoke/full subset before any dispatch.
241    let total_cases = golden.cases.len();
242    let cases = select_and_subset(&golden, &cfg.filter)?;
243    let selected_cases = cases.len();
244
245    // Trace cache: context_sha is constant across points (one bound MDL); each dispatch keys on its
246    // own agent SHA. Disabled visibly on --no-cache or if the MDL SHA can't be computed.
247    let (store, context_sha) = build_store(cfg.no_cache, cfg.cache_dir.as_deref(), &cfg.project);
248    let context_version = golden.context_version.as_deref();
249
250    let ir_text = std::fs::read_to_string(&cfg.ir_path)
251        .map_err(|e| format!("read {}: {e}", cfg.ir_path.display()))?;
252    let ir: WarbleIr = serde_json::from_str(&ir_text).map_err(|e| format!("parse IR: {e}"))?;
253
254    let models = match &cfg.models_config_path {
255        Some(path) => {
256            let text = std::fs::read_to_string(path)
257                .map_err(|e| format!("read {}: {e}", path.display()))?;
258            ModelConfig::from_yaml(&text).map_err(|e| e.to_string())?
259        }
260        None => ModelConfig::default(),
261    };
262    // Front-load the tier→model checks so a bad config fails before any dispatch/claude spend.
263    models.require(&cfg.base_tier).map_err(|e| e.to_string())?;
264    for tier in &cfg.sweep_tiers {
265        models.require(tier).map_err(|e| e.to_string())?;
266    }
267
268    let steps = enumerate_steps(&ir);
269    if steps.is_empty() {
270        return Err("IR has no llm_calls to ablate".to_string());
271    }
272
273    // Log the plan (no silent caps): what we sweep vs the full grid we deliberately skip.
274    let per_step_tiers: Vec<&String> = cfg
275        .sweep_tiers
276        .iter()
277        .filter(|t| *t != &cfg.base_tier)
278        .collect();
279    let planned = 1 + steps.len() * per_step_tiers.len();
280    let full_grid = (cfg.sweep_tiers.len().max(1)).pow(steps.len() as u32);
281    let parallel = cfg.parallel.max(1);
282    let par = if parallel > 1 {
283        format!(", parallel={parallel}")
284    } else {
285        String::new()
286    };
287    eprintln!(
288        "### per-step ablation: {} step(s), base_tier={}, sweep={:?}{par}",
289        steps.len(),
290        cfg.base_tier,
291        cfg.sweep_tiers
292    );
293    eprintln!(
294        "    steps: {}",
295        steps
296            .iter()
297            .map(StepRef::label)
298            .collect::<Vec<_>>()
299            .join(", ")
300    );
301    eprintln!(
302        "    dispatches: {planned} (1 baseline + {} steps × {} swept tier(s)); \
303skipping the full {full_grid}-combo grid (one step moves at a time)",
304        steps.len(),
305        per_step_tiers.len()
306    );
307
308    // Baseline: every step at base_tier.
309    eprintln!("\n## baseline: all steps → {}", cfg.base_tier);
310    let baseline_ir = rebind_tiers(&ir, &cfg.base_tier, None, &cfg.base_tier);
311    let baseline = dispatch_and_run(
312        &baseline_ir,
313        &models,
314        &cfg.target,
315        &cfg.project,
316        &cases,
317        &format!("baseline:all→{}", cfg.base_tier),
318        cfg.parallel.max(1),
319        &store,
320        &context_sha,
321        context_version,
322        cfg.backend,
323        adapter.as_ref(),
324    )?;
325
326    // The baseline's own cost anchors every per-step delta below. A backend can genuinely omit
327    // `cost_total_usd` (see `ClaudeCodeCliAdapter::invoke`'s `.and_then(|v| v.as_f64())`, which
328    // yields `None` whenever the CLI's JSON envelope omits or renames the cost field) — silently
329    // treating that as $0 would price a real run at zero and could steer `recommend()` into
330    // picking a tier on a fabricated cost. Fail loudly instead.
331    let baseline_cost = baseline.cost_total_usd.ok_or_else(|| {
332        format!(
333            "baseline (all steps at '{}') reported no cost_total_usd from backend '{}' — \
334refusing to default it to $0; ablation cost deltas require a real cost from every dispatch",
335            cfg.base_tier, cfg.backend
336        )
337    })?;
338
339    // Per-step sweep: move one step to each swept tier, hold the rest at base_tier.
340    let mut points = Vec::new();
341    for step in &steps {
342        for tier in &per_step_tiers {
343            eprintln!(
344                "\n## {} → {} (others → {})",
345                step.label(),
346                tier,
347                cfg.base_tier
348            );
349            let point_ir = rebind_tiers(&ir, &cfg.base_tier, Some(step), tier);
350            let report = dispatch_and_run(
351                &point_ir,
352                &models,
353                &cfg.target,
354                &cfg.project,
355                &cases,
356                &format!("{}→{}", step.label(), tier),
357                cfg.parallel.max(1),
358                &store,
359                &context_sha,
360                context_version,
361                cfg.backend,
362                adapter.as_ref(),
363            )?;
364            // Same "absent stays absent, or fail loud" rule as the baseline check above — a
365            // missing cost here must not silently become $0 for this point.
366            let report_cost = report.cost_total_usd.ok_or_else(|| {
367                format!(
368                    "point '{}→{}' reported no cost_total_usd from backend '{}' — \
369refusing to default it to $0",
370                    step.label(),
371                    tier,
372                    cfg.backend
373                )
374            })?;
375            points.push(AblationPoint {
376                step: step.label(),
377                tier: (*tier).clone(),
378                accuracy: report.accuracy,
379                cost_total_usd: report_cost,
380                latency_ms_avg: report.latency_ms_avg,
381                delta_accuracy: report.accuracy - baseline.accuracy,
382                delta_cost: report_cost - baseline_cost,
383            });
384        }
385    }
386
387    let recommendations = recommend(
388        &steps,
389        &cfg.base_tier,
390        &baseline,
391        &points,
392        cfg.accuracy_drop_tolerance,
393    );
394
395    Ok(AblationReport {
396        dataset: golden.dataset,
397        context_version: golden.context_version,
398        parallel,
399        selected_cases,
400        total_cases,
401        base_tier: cfg.base_tier.clone(),
402        sweep_tiers: cfg.sweep_tiers.clone(),
403        baseline,
404        points,
405        recommendations,
406    })
407}
408
409/// For each step, pick the cheapest tier whose accuracy stays within `tolerance` of the baseline.
410/// Candidates are the swept points for that step plus the baseline (which represents that step at
411/// `base_tier`). "Cheapest" is lowest measured cost; when costs are all zero (subscription mode) we
412/// fall back to preferring the swept tier and say so in the note.
413fn recommend(
414    steps: &[StepRef],
415    base_tier: &str,
416    baseline: &ConfigReport,
417    points: &[AblationPoint],
418    tolerance: f64,
419) -> Vec<StepRecommendation> {
420    let floor = baseline.accuracy - tolerance;
421    let mut out = Vec::new();
422    for step in steps {
423        let label = step.label();
424        // Candidate: (tier, accuracy, cost). Start with the baseline (this step at base_tier).
425        // `baseline.cost_total_usd` is `Option` at the `ConfigReport` level (a backend can lack cost
426        // reporting in general), but `run_ablation` already fails loudly before ever calling
427        // `recommend` if the baseline (or any point) came back with no cost — see its
428        // `baseline_cost`/`report_cost` checks. By the time we're here the value is guaranteed
429        // present, so `.expect` documents that invariant instead of defaulting a possibly-missing
430        // value to $0.
431        let mut candidates: Vec<(String, f64, f64)> = vec![(
432            base_tier.to_string(),
433            baseline.accuracy,
434            baseline
435                .cost_total_usd
436                .expect("run_ablation validates baseline cost before calling recommend"),
437        )];
438        for p in points.iter().filter(|p| p.step == label) {
439            candidates.push((p.tier.clone(), p.accuracy, p.cost_total_usd));
440        }
441
442        let qualifying: Vec<&(String, f64, f64)> = candidates
443            .iter()
444            .filter(|(_, acc, _)| *acc >= floor)
445            .collect();
446        let costs_all_zero = qualifying.iter().all(|(_, _, cost)| *cost <= 0.0);
447
448        // Cheapest qualifying tier: lowest cost, ties → non-base (the ablation's candidate) wins.
449        let best = qualifying.iter().min_by(|a, b| {
450            a.2.partial_cmp(&b.2)
451                .unwrap_or(std::cmp::Ordering::Equal)
452                .then_with(|| {
453                    // prefer a non-base tier on ties so a cost-neutral downgrade is surfaced
454                    let a_base = a.0 == base_tier;
455                    let b_base = b.0 == base_tier;
456                    a_base.cmp(&b_base)
457                })
458        });
459
460        match best {
461            Some((tier, acc, _)) => {
462                let note = if costs_all_zero {
463                    format!(
464                        "costs are zero (subscription-computed); recommending the cheapest tier by \
465config order at/above the {floor:.2} accuracy floor"
466                    )
467                } else if tier == base_tier {
468                    format!("no swept tier held the {floor:.2} accuracy floor more cheaply")
469                } else {
470                    format!("holds the {floor:.2} accuracy floor at lower cost than {base_tier}")
471                };
472                out.push(StepRecommendation {
473                    step: label,
474                    recommended_tier: tier.clone(),
475                    accuracy: *acc,
476                    note,
477                });
478            }
479            None => out.push(StepRecommendation {
480                step: label,
481                recommended_tier: base_tier.to_string(),
482                accuracy: baseline.accuracy,
483                note: format!("no tier met the {floor:.2} accuracy floor; keeping {base_tier}"),
484            }),
485        }
486    }
487    out
488}
489
490/// Render the per-step ablation as a table: baseline first, then each (step, tier) with Δacc / Δcost,
491/// followed by the per-step recommendation.
492pub fn format_ablation(report: &AblationReport) -> String {
493    let mut out = String::new();
494    out.push_str("\n=== Warble eval — per-step tier ablation (closed loop) ===\n");
495    if report.selected_cases < report.total_cases {
496        out.push_str(&format!(
497            "  (subset: {}/{} golden cases — smoke run, not a full scoring)\n",
498            report.selected_cases, report.total_cases
499        ));
500    }
501    out.push_str(&format!(
502        "baseline: all steps → {}   acc={:.2}  cost={}  lat={}ms\n\n",
503        report.base_tier,
504        report.baseline.accuracy,
505        fmt_cost(report.baseline.cost_total_usd),
506        report.baseline.latency_ms_avg
507    ));
508    out.push_str(&format!(
509        "{:<28} {:<8} {:<7} {:<9} {:<9} {:<9}\n",
510        "step", "tier", "acc", "Δacc", "cost($)", "Δcost($)"
511    ));
512    for p in &report.points {
513        out.push_str(&format!(
514            "{:<28} {:<8} {:<7} {:<+9.2} {:<9} {:<+9.4}\n",
515            p.step,
516            p.tier,
517            format!("{:.2}", p.accuracy),
518            p.delta_accuracy,
519            fmt_cost(Some(p.cost_total_usd)),
520            p.delta_cost,
521        ));
522    }
523    out.push_str("\n--- per-step recommendation (cheapest tier at/above accuracy floor) ---\n");
524    for r in &report.recommendations {
525        out.push_str(&format!(
526            "{:<28} → {:<8} (acc {:.2})  {}\n",
527            r.step, r.recommended_tier, r.accuracy, r.note
528        ));
529    }
530    out
531}
532
533/// Absent is not zero: a backend that reported no cost renders as `n/a`, never a misleading
534/// `$0.0000` — mirrors the same principle enforced on `AdapterResult`'s fields in `backend.rs`.
535fn fmt_cost(cost: Option<f64>) -> String {
536    match cost {
537        Some(c) => format!("{c:.4}"),
538        None => "n/a".to_string(),
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::{CaseResult, ConfigReport};
546    use std::collections::BTreeMap;
547
548    /// A two-step, single-component IR (steps `plan`@strong, `draft`@cheap) for enumerate/rebind.
549    fn two_step_ir() -> WarbleIr {
550        let json = r#"{
551          "warble_ir_version": "0.5",
552          "profile": "t",
553          "context_binding": {"project": "p", "binding_mode": "runtime_selected"},
554          "config": {},
555          "components": [{
556            "id": "build_thing", "verb": "build_thing", "type": "analytical",
557            "realization_kind": "skill",
558            "context_binding": {"project": "p", "binding_mode": "runtime_selected"},
559            "precondition_result": {"status": "ok", "checks": []},
560            "prompt_fragment": "",
561            "llm_calls": [
562              {"name": "plan", "tier": "strong", "prompt": "plan it"},
563              {"name": "draft", "tier": "cheap", "prompt": "draft it"}
564            ],
565            "guardrails": [], "trigger": {"kind": "one_shot"},
566            "eval_ref": "eval/",
567            "effect": {"render_blocks": [], "outcome": {"kind": "none"}}
568          }]
569        }"#;
570        serde_json::from_str(json).expect("fixture IR parses")
571    }
572
573    fn config(model: &str, accuracy: f64, cost: f64) -> ConfigReport {
574        ConfigReport {
575            model: model.to_string(),
576            n: 10,
577            accuracy,
578            cost_total_usd: Some(cost),
579            latency_ms_avg: 1000,
580            turns_avg: Some(0),
581            cache_hits: 0,
582            cache_misses: 0,
583            flaky_cases: 0,
584            by_tag: BTreeMap::new(),
585            cases: vec![CaseResult {
586                id: "c".into(),
587                tags: vec![],
588                samples: 1,
589                passes: 1,
590                pass_rate: 1.0,
591                pass: true,
592                flaky: false,
593                reason: "match".into(),
594                cost: Some(cost),
595                latency_ms: 1000,
596                turns: Some(0),
597                cache_hits: 0,
598                cache_misses: 1,
599                samples_detail: Vec::new(),
600                answer_dist: None,
601            }],
602        }
603    }
604
605    #[test]
606    fn enumerate_lists_every_named_step_in_order() {
607        let ir = two_step_ir();
608        let steps = enumerate_steps(&ir);
609        let labels: Vec<String> = steps.iter().map(StepRef::label).collect();
610        assert_eq!(labels, vec!["build_thing.plan", "build_thing.draft"]);
611    }
612
613    #[test]
614    fn rebind_pins_others_to_base_and_moves_only_the_focus_step() {
615        let ir = two_step_ir();
616        let steps = enumerate_steps(&ir);
617        // Move `draft` (index 1) to strong, everything else to base=cheap.
618        let rebound = rebind_tiers(&ir, "cheap", Some(&steps[1]), "strong");
619        let calls = &rebound.components[0].llm_calls;
620        assert_eq!(calls[0].tier, "cheap", "non-focus step pinned to base");
621        assert_eq!(calls[1].tier, "strong", "focus step moved to swept tier");
622    }
623
624    #[test]
625    fn rebind_baseline_sets_all_steps_to_base() {
626        let ir = two_step_ir();
627        let baseline = rebind_tiers(&ir, "strong", None, "strong");
628        for call in &baseline.components[0].llm_calls {
629            assert_eq!(call.tier, "strong");
630        }
631    }
632
633    #[test]
634    fn recommend_downgrades_a_step_that_holds_accuracy_more_cheaply() {
635        // baseline: all strong, acc 1.0, cost 0.30. Moving `answer` to cheap keeps acc 1.0 at 0.10.
636        let steps = enumerate_steps(&two_step_ir());
637        let baseline = config("all-strong", 1.0, 0.30);
638        let points = vec![
639            AblationPoint {
640                step: "build_thing.plan".into(),
641                tier: "cheap".into(),
642                accuracy: 1.0,
643                cost_total_usd: 0.10,
644                latency_ms_avg: 900,
645                delta_accuracy: 0.0,
646                delta_cost: -0.20,
647            },
648            AblationPoint {
649                step: "build_thing.draft".into(),
650                tier: "cheap".into(),
651                accuracy: 0.80, // regresses — must NOT be recommended
652                cost_total_usd: 0.10,
653                latency_ms_avg: 900,
654                delta_accuracy: -0.20,
655                delta_cost: -0.20,
656            },
657        ];
658        let recs = recommend(&steps, "strong", &baseline, &points, 0.0);
659        let plan = recs.iter().find(|r| r.step == "build_thing.plan").unwrap();
660        let draft = recs.iter().find(|r| r.step == "build_thing.draft").unwrap();
661        assert_eq!(
662            plan.recommended_tier, "cheap",
663            "cost-neutral downgrade taken"
664        );
665        assert_eq!(
666            draft.recommended_tier, "strong",
667            "accuracy regression kept at base"
668        );
669    }
670
671    #[test]
672    fn recommend_respects_accuracy_drop_tolerance() {
673        let steps = enumerate_steps(&two_step_ir());
674        let baseline = config("all-strong", 1.0, 0.30);
675        let points = vec![AblationPoint {
676            step: "build_thing.plan".into(),
677            tier: "cheap".into(),
678            accuracy: 0.95,
679            cost_total_usd: 0.10,
680            latency_ms_avg: 900,
681            delta_accuracy: -0.05,
682            delta_cost: -0.20,
683        }];
684        // With a 0.10 tolerance, a 0.05 drop qualifies → downgrade.
685        let lenient = recommend(&steps, "strong", &baseline, &points, 0.10);
686        assert_eq!(
687            lenient
688                .iter()
689                .find(|r| r.step == "build_thing.plan")
690                .unwrap()
691                .recommended_tier,
692            "cheap"
693        );
694        // With zero tolerance, any drop disqualifies → keep base.
695        let strict = recommend(&steps, "strong", &baseline, &points, 0.0);
696        assert_eq!(
697            strict
698                .iter()
699                .find(|r| r.step == "build_thing.plan")
700                .unwrap()
701                .recommended_tier,
702            "strong"
703        );
704    }
705
706    #[test]
707    fn format_ablation_shows_baseline_points_and_recommendations() {
708        let report = AblationReport {
709            dataset: Some("jaffle".into()),
710            context_version: None,
711            parallel: 1,
712            selected_cases: 10,
713            total_cases: 10,
714            base_tier: "strong".into(),
715            sweep_tiers: vec!["cheap".into(), "strong".into()],
716            baseline: config("all-strong", 1.0, 0.30),
717            points: vec![AblationPoint {
718                step: "answer_query.answer".into(),
719                tier: "cheap".into(),
720                accuracy: 1.0,
721                cost_total_usd: 0.10,
722                latency_ms_avg: 900,
723                delta_accuracy: 0.0,
724                delta_cost: -0.20,
725            }],
726            recommendations: vec![StepRecommendation {
727                step: "answer_query.answer".into(),
728                recommended_tier: "cheap".into(),
729                accuracy: 1.0,
730                note: "cheaper".into(),
731            }],
732        };
733        let out = format_ablation(&report);
734        assert!(out.contains("per-step tier ablation"));
735        assert!(out.contains("answer_query.answer"));
736        assert!(out.contains("cheap"));
737        assert!(out.contains("recommendation"));
738    }
739}