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            description: "tests pass".into(),
262            checks: vec![],
263        }
264    }
265
266    fn tasks(n: usize) -> Vec<AbTask> {
267        (0..n)
268            .map(|i| AbTask {
269                id: format!("t{i}"),
270                intent: format!("task {i}"),
271                contract: empty_contract(),
272                repo: None,
273            })
274            .collect()
275    }
276
277    /// A runner whose native pass-rate *improves* as fixes land: it passes the
278    /// first `native_passes(fixes)` tasks, where `fixes` is the shared counter
279    /// the fixer bumps. Every native loss carries a transcript that attributes
280    /// to a harness lever, so the loop keeps a reason to fix until the bar. The
281    /// external arm is fixed at a middling pass-rate.
282    struct ImprovingRunner {
283        fixes: Arc<AtomicUsize>,
284        treatment_passes: fn(usize) -> usize,
285        control_passes: usize,
286        n: usize,
287        loss_transcript: &'static str,
288    }
289
290    #[async_trait]
291    impl AbArmRunner for ImprovingRunner {
292        async fn run_arm(&self, task: &AbTask, arm: &ArmSpec) -> ArmOutcome {
293            let idx: usize = task.id.trim_start_matches('t').parse().unwrap();
294            match arm.engine {
295                ArmEngine::Native => {
296                    let passes =
297                        (self.treatment_passes)(self.fixes.load(Ordering::SeqCst)).min(self.n);
298                    if idx < passes {
299                        ArmOutcome {
300                            passed: true,
301                            iterations: 1,
302                            cost_usd: 0.0,
303                            wall_ms: 1,
304                            error: None,
305                            transcript_path: None,
306                            infra_failed: false,
307                        }
308                    } else {
309                        ArmOutcome {
310                            passed: false,
311                            iterations: 3,
312                            cost_usd: 0.0,
313                            wall_ms: 1,
314                            error: None,
315                            transcript_path: Some(self.loss_transcript.into()),
316                            infra_failed: false,
317                        }
318                    }
319                }
320                ArmEngine::External(_) => ArmOutcome {
321                    passed: idx < self.control_passes,
322                    iterations: 1,
323                    cost_usd: 0.0,
324                    wall_ms: 1,
325                    error: None,
326                    transcript_path: None,
327                    infra_failed: false,
328                },
329            }
330        }
331    }
332
333    /// A fixer that lands a change every call and bumps the shared counter, so
334    /// the *next* A/B round improves (simulating a real harness fix landing).
335    struct LandingFixer {
336        fixes: Arc<AtomicUsize>,
337    }
338    #[async_trait]
339    impl AbFixer for LandingFixer {
340        async fn apply(&self, interventions: &[HarnessIntervention]) -> FixResult {
341            self.fixes.fetch_add(1, Ordering::SeqCst);
342            FixResult {
343                applied: true,
344                note: format!("applied {} intervention(s)", interventions.len()),
345            }
346        }
347    }
348
349    /// A fixer that never lands anything (everything pending approval).
350    struct StalledFixer;
351    #[async_trait]
352    impl AbFixer for StalledFixer {
353        async fn apply(&self, _i: &[HarnessIntervention]) -> FixResult {
354            FixResult {
355                applied: false,
356                note: "all interventions pending human approval".into(),
357            }
358        }
359    }
360
361    /// A trajectory-regulation transcript so every native loss attributes to a
362    /// lever (keeps the loop fixing rather than declaring no-lever).
363    fn lever_events() -> HashMap<String, String> {
364        let jsonl = (0..2)
365            .map(|_| {
366                r#"{"kind":"action_failed","action_id":"run_command","data":{"error":"runtime boom"}}"#
367                    .to_string()
368            })
369            .collect::<Vec<_>>()
370            .join("\n");
371        [("loss.jsonl".to_string(), jsonl)].into_iter().collect()
372    }
373
374    #[tokio::test]
375    async fn loop_converges_to_quality_met() {
376        // 5 tasks. native passes = 2 + 2*fixes → round0: 2/5=0.4 (bar .9 unmet,
377        // fix), round1: 4/5=0.8 (unmet, fix), round2: 5/5=1.0 → QualityMet.
378        // External at 3/5.
379        let fixes = Arc::new(AtomicUsize::new(0));
380        let runner = ImprovingRunner {
381            fixes: fixes.clone(),
382            treatment_passes: |f| 2 + 2 * f,
383            control_passes: 3,
384            n: 5,
385            loss_transcript: "loss.jsonl",
386        };
387        let fixer = LandingFixer {
388            fixes: fixes.clone(),
389        };
390        let events = lever_events();
391        let cfg = LoopConfig {
392            quality_bar: 0.9,
393            require_not_behind: true,
394            max_rounds: 5,
395            min_evidence: 2,
396        };
397        let run = run_improvement_loop(
398            &tasks(5),
399            &tspec(),
400            &cspec(),
401            &runner,
402            |p| events.get(p).cloned(),
403            &fixer,
404            &cfg,
405        )
406        .await;
407
408        assert_eq!(run.stop, LoopStop::QualityMet);
409        assert!(run.stop.is_success());
410        assert_eq!(run.rounds.len(), 3, "two fix rounds then a met round");
411        // The first two rounds attempted+applied a fix; the last did not.
412        assert!(run.rounds[0].fix_applied && run.rounds[1].fix_applied);
413        assert!(!run.rounds[2].fix_attempted);
414        // Headline: native pass-rate climbed from 0.4 to 1.0.
415        assert!((run.rounds[0].stats.treatment_pass_rate - 0.4).abs() < 1e-9);
416        assert!((run.final_stats().unwrap().treatment_pass_rate - 1.0).abs() < 1e-9);
417        assert!((run.pass_rate_gain() - 0.6).abs() < 1e-9);
418    }
419
420    #[tokio::test]
421    async fn loop_stops_when_no_harness_lever_remains() {
422        // native stuck at 2/5, but every loss is a CLEAN loss (no lever events)
423        // → backbone-bound → NoLeverLeft on round 0, no fix attempted.
424        let fixes = Arc::new(AtomicUsize::new(0));
425        let runner = ImprovingRunner {
426            fixes: fixes.clone(),
427            treatment_passes: |_| 2,
428            control_passes: 3,
429            n: 5,
430            loss_transcript: "clean.jsonl", // not in the events map → empty → no lever
431        };
432        let fixer = LandingFixer { fixes };
433        let events: HashMap<String, String> = HashMap::new(); // no transcripts resolve
434        let run = run_improvement_loop(
435            &tasks(5),
436            &tspec(),
437            &cspec(),
438            &runner,
439            |p| events.get(p).cloned(),
440            &fixer,
441            &LoopConfig::default(),
442        )
443        .await;
444        assert_eq!(run.stop, LoopStop::NoLeverLeft);
445        assert!(!run.stop.is_success());
446        assert_eq!(run.rounds.len(), 1);
447        assert!(!run.rounds[0].fix_attempted);
448    }
449
450    #[tokio::test]
451    async fn loop_stops_when_fix_stalls() {
452        // Levers exist but the fixer lands nothing → FixStalled on round 0.
453        let fixes = Arc::new(AtomicUsize::new(0));
454        let runner = ImprovingRunner {
455            fixes,
456            treatment_passes: |_| 2,
457            control_passes: 3,
458            n: 5,
459            loss_transcript: "loss.jsonl",
460        };
461        let events = lever_events();
462        let run = run_improvement_loop(
463            &tasks(5),
464            &tspec(),
465            &cspec(),
466            &runner,
467            |p| events.get(p).cloned(),
468            &StalledFixer,
469            &LoopConfig::default(),
470        )
471        .await;
472        assert_eq!(run.stop, LoopStop::FixStalled);
473        assert_eq!(run.rounds.len(), 1);
474        assert!(run.rounds[0].fix_attempted && !run.rounds[0].fix_applied);
475    }
476
477    #[tokio::test]
478    async fn loop_respects_max_rounds() {
479        // native improves too slowly to hit the bar within the cap → MaxRounds.
480        let fixes = Arc::new(AtomicUsize::new(0));
481        let runner = ImprovingRunner {
482            fixes: fixes.clone(),
483            treatment_passes: |_| 2, // never improves despite applied fixes
484            control_passes: 1,
485            n: 5,
486            loss_transcript: "loss.jsonl",
487        };
488        let fixer = LandingFixer { fixes };
489        let events = lever_events();
490        let cfg = LoopConfig {
491            quality_bar: 0.8,
492            require_not_behind: false,
493            max_rounds: 3,
494            min_evidence: 2,
495        };
496        let run = run_improvement_loop(
497            &tasks(5),
498            &tspec(),
499            &cspec(),
500            &runner,
501            |p| events.get(p).cloned(),
502            &fixer,
503            &cfg,
504        )
505        .await;
506        assert_eq!(run.stop, LoopStop::MaxRounds);
507        assert_eq!(run.rounds.len(), 3);
508        assert!(run.rounds.iter().all(|r| r.fix_attempted && r.fix_applied));
509    }
510
511    #[tokio::test]
512    async fn not_behind_gate_blocks_a_high_but_losing_pass_rate() {
513        // Give an explicit stats scenario: native 4/5 (≥ .8) but external wins a
514        // significant discordant set → require_not_behind blocks QualityMet.
515        // Build it via a runner where native passes t0..t4 except t4, external
516        // passes exactly the tasks native fails plus enough discordant wins.
517        // Simpler: assert quality_met directly on a crafted PairedStats.
518        let behind = PairedStats {
519            paired_tasks: 20,
520            treatment_passes: 16,
521            control_passes: 20,
522            treatment_pass_rate: 0.8,
523            control_pass_rate: 1.0,
524            pass_rate_delta: -0.2,
525            both_pass: 16,
526            treatment_only: 0,
527            control_only: 4, // external wins 4 discordant, native 0
528            both_fail: 0,
529            mcnemar_chi2: 100.0, // clearly significant
530            mcnemar_significant_05: true,
531            treatment_infra_failures: 0,
532            control_infra_failures: 0,
533            treatment_mean_cost_usd: 0.0,
534            control_mean_cost_usd: 0.0,
535            treatment_cost_per_pass: None,
536            control_cost_per_pass: None,
537        };
538        let cfg = LoopConfig {
539            quality_bar: 0.8,
540            require_not_behind: true,
541            ..LoopConfig::default()
542        };
543        assert!(
544            !quality_met(&behind, &cfg),
545            "significantly behind → not met"
546        );
547        // Same numbers, gate off → met (absolute bar cleared).
548        let cfg_off = LoopConfig {
549            require_not_behind: false,
550            ..cfg.clone()
551        };
552        assert!(quality_met(&behind, &cfg_off));
553    }
554}