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::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
28pub struct AblationConfig {
30 pub project: PathBuf,
32 pub ir_path: PathBuf,
34 pub golden_path: PathBuf,
36 pub target: String,
38 pub models_config_path: Option<PathBuf>,
40 pub sweep_tiers: Vec<String>,
42 pub base_tier: String,
44 pub accuracy_drop_tolerance: f64,
46 pub out: Option<PathBuf>,
48 pub parallel: usize,
50 pub filter: CaseFilter,
52 pub no_cache: bool,
54 pub cache_dir: Option<PathBuf>,
56}
57
58#[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#[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 pub delta_accuracy: f64,
83 pub delta_cost: f64,
85}
86
87#[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 pub parallel: usize,
103 pub selected_cases: usize,
106 pub total_cases: usize,
107 pub base_tier: String,
108 pub sweep_tiers: Vec<String>,
109 pub baseline: ConfigReport,
111 pub points: Vec<AblationPoint>,
112 pub recommendations: Vec<StepRecommendation>,
113}
114
115fn 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
131fn 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#[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 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
209pub 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 let total_cases = golden.cases.len();
219 let cases = select_and_subset(&golden, &cfg.filter)?;
220 let selected_cases = cases.len();
221
222 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 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 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 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 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
358fn 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 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 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 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
431pub 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 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 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 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, 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 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 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}