Skip to main content

car_server_core/coder/
ab_loop.rs

1//! The self-improvement loop: **A/B → attribute → CAR fix → A/B**, until CAR's
2//! coder clears the quality bar or no harness lever remains.
3//!
4//! This is the loop the `/goal` "make a loop of a/b testing, car fixes, and more
5//! a/b testing until car quality is high enough" asks for, expressed as a
6//! deterministic driver with injected seams so it is unit-testable and its
7//! *termination* is provable:
8//!
9//! 1. Run the paired A/B suite ([`run_ab_suite`]) — CAR's native coder (pinned
10//!    on a pinned backbone) vs. the control arm, on the same contracts.
11//! 2. [`attribute_round`] the native losses into harness-addressable
12//!    interventions vs. backbone-bound (no-lever) losses.
13//! 3. Decide against the [`LoopConfig`] quality bar:
14//!    - **met** → stop [`LoopStop::QualityMet`];
15//!    - **no lever** (remaining losses are all backbone-bound) → stop
16//!      [`LoopStop::NoLeverLeft`] (the honest "a better model, not a harness
17//!      change, is what's left" outcome — the ALE finding, enforced);
18//!    - otherwise hand the interventions to the injected [`AbFixer`] (live: the
19//!      Evolution Agent's `evolution.run` harness diagnose→gate→apply, HITL-gated
20//!      on the approval ledger). If nothing lands → stop [`LoopStop::FixStalled`].
21//! 4. Re-run the A/B — **the applied change's own regression gate**: a fix only
22//!    "counts" if the next round's numbers move, and McNemar says whether the
23//!    move is real or noise.
24//!
25//! The fixer and the arm runner are injected (the `car-builder` / `car-verify::cwm`
26//! philosophy), so this core carries no live-inference or daemon dependency and
27//! its convergence is exercised with scripted doubles. The live wiring (real
28//! runner + real `evolution.run` fixer + the `car coder-ab-loop` CLI) sits on
29//! top of this exactly as the live A/B runner sits on `run_ab_suite`.
30
31use async_trait::async_trait;
32use car_eventlog::harness_adapt::HarnessIntervention;
33use serde::{Deserialize, Serialize};
34
35use super::ab::{
36    attribute_round, run_ab_suite, AbArmRunner, AbTask, ArmSpec, PairedStats, RoundAttribution,
37};
38
39/// Tuning for the improvement loop.
40#[derive(Debug, Clone)]
41pub struct LoopConfig {
42    /// Native pass-rate over the paired intersection required to clear the bar
43    /// (e.g. `0.8`).
44    pub quality_bar: f64,
45    /// Also require that CAR is not *significantly* behind the external arm —
46    /// even at a high absolute pass rate, ship only when the competitor isn't
47    /// beating us on a statistically real set of tasks (McNemar).
48    pub require_not_behind: bool,
49    /// Hard cap on rounds — the loop always terminates.
50    pub max_rounds: u32,
51    /// `min_occurrences` handed to attribution: how many times a pattern must
52    /// recur within a run before it's a lever (one-offs are noise).
53    pub min_evidence: usize,
54}
55
56impl Default for LoopConfig {
57    fn default() -> Self {
58        Self {
59            quality_bar: 0.8,
60            require_not_behind: true,
61            max_rounds: 5,
62            min_evidence: 2,
63        }
64    }
65}
66
67/// Why the loop stopped.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum LoopStop {
71    /// Native cleared the bar (and, if required, is not significantly behind).
72    QualityMet,
73    /// No harness-addressable lever remains — the remaining losses are all
74    /// backbone-bound. A harness change can't move the number further; a
75    /// stronger model is the lever now.
76    NoLeverLeft,
77    /// A fix round applied nothing (fixer declined, or every intervention is
78    /// pending human approval). The loop can't make progress unattended.
79    FixStalled,
80    /// Exhausted `max_rounds` still below the bar with levers remaining.
81    MaxRounds,
82}
83
84impl LoopStop {
85    /// Whether the loop ended because CAR's quality reached the target. This is
86    /// the *goal-satisfied* terminal; the others are honest non-completions.
87    pub fn is_success(self) -> bool {
88        matches!(self, LoopStop::QualityMet)
89    }
90}
91
92/// The result of applying a fix round.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FixResult {
95    /// A change actually landed (not merely diagnosed or left pending approval).
96    pub applied: bool,
97    /// Human-readable note (what changed, or why nothing did).
98    pub note: String,
99}
100
101/// The injected fix seam. The live impl drives the Evolution Agent
102/// (`evolution.run`'s harness diagnose→gate→apply) over the interventions,
103/// HITL-gated on the shared approval ledger.
104#[async_trait]
105pub trait AbFixer: Send + Sync {
106    async fn apply(&self, interventions: &[HarnessIntervention]) -> FixResult;
107}
108
109/// One round of the loop, recorded for the report and the operator trail.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct RoundRecord {
112    pub round: u32,
113    pub stats: PairedStats,
114    pub attribution: RoundAttribution,
115    /// A fix was attempted this round (i.e. the round did not terminate the loop).
116    pub fix_attempted: bool,
117    pub fix_applied: bool,
118    pub fix_note: String,
119}
120
121/// The full loop trace.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ImprovementRun {
124    pub rounds: Vec<RoundRecord>,
125    pub stop: LoopStop,
126    /// The exact arms the loop ran, for provenance.
127    pub treatment: ArmSpec,
128    pub control: ArmSpec,
129}
130
131impl ImprovementRun {
132    /// The final round's stats (the loop's exit state), if any round ran.
133    pub fn final_stats(&self) -> Option<&PairedStats> {
134        self.rounds.last().map(|r| &r.stats)
135    }
136
137    /// Native pass-rate improvement from the first round to the last — the
138    /// headline "did the loop make CAR better" number.
139    pub fn pass_rate_gain(&self) -> f64 {
140        match (self.rounds.first(), self.rounds.last()) {
141            (Some(first), Some(last)) => {
142                last.stats.treatment_pass_rate - first.stats.treatment_pass_rate
143            }
144            _ => 0.0,
145        }
146    }
147}
148
149/// Whether the current round's stats clear the configured quality bar.
150fn quality_met(stats: &PairedStats, cfg: &LoopConfig) -> bool {
151    if stats.paired_tasks == 0 {
152        return false; // nothing scored — never a pass
153    }
154    if stats.treatment_pass_rate < cfg.quality_bar {
155        return false;
156    }
157    if cfg.require_not_behind {
158        // "Significantly behind" = the discordant pairs favor the control arm
159        // AND McNemar calls that difference real.
160        let behind = stats.mcnemar_significant_05 && stats.control_only > stats.treatment_only;
161        if behind {
162            return false;
163        }
164    }
165    true
166}
167
168/// Run the self-improvement loop to a terminal [`LoopStop`]. Always terminates
169/// (bounded by `max_rounds`; each non-terminal round applies a fix or stops).
170///
171/// `read_events(path)` resolves the treatment arm's transcript path to its event-log
172/// JSONL for attribution (live: read the file; tests: an in-memory map).
173pub async fn run_improvement_loop(
174    corpus: &[AbTask],
175    treatment: &ArmSpec,
176    control: &ArmSpec,
177    runner: &dyn AbArmRunner,
178    read_events: impl Fn(&str) -> Option<String>,
179    fixer: &dyn AbFixer,
180    cfg: &LoopConfig,
181) -> ImprovementRun {
182    let mut rounds = Vec::new();
183    let mut stop = LoopStop::MaxRounds;
184
185    for round in 0..cfg.max_rounds {
186        let report = run_ab_suite(corpus, treatment, control, runner).await;
187        let attribution = attribute_round(&report, &read_events, cfg.min_evidence);
188        let met = quality_met(&report.stats, cfg);
189
190        // Terminal-this-round decisions record the round without a fix attempt.
191        if met {
192            rounds.push(RoundRecord {
193                round,
194                stats: report.stats,
195                attribution,
196                fix_attempted: false,
197                fix_applied: false,
198                fix_note: String::new(),
199            });
200            stop = LoopStop::QualityMet;
201            break;
202        }
203        if !attribution.has_lever() {
204            rounds.push(RoundRecord {
205                round,
206                stats: report.stats,
207                attribution,
208                fix_attempted: false,
209                fix_applied: false,
210                fix_note: "no harness-addressable lever — remaining losses are backbone-bound"
211                    .into(),
212            });
213            stop = LoopStop::NoLeverLeft;
214            break;
215        }
216
217        // A lever exists and the bar isn't met → attempt the fix.
218        let fix = fixer.apply(&attribution.interventions).await;
219        let applied = fix.applied;
220        rounds.push(RoundRecord {
221            round,
222            stats: report.stats,
223            attribution,
224            fix_attempted: true,
225            fix_applied: applied,
226            fix_note: fix.note,
227        });
228        if !applied {
229            stop = LoopStop::FixStalled;
230            break;
231        }
232        // else: loop → the next A/B round is this fix's regression gate.
233    }
234
235    ImprovementRun {
236        rounds,
237        stop,
238        treatment: treatment.clone(),
239        control: control.clone(),
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::coder::ab::{ArmEngine, ArmOutcome, ArmSpec};
247
248    fn tspec() -> ArmSpec {
249        ArmSpec::native(Some("parslee/reasoning".into()))
250    }
251    fn cspec() -> ArmSpec {
252        ArmSpec::external("codex", Some("parslee/reasoning".into()))
253    }
254    use crate::coder::contract::OutcomeContract;
255    use std::collections::HashMap;
256    use std::sync::atomic::{AtomicUsize, Ordering};
257    use std::sync::Arc;
258
259    fn empty_contract() -> OutcomeContract {
260        OutcomeContract {
261            allow_credentials: false,
262            description: "tests pass".into(),
263            checks: vec![],
264        }
265    }
266
267    fn tasks(n: usize) -> Vec<AbTask> {
268        (0..n)
269            .map(|i| AbTask {
270                id: format!("t{i}"),
271                intent: format!("task {i}"),
272                contract: empty_contract(),
273                repo: None,
274            })
275            .collect()
276    }
277
278    /// A runner whose native pass-rate *improves* as fixes land: it passes the
279    /// first `native_passes(fixes)` tasks, where `fixes` is the shared counter
280    /// the fixer bumps. Every native loss carries a transcript that attributes
281    /// to a harness lever, so the loop keeps a reason to fix until the bar. The
282    /// external arm is fixed at a middling pass-rate.
283    struct ImprovingRunner {
284        fixes: Arc<AtomicUsize>,
285        treatment_passes: fn(usize) -> usize,
286        control_passes: usize,
287        n: usize,
288        loss_transcript: &'static str,
289    }
290
291    #[async_trait]
292    impl AbArmRunner for ImprovingRunner {
293        async fn run_arm(&self, task: &AbTask, arm: &ArmSpec) -> ArmOutcome {
294            let idx: usize = task.id.trim_start_matches('t').parse().unwrap();
295            match arm.engine {
296                ArmEngine::Native => {
297                    let passes =
298                        (self.treatment_passes)(self.fixes.load(Ordering::SeqCst)).min(self.n);
299                    if idx < passes {
300                        ArmOutcome {
301                            passed: true,
302                            iterations: 1,
303                            cost_usd: 0.0,
304                            wall_ms: 1,
305                            error: None,
306                            transcript_path: None,
307                            infra_failed: false,
308                        }
309                    } else {
310                        ArmOutcome {
311                            passed: false,
312                            iterations: 3,
313                            cost_usd: 0.0,
314                            wall_ms: 1,
315                            error: None,
316                            transcript_path: Some(self.loss_transcript.into()),
317                            infra_failed: false,
318                        }
319                    }
320                }
321                ArmEngine::External(_) => ArmOutcome {
322                    passed: idx < self.control_passes,
323                    iterations: 1,
324                    cost_usd: 0.0,
325                    wall_ms: 1,
326                    error: None,
327                    transcript_path: None,
328                    infra_failed: false,
329                },
330            }
331        }
332    }
333
334    /// A fixer that lands a change every call and bumps the shared counter, so
335    /// the *next* A/B round improves (simulating a real harness fix landing).
336    struct LandingFixer {
337        fixes: Arc<AtomicUsize>,
338    }
339    #[async_trait]
340    impl AbFixer for LandingFixer {
341        async fn apply(&self, interventions: &[HarnessIntervention]) -> FixResult {
342            self.fixes.fetch_add(1, Ordering::SeqCst);
343            FixResult {
344                applied: true,
345                note: format!("applied {} intervention(s)", interventions.len()),
346            }
347        }
348    }
349
350    /// A fixer that never lands anything (everything pending approval).
351    struct StalledFixer;
352    #[async_trait]
353    impl AbFixer for StalledFixer {
354        async fn apply(&self, _i: &[HarnessIntervention]) -> FixResult {
355            FixResult {
356                applied: false,
357                note: "all interventions pending human approval".into(),
358            }
359        }
360    }
361
362    /// A trajectory-regulation transcript so every native loss attributes to a
363    /// lever (keeps the loop fixing rather than declaring no-lever).
364    fn lever_events() -> HashMap<String, String> {
365        let jsonl = (0..2)
366            .map(|_| {
367                r#"{"kind":"action_failed","action_id":"run_command","data":{"error":"runtime boom"}}"#
368                    .to_string()
369            })
370            .collect::<Vec<_>>()
371            .join("\n");
372        [("loss.jsonl".to_string(), jsonl)].into_iter().collect()
373    }
374
375    #[tokio::test]
376    async fn loop_converges_to_quality_met() {
377        // 5 tasks. native passes = 2 + 2*fixes → round0: 2/5=0.4 (bar .9 unmet,
378        // fix), round1: 4/5=0.8 (unmet, fix), round2: 5/5=1.0 → QualityMet.
379        // External at 3/5.
380        let fixes = Arc::new(AtomicUsize::new(0));
381        let runner = ImprovingRunner {
382            fixes: fixes.clone(),
383            treatment_passes: |f| 2 + 2 * f,
384            control_passes: 3,
385            n: 5,
386            loss_transcript: "loss.jsonl",
387        };
388        let fixer = LandingFixer {
389            fixes: fixes.clone(),
390        };
391        let events = lever_events();
392        let cfg = LoopConfig {
393            quality_bar: 0.9,
394            require_not_behind: true,
395            max_rounds: 5,
396            min_evidence: 2,
397        };
398        let run = run_improvement_loop(
399            &tasks(5),
400            &tspec(),
401            &cspec(),
402            &runner,
403            |p| events.get(p).cloned(),
404            &fixer,
405            &cfg,
406        )
407        .await;
408
409        assert_eq!(run.stop, LoopStop::QualityMet);
410        assert!(run.stop.is_success());
411        assert_eq!(run.rounds.len(), 3, "two fix rounds then a met round");
412        // The first two rounds attempted+applied a fix; the last did not.
413        assert!(run.rounds[0].fix_applied && run.rounds[1].fix_applied);
414        assert!(!run.rounds[2].fix_attempted);
415        // Headline: native pass-rate climbed from 0.4 to 1.0.
416        assert!((run.rounds[0].stats.treatment_pass_rate - 0.4).abs() < 1e-9);
417        assert!((run.final_stats().unwrap().treatment_pass_rate - 1.0).abs() < 1e-9);
418        assert!((run.pass_rate_gain() - 0.6).abs() < 1e-9);
419    }
420
421    #[tokio::test]
422    async fn loop_stops_when_no_harness_lever_remains() {
423        // native stuck at 2/5, but every loss is a CLEAN loss (no lever events)
424        // → backbone-bound → NoLeverLeft on round 0, no fix attempted.
425        let fixes = Arc::new(AtomicUsize::new(0));
426        let runner = ImprovingRunner {
427            fixes: fixes.clone(),
428            treatment_passes: |_| 2,
429            control_passes: 3,
430            n: 5,
431            loss_transcript: "clean.jsonl", // not in the events map → empty → no lever
432        };
433        let fixer = LandingFixer { fixes };
434        let events: HashMap<String, String> = HashMap::new(); // no transcripts resolve
435        let run = run_improvement_loop(
436            &tasks(5),
437            &tspec(),
438            &cspec(),
439            &runner,
440            |p| events.get(p).cloned(),
441            &fixer,
442            &LoopConfig::default(),
443        )
444        .await;
445        assert_eq!(run.stop, LoopStop::NoLeverLeft);
446        assert!(!run.stop.is_success());
447        assert_eq!(run.rounds.len(), 1);
448        assert!(!run.rounds[0].fix_attempted);
449    }
450
451    #[tokio::test]
452    async fn loop_stops_when_fix_stalls() {
453        // Levers exist but the fixer lands nothing → FixStalled on round 0.
454        let fixes = Arc::new(AtomicUsize::new(0));
455        let runner = ImprovingRunner {
456            fixes,
457            treatment_passes: |_| 2,
458            control_passes: 3,
459            n: 5,
460            loss_transcript: "loss.jsonl",
461        };
462        let events = lever_events();
463        let run = run_improvement_loop(
464            &tasks(5),
465            &tspec(),
466            &cspec(),
467            &runner,
468            |p| events.get(p).cloned(),
469            &StalledFixer,
470            &LoopConfig::default(),
471        )
472        .await;
473        assert_eq!(run.stop, LoopStop::FixStalled);
474        assert_eq!(run.rounds.len(), 1);
475        assert!(run.rounds[0].fix_attempted && !run.rounds[0].fix_applied);
476    }
477
478    #[tokio::test]
479    async fn loop_respects_max_rounds() {
480        // native improves too slowly to hit the bar within the cap → MaxRounds.
481        let fixes = Arc::new(AtomicUsize::new(0));
482        let runner = ImprovingRunner {
483            fixes: fixes.clone(),
484            treatment_passes: |_| 2, // never improves despite applied fixes
485            control_passes: 1,
486            n: 5,
487            loss_transcript: "loss.jsonl",
488        };
489        let fixer = LandingFixer { fixes };
490        let events = lever_events();
491        let cfg = LoopConfig {
492            quality_bar: 0.8,
493            require_not_behind: false,
494            max_rounds: 3,
495            min_evidence: 2,
496        };
497        let run = run_improvement_loop(
498            &tasks(5),
499            &tspec(),
500            &cspec(),
501            &runner,
502            |p| events.get(p).cloned(),
503            &fixer,
504            &cfg,
505        )
506        .await;
507        assert_eq!(run.stop, LoopStop::MaxRounds);
508        assert_eq!(run.rounds.len(), 3);
509        assert!(run.rounds.iter().all(|r| r.fix_attempted && r.fix_applied));
510    }
511
512    #[tokio::test]
513    async fn not_behind_gate_blocks_a_high_but_losing_pass_rate() {
514        // Give an explicit stats scenario: native 4/5 (≥ .8) but external wins a
515        // significant discordant set → require_not_behind blocks QualityMet.
516        // Build it via a runner where native passes t0..t4 except t4, external
517        // passes exactly the tasks native fails plus enough discordant wins.
518        // Simpler: assert quality_met directly on a crafted PairedStats.
519        let behind = PairedStats {
520            paired_tasks: 20,
521            treatment_passes: 16,
522            control_passes: 20,
523            treatment_pass_rate: 0.8,
524            control_pass_rate: 1.0,
525            pass_rate_delta: -0.2,
526            both_pass: 16,
527            treatment_only: 0,
528            control_only: 4, // external wins 4 discordant, native 0
529            both_fail: 0,
530            mcnemar_chi2: 100.0, // clearly significant
531            mcnemar_significant_05: true,
532            treatment_infra_failures: 0,
533            control_infra_failures: 0,
534            treatment_mean_cost_usd: 0.0,
535            control_mean_cost_usd: 0.0,
536            treatment_cost_per_pass: None,
537            control_cost_per_pass: None,
538        };
539        let cfg = LoopConfig {
540            quality_bar: 0.8,
541            require_not_behind: true,
542            ..LoopConfig::default()
543        };
544        assert!(
545            !quality_met(&behind, &cfg),
546            "significantly behind → not met"
547        );
548        // Same numbers, gate off → met (absolute bar cleared).
549        let cfg_off = LoopConfig {
550            require_not_behind: false,
551            ..cfg.clone()
552        };
553        assert!(quality_met(&behind, &cfg_off));
554    }
555}