1use 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#[derive(Debug, Clone)]
41pub struct LoopConfig {
42 pub quality_bar: f64,
45 pub require_not_behind: bool,
49 pub max_rounds: u32,
51 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum LoopStop {
71 QualityMet,
73 NoLeverLeft,
77 FixStalled,
80 MaxRounds,
82}
83
84impl LoopStop {
85 pub fn is_success(self) -> bool {
88 matches!(self, LoopStop::QualityMet)
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FixResult {
95 pub applied: bool,
97 pub note: String,
99}
100
101#[async_trait]
105pub trait AbFixer: Send + Sync {
106 async fn apply(&self, interventions: &[HarnessIntervention]) -> FixResult;
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct RoundRecord {
112 pub round: u32,
113 pub stats: PairedStats,
114 pub attribution: RoundAttribution,
115 pub fix_attempted: bool,
117 pub fix_applied: bool,
118 pub fix_note: String,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ImprovementRun {
124 pub rounds: Vec<RoundRecord>,
125 pub stop: LoopStop,
126 pub treatment: ArmSpec,
128 pub control: ArmSpec,
129}
130
131impl ImprovementRun {
132 pub fn final_stats(&self) -> Option<&PairedStats> {
134 self.rounds.last().map(|r| &r.stats)
135 }
136
137 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
149fn quality_met(stats: &PairedStats, cfg: &LoopConfig) -> bool {
151 if stats.paired_tasks == 0 {
152 return false; }
154 if stats.treatment_pass_rate < cfg.quality_bar {
155 return false;
156 }
157 if cfg.require_not_behind {
158 let behind = stats.mcnemar_significant_05 && stats.control_only > stats.treatment_only;
161 if behind {
162 return false;
163 }
164 }
165 true
166}
167
168pub 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 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 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 }
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 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 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 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 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 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 assert!(run.rounds[0].fix_applied && run.rounds[1].fix_applied);
413 assert!(!run.rounds[2].fix_attempted);
414 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 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", };
432 let fixer = LandingFixer { fixes };
433 let events: HashMap<String, String> = HashMap::new(); 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 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 let fixes = Arc::new(AtomicUsize::new(0));
481 let runner = ImprovingRunner {
482 fixes: fixes.clone(),
483 treatment_passes: |_| 2, 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 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, both_fail: 0,
529 mcnemar_chi2: 100.0, 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 let cfg_off = LoopConfig {
549 require_not_behind: false,
550 ..cfg.clone()
551 };
552 assert!(quality_met(&behind, &cfg_off));
553 }
554}