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