1use 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
29pub struct AblationConfig {
31 pub project: PathBuf,
33 pub ir_path: PathBuf,
35 pub golden_path: PathBuf,
37 pub target: String,
39 pub models_config_path: Option<PathBuf>,
41 pub sweep_tiers: Vec<String>,
43 pub base_tier: String,
45 pub accuracy_drop_tolerance: f64,
47 pub out: Option<PathBuf>,
49 pub parallel: usize,
51 pub filter: CaseFilter,
53 pub no_cache: bool,
55 pub cache_dir: Option<PathBuf>,
57 pub backend: Backend,
63}
64
65#[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#[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 pub delta_accuracy: f64,
90 pub delta_cost: f64,
92}
93
94#[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 pub parallel: usize,
110 pub selected_cases: usize,
113 pub total_cases: usize,
114 pub base_tier: String,
115 pub sweep_tiers: Vec<String>,
116 pub baseline: ConfigReport,
118 pub points: Vec<AblationPoint>,
119 pub recommendations: Vec<StepRecommendation>,
120}
121
122fn 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
138fn 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#[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 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 max_turns: None,
218 tier_binding: None,
224 };
225 let rows = run_cases(project, &agent, &path_env, cases, 1, parallel, &ctx);
226 Ok(aggregate(label, rows))
227}
228
229pub fn run_ablation(cfg: &AblationConfig) -> Result<AblationReport, String> {
232 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 let total_cases = golden.cases.len();
251 let cases = select_and_subset(&golden, &cfg.filter)?;
252 let selected_cases = cases.len();
253
254 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 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 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 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 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 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 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
418fn 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 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 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 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
499pub 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
542fn 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 fn two_step_ir() -> WarbleIr {
559 let json = r#"{
560 "warble_ir_version": "0.8",
561 "profile": "t",
562 "context_binding": {"project": "p", "binding_mode": "runtime_selected"},
563 "config": {},
564 "components": [{
565 "id": "build_thing", "entrypoint": true, "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 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 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, 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 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 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}