1use std::collections::HashMap;
44
45use async_trait::async_trait;
46use car_eventlog::harness_adapt::{diagnose_from_jsonl, HarnessIntervention, InterventionLayer};
47use serde::{Deserialize, Serialize};
48
49use super::contract::OutcomeContract;
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct AbTask {
57 pub id: String,
59 pub intent: String,
61 pub contract: OutcomeContract,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub repo: Option<String>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum ArmEngine {
74 Native,
76 External(String),
78}
79
80impl ArmEngine {
81 pub fn label(&self) -> String {
83 match self {
84 Self::Native => "native".to_string(),
85 Self::External(id) => id.clone(),
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ArmSpec {
111 pub engine: ArmEngine,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub model: Option<String>,
117}
118
119impl ArmSpec {
120 pub fn native(model: Option<String>) -> Self {
122 Self {
123 engine: ArmEngine::Native,
124 model,
125 }
126 }
127
128 pub fn external(id: impl Into<String>, model: Option<String>) -> Self {
130 Self {
131 engine: ArmEngine::External(id.into()),
132 model,
133 }
134 }
135
136 pub fn label(&self) -> String {
140 match &self.model {
141 Some(m) if !m.trim().is_empty() => format!("{}@{}", self.engine.label(), m),
142 _ => self.engine.label(),
143 }
144 }
145
146 pub fn slug(&self) -> String {
154 self.label()
155 .chars()
156 .map(|c| {
157 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '@') {
158 c
159 } else {
160 '_'
161 }
162 })
163 .collect()
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct ArmOutcome {
172 pub passed: bool,
174 pub iterations: u32,
176 #[serde(default)]
179 pub cost_usd: f64,
180 pub wall_ms: u64,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub error: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub transcript_path: Option<String>,
190 #[serde(default)]
194 pub infra_failed: bool,
195}
196
197impl ArmOutcome {
198 pub fn infra(reason: impl Into<String>, wall_ms: u64) -> Self {
201 Self {
202 passed: false,
203 iterations: 0,
204 cost_usd: 0.0,
205 wall_ms,
206 error: Some(reason.into()),
207 transcript_path: None,
208 infra_failed: true,
209 }
210 }
211
212 pub fn timeout_loss(reason: impl Into<String>, wall_ms: u64) -> Self {
226 Self {
227 passed: false,
228 iterations: 0,
229 cost_usd: 0.0,
230 wall_ms,
231 error: Some(reason.into()),
232 transcript_path: None,
233 infra_failed: false,
235 }
236 }
237
238 pub fn scorable(&self) -> bool {
240 !self.infra_failed
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
253pub struct AbCell {
254 pub task_id: String,
255 #[serde(alias = "native")]
256 pub treatment: ArmOutcome,
257 #[serde(alias = "external")]
258 pub control: ArmOutcome,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub treatment_spec: Option<ArmSpec>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub control_spec: Option<ArmSpec>,
273}
274
275impl AbCell {
276 pub fn paired_scorable(&self) -> bool {
279 self.treatment.scorable() && self.control.scorable()
280 }
281
282 pub fn matches(&self, treatment: &ArmSpec, control: &ArmSpec) -> bool {
286 self.treatment_spec.as_ref() == Some(treatment)
287 && self.control_spec.as_ref() == Some(control)
288 }
289}
290
291const CHI2_CRIT_05: f64 = 3.841;
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct PairedStats {
297 pub paired_tasks: usize,
299 pub treatment_passes: usize,
300 pub control_passes: usize,
301 pub treatment_pass_rate: f64,
302 pub control_pass_rate: f64,
303 pub pass_rate_delta: f64,
305 pub both_pass: usize,
307 pub treatment_only: usize,
309 pub control_only: usize,
311 pub both_fail: usize,
312 pub mcnemar_chi2: f64,
315 pub mcnemar_significant_05: bool,
317 pub treatment_infra_failures: usize,
319 pub control_infra_failures: usize,
320 pub treatment_mean_cost_usd: f64,
322 pub control_mean_cost_usd: f64,
323 pub treatment_cost_per_pass: Option<f64>,
325 pub control_cost_per_pass: Option<f64>,
326}
327
328impl PairedStats {
329 fn from_cells(cells: &[AbCell]) -> Self {
330 let treatment_infra_failures = cells.iter().filter(|c| c.treatment.infra_failed).count();
331 let control_infra_failures = cells.iter().filter(|c| c.control.infra_failed).count();
332
333 let paired: Vec<&AbCell> = cells.iter().filter(|c| c.paired_scorable()).collect();
335 let paired_tasks = paired.len();
336
337 let treatment_passes = paired.iter().filter(|c| c.treatment.passed).count();
338 let control_passes = paired.iter().filter(|c| c.control.passed).count();
339
340 let rate = |n: usize| {
341 if paired_tasks == 0 {
342 0.0
343 } else {
344 n as f64 / paired_tasks as f64
345 }
346 };
347 let treatment_pass_rate = rate(treatment_passes);
348 let control_pass_rate = rate(control_passes);
349
350 let both_pass = paired
351 .iter()
352 .filter(|c| c.treatment.passed && c.control.passed)
353 .count();
354 let treatment_only = paired
355 .iter()
356 .filter(|c| c.treatment.passed && !c.control.passed)
357 .count();
358 let control_only = paired
359 .iter()
360 .filter(|c| !c.treatment.passed && c.control.passed)
361 .count();
362 let both_fail = paired
363 .iter()
364 .filter(|c| !c.treatment.passed && !c.control.passed)
365 .count();
366
367 let b = treatment_only as f64;
369 let c = control_only as f64;
370 let discordant = b + c;
371 let mcnemar_chi2 = if discordant > 0.0 {
372 let num = (b - c).abs() - 1.0;
373 let num = num.max(0.0);
375 num * num / discordant
376 } else {
377 0.0
378 };
379 let mcnemar_significant_05 = discordant > 0.0 && mcnemar_chi2 > CHI2_CRIT_05;
380
381 let mean = |sel: &dyn Fn(&AbCell) -> Option<f64>| {
383 let xs: Vec<f64> = cells.iter().filter_map(sel).collect();
384 if xs.is_empty() {
385 0.0
386 } else {
387 xs.iter().sum::<f64>() / xs.len() as f64
388 }
389 };
390 let treatment_mean_cost_usd =
391 mean(&|c| c.treatment.scorable().then_some(c.treatment.cost_usd));
392 let control_mean_cost_usd = mean(&|c| c.control.scorable().then_some(c.control.cost_usd));
393
394 let cost_per_pass = |sel: &dyn Fn(&AbCell) -> &ArmOutcome| {
395 let scorable: Vec<&ArmOutcome> =
396 cells.iter().map(sel).filter(|o| o.scorable()).collect();
397 let passes = scorable.iter().filter(|o| o.passed).count();
398 if passes == 0 {
399 None
400 } else {
401 let total: f64 = scorable.iter().map(|o| o.cost_usd).sum();
402 Some(total / passes as f64)
403 }
404 };
405 let treatment_cost_per_pass = cost_per_pass(&|c| &c.treatment);
406 let control_cost_per_pass = cost_per_pass(&|c| &c.control);
407
408 Self {
409 paired_tasks,
410 treatment_passes,
411 control_passes,
412 treatment_pass_rate,
413 control_pass_rate,
414 pass_rate_delta: treatment_pass_rate - control_pass_rate,
415 both_pass,
416 treatment_only,
417 control_only,
418 both_fail,
419 mcnemar_chi2,
420 mcnemar_significant_05,
421 treatment_infra_failures,
422 control_infra_failures,
423 treatment_mean_cost_usd,
424 control_mean_cost_usd,
425 treatment_cost_per_pass,
426 control_cost_per_pass,
427 }
428 }
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
434pub struct AbReport {
435 pub treatment: ArmSpec,
437 pub control: ArmSpec,
439 pub cells: Vec<AbCell>,
440 pub stats: PairedStats,
441}
442
443impl AbReport {
444 pub fn from_cells(cells: Vec<AbCell>, treatment: ArmSpec, control: ArmSpec) -> Self {
446 let stats = PairedStats::from_cells(&cells);
447 Self {
448 treatment,
449 control,
450 cells,
451 stats,
452 }
453 }
454
455 pub fn same_backbone(&self) -> Option<bool> {
472 let t = self.treatment.model.as_deref()?;
473 let c = self.control.model.as_deref()?;
474 Some(t == c)
475 }
476
477 pub fn summary_line(&self) -> String {
479 let s = &self.stats;
480 let sig = if s.mcnemar_significant_05 {
481 "significant (p<.05)"
482 } else {
483 "not significant"
484 };
485 let kind = match self.same_backbone() {
488 Some(true) => "same-backbone harness delta",
489 Some(false) => "DIFFERENT PINS (not a same-backbone harness delta)",
490 None => "UNPINNED (backbone unverified)",
491 };
492 format!(
493 "{} {}/{} ({:.0}%) vs {} {}/{} ({:.0}%) over {} paired tasks — delta {:+.1} pp, McNemar χ²={:.2} {} [{}]",
494 self.treatment.label(),
495 s.treatment_passes,
496 s.paired_tasks,
497 s.treatment_pass_rate * 100.0,
498 self.control.label(),
499 s.control_passes,
500 s.paired_tasks,
501 s.control_pass_rate * 100.0,
502 s.paired_tasks,
503 s.pass_rate_delta * 100.0,
504 s.mcnemar_chi2,
505 sig,
506 kind,
507 )
508 }
509}
510
511#[async_trait]
516pub trait AbArmRunner: Send + Sync {
517 async fn run_arm(&self, task: &AbTask, arm: &ArmSpec) -> ArmOutcome;
518}
519
520pub async fn run_ab_suite(
523 tasks: &[AbTask],
524 treatment: &ArmSpec,
525 control: &ArmSpec,
526 runner: &dyn AbArmRunner,
527) -> AbReport {
528 run_ab_suite_resumable(tasks, treatment, control, runner, Vec::new(), None, |_| {}).await
529}
530
531pub async fn run_ab_suite_resumable(
548 tasks: &[AbTask],
549 treatment: &ArmSpec,
550 control: &ArmSpec,
551 runner: &dyn AbArmRunner,
552 done: Vec<AbCell>,
553 limit: Option<usize>,
554 mut on_cell: impl FnMut(&AbCell),
555) -> AbReport {
556 let mut cells: Vec<AbCell> = done
559 .into_iter()
560 .filter(|c| c.matches(treatment, control))
561 .collect();
562 let done_ids: std::collections::HashSet<String> =
563 cells.iter().map(|c| c.task_id.clone()).collect();
564 let mut ran = 0usize;
565 for task in tasks {
566 if done_ids.contains(&task.id) {
567 continue; }
569 if limit.is_some_and(|lim| ran >= lim) {
570 break; }
572 let treatment_outcome = runner.run_arm(task, treatment).await;
573 let control_outcome = runner.run_arm(task, control).await;
574 let cell = AbCell {
575 task_id: task.id.clone(),
576 treatment: treatment_outcome,
577 control: control_outcome,
578 treatment_spec: Some(treatment.clone()),
579 control_spec: Some(control.clone()),
580 };
581 on_cell(&cell); cells.push(cell);
583 ran += 1;
584 }
585 AbReport::from_cells(cells, treatment.clone(), control.clone())
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize, Default)]
608pub struct RoundAttribution {
609 pub harness_addressable_losses: Vec<String>,
611 pub backbone_bound_losses: Vec<String>,
614 pub interventions: Vec<HarnessIntervention>,
617}
618
619impl RoundAttribution {
620 pub fn has_lever(&self) -> bool {
622 !self.interventions.is_empty()
623 }
624}
625
626pub fn is_evolution_actionable(layer: InterventionLayer) -> bool {
630 matches!(
631 layer,
632 InterventionLayer::EnvironmentContract
633 | InterventionLayer::ActionRealization
634 | InterventionLayer::TrajectoryRegulation
635 )
636}
637
638pub fn attribute_round(
645 report: &AbReport,
646 read_events: impl Fn(&str) -> Option<String>,
647 min_occurrences: usize,
648) -> RoundAttribution {
649 let mut addressable = Vec::new();
650 let mut backbone = Vec::new();
651 let mut merged: HashMap<(String, String), HarnessIntervention> = HashMap::new();
654
655 for cell in &report.cells {
656 if !cell.paired_scorable() || cell.treatment.passed {
657 continue;
658 }
659 let jsonl = cell
660 .treatment
661 .transcript_path
662 .as_deref()
663 .and_then(&read_events)
664 .unwrap_or_default();
665 let diag = diagnose_from_jsonl(&jsonl, min_occurrences);
666 let actionable: Vec<HarnessIntervention> = diag
667 .interventions
668 .into_iter()
669 .filter(|iv| is_evolution_actionable(iv.layer))
670 .collect();
671 if actionable.is_empty() {
672 backbone.push(cell.task_id.clone());
673 } else {
674 addressable.push(cell.task_id.clone());
675 for iv in actionable {
676 let key = (format!("{:?}", iv.layer), iv.target.clone());
677 merged
678 .entry(key)
679 .and_modify(|e| e.evidence_count += iv.evidence_count)
680 .or_insert(iv);
681 }
682 }
683 }
684
685 let mut interventions: Vec<HarnessIntervention> = merged.into_values().collect();
686 interventions.sort_by(|a, b| b.evidence_count.cmp(&a.evidence_count));
687 RoundAttribution {
688 harness_addressable_losses: addressable,
689 backbone_bound_losses: backbone,
690 interventions,
691 }
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 fn contract() -> OutcomeContract {
699 OutcomeContract {
700 allow_credentials: false,
701 description: "tests pass".into(),
702 checks: vec![],
703 }
704 }
705
706 fn task(id: &str) -> AbTask {
707 AbTask {
708 id: id.into(),
709 intent: format!("do {id}"),
710 contract: contract(),
711 repo: None,
712 }
713 }
714
715 fn done(passed: bool, cost: f64) -> ArmOutcome {
716 ArmOutcome {
717 passed,
718 iterations: 1,
719 cost_usd: cost,
720 wall_ms: 10,
721 error: None,
722 transcript_path: None,
723 infra_failed: false,
724 }
725 }
726
727 fn tspec() -> ArmSpec {
729 ArmSpec::native(Some("parslee/reasoning".into()))
730 }
731 fn cspec() -> ArmSpec {
732 ArmSpec::external("codex", Some("parslee/reasoning".into()))
733 }
734
735 fn cell_of(id: &str, t: ArmOutcome, c: ArmOutcome) -> AbCell {
737 AbCell {
738 task_id: id.into(),
739 treatment: t,
740 control: c,
741 treatment_spec: Some(tspec()),
742 control_spec: Some(cspec()),
743 }
744 }
745
746 struct Scripted(HashMap<(String, String), ArmOutcome>);
748
749 #[async_trait]
750 impl AbArmRunner for Scripted {
751 async fn run_arm(&self, task: &AbTask, arm: &ArmSpec) -> ArmOutcome {
752 self.0
753 .get(&(task.id.clone(), arm.engine.label()))
754 .cloned()
755 .unwrap_or_else(|| ArmOutcome::infra("no script", 0))
756 }
757 }
758
759 fn script(entries: Vec<(&str, &str, ArmOutcome)>) -> Scripted {
760 Scripted(
761 entries
762 .into_iter()
763 .map(|(t, a, o)| ((t.to_string(), a.to_string()), o))
764 .collect(),
765 )
766 }
767
768 #[tokio::test]
769 async fn paired_delta_and_mcnemar_over_discordant_pairs() {
770 let tasks: Vec<AbTask> = (0..5).map(|i| task(&format!("t{i}"))).collect();
773 let runner = script(vec![
774 ("t0", "native", done(true, 0.10)),
775 ("t0", "codex", done(false, 0.20)),
776 ("t1", "native", done(true, 0.10)),
777 ("t1", "codex", done(false, 0.20)),
778 ("t2", "native", done(true, 0.10)),
779 ("t2", "codex", done(false, 0.20)),
780 ("t3", "native", done(true, 0.10)),
781 ("t3", "codex", done(true, 0.20)),
782 ("t4", "native", done(false, 0.10)),
783 ("t4", "codex", done(false, 0.20)),
784 ]);
785 let report = run_ab_suite(&tasks, &tspec(), &cspec(), &runner).await;
786 let s = &report.stats;
787 assert_eq!(s.paired_tasks, 5);
788 assert_eq!(s.treatment_passes, 4);
789 assert_eq!(s.control_passes, 1);
790 assert_eq!(s.treatment_only, 3);
791 assert_eq!(s.control_only, 0);
792 assert_eq!(s.both_pass, 1);
793 assert_eq!(s.both_fail, 1);
794 assert!((s.pass_rate_delta - 0.6).abs() < 1e-9);
795 assert!((s.mcnemar_chi2 - 4.0 / 3.0).abs() < 1e-9);
796 assert!(!s.mcnemar_significant_05);
797 assert!((s.treatment_cost_per_pass.unwrap() - 0.50 / 4.0).abs() < 1e-9);
799 assert!((s.control_cost_per_pass.unwrap() - 1.0).abs() < 1e-9);
801 }
802
803 #[tokio::test]
804 async fn resumable_skips_done_caps_by_limit_and_observes_each_cell() {
805 let tasks: Vec<AbTask> = (0..4).map(|i| task(&format!("t{i}"))).collect();
806 let runner = script(vec![
807 ("t0", "native", done(true, 0.0)),
808 ("t0", "codex", done(false, 0.0)),
809 ("t1", "native", done(true, 0.0)),
810 ("t1", "codex", done(false, 0.0)),
811 ("t2", "native", done(true, 0.0)),
812 ("t2", "codex", done(false, 0.0)),
813 ("t3", "native", done(true, 0.0)),
814 ("t3", "codex", done(false, 0.0)),
815 ]);
816 let cell = |id: &str| cell_of(id, done(true, 0.0), done(false, 0.0));
817
818 let mut observed: Vec<String> = Vec::new();
821 let report = run_ab_suite_resumable(
822 &tasks,
823 &tspec(),
824 &cspec(),
825 &runner,
826 vec![cell("t0")],
827 Some(1),
828 |c| observed.push(c.task_id.clone()),
829 )
830 .await;
831 assert_eq!(observed, vec!["t1"], "only the ONE new task fires on_cell");
832 assert_eq!(report.stats.paired_tasks, 2, "t0 (resumed) + t1 (new)");
833
834 let mut observed2: Vec<String> = Vec::new();
836 let report2 = run_ab_suite_resumable(
837 &tasks,
838 &tspec(),
839 &cspec(),
840 &runner,
841 vec![cell("t0"), cell("t1")],
842 None,
843 |c| observed2.push(c.task_id.clone()),
844 )
845 .await;
846 assert_eq!(observed2, vec!["t2", "t3"]);
847 assert_eq!(report2.stats.paired_tasks, 4, "all scored once resumed");
848 }
849
850 #[tokio::test]
855 async fn resume_refuses_cells_from_a_different_backbone() {
856 let tasks: Vec<AbTask> = (0..2).map(|i| task(&format!("t{i}"))).collect();
857 let runner = script(vec![
858 ("t0", "native", done(false, 0.0)),
859 ("t0", "codex", done(false, 0.0)),
860 ("t1", "native", done(false, 0.0)),
861 ("t1", "codex", done(false, 0.0)),
862 ]);
863 let stale = AbCell {
865 task_id: "t0".into(),
866 treatment: done(true, 0.0),
867 control: done(false, 0.0),
868 treatment_spec: Some(ArmSpec::native(Some("gpt-5.5".into()))),
869 control_spec: Some(ArmSpec::external("codex", Some("gpt-5.5".into()))),
870 };
871 let t54 = ArmSpec::native(Some("gpt-5.4".into()));
873 let c54 = ArmSpec::external("codex", Some("gpt-5.4".into()));
874 let mut observed = Vec::new();
875 let report = run_ab_suite_resumable(&tasks, &t54, &c54, &runner, vec![stale], None, |c| {
876 observed.push(c.task_id.clone())
877 })
878 .await;
879 assert_eq!(observed, vec!["t0", "t1"], "the stale cell is re-run");
880 assert_eq!(report.stats.paired_tasks, 2);
881 assert_eq!(
882 report.stats.treatment_passes, 0,
883 "the gpt-5.5 cell's pass must not leak into the gpt-5.4 report"
884 );
885 assert!(report.cells.iter().all(|c| c.matches(&t54, &c54)));
886
887 let legacy = AbCell {
889 task_id: "t0".into(),
890 treatment: done(true, 0.0),
891 control: done(false, 0.0),
892 treatment_spec: None,
893 control_spec: None,
894 };
895 let report2 =
896 run_ab_suite_resumable(&tasks, &t54, &c54, &runner, vec![legacy], None, |_| {}).await;
897 assert_eq!(report2.stats.treatment_passes, 0);
898 }
899
900 #[test]
904 fn slug_is_filename_safe_even_though_labels_carry_path_separators() {
905 let a = ArmSpec::native(Some("openai/gpt-5.4:latest".into()));
906 assert_eq!(a.label(), "native@openai/gpt-5.4:latest");
907 assert_eq!(a.slug(), "native@openai_gpt-5.4_latest");
908 for bad in ['/', '\\', ':', '<', '>', '"', '|', '?', '*'] {
909 assert!(!a.slug().contains(bad), "slug must not contain {bad:?}");
910 }
911 assert_eq!(ArmSpec::external("codex", None).slug(), "codex");
913 }
914
915 #[test]
918 fn same_backbone_is_checked_not_asserted() {
919 let paired = |t: ArmSpec, c: ArmSpec| AbReport::from_cells(vec![], t, c);
920 assert_eq!(
921 paired(
922 ArmSpec::native(Some("gpt-5.5".into())),
923 ArmSpec::external("codex", Some("gpt-5.5".into()))
924 )
925 .same_backbone(),
926 Some(true)
927 );
928 let diagonal = paired(
930 ArmSpec::native(Some("gpt-5.4".into())),
931 ArmSpec::external("codex", Some("gpt-5.5".into())),
932 );
933 assert_eq!(diagonal.same_backbone(), Some(false));
934 assert!(diagonal.summary_line().contains("DIFFERENT PINS"));
935 let unpinned = paired(
937 ArmSpec::native(None),
938 ArmSpec::external("codex", Some("gpt-5.5".into())),
939 );
940 assert_eq!(unpinned.same_backbone(), None);
941 assert!(unpinned.summary_line().contains("UNPINNED"));
942 }
943
944 #[tokio::test]
945 async fn strong_discordance_is_significant() {
946 let tasks: Vec<AbTask> = (0..10).map(|i| task(&format!("t{i}"))).collect();
948 let mut entries = Vec::new();
949 for i in 0..10 {
950 let id = format!("t{i}");
951 entries.push((id.clone(), "native".to_string(), done(true, 0.0)));
952 entries.push((id.clone(), "codex".to_string(), done(false, 0.0)));
953 }
954 let runner = Scripted(entries.into_iter().map(|(t, a, o)| ((t, a), o)).collect());
955 let report = run_ab_suite(&tasks, &tspec(), &cspec(), &runner).await;
956 assert!((report.stats.mcnemar_chi2 - 8.1).abs() < 1e-9);
957 assert!(report.stats.mcnemar_significant_05);
958 }
959
960 #[test]
964 fn a_timeout_is_a_scored_loss_not_an_infra_exclusion() {
965 let t = ArmOutcome::timeout_loss("car code exceeded 900s wall bound", 900_000);
966 assert!(
967 t.scorable(),
968 "a timed-out arm was attempted; it must be scored"
969 );
970 assert!(!t.passed);
971 assert!(!t.infra_failed);
972 let i = ArmOutcome::infra("car-server binary not found", 12);
974 assert!(!i.scorable());
975 }
976
977 #[tokio::test]
978 async fn infra_failures_are_excluded_from_the_denominator() {
979 let tasks: Vec<AbTask> = (0..3).map(|i| task(&format!("t{i}"))).collect();
982 let runner = script(vec![
983 ("t0", "native", done(true, 0.0)),
984 ("t0", "codex", done(true, 0.0)),
985 ("t1", "native", done(true, 0.0)),
986 ("t1", "codex", done(false, 0.0)),
987 ("t2", "native", done(true, 0.0)),
988 ("t2", "codex", ArmOutcome::infra("codex launch failed", 5)),
989 ]);
990 let report = run_ab_suite(&tasks, &tspec(), &cspec(), &runner).await;
991 let s = &report.stats;
992 assert_eq!(s.paired_tasks, 2, "t2 excluded — external infra failure");
993 assert_eq!(s.control_infra_failures, 1);
994 assert_eq!(s.treatment_passes, 2);
995 assert_eq!(s.control_passes, 1);
996 }
997
998 #[tokio::test]
999 async fn empty_suite_is_well_defined() {
1000 let report = run_ab_suite(&[], &tspec(), &cspec(), &NoRunner).await;
1001 assert_eq!(report.stats.paired_tasks, 0);
1002 assert_eq!(report.stats.treatment_pass_rate, 0.0);
1003 assert!(!report.stats.mcnemar_significant_05);
1004 assert_eq!(report.stats.treatment_cost_per_pass, None);
1005 }
1006
1007 struct NoRunner;
1008 #[async_trait]
1009 impl AbArmRunner for NoRunner {
1010 async fn run_arm(&self, _task: &AbTask, _arm: &ArmSpec) -> ArmOutcome {
1011 ArmOutcome::infra("unused", 0)
1012 }
1013 }
1014
1015 #[test]
1016 fn summary_line_is_readable() {
1017 let cells = vec![cell_of("t0", done(true, 0.1), done(false, 0.2))];
1018 let report = AbReport::from_cells(cells, tspec(), cspec());
1019 let line = report.summary_line();
1020 assert!(line.contains("native@parslee/reasoning 1/1"));
1021 assert!(line.contains("codex@parslee/reasoning 0/1"));
1022 assert!(line.contains("same-backbone harness delta"));
1024 }
1025
1026 fn lost_with_transcript(key: &str) -> ArmOutcome {
1030 ArmOutcome {
1031 passed: false,
1032 iterations: 3,
1033 cost_usd: 0.0,
1034 wall_ms: 10,
1035 error: None,
1036 transcript_path: Some(key.into()),
1037 infra_failed: false,
1038 }
1039 }
1040
1041 fn failing_transcript(action: &str, err: &str, n: usize) -> String {
1044 (0..n)
1045 .map(|_| {
1046 format!(
1047 r#"{{"kind":"action_failed","action_id":"{action}","data":{{"error":"{err}"}}}}"#
1048 )
1049 })
1050 .collect::<Vec<_>>()
1051 .join("\n")
1052 }
1053
1054 fn report_with(cells: Vec<AbCell>) -> AbReport {
1055 AbReport::from_cells(cells, tspec(), cspec())
1056 }
1057
1058 #[test]
1059 fn treatment_loss_with_recurring_failure_is_harness_addressable() {
1060 let cells = vec![cell_of(
1062 "t0",
1063 lost_with_transcript("t0.jsonl"),
1064 done(true, 0.0),
1065 )];
1066 let report = report_with(cells);
1067 let events: HashMap<String, String> = [(
1068 "t0.jsonl".to_string(),
1069 failing_transcript("run_command", "exited 1 at runtime", 3),
1070 )]
1071 .into_iter()
1072 .collect();
1073 let attr = attribute_round(&report, |p| events.get(p).cloned(), 2);
1074 assert_eq!(attr.harness_addressable_losses, vec!["t0".to_string()]);
1075 assert!(attr.backbone_bound_losses.is_empty());
1076 assert!(attr.has_lever());
1077 assert_eq!(attr.interventions.len(), 1);
1078 assert_eq!(
1079 attr.interventions[0].layer,
1080 InterventionLayer::TrajectoryRegulation
1081 );
1082 assert_eq!(attr.interventions[0].evidence_count, 3);
1083 }
1084
1085 #[test]
1086 fn clean_treatment_loss_is_backbone_bound() {
1087 let cells = vec![cell_of(
1090 "t1",
1091 lost_with_transcript("t1.jsonl"),
1092 done(false, 0.0),
1093 )];
1094 let report = report_with(cells);
1095 let events: HashMap<String, String> = [("t1.jsonl".to_string(), String::new())]
1096 .into_iter()
1097 .collect();
1098 let attr = attribute_round(&report, |p| events.get(p).cloned(), 2);
1099 assert!(attr.harness_addressable_losses.is_empty());
1100 assert_eq!(attr.backbone_bound_losses, vec!["t1".to_string()]);
1101 assert!(!attr.has_lever());
1102 }
1103
1104 #[test]
1105 fn interventions_merge_and_rank_across_losing_cells() {
1106 let cells = vec![
1109 cell_of("a", lost_with_transcript("a.jsonl"), done(true, 0.0)),
1110 cell_of("b", lost_with_transcript("b.jsonl"), done(true, 0.0)),
1111 cell_of("c", lost_with_transcript("c.jsonl"), done(true, 0.0)),
1112 cell_of("d", done(true, 0.0), done(true, 0.0)),
1114 ];
1115 let report = report_with(cells);
1116 let events: HashMap<String, String> = [
1117 (
1118 "a.jsonl".to_string(),
1119 failing_transcript("run_command", "runtime boom", 2),
1120 ),
1121 (
1122 "b.jsonl".to_string(),
1123 failing_transcript("run_command", "runtime boom", 3),
1124 ),
1125 (
1126 "c.jsonl".to_string(),
1127 failing_transcript("edit_file", "runtime splat", 2),
1128 ),
1129 ]
1130 .into_iter()
1131 .collect();
1132 let attr = attribute_round(&report, |p| events.get(p).cloned(), 2);
1133 assert_eq!(attr.harness_addressable_losses.len(), 3);
1134 assert_eq!(
1135 attr.interventions.len(),
1136 2,
1137 "run_command merged, edit_file distinct"
1138 );
1139 assert_eq!(attr.interventions[0].target, "run_command");
1141 assert_eq!(attr.interventions[0].evidence_count, 5);
1142 assert_eq!(attr.interventions[1].target, "edit_file");
1143 }
1144}