Skip to main content

car_server_core/coder/
ab_fixer.rs

1//! The live [`AbFixer`]: apply the coder A/B's
2//! harness-addressable interventions through the SAME governed path
3//! `evolution.run` uses (`car_memgine::harness_evolution`), HITL-gated on the
4//! shared `ApprovalLedger`.
5//!
6//! Three honest realities shape this (see the map in the proposal):
7//!
8//! 1. **No built-in bridge.** The A/B produces `HarnessIntervention`s
9//!    (`car_eventlog::harness_adapt`); the gated apply path consumes
10//!    `HarnessMutation` + a `HarnessConfigPatch`. [`mutations_from_interventions`]
11//!    is that bridge — and it can only translate the interventions that map onto
12//!    a real tunable knob.
13//! 2. **Budgets auto-apply; the prompt does not.** `HarnessConfig` carries
14//!    `{max_retries, retry_backoff_ms, planning_max_replans}` plus, since
15//!    car#708, a `prompt_overlay`. The overlay closed the gap this note used to
16//!    describe — a prompt change was previously not applyable *at all*, so even
17//!    a correct proposal had nowhere to live and could never be measured or
18//!    rolled back. It is still **not auto-applied**: `HarnessComponent::Prompt`
19//!    is safety-affecting, because prompt text can talk a model out of rules the
20//!    prose rather than the policy layer is holding, so it routes through the
21//!    human-approval gate on the ledger. What changed is that an approved prompt
22//!    change now lands through the same governed apply/rollback path as a budget
23//!    bump instead of waiting on a hand-authored code change.
24//! 3. **The coder doesn't read `HarnessConfig` directly.** That gap is closed by
25//!    [`NativeLoopConfig::merge_harness`](super::native_loop::NativeLoopConfig::merge_harness),
26//!    which folds the applied knobs onto the coder's own budgets at the loop
27//!    build site — otherwise an applied patch would never change coder behavior
28//!    and the loop could not converge.
29//!
30//! The regression gate is the loop's own next A/B round (`run_improvement_loop`):
31//! a non-safety budget bump is applied optimistically and the re-measurement
32//! confirms or refutes it — the paper's "falsifying eval" made concrete.
33
34use std::collections::HashSet;
35
36use async_trait::async_trait;
37use car_eventlog::harness_adapt::{HarnessIntervention, InterventionLayer};
38use car_memgine::harness_evolution::{
39    mutation_fingerprint, ChangeContract, Governance, HarnessComponent, HarnessConfig,
40    HarnessConfigPatch, HarnessMutation, PromotionDecision,
41};
42use car_policy::permission::ApprovalDecision;
43
44use super::ab_loop::{AbFixer, FixResult};
45
46/// Bridge the A/B's harness-addressable interventions into concrete, applyable
47/// [`HarnessMutation`]s, relative to the `current` config (so a bump is
48/// `current + evidence`, not a fixed absolute). Only `TrajectoryRegulation`
49/// interventions map onto a tunable knob — retry thrash → `max_retries`,
50/// everything else trajectory-shaped (runtime failure / replan exhaustion /
51/// turn exhaustion) → `planning_max_replans`. `EnvironmentContract` /
52/// `ActionRealization` / `ProceduralSkill` interventions have no `HarnessConfig`
53/// knob and are deliberately dropped here (a human designs those). Duplicate
54/// mutations (same knob + target) collapse by fingerprint.
55pub fn mutations_from_interventions(
56    interventions: &[HarnessIntervention],
57    current: &HarnessConfig,
58) -> Vec<HarnessMutation> {
59    let mut out = Vec::new();
60    let mut seen = HashSet::new();
61    for iv in interventions {
62        // The prompt layer (car#708). `EnvironmentContract` is diagnosed when
63        // the model keeps proposing something the rules forbid — a recurring
64        // pattern that guidance can address and a budget cannot. Before the
65        // overlay existed these were reported as pending human design and went
66        // nowhere; now they become a real, applyable mutation.
67        //
68        // Still not auto-applied: `HarnessComponent::Prompt` is safety-affecting,
69        // so this proposal routes through the ledger's human-approval gate. What
70        // changed is that an approved one lands through the governed path
71        // instead of waiting on a hand-authored code change.
72        if iv.layer == InterventionLayer::EnvironmentContract {
73            if let Some(mutation) = prompt_overlay_mutation(iv, current) {
74                if seen.insert(mutation_fingerprint(&mutation)) {
75                    out.push(mutation);
76                }
77            }
78            continue;
79        }
80        if iv.layer != InterventionLayer::TrajectoryRegulation {
81            continue;
82        }
83        // Bump scaled by evidence, bounded so one noisy signal can't blow up a budget.
84        let bump = (iv.evidence_count as u32).clamp(1, 4);
85        let trig = iv.trigger.to_lowercase();
86        let (component, patch, predicted) = if trig.contains("retried") {
87            let target = current.max_retries.saturating_add(bump);
88            (
89                HarnessComponent::RetryConfig,
90                HarnessConfigPatch {
91                    max_retries: Some(target),
92                    ..Default::default()
93                },
94                format!(
95                    "raise max_retries {} → {} to absorb retry thrash on '{}'",
96                    current.max_retries, target, iv.target
97                ),
98            )
99        } else {
100            let target = current.planning_max_replans.saturating_add(bump);
101            (
102                HarnessComponent::PlanningConfig,
103                HarnessConfigPatch {
104                    planning_max_replans: Some(target),
105                    ..Default::default()
106                },
107                format!(
108                    "raise planning_max_replans {} → {} for recurring failure on '{}'",
109                    current.planning_max_replans, target, iv.target
110                ),
111            )
112        };
113        let mutation = HarnessMutation {
114            id: format!("ab:{}:{}", component_slug(component), iv.target),
115            contract: ChangeContract {
116                component,
117                target_failure: iv.trigger.clone(),
118                predicted_improvement: predicted,
119                invariants: vec![
120                    "no new tool, permission, or validator surface".to_string(),
121                    "coder's contract remains the trust boundary".to_string(),
122                ],
123                falsifying_eval: "the next coder A/B round's paired pass-rate does not improve"
124                    .to_string(),
125                rollback: "apply the inverse patch (restore the prior knob value)".to_string(),
126            },
127            rationale: format!(
128                "coder A/B attribution: {} (evidence {})",
129                iv.intervention, iv.evidence_count
130            ),
131            patch: Some(patch),
132        };
133        if seen.insert(mutation_fingerprint(&mutation)) {
134            out.push(mutation);
135        }
136    }
137    out
138}
139
140/// One guidance line for the prompt overlay, derived from a diagnosis.
141///
142/// Kept mechanical rather than model-authored: this is a *proposal* a human
143/// reads and approves, and a line that faithfully echoes the observed pattern is
144/// far easier to judge than one a second model paraphrased. Generating better
145/// prose (the GEPA reflection loop) is separate work — this is the path that
146/// carries it once it exists.
147fn overlay_guidance_line(iv: &HarnessIntervention) -> String {
148    format!(
149        "- {} (recurring: {})",
150        iv.intervention.trim(),
151        iv.trigger.trim()
152    )
153}
154
155/// Propose an overlay that appends this diagnosis's guidance to whatever is
156/// already in force.
157///
158/// Appending, not replacing: two diagnoses in one round must both survive, and
159/// an overlay that replaced the current text would silently drop guidance a
160/// human already approved. Returns `None` when the line is already present, so
161/// a pattern that recurs across rounds does not accrete duplicates.
162fn prompt_overlay_mutation(
163    iv: &HarnessIntervention,
164    current: &HarnessConfig,
165) -> Option<HarnessMutation> {
166    let line = overlay_guidance_line(iv);
167    let existing = current.prompt_overlay.clone().unwrap_or_default();
168    if existing.contains(&line) {
169        return None;
170    }
171    let next = if existing.trim().is_empty() {
172        line.clone()
173    } else {
174        format!("{existing}\n{line}")
175    };
176
177    Some(HarnessMutation {
178        id: format!("ab:prompt:{}", iv.target),
179        contract: ChangeContract {
180            component: HarnessComponent::Prompt,
181            target_failure: iv.trigger.clone(),
182            predicted_improvement: format!(
183                "add prompt guidance for the recurring pattern on '{}'",
184                iv.target
185            ),
186            invariants: vec![
187                "the base prompt is unchanged; guidance is appended only".to_string(),
188                "no new tool, permission, or validator surface".to_string(),
189                "coder's contract remains the trust boundary".to_string(),
190            ],
191            falsifying_eval: "the next coder A/B round's paired pass-rate does not improve"
192                .to_string(),
193            rollback: "apply the inverse patch (restore the prior overlay, or clear it)"
194                .to_string(),
195        },
196        rationale: format!(
197            "coder A/B attribution: {} (evidence {})",
198            iv.intervention, iv.evidence_count
199        ),
200        patch: Some(HarnessConfigPatch {
201            prompt_overlay: Some(next),
202            ..Default::default()
203        }),
204    })
205}
206
207fn component_slug(c: HarnessComponent) -> &'static str {
208    match c {
209        HarnessComponent::RetryConfig => "retry",
210        HarnessComponent::PlanningConfig => "planning",
211        HarnessComponent::ToolSchema => "tool_schema",
212        HarnessComponent::RetrievalPolicy => "retrieval",
213        HarnessComponent::ContextBudget => "context",
214        HarnessComponent::WorkflowTopology => "topology",
215        HarnessComponent::PermissionRule => "permission",
216        HarnessComponent::Validator => "validator",
217        HarnessComponent::Prompt => "prompt",
218    }
219}
220
221/// The daemon-side seam the fixer drives: look up a prior human decision for a
222/// mutation fingerprint on the shared ledger, and apply a mutation to the live
223/// harness config. Injected so [`EvolutionAbFixer`] is testable without a
224/// `ServerState`/runtime; the daemon impl reads `state.approval_ledger` and
225/// calls `runtime.update_harness_config(|c| c.apply(m, gov))`.
226#[async_trait]
227pub trait HarnessApply: Send + Sync {
228    async fn approval(&self, fingerprint: &str) -> Option<ApprovalDecision>;
229    async fn apply(&self, mutation: &HarnessMutation, governance: Governance)
230        -> Result<(), String>;
231}
232
233/// The live fixer. `optimistic` = apply a non-safety budget bump without a prior
234/// human approval (the next A/B round is the regression gate — the honest reading
235/// of the evolution governance for reversible non-safety knobs); when `false`,
236/// an un-approved mutation is left pending on the ledger.
237pub struct EvolutionAbFixer<A: HarnessApply> {
238    pub current: HarnessConfig,
239    pub backend: A,
240    pub optimistic: bool,
241}
242
243#[async_trait]
244impl<A: HarnessApply> AbFixer for EvolutionAbFixer<A> {
245    async fn apply(&self, interventions: &[HarnessIntervention]) -> FixResult {
246        let mutations = mutations_from_interventions(interventions, &self.current);
247        let patchless = interventions
248            .iter()
249            .filter(|i| i.layer != InterventionLayer::TrajectoryRegulation)
250            .count();
251        if mutations.is_empty() {
252            return FixResult {
253                applied: false,
254                note: format!(
255                    "no auto-applicable budget knob among {} intervention(s); {} need human design (prompt/validator/permission changes are code, not knobs)",
256                    interventions.len(),
257                    patchless
258                ),
259            };
260        }
261        let mut applied = 0usize;
262        let mut pending = 0usize;
263        let mut blocked = 0usize;
264        for m in &mutations {
265            let fp = mutation_fingerprint(m);
266            match self.backend.approval(&fp).await {
267                Some(ApprovalDecision::Rejected) => blocked += 1,
268                Some(ApprovalDecision::Approved) => {
269                    if self
270                        .backend
271                        .apply(m, Governance::HumanApproved)
272                        .await
273                        .is_ok()
274                    {
275                        applied += 1;
276                    } else {
277                        blocked += 1;
278                    }
279                }
280                None => {
281                    // Safety-affecting mutations can never be optimistic; they
282                    // are always pending a human. (The bridge only emits
283                    // Retry/Planning, so this stays defensive.)
284                    if self.optimistic && !m.requires_human_approval() {
285                        let gov = Governance::Promoted(PromotionDecision::Promote {
286                            reason:
287                                "non-safety budget bump; the next A/B round regression-gates it"
288                                    .into(),
289                        });
290                        if self.backend.apply(m, gov).await.is_ok() {
291                            applied += 1;
292                        } else {
293                            pending += 1;
294                        }
295                    } else {
296                        pending += 1;
297                    }
298                }
299            }
300        }
301        FixResult {
302            applied: applied > 0,
303            note: format!(
304                "{applied} applied, {pending} pending approval, {blocked} blocked ({} mutation(s), {patchless} non-knob intervention(s) deferred to human design)",
305                mutations.len()
306            ),
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use std::collections::HashMap;
315    use std::sync::Mutex;
316
317    fn iv(
318        layer: InterventionLayer,
319        target: &str,
320        trigger: &str,
321        evidence: usize,
322    ) -> HarnessIntervention {
323        HarnessIntervention {
324            layer,
325            target: target.into(),
326            trigger: trigger.into(),
327            intervention: "do the thing".into(),
328            evidence_count: evidence,
329        }
330    }
331
332    /// Renamed and widened for car#708: `EnvironmentContract` used to have no
333    /// knob and was dropped here. It now maps onto a prompt-overlay proposal,
334    /// so this pins which layers map onto *what* rather than asserting that
335    /// only one layer maps at all.
336    #[test]
337    fn bridge_maps_trajectory_onto_knobs_and_environment_contract_onto_the_prompt() {
338        let cur = HarnessConfig::default(); // {3, 0, 2}
339        let ivs = vec![
340            iv(
341                InterventionLayer::TrajectoryRegulation,
342                "run_command",
343                "action 'run_command' retried 3×",
344                3,
345            ),
346            iv(
347                InterventionLayer::TrajectoryRegulation,
348                "proposal:p1",
349                "replanning exhausted 2× for proposal 'p1'",
350                2,
351            ),
352            // Maps onto a prompt overlay (car#708) — it used to be dropped.
353            iv(
354                InterventionLayer::EnvironmentContract,
355                "edit_file",
356                "rejected before execution 2×",
357                2,
358            ),
359            // Still has no knob → dropped.
360            iv(
361                InterventionLayer::ActionRealization,
362                "tool_x",
363                "no tool 'tool_x'",
364                4,
365            ),
366        ];
367        let muts = mutations_from_interventions(&ivs, &cur);
368        assert_eq!(
369            muts.len(),
370            3,
371            "two trajectory knobs plus one prompt overlay; ActionRealization \
372             still has nothing to turn"
373        );
374        assert_eq!(
375            muts.iter()
376                .filter(|m| m.contract.component == HarnessComponent::Prompt)
377                .count(),
378            1
379        );
380        // retry thrash → max_retries bumped by evidence (3) → 6.
381        let retry = muts
382            .iter()
383            .find(|m| m.contract.component == HarnessComponent::RetryConfig)
384            .unwrap();
385        assert_eq!(retry.patch.as_ref().unwrap().max_retries, Some(6));
386        // replan exhaustion → planning_max_replans bumped by evidence (2) → 4.
387        let plan = muts
388            .iter()
389            .find(|m| m.contract.component == HarnessComponent::PlanningConfig)
390            .unwrap();
391        assert_eq!(plan.patch.as_ref().unwrap().planning_max_replans, Some(4));
392        // Every mutation carries a falsifying eval (the A/B re-measure).
393        assert!(muts
394            .iter()
395            .all(|m| m.contract.falsifying_eval.contains("A/B")));
396    }
397
398    #[test]
399    fn bridge_dedups_identical_bumps() {
400        let cur = HarnessConfig::default();
401        // Two runtime-failure interventions on the SAME target + evidence →
402        // identical planning patch → one mutation.
403        let ivs = vec![
404            iv(
405                InterventionLayer::TrajectoryRegulation,
406                "t",
407                "action 't' failed 2×",
408                2,
409            ),
410            iv(
411                InterventionLayer::TrajectoryRegulation,
412                "t",
413                "action 't' failed 2×",
414                2,
415            ),
416        ];
417        assert_eq!(mutations_from_interventions(&ivs, &cur).len(), 1);
418    }
419
420    /// A scriptable HarnessApply: preset ledger decisions + a record of applies.
421    struct FakeApply {
422        decisions: HashMap<String, ApprovalDecision>,
423        applied: Mutex<Vec<String>>,
424        fail_apply: bool,
425    }
426    #[async_trait]
427    impl HarnessApply for FakeApply {
428        async fn approval(&self, fp: &str) -> Option<ApprovalDecision> {
429            self.decisions.get(fp).cloned()
430        }
431        async fn apply(&self, m: &HarnessMutation, _g: Governance) -> Result<(), String> {
432            if self.fail_apply {
433                return Err("apply failed".into());
434            }
435            self.applied.lock().unwrap().push(mutation_fingerprint(m));
436            Ok(())
437        }
438    }
439
440    fn traj(target: &str, evidence: usize) -> HarnessIntervention {
441        iv(
442            InterventionLayer::TrajectoryRegulation,
443            target,
444            &format!("action '{target}' failed {evidence}×"),
445            evidence,
446        )
447    }
448
449    #[tokio::test]
450    async fn optimistic_applies_non_safety_budget_bumps() {
451        let fixer = EvolutionAbFixer {
452            current: HarnessConfig::default(),
453            backend: FakeApply {
454                decisions: HashMap::new(),
455                applied: Mutex::new(vec![]),
456                fail_apply: false,
457            },
458            optimistic: true,
459        };
460        let r = fixer.apply(&[traj("a", 2), traj("b", 1)]).await;
461        assert!(r.applied, "{}", r.note);
462        assert_eq!(fixer.backend.applied.lock().unwrap().len(), 2);
463    }
464
465    #[tokio::test]
466    async fn non_optimistic_leaves_everything_pending() {
467        let fixer = EvolutionAbFixer {
468            current: HarnessConfig::default(),
469            backend: FakeApply {
470                decisions: HashMap::new(),
471                applied: Mutex::new(vec![]),
472                fail_apply: false,
473            },
474            optimistic: false,
475        };
476        let r = fixer.apply(&[traj("a", 2)]).await;
477        assert!(!r.applied);
478        assert!(r.note.contains("1 pending"), "{}", r.note);
479        assert!(fixer.backend.applied.lock().unwrap().is_empty());
480    }
481
482    #[tokio::test]
483    async fn ledger_approval_and_rejection_are_honored() {
484        let cur = HarnessConfig::default();
485        let approved = &mutations_from_interventions(&[traj("a", 2)], &cur)[0];
486        let rejected = &mutations_from_interventions(&[traj("b", 3)], &cur)[0];
487        let mut decisions = HashMap::new();
488        decisions.insert(mutation_fingerprint(approved), ApprovalDecision::Approved);
489        decisions.insert(mutation_fingerprint(rejected), ApprovalDecision::Rejected);
490        let fixer = EvolutionAbFixer {
491            current: cur,
492            backend: FakeApply {
493                decisions,
494                applied: Mutex::new(vec![]),
495                fail_apply: false,
496            },
497            // Non-optimistic: only ledger-approved lands; rejected is blocked.
498            optimistic: false,
499        };
500        let r = fixer.apply(&[traj("a", 2), traj("b", 3)]).await;
501        assert!(r.applied);
502        assert!(
503            r.note.contains("1 applied") && r.note.contains("1 blocked"),
504            "{}",
505            r.note
506        );
507        assert_eq!(fixer.backend.applied.lock().unwrap().len(), 1);
508    }
509
510    #[tokio::test]
511    async fn no_knob_interventions_report_not_applied() {
512        let fixer = EvolutionAbFixer {
513            current: HarnessConfig::default(),
514            backend: FakeApply {
515                decisions: HashMap::new(),
516                applied: Mutex::new(vec![]),
517                fail_apply: false,
518            },
519            optimistic: true,
520        };
521        let r = fixer
522            .apply(&[iv(
523                InterventionLayer::EnvironmentContract,
524                "x",
525                "rejected 2×",
526                2,
527            )])
528            .await;
529        assert!(!r.applied);
530        assert!(r.note.contains("human design"), "{}", r.note);
531    }
532}
533
534#[cfg(test)]
535mod prompt_overlay_tests {
536    use super::*;
537
538    fn intervention(layer: InterventionLayer, target: &str) -> HarnessIntervention {
539        HarnessIntervention {
540            layer,
541            target: target.to_string(),
542            trigger: "model proposed a denied git commit".to_string(),
543            intervention: "State that committing is never the agent's job".to_string(),
544            evidence_count: 3,
545        }
546    }
547
548    /// car#708: an `EnvironmentContract` diagnosis — the model repeatedly doing
549    /// something the rules forbid — now becomes a real prompt mutation instead
550    /// of being reported as pending human design and going nowhere.
551    #[test]
552    fn an_environment_contract_diagnosis_proposes_a_prompt_overlay() {
553        let ivs = [intervention(
554            InterventionLayer::EnvironmentContract,
555            "proposal:1",
556        )];
557        let muts = mutations_from_interventions(&ivs, &HarnessConfig::default());
558
559        assert_eq!(muts.len(), 1);
560        assert_eq!(muts[0].contract.component, HarnessComponent::Prompt);
561        let overlay = muts[0]
562            .patch
563            .as_ref()
564            .unwrap()
565            .prompt_overlay
566            .as_ref()
567            .unwrap();
568        assert!(overlay.contains("State that committing is never the agent's job"));
569        assert!(overlay.contains("recurring:"));
570    }
571
572    /// It must never auto-apply: prompt text can talk a model out of rules the
573    /// prose rather than the policy layer is holding.
574    #[test]
575    fn a_prompt_mutation_requires_human_approval() {
576        let ivs = [intervention(InterventionLayer::EnvironmentContract, "p")];
577        let muts = mutations_from_interventions(&ivs, &HarnessConfig::default());
578        assert!(
579            muts[0].requires_human_approval(),
580            "a prompt change must route through the ledger, not the optimistic path"
581        );
582    }
583
584    /// Appending, not replacing — guidance a human already approved must not be
585    /// silently dropped by the next proposal.
586    #[test]
587    fn a_proposal_appends_to_the_existing_overlay() {
588        let current = HarnessConfig {
589            prompt_overlay: Some("- Existing approved guidance (recurring: x)".into()),
590            ..Default::default()
591        };
592        let ivs = [intervention(InterventionLayer::EnvironmentContract, "p")];
593        let muts = mutations_from_interventions(&ivs, &current);
594
595        let overlay = muts[0]
596            .patch
597            .as_ref()
598            .unwrap()
599            .prompt_overlay
600            .clone()
601            .unwrap();
602        assert!(overlay.contains("Existing approved guidance"));
603        assert!(overlay.contains("State that committing is never the agent's job"));
604    }
605
606    /// A pattern that recurs across rounds must not accrete duplicate lines.
607    #[test]
608    fn an_already_present_line_proposes_nothing() {
609        let iv = intervention(InterventionLayer::EnvironmentContract, "p");
610        let first =
611            mutations_from_interventions(std::slice::from_ref(&iv), &HarnessConfig::default());
612        let applied = HarnessConfig {
613            prompt_overlay: first[0].patch.as_ref().unwrap().prompt_overlay.clone(),
614            ..Default::default()
615        };
616        assert!(
617            mutations_from_interventions(std::slice::from_ref(&iv), &applied).is_empty(),
618            "the same diagnosis must not propose the same line twice"
619        );
620    }
621
622    /// Trajectory diagnoses keep proposing budget bumps — this adds a layer, it
623    /// does not redirect the existing one.
624    #[test]
625    fn trajectory_diagnoses_still_propose_budget_bumps() {
626        let mut iv = intervention(InterventionLayer::TrajectoryRegulation, "action:9");
627        iv.trigger = "action retried repeatedly".into();
628        let muts = mutations_from_interventions(&[iv], &HarnessConfig::default());
629        assert_eq!(muts[0].contract.component, HarnessComponent::RetryConfig);
630    }
631}