Skip to main content

car_server_core/coder/
ab.rs

1//! Paired A/B comparator: two arms — each an ([`ArmSpec`]) **engine on a
2//! backbone** — over the **same task and the same `OutcomeContract`**.
3//!
4//! The original claim was "CAR + gpt-5.5 codes as well as Codex + gpt-5.5, and
5//! here is where its harness wins." That is only meaningful when both arms run
6//! the *same* model — ALE's finding is that backbone choice is ~3× the spread of
7//! harness choice, so native-on-Haiku vs Codex-on-gpt-5.5 measures the model,
8//! not the runtime.
9//!
10//! Arms therefore carry their own backbone, which lets one comparator express
11//! the whole family (see `docs/proposals/coder-ab-value-surface.md`):
12//! the same-backbone harness delta, the **cross-tier diagonal**
13//! (`native@gpt-5.4` vs `codex@gpt-5.5` — what the runtime is worth in model
14//! tiers), and **ablation** (native vs native with a mechanism off, the only
15//! shape that isolates a single mechanism).
16//!
17//! The fairness rule is now *checked* rather than asserted
18//! ([`AbReport::same_backbone`]). It previously could not be: the report held one
19//! operator-supplied `backbone` string while the pin reached only the native
20//! loop, so the external arm ran on whatever its CLI was configured for.
21//!
22//! Design mirrors `bench/car_bench/ale`'s orchestrator, ported to Rust and to
23//! the in-process coder path:
24//!
25//! - **Honest denominator.** An arm that never actually ran (worktree/setup/
26//!   inference-transport failure) is `infra_failed` and excluded — never scored
27//!   as a task-0. The paired stats are computed over the tasks *both* arms
28//!   scored, exactly like `compare_pair`'s `treatment ∩ control` intersection.
29//! - **Paired significance.** McNemar's test over the discordant pairs
30//!   (treatment-pass/control-fail vs treatment-fail/control-pass) — the right test
31//!   for two harnesses judged on the same items, and the regression gate Slice 4
32//!   re-runs to decide whether an applied change was a real improvement or noise.
33//! - **Cost axis.** Per-arm mean cost and cost-per-pass, so "as good" can be
34//!   qualified by "and cheaper/dearer."
35//!
36//! The execution seam is injected ([`AbArmRunner`]) — the same
37//! injected-closure philosophy as `car-builder`'s generate seam and
38//! `car-verify::cwm`'s `EffectModel` — so the statistics core is unit-testable
39//! with scripted arms and no live inference. The live runner (real
40//! `run_native_loop` / `run_external_loop` in fresh worktrees) is wired by the
41//! `car coder-ab` CLI.
42
43use 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/// One coding task both arms attempt, verified against the identical contract.
52/// The corpus is a JSONL of these — curated from CAR's own merged PRs / closed
53/// issues (revert the fix, keep the PR's tests as the contract, have both arms
54/// re-derive it).
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct AbTask {
57    /// Stable id (e.g. the PR number or issue slug).
58    pub id: String,
59    /// The natural-language task both arms are given.
60    pub intent: String,
61    /// The pass/fail ground truth both arms are verified against — identical
62    /// across arms so the only variable is the harness.
63    pub contract: OutcomeContract,
64    /// Repo the task runs against. `None` = the CLI's `--repo`/cwd default; each
65    /// arm still works in its own throwaway worktree off it.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub repo: Option<String>,
68}
69
70/// Which engine an arm drives.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum ArmEngine {
74    /// CAR's native loop.
75    Native,
76    /// An external CLI session (`codex`, `claude-code`, `gemini`).
77    External(String),
78}
79
80impl ArmEngine {
81    /// Stable engine key (`"native"`, `"codex"`).
82    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/// A fully-specified arm: **which engine, on which backbone**.
91///
92/// The backbone lives here rather than once per report because the interesting
93/// experiments need the two arms to differ on it:
94///
95/// - **same-backbone** (`native@gpt-5.5` vs `codex@gpt-5.5`) — the harness delta,
96///   the original claim;
97/// - **cross-tier diagonal** (`native@gpt-5.4` vs `codex@gpt-5.5`) — what the
98///   runtime is worth in *model tiers*, which is the economically legible claim
99///   (see `docs/proposals/coder-ab-value-surface.md`);
100/// - **ablation** (`native@gpt-5.5` vs `native@gpt-5.5` with a mechanism off) —
101///   isolates one mechanism, which a CAR-vs-Codex comparison can never do since
102///   it confounds the harness with everything else about Codex.
103///
104/// The old shape (an `Arm` enum plus one report-level `backbone`) could express
105/// only the first, and even then it did not *enforce* it: the pin reached the
106/// native loop alone, so the external arm silently ran on its CLI's configured
107/// default. `model` here is threaded to whichever engine runs, so the invariant
108/// is now the runtime's job, not the operator's memory.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ArmSpec {
111    pub engine: ArmEngine,
112    /// The pinned backbone. `None` = the engine's own default (adaptive routing
113    /// for native; the CLI's config for external) — unpinned, so a same-backbone
114    /// claim is unverifiable.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub model: Option<String>,
117}
118
119impl ArmSpec {
120    /// CAR's native loop on `model`.
121    pub fn native(model: Option<String>) -> Self {
122        Self {
123            engine: ArmEngine::Native,
124            model,
125        }
126    }
127
128    /// An external CLI (`codex`) on `model`.
129    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    /// Report label: `native@gpt-5.4`, `codex@gpt-5.5`, or bare `native` when
137    /// unpinned. Two arms in one run can share an engine (ablation) or a model
138    /// (the harness delta), so the label carries both to stay unambiguous.
139    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    /// Filename-safe [`label`](crate::coder::ab::ArmSpec::label): `native@openai_gpt-5.4_latest`.
147    ///
148    /// Model ids carry `/` and `:` — a path separator, and illegal in a Windows
149    /// filename — so the label must never be used as one directly. It was: the
150    /// A/B wrote `<task>-<label>.log` and silently lost **every** native
151    /// transcript (the `/` made it a path into a nonexistent dir), which are the
152    /// exact records `ab_learnings` proposals tell a human to go read.
153    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/// A single arm's result on a single task, normalized so both the native
168/// [`LoopOutcome`](super::native_loop::LoopOutcome) and the external CLI's
169/// result map onto one shape.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct ArmOutcome {
172    /// Every contract check passed (the ground-truth win condition).
173    pub passed: bool,
174    /// Contract-evaluation rounds actually executed.
175    pub iterations: u32,
176    /// USD spent on inference for this arm's run (0.0 when unknown — the native
177    /// loop does not always meter; the external CLI reports `total_cost_usd`).
178    #[serde(default)]
179    pub cost_usd: f64,
180    /// Wall-clock for the arm's run.
181    pub wall_ms: u64,
182    /// Terminal error (cancellation, repeated inference failure). Distinct from
183    /// `infra_failed`: an arm can error late (task-attributable) after really
184    /// running.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub error: Option<String>,
187    /// Where the arm's transcript/event stream landed, for Slice 3 attribution.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub transcript_path: Option<String>,
190    /// The arm never actually attempted the task — worktree/setup/CLI-launch/
191    /// transport failure before any real work. Excluded from the honest
192    /// denominator (ALE's infra-vs-task split); NOT a scored task-0.
193    #[serde(default)]
194    pub infra_failed: bool,
195}
196
197impl ArmOutcome {
198    /// An infra failure: the arm couldn't be attempted. Kept out of the scored
199    /// denominator.
200    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    /// An arm that ran but was stopped by the harness's own wall bound.
213    ///
214    /// A **scored loss**, deliberately — not [`ArmOutcome::infra`]. The infra
215    /// exclusion means "the arm could not be *attempted*"; an arm that ran for
216    /// its entire wall budget was attempted, and very hard. Excluding it would
217    /// drop precisely the SLOW tasks from the denominator, which is the same
218    /// upward bias `coder_ab::INFRA_MARKERS` documents at length and warns
219    /// against — it excluded "precisely the dependency-trouble tasks, i.e. the
220    /// hard ones".
221    ///
222    /// The asymmetry made it worse: the two arms carry different wall bounds
223    /// (900s native, 300s external), so excluding on timeout gave a *paired*
224    /// design two different exclusion rates feeding one denominator.
225    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            // The load-bearing difference from `infra`.
234            infra_failed: false,
235        }
236    }
237
238    /// Whether this outcome counts toward the scored denominator.
239    pub fn scorable(&self) -> bool {
240        !self.infra_failed
241    }
242}
243
244/// Both arms' outcomes on one task — the paired unit.
245///
246/// `treatment`/`control` (ALE's `compare_pair` vocabulary, which this module's
247/// statistics already mirror) rather than `native`/`external`: the arms are no
248/// longer engine-typed, and under ablation both are native. The asymmetry is
249/// kept deliberately — `treatment` is the arm under test, `control` the
250/// reference — because the delta's sign and `ab_loop`'s quality bar both need to
251/// know which arm is "us".
252#[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    /// The exact arms that produced this cell.
260    ///
261    /// Per-cell, not per-report, because the resume checkpoint is keyed by
262    /// corpus path alone: without provenance, running `flask@gpt-5.5` and then
263    /// resuming the same corpus at `flask@gpt-5.4` silently reuses the 5.5 cells
264    /// and folds two backbones into one report — a contaminated result that
265    /// looks perfectly clean. [`AbCell::matches`] is the guard.
266    ///
267    /// `None` = a legacy checkpoint written before provenance existed; treated
268    /// as *unknown*, hence never reusable.
269    #[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    /// The cell is *paired-scorable* only when BOTH arms really ran — the
277    /// intersection ALE's `compare_pair` measures the delta over.
278    pub fn paired_scorable(&self) -> bool {
279        self.treatment.scorable() && self.control.scorable()
280    }
281
282    /// Whether this cell was produced by exactly these two arms, and is
283    /// therefore safe to reuse on resume. Unknown provenance is never a match —
284    /// re-running a task is cheap next to publishing a mixed-backbone report.
285    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
291/// McNemar's chi-square (df=1) critical value at α=0.05.
292const CHI2_CRIT_05: f64 = 3.841;
293
294/// Aggregate paired statistics over the scorable intersection.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct PairedStats {
297    /// Tasks both arms scored (the honest denominator).
298    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    /// `treatment_pass_rate - control_pass_rate` (>0 = the arm under test ahead).
304    pub pass_rate_delta: f64,
305    /// Concordant/discordant breakdown of the paired outcomes.
306    pub both_pass: usize,
307    /// Discordant, treatment's favor (McNemar's `b`).
308    pub treatment_only: usize,
309    /// Discordant, control's favor (McNemar's `c`).
310    pub control_only: usize,
311    pub both_fail: usize,
312    /// McNemar's chi-square with continuity correction: `(|b-c|-1)^2/(b+c)`.
313    /// `0.0` when there are no discordant pairs.
314    pub mcnemar_chi2: f64,
315    /// `mcnemar_chi2 > 3.841` — the delta is unlikely to be noise at α=0.05.
316    pub mcnemar_significant_05: bool,
317    /// Arms that couldn't be attempted (reported, excluded from the delta).
318    pub treatment_infra_failures: usize,
319    pub control_infra_failures: usize,
320    /// Mean inference cost per scorable arm run.
321    pub treatment_mean_cost_usd: f64,
322    pub control_mean_cost_usd: f64,
323    /// Total cost / passes — `None` when an arm never passed (undefined).
324    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        // The honest denominator: only cells where BOTH arms really ran.
334        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        // McNemar with Yates continuity correction over the discordant pairs.
368        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            // Clamp the corrected numerator at 0 (|b-c| can be < 1).
374            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        // Cost axis over scorable arms only.
382        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/// The full report: every paired cell plus the aggregate stats. Serializes to
432/// the timestamped JSON the `car coder-ab` CLI writes to `bench/results`.
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
434pub struct AbReport {
435    /// The arm under test.
436    pub treatment: ArmSpec,
437    /// The reference arm.
438    pub control: ArmSpec,
439    pub cells: Vec<AbCell>,
440    pub stats: PairedStats,
441}
442
443impl AbReport {
444    /// Fold cells into a report against the two arms that produced them.
445    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    /// Whether both arms ran the **same pinned backbone** — the fairness rule
456    /// that makes `pass_rate_delta` a *harness* measurement rather than a model
457    /// measurement (backbone choice is ~3× the harness spread).
458    ///
459    /// `None` when either arm is unpinned: the honest answer is then *unknown*,
460    /// not "yes". This used to be unknowable — the report recorded a single
461    /// `backbone` string the operator asserted, while the pin reached only the
462    /// native loop. `false` is a legitimate, deliberate configuration (the
463    /// cross-tier diagonal); what must never happen is *believing* it is `true`
464    /// without having checked.
465    /// `Some(false)` means the two arms carry **different pin strings** — which
466    /// is not quite the same as "different models", because the id namespaces
467    /// differ per engine (`parslee/reasoning` and `gpt-5.5` name one backbone by
468    /// two routes). We cannot resolve that equivalence here, so the honest
469    /// reading is "different pins, check what you intended", not "different
470    /// tiers". Identical pins are unambiguous.
471    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    /// A one-line human summary for the CLI table footer.
478    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        // Name the comparison being made, so a cross-tier diagonal can never be
486        // read as a same-backbone harness delta.
487        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/// The injected execution seam: run one arm on one task, in its own fresh
512/// worktree, verifying against `task.contract`. Never panics — an arm that
513/// can't be attempted returns [`ArmOutcome::infra`]. Scripted in tests; the
514/// live impl (real `run_native_loop` / `run_external_loop`) is wired by the CLI.
515#[async_trait]
516pub trait AbArmRunner: Send + Sync {
517    async fn run_arm(&self, task: &AbTask, arm: &ArmSpec) -> ArmOutcome;
518}
519
520/// Run the full paired suite: each task through both arms, against the same
521/// contract, folded into an [`AbReport`].
522pub 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
531/// Resumable, incrementally-observable [`run_ab_suite`]. A long real-corpus run
532/// is expensive, so this makes it interruptible and iterative:
533///
534/// - `done` are cells already scored (loaded from a durable checkpoint); their
535///   tasks are skipped and the cells kept, so a killed run loses nothing — a
536///   re-run picks up where it stopped. **Only cells produced by these exact two
537///   arms are reused** ([`AbCell::matches`]); the rest are dropped and re-run,
538///   because the checkpoint is keyed by corpus path alone and would otherwise
539///   fold a previous run's *different backbone* into this report.
540/// - `limit` caps how many NEW tasks to run this invocation (`None` = all
541///   remaining): run one, inspect the result, fix whatever it surfaced, resume.
542/// - `on_cell` fires the instant each new cell is scored, BEFORE the next task
543///   starts — the caller persists it there (append to the checkpoint), which is
544///   what makes a mid-run stop lossless.
545///
546/// Returns the report over ALL cells (previously `done` + newly run).
547pub 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    // Reuse only what THIS pair of arms produced. A cell from a different
557    // backbone (or unknown provenance) is silent contamination, not a saving.
558    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; // already scored by these arms — resume past it
568        }
569        if limit.is_some_and(|lim| ran >= lim) {
570            break; // stop after this batch; the rest resume on the next run
571        }
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); // durably persist BEFORE moving on
582        cells.push(cell);
583        ran += 1;
584    }
585    AbReport::from_cells(cells, treatment.clone(), control.clone())
586}
587
588// ---------------------------------------------------------------------------
589// Slice 3 — mechanism attribution.
590//
591// The A/B tells us *that* CAR's coder lost a task; attribution tells us *why*,
592// and — crucially — whether it's a loss CAR can fix. We fold each native loss's
593// event stream through `car_eventlog::harness_adapt::diagnose`, which already
594// recognizes the loss mechanisms (premature certification / ungrounded
595// completion, truncation, turn-exhaustion, retry thrash, malformed calls) and
596// maps them to a Life-Harness intervention layer. The operational split (the
597// ALE finding "verify-mode can't fix clean-but-wrong"):
598//
599//   - a losing native cell whose events yield ≥1 intervention → HARNESS-ADDRESSABLE
600//     (Slice 4 hands these to the Evolution Agent),
601//   - a losing native cell that ran clean and simply produced a wrong answer,
602//     yielding no diagnosed pattern → BACKBONE-BOUND (parked — no harness lever;
603//     wait for a stronger model).
604// ---------------------------------------------------------------------------
605
606/// Attribution over one A/B round's treatment-arm losses.
607#[derive(Debug, Clone, Serialize, Deserialize, Default)]
608pub struct RoundAttribution {
609    /// Treatment-losing paired tasks whose event stream yielded ≥1 intervention.
610    pub harness_addressable_losses: Vec<String>,
611    /// Treatment-losing paired tasks that ran clean but produced a wrong answer
612    /// (no diagnosed pattern) — a backbone/domain signal, not a harness lever.
613    pub backbone_bound_losses: Vec<String>,
614    /// Interventions merged across all addressable losses, highest evidence
615    /// first — the telemetry Slice 4 feeds to `evolution.run`.
616    pub interventions: Vec<HarnessIntervention>,
617}
618
619impl RoundAttribution {
620    /// Whether there is any harness-addressable lever to act on this round.
621    pub fn has_lever(&self) -> bool {
622        !self.interventions.is_empty()
623    }
624}
625
626/// Whether a diagnosed layer is one the Evolution Agent can act on autonomously.
627/// `ProceduralSkill` defers to CAR's separate skill-distillation path, so it is
628/// not fed to the harness-evolution fixer here.
629pub fn is_evolution_actionable(layer: InterventionLayer) -> bool {
630    matches!(
631        layer,
632        InterventionLayer::EnvironmentContract
633            | InterventionLayer::ActionRealization
634            | InterventionLayer::TrajectoryRegulation
635    )
636}
637
638/// Attribute the treatment-arm losses in `report`. `read_events(path)` returns the
639/// event-log JSONL for a transcript path (live: read the file; tests: an
640/// in-memory map). Only *paired-scorable* native losses are attributed — an
641/// infra failure is not a harness lesson. Interventions from a layer that
642/// defers elsewhere ([`is_evolution_actionable`] false) still mark a loss as
643/// addressed but are dropped from the fixer feed.
644pub 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    // Merge interventions across losing cells by (layer, target), summing
652    // evidence so a pattern recurring across tasks ranks above a one-off.
653    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    /// The standard pair: CAR native vs codex, both pinned to one backbone.
727    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    /// A cell carrying the standard pair's provenance (so it is resume-reusable).
735    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    /// A scripted runner: outcomes keyed by (task_id, engine_label).
746    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        // 5 tasks. native beats external on 3 discordant, loses 0, both-pass 1,
770        // both-fail 1. b=3, c=0 → chi2 = (|3-0|-1)^2/3 = 4/3 = 1.333 (not sig).
771        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        // Cost-per-pass: native 5 scorable runs × 0.10 = 0.50 / 4 passes.
797        assert!((s.treatment_cost_per_pass.unwrap() - 0.50 / 4.0).abs() < 1e-9);
798        // External never... passed once (t3): 5 × 0.20 = 1.0 / 1.
799        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        // t0 already scored (from a checkpoint). limit=1 → run exactly ONE new
818        // task (t1), skip t0, leave t2/t3 for a later resume.
819        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        // Resume: feed both done cells back, no limit → runs the remaining t2, t3.
834        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    /// The checkpoint is keyed by corpus path alone, so a re-run at a DIFFERENT
850    /// backbone would otherwise resume the previous backbone's cells and fold
851    /// two models into one report — contamination that looks perfectly clean.
852    /// A cell is reusable only when both arms match exactly.
853    #[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        // A checkpoint written by an earlier gpt-5.5 run…
863        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        // …must NOT be reused by a gpt-5.4 run of the same corpus.
871        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        // Legacy cells (no provenance) are unknown, hence never reusable.
887        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    /// A model id carries `/` and `:`. Used raw as a filename, the `/` makes it
900    /// a path and the write silently fails — which lost every native transcript
901    /// of a real 22-task run. Also keeps filenames Windows-legal.
902    #[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        // An unpinned arm's slug stays the plain engine name.
911        assert_eq!(ArmSpec::external("codex", None).slug(), "codex");
912    }
913
914    /// A same-backbone pair is a harness delta; a cross-tier pair is not, and the
915    /// report must say so rather than let the diagonal read as a harness win.
916    #[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        // Different pins: deliberate (the diagonal), and never a harness delta.
928        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        // An unpinned arm makes the invariant unknowable — never silently "true".
935        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        // b=10, c=0 → chi2 = (10-1)^2/10 = 8.1 > 3.841 → significant.
946        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    /// A wall-bound timeout must stay IN the scored denominator. Excluding it
960    /// drops the slow tasks, biasing the pass rate upward — and with the two
961    /// arms carrying different bounds, at two different rates.
962    #[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        // The contrast that makes the distinction meaningful.
972        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        // 3 tasks; on t2 the external arm never ran (infra). That whole cell
979        // drops from the paired denominator — not scored as an external loss.
980        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        // Both arms pinned to one model → an honest harness delta.
1022        assert!(line.contains("same-backbone harness delta"));
1023    }
1024
1025    // --- Slice 3: attribution ---
1026
1027    /// A native ArmOutcome that lost, tagged with a transcript key.
1028    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    /// A JSONL event stream with `n` runtime failures of `action` — a
1041    /// trajectory-regulation signature `diagnose` recognizes at `min≤n`.
1042    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        // control won → a CAR-specific harness gap
1060        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        // No failure events — the arm ran clean and simply produced a wrong
1087        // answer. No harness lever; parked as a backbone signal.
1088        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        // Two losing cells fail the SAME action → evidence sums, one merged
1106        // intervention. A third cell fails a different action → ranked below.
1107        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            // A passed treatment cell and an infra cell must be ignored.
1112            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        // run_command evidence = 2 + 3 = 5, ranked first.
1139        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}