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 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 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 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 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 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 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 assert!(run.rounds[0].fix_applied && run.rounds[1].fix_applied);
414 assert!(!run.rounds[2].fix_attempted);
415 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 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", };
433 let fixer = LandingFixer { fixes };
434 let events: HashMap<String, String> = HashMap::new(); 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 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 let fixes = Arc::new(AtomicUsize::new(0));
482 let runner = ImprovingRunner {
483 fixes: fixes.clone(),
484 treatment_passes: |_| 2, 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 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, both_fail: 0,
530 mcnemar_chi2: 100.0, 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 let cfg_off = LoopConfig {
550 require_not_behind: false,
551 ..cfg.clone()
552 };
553 assert!(quality_met(&behind, &cfg_off));
554 }
555}