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 };
216 let rows = run_cases(project, &agent, &path_env, cases, 1, parallel, &ctx);
217 Ok(aggregate(label, rows))
218}
219
220pub fn run_ablation(cfg: &AblationConfig) -> Result<AblationReport, String> {
223 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 let total_cases = golden.cases.len();
242 let cases = select_and_subset(&golden, &cfg.filter)?;
243 let selected_cases = cases.len();
244
245 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 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 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 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 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 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 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
409fn 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 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 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 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
490pub 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
533fn 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 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 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 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, 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 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 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}