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 description: "tests pass".into(),
701 checks: vec![],
702 }
703 }
704
705 fn task(id: &str) -> AbTask {
706 AbTask {
707 id: id.into(),
708 intent: format!("do {id}"),
709 contract: contract(),
710 repo: None,
711 }
712 }
713
714 fn done(passed: bool, cost: f64) -> ArmOutcome {
715 ArmOutcome {
716 passed,
717 iterations: 1,
718 cost_usd: cost,
719 wall_ms: 10,
720 error: None,
721 transcript_path: None,
722 infra_failed: false,
723 }
724 }
725
726 fn tspec() -> ArmSpec {
728 ArmSpec::native(Some("parslee/reasoning".into()))
729 }
730 fn cspec() -> ArmSpec {
731 ArmSpec::external("codex", Some("parslee/reasoning".into()))
732 }
733
734 fn cell_of(id: &str, t: ArmOutcome, c: ArmOutcome) -> AbCell {
736 AbCell {
737 task_id: id.into(),
738 treatment: t,
739 control: c,
740 treatment_spec: Some(tspec()),
741 control_spec: Some(cspec()),
742 }
743 }
744
745 struct Scripted(HashMap<(String, String), ArmOutcome>);
747
748 #[async_trait]
749 impl AbArmRunner for Scripted {
750 async fn run_arm(&self, task: &AbTask, arm: &ArmSpec) -> ArmOutcome {
751 self.0
752 .get(&(task.id.clone(), arm.engine.label()))
753 .cloned()
754 .unwrap_or_else(|| ArmOutcome::infra("no script", 0))
755 }
756 }
757
758 fn script(entries: Vec<(&str, &str, ArmOutcome)>) -> Scripted {
759 Scripted(
760 entries
761 .into_iter()
762 .map(|(t, a, o)| ((t.to_string(), a.to_string()), o))
763 .collect(),
764 )
765 }
766
767 #[tokio::test]
768 async fn paired_delta_and_mcnemar_over_discordant_pairs() {
769 let tasks: Vec<AbTask> = (0..5).map(|i| task(&format!("t{i}"))).collect();
772 let runner = script(vec![
773 ("t0", "native", done(true, 0.10)),
774 ("t0", "codex", done(false, 0.20)),
775 ("t1", "native", done(true, 0.10)),
776 ("t1", "codex", done(false, 0.20)),
777 ("t2", "native", done(true, 0.10)),
778 ("t2", "codex", done(false, 0.20)),
779 ("t3", "native", done(true, 0.10)),
780 ("t3", "codex", done(true, 0.20)),
781 ("t4", "native", done(false, 0.10)),
782 ("t4", "codex", done(false, 0.20)),
783 ]);
784 let report = run_ab_suite(&tasks, &tspec(), &cspec(), &runner).await;
785 let s = &report.stats;
786 assert_eq!(s.paired_tasks, 5);
787 assert_eq!(s.treatment_passes, 4);
788 assert_eq!(s.control_passes, 1);
789 assert_eq!(s.treatment_only, 3);
790 assert_eq!(s.control_only, 0);
791 assert_eq!(s.both_pass, 1);
792 assert_eq!(s.both_fail, 1);
793 assert!((s.pass_rate_delta - 0.6).abs() < 1e-9);
794 assert!((s.mcnemar_chi2 - 4.0 / 3.0).abs() < 1e-9);
795 assert!(!s.mcnemar_significant_05);
796 assert!((s.treatment_cost_per_pass.unwrap() - 0.50 / 4.0).abs() < 1e-9);
798 assert!((s.control_cost_per_pass.unwrap() - 1.0).abs() < 1e-9);
800 }
801
802 #[tokio::test]
803 async fn resumable_skips_done_caps_by_limit_and_observes_each_cell() {
804 let tasks: Vec<AbTask> = (0..4).map(|i| task(&format!("t{i}"))).collect();
805 let runner = script(vec![
806 ("t0", "native", done(true, 0.0)),
807 ("t0", "codex", done(false, 0.0)),
808 ("t1", "native", done(true, 0.0)),
809 ("t1", "codex", done(false, 0.0)),
810 ("t2", "native", done(true, 0.0)),
811 ("t2", "codex", done(false, 0.0)),
812 ("t3", "native", done(true, 0.0)),
813 ("t3", "codex", done(false, 0.0)),
814 ]);
815 let cell = |id: &str| cell_of(id, done(true, 0.0), done(false, 0.0));
816
817 let mut observed: Vec<String> = Vec::new();
820 let report = run_ab_suite_resumable(
821 &tasks,
822 &tspec(),
823 &cspec(),
824 &runner,
825 vec![cell("t0")],
826 Some(1),
827 |c| observed.push(c.task_id.clone()),
828 )
829 .await;
830 assert_eq!(observed, vec!["t1"], "only the ONE new task fires on_cell");
831 assert_eq!(report.stats.paired_tasks, 2, "t0 (resumed) + t1 (new)");
832
833 let mut observed2: Vec<String> = Vec::new();
835 let report2 = run_ab_suite_resumable(
836 &tasks,
837 &tspec(),
838 &cspec(),
839 &runner,
840 vec![cell("t0"), cell("t1")],
841 None,
842 |c| observed2.push(c.task_id.clone()),
843 )
844 .await;
845 assert_eq!(observed2, vec!["t2", "t3"]);
846 assert_eq!(report2.stats.paired_tasks, 4, "all scored once resumed");
847 }
848
849 #[tokio::test]
854 async fn resume_refuses_cells_from_a_different_backbone() {
855 let tasks: Vec<AbTask> = (0..2).map(|i| task(&format!("t{i}"))).collect();
856 let runner = script(vec![
857 ("t0", "native", done(false, 0.0)),
858 ("t0", "codex", done(false, 0.0)),
859 ("t1", "native", done(false, 0.0)),
860 ("t1", "codex", done(false, 0.0)),
861 ]);
862 let stale = AbCell {
864 task_id: "t0".into(),
865 treatment: done(true, 0.0),
866 control: done(false, 0.0),
867 treatment_spec: Some(ArmSpec::native(Some("gpt-5.5".into()))),
868 control_spec: Some(ArmSpec::external("codex", Some("gpt-5.5".into()))),
869 };
870 let t54 = ArmSpec::native(Some("gpt-5.4".into()));
872 let c54 = ArmSpec::external("codex", Some("gpt-5.4".into()));
873 let mut observed = Vec::new();
874 let report = run_ab_suite_resumable(&tasks, &t54, &c54, &runner, vec![stale], None, |c| {
875 observed.push(c.task_id.clone())
876 })
877 .await;
878 assert_eq!(observed, vec!["t0", "t1"], "the stale cell is re-run");
879 assert_eq!(report.stats.paired_tasks, 2);
880 assert_eq!(
881 report.stats.treatment_passes, 0,
882 "the gpt-5.5 cell's pass must not leak into the gpt-5.4 report"
883 );
884 assert!(report.cells.iter().all(|c| c.matches(&t54, &c54)));
885
886 let legacy = AbCell {
888 task_id: "t0".into(),
889 treatment: done(true, 0.0),
890 control: done(false, 0.0),
891 treatment_spec: None,
892 control_spec: None,
893 };
894 let report2 =
895 run_ab_suite_resumable(&tasks, &t54, &c54, &runner, vec![legacy], None, |_| {}).await;
896 assert_eq!(report2.stats.treatment_passes, 0);
897 }
898
899 #[test]
903 fn slug_is_filename_safe_even_though_labels_carry_path_separators() {
904 let a = ArmSpec::native(Some("openai/gpt-5.4:latest".into()));
905 assert_eq!(a.label(), "native@openai/gpt-5.4:latest");
906 assert_eq!(a.slug(), "native@openai_gpt-5.4_latest");
907 for bad in ['/', '\\', ':', '<', '>', '"', '|', '?', '*'] {
908 assert!(!a.slug().contains(bad), "slug must not contain {bad:?}");
909 }
910 assert_eq!(ArmSpec::external("codex", None).slug(), "codex");
912 }
913
914 #[test]
917 fn same_backbone_is_checked_not_asserted() {
918 let paired = |t: ArmSpec, c: ArmSpec| AbReport::from_cells(vec![], t, c);
919 assert_eq!(
920 paired(
921 ArmSpec::native(Some("gpt-5.5".into())),
922 ArmSpec::external("codex", Some("gpt-5.5".into()))
923 )
924 .same_backbone(),
925 Some(true)
926 );
927 let diagonal = paired(
929 ArmSpec::native(Some("gpt-5.4".into())),
930 ArmSpec::external("codex", Some("gpt-5.5".into())),
931 );
932 assert_eq!(diagonal.same_backbone(), Some(false));
933 assert!(diagonal.summary_line().contains("DIFFERENT PINS"));
934 let unpinned = paired(
936 ArmSpec::native(None),
937 ArmSpec::external("codex", Some("gpt-5.5".into())),
938 );
939 assert_eq!(unpinned.same_backbone(), None);
940 assert!(unpinned.summary_line().contains("UNPINNED"));
941 }
942
943 #[tokio::test]
944 async fn strong_discordance_is_significant() {
945 let tasks: Vec<AbTask> = (0..10).map(|i| task(&format!("t{i}"))).collect();
947 let mut entries = Vec::new();
948 for i in 0..10 {
949 let id = format!("t{i}");
950 entries.push((id.clone(), "native".to_string(), done(true, 0.0)));
951 entries.push((id.clone(), "codex".to_string(), done(false, 0.0)));
952 }
953 let runner = Scripted(entries.into_iter().map(|(t, a, o)| ((t, a), o)).collect());
954 let report = run_ab_suite(&tasks, &tspec(), &cspec(), &runner).await;
955 assert!((report.stats.mcnemar_chi2 - 8.1).abs() < 1e-9);
956 assert!(report.stats.mcnemar_significant_05);
957 }
958
959 #[test]
963 fn a_timeout_is_a_scored_loss_not_an_infra_exclusion() {
964 let t = ArmOutcome::timeout_loss("car code exceeded 900s wall bound", 900_000);
965 assert!(
966 t.scorable(),
967 "a timed-out arm was attempted; it must be scored"
968 );
969 assert!(!t.passed);
970 assert!(!t.infra_failed);
971 let i = ArmOutcome::infra("car-server binary not found", 12);
973 assert!(!i.scorable());
974 }
975
976 #[tokio::test]
977 async fn infra_failures_are_excluded_from_the_denominator() {
978 let tasks: Vec<AbTask> = (0..3).map(|i| task(&format!("t{i}"))).collect();
981 let runner = script(vec![
982 ("t0", "native", done(true, 0.0)),
983 ("t0", "codex", done(true, 0.0)),
984 ("t1", "native", done(true, 0.0)),
985 ("t1", "codex", done(false, 0.0)),
986 ("t2", "native", done(true, 0.0)),
987 ("t2", "codex", ArmOutcome::infra("codex launch failed", 5)),
988 ]);
989 let report = run_ab_suite(&tasks, &tspec(), &cspec(), &runner).await;
990 let s = &report.stats;
991 assert_eq!(s.paired_tasks, 2, "t2 excluded — external infra failure");
992 assert_eq!(s.control_infra_failures, 1);
993 assert_eq!(s.treatment_passes, 2);
994 assert_eq!(s.control_passes, 1);
995 }
996
997 #[tokio::test]
998 async fn empty_suite_is_well_defined() {
999 let report = run_ab_suite(&[], &tspec(), &cspec(), &NoRunner).await;
1000 assert_eq!(report.stats.paired_tasks, 0);
1001 assert_eq!(report.stats.treatment_pass_rate, 0.0);
1002 assert!(!report.stats.mcnemar_significant_05);
1003 assert_eq!(report.stats.treatment_cost_per_pass, None);
1004 }
1005
1006 struct NoRunner;
1007 #[async_trait]
1008 impl AbArmRunner for NoRunner {
1009 async fn run_arm(&self, _task: &AbTask, _arm: &ArmSpec) -> ArmOutcome {
1010 ArmOutcome::infra("unused", 0)
1011 }
1012 }
1013
1014 #[test]
1015 fn summary_line_is_readable() {
1016 let cells = vec![cell_of("t0", done(true, 0.1), done(false, 0.2))];
1017 let report = AbReport::from_cells(cells, tspec(), cspec());
1018 let line = report.summary_line();
1019 assert!(line.contains("native@parslee/reasoning 1/1"));
1020 assert!(line.contains("codex@parslee/reasoning 0/1"));
1021 assert!(line.contains("same-backbone harness delta"));
1023 }
1024
1025 fn lost_with_transcript(key: &str) -> ArmOutcome {
1029 ArmOutcome {
1030 passed: false,
1031 iterations: 3,
1032 cost_usd: 0.0,
1033 wall_ms: 10,
1034 error: None,
1035 transcript_path: Some(key.into()),
1036 infra_failed: false,
1037 }
1038 }
1039
1040 fn failing_transcript(action: &str, err: &str, n: usize) -> String {
1043 (0..n)
1044 .map(|_| {
1045 format!(
1046 r#"{{"kind":"action_failed","action_id":"{action}","data":{{"error":"{err}"}}}}"#
1047 )
1048 })
1049 .collect::<Vec<_>>()
1050 .join("\n")
1051 }
1052
1053 fn report_with(cells: Vec<AbCell>) -> AbReport {
1054 AbReport::from_cells(cells, tspec(), cspec())
1055 }
1056
1057 #[test]
1058 fn treatment_loss_with_recurring_failure_is_harness_addressable() {
1059 let cells = vec![cell_of(
1061 "t0",
1062 lost_with_transcript("t0.jsonl"),
1063 done(true, 0.0),
1064 )];
1065 let report = report_with(cells);
1066 let events: HashMap<String, String> = [(
1067 "t0.jsonl".to_string(),
1068 failing_transcript("run_command", "exited 1 at runtime", 3),
1069 )]
1070 .into_iter()
1071 .collect();
1072 let attr = attribute_round(&report, |p| events.get(p).cloned(), 2);
1073 assert_eq!(attr.harness_addressable_losses, vec!["t0".to_string()]);
1074 assert!(attr.backbone_bound_losses.is_empty());
1075 assert!(attr.has_lever());
1076 assert_eq!(attr.interventions.len(), 1);
1077 assert_eq!(
1078 attr.interventions[0].layer,
1079 InterventionLayer::TrajectoryRegulation
1080 );
1081 assert_eq!(attr.interventions[0].evidence_count, 3);
1082 }
1083
1084 #[test]
1085 fn clean_treatment_loss_is_backbone_bound() {
1086 let cells = vec![cell_of(
1089 "t1",
1090 lost_with_transcript("t1.jsonl"),
1091 done(false, 0.0),
1092 )];
1093 let report = report_with(cells);
1094 let events: HashMap<String, String> = [("t1.jsonl".to_string(), String::new())]
1095 .into_iter()
1096 .collect();
1097 let attr = attribute_round(&report, |p| events.get(p).cloned(), 2);
1098 assert!(attr.harness_addressable_losses.is_empty());
1099 assert_eq!(attr.backbone_bound_losses, vec!["t1".to_string()]);
1100 assert!(!attr.has_lever());
1101 }
1102
1103 #[test]
1104 fn interventions_merge_and_rank_across_losing_cells() {
1105 let cells = vec![
1108 cell_of("a", lost_with_transcript("a.jsonl"), done(true, 0.0)),
1109 cell_of("b", lost_with_transcript("b.jsonl"), done(true, 0.0)),
1110 cell_of("c", lost_with_transcript("c.jsonl"), done(true, 0.0)),
1111 cell_of("d", done(true, 0.0), done(true, 0.0)),
1113 ];
1114 let report = report_with(cells);
1115 let events: HashMap<String, String> = [
1116 (
1117 "a.jsonl".to_string(),
1118 failing_transcript("run_command", "runtime boom", 2),
1119 ),
1120 (
1121 "b.jsonl".to_string(),
1122 failing_transcript("run_command", "runtime boom", 3),
1123 ),
1124 (
1125 "c.jsonl".to_string(),
1126 failing_transcript("edit_file", "runtime splat", 2),
1127 ),
1128 ]
1129 .into_iter()
1130 .collect();
1131 let attr = attribute_round(&report, |p| events.get(p).cloned(), 2);
1132 assert_eq!(attr.harness_addressable_losses.len(), 3);
1133 assert_eq!(
1134 attr.interventions.len(),
1135 2,
1136 "run_command merged, edit_file distinct"
1137 );
1138 assert_eq!(attr.interventions[0].target, "run_command");
1140 assert_eq!(attr.interventions[0].evidence_count, 5);
1141 assert_eq!(attr.interventions[1].target, "edit_file");
1142 }
1143}