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            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    /// The standard pair: CAR native vs codex, both pinned to one backbone.
728    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    /// A cell carrying the standard pair's provenance (so it is resume-reusable).
736    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    /// A scripted runner: outcomes keyed by (task_id, engine_label).
747    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        // 5 tasks. native beats external on 3 discordant, loses 0, both-pass 1,
771        // both-fail 1. b=3, c=0 → chi2 = (|3-0|-1)^2/3 = 4/3 = 1.333 (not sig).
772        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        // Cost-per-pass: native 5 scorable runs × 0.10 = 0.50 / 4 passes.
798        assert!((s.treatment_cost_per_pass.unwrap() - 0.50 / 4.0).abs() < 1e-9);
799        // External never... passed once (t3): 5 × 0.20 = 1.0 / 1.
800        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        // t0 already scored (from a checkpoint). limit=1 → run exactly ONE new
819        // task (t1), skip t0, leave t2/t3 for a later resume.
820        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        // Resume: feed both done cells back, no limit → runs the remaining t2, t3.
835        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    /// The checkpoint is keyed by corpus path alone, so a re-run at a DIFFERENT
851    /// backbone would otherwise resume the previous backbone's cells and fold
852    /// two models into one report — contamination that looks perfectly clean.
853    /// A cell is reusable only when both arms match exactly.
854    #[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        // A checkpoint written by an earlier gpt-5.5 run…
864        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        // …must NOT be reused by a gpt-5.4 run of the same corpus.
872        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        // Legacy cells (no provenance) are unknown, hence never reusable.
888        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    /// A model id carries `/` and `:`. Used raw as a filename, the `/` makes it
901    /// a path and the write silently fails — which lost every native transcript
902    /// of a real 22-task run. Also keeps filenames Windows-legal.
903    #[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        // An unpinned arm's slug stays the plain engine name.
912        assert_eq!(ArmSpec::external("codex", None).slug(), "codex");
913    }
914
915    /// A same-backbone pair is a harness delta; a cross-tier pair is not, and the
916    /// report must say so rather than let the diagonal read as a harness win.
917    #[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        // Different pins: deliberate (the diagonal), and never a harness delta.
929        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        // An unpinned arm makes the invariant unknowable — never silently "true".
936        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        // b=10, c=0 → chi2 = (10-1)^2/10 = 8.1 > 3.841 → significant.
947        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    /// A wall-bound timeout must stay IN the scored denominator. Excluding it
961    /// drops the slow tasks, biasing the pass rate upward — and with the two
962    /// arms carrying different bounds, at two different rates.
963    #[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        // The contrast that makes the distinction meaningful.
973        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        // 3 tasks; on t2 the external arm never ran (infra). That whole cell
980        // drops from the paired denominator — not scored as an external loss.
981        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        // Both arms pinned to one model → an honest harness delta.
1023        assert!(line.contains("same-backbone harness delta"));
1024    }
1025
1026    // --- Slice 3: attribution ---
1027
1028    /// A native ArmOutcome that lost, tagged with a transcript key.
1029    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    /// A JSONL event stream with `n` runtime failures of `action` — a
1042    /// trajectory-regulation signature `diagnose` recognizes at `min≤n`.
1043    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        // control won → a CAR-specific harness gap
1061        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        // No failure events — the arm ran clean and simply produced a wrong
1088        // answer. No harness lever; parked as a backbone signal.
1089        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        // Two losing cells fail the SAME action → evidence sums, one merged
1107        // intervention. A third cell fails a different action → ranked below.
1108        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            // A passed treatment cell and an infra cell must be ignored.
1113            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        // run_command evidence = 2 + 3 = 5, ranked first.
1140        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}