Skip to main content

car_server_core/coder/
ab_learnings.rs

1//! Turn a coder A/B round into ranked, evidence-backed **durable code-fix
2//! proposals** — the "learn what durable fix to make to CAR" step.
3//!
4//! This is deliberately NOT the runtime-knob path (`ab_fixer`). The valuable
5//! output of dogfooding is a *durable source change* — the kind landed by hand
6//! this cycle (the coder prompt's set()-order guidance that lifted a corpus
7//! 55%→100%, the contract-derivation repair, the MLX device-lock fix). The
8//! synthesizer folds the A/B's losses + attribution into concrete proposals that
9//! say **which CAR component to change, roughly where, what change, and on what
10//! evidence** — ranked so the highest-leverage fix is acted on first. A human
11//! (or a coding agent) implements and merges; the next A/B round is the gate.
12//!
13//! Deterministic by design (like the rest of the `ab` machinery): it maps
14//! diagnosed failure mechanisms onto CAR's real coder components via a curated
15//! table. It does not invent the *specific* fix — that needs a human reading the
16//! evidence (or an injected LLM refiner, a documented extension) — it narrows
17//! **where to look and what class of change**, which is the expensive part.
18
19use car_eventlog::harness_adapt::{HarnessIntervention, InterventionLayer};
20use serde::{Deserialize, Serialize};
21
22use super::ab::{AbReport, RoundAttribution};
23
24/// Whether the pattern is one the harness can fix (a diagnosable interaction
25/// failure) or one that looks like the model reasoning wrong (a clean run, wrong
26/// answer). Both can warrant a durable code fix — the biggest win this cycle
27/// (the pitfall-guidance prompt change) targeted a *backbone-bound* cluster —
28/// but the confidence and the kind of change differ.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ProposalKind {
32    HarnessAddressable,
33    BackboneBound,
34}
35
36/// How strongly the evidence points at the proposed change.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum Confidence {
40    /// A recurring, diagnosable interaction pattern → a specific component.
41    Strong,
42    /// A real signal, but the specific fix needs a human to read the failures.
43    Tentative,
44}
45
46/// One durable code-fix proposal targeting CAR's own source.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DurableFixProposal {
49    /// Short imperative title.
50    pub title: String,
51    /// The failure pattern this addresses (human-readable).
52    pub pattern: String,
53    /// Which CAR component to change.
54    pub target_component: String,
55    /// A file/area hint into CAR's source (best-effort — verify before editing).
56    pub target_hint: String,
57    /// The proposed durable change (a class of edit, not a patch).
58    pub proposed_change: String,
59    /// Task ids that evidence this pattern.
60    pub evidence_tasks: Vec<String>,
61    /// Recurrence weight (summed evidence / loss count).
62    pub evidence_count: usize,
63    pub kind: ProposalKind,
64    pub confidence: Confidence,
65    /// Higher acts first. Derived from evidence + confidence.
66    pub priority: u32,
67}
68
69/// Map a diagnosed harness intervention onto a CAR coder component + a proposed
70/// durable change. Grounded in CAR's actual coder source (`coder/native_loop.rs`
71/// system prompt, `coder/contract.rs`, `car-inference` tool rendering).
72fn proposal_from_intervention(iv: &HarnessIntervention) -> DurableFixProposal {
73    let trig = iv.trigger.to_lowercase();
74    let (component, hint, change) = match iv.layer {
75        InterventionLayer::EnvironmentContract => (
76            "coder tool contract / system prompt",
77            "coder/native_loop.rs (system_prompt) or the tool descriptions",
78            format!(
79                "The model repeatedly proposes a call the runtime rejects for '{}'. Durably clarify \
80                 the tool's description/constraints (or the prompt's guidance about it) so the model \
81                 stops proposing the disallowed/ill-formed call up front.",
82                iv.target
83            ),
84        ),
85        InterventionLayer::ActionRealization => (
86            "tool schema / prompt tool-call format",
87            "car-inference render_chat_prompt/parse_tool_calls or coder/native_loop.rs prompt",
88            format!(
89                "The model emits structurally-invalid calls to '{}'. Durably fix the tool-call \
90                 rendering/parsing or tighten the prompt's format guidance so valid calls are the \
91                 default, rather than relying on retries.",
92                iv.target
93            ),
94        ),
95        InterventionLayer::TrajectoryRegulation => {
96            if trig.contains("retried") || trig.contains("failed") {
97                (
98                    "coder repair discipline",
99                    "coder/native_loop.rs (system_prompt 'read the actual error before retrying' + failure_feedback)",
100                    format!(
101                        "'{}' fails/retries repeatedly without recovering. Durably strengthen the \
102                         read-the-error-then-fix-the-named-cause discipline (prompt + the failure \
103                         feedback the repair loop injects), so the coder converges instead of \
104                         thrashing.",
105                        iv.target
106                    ),
107                )
108            } else {
109                (
110                    "coder repair loop / iteration handling",
111                    "coder/native_loop.rs (max_iterations, failure_feedback) or the replan path",
112                    format!(
113                        "Repair for '{}' gives up (replanning exhausted). Durably improve how the \
114                         coder carries context across repair rounds (or reconsiders the approach) so \
115                         it doesn't burn the budget re-deriving the same dead end.",
116                        iv.target
117                    ),
118                )
119            }
120        }
121        InterventionLayer::ProceduralSkill => (
122            "skill distillation",
123            "car-memgine skill distillation / coder skill_memory",
124            format!(
125                "A reusable procedure keeps being re-derived for '{}'. Durably distill it as a coder \
126                 skill so future sessions recall it.",
127                iv.target
128            ),
129        ),
130    };
131    let evidence_count = iv.evidence_count;
132    DurableFixProposal {
133        title: format!("Fix {component} for recurring failure on '{}'", iv.target),
134        pattern: iv.trigger.clone(),
135        target_component: component.to_string(),
136        target_hint: hint.to_string(),
137        proposed_change: change,
138        evidence_tasks: Vec::new(), // filled by the caller (attribution loses the per-task link)
139        evidence_count,
140        kind: ProposalKind::HarnessAddressable,
141        confidence: Confidence::Strong,
142        // Harness-addressable + strong: base 100 + evidence.
143        priority: 100 + evidence_count as u32,
144    }
145}
146
147/// The backbone-bound cluster: native lost these tasks running *clean* (no
148/// diagnosable interaction pattern) — i.e. it reasoned wrong. Often still fixable
149/// with a durable prompt-discipline change (the set()-order guidance that lifted
150/// 55%→100% targeted exactly this), but the *specific* guidance needs a human to
151/// read the failing cases and name the common error class.
152fn backbone_bound_proposal(tasks: &[String]) -> DurableFixProposal {
153    DurableFixProposal {
154        title: format!(
155            "Inspect {} clean-but-wrong loss(es) for a common error class",
156            tasks.len()
157        ),
158        pattern: "coder ran cleanly but produced a wrong answer (no diagnosable interaction failure)"
159            .to_string(),
160        target_component: "coder system prompt (task discipline)".to_string(),
161        target_hint: "coder/native_loop.rs (system_prompt)".to_string(),
162        proposed_change:
163            "Read these failing cases and name the common mistake (e.g. losing collection order, \
164             off-by-one, wrong edge case). If they share one, add targeted pitfall guidance to the \
165             coder's system prompt — the same move that took a corpus 55%→100% by warning against \
166             set() dropping order. If they don't share one, this is a backbone limit, not a harness \
167             fix — record it and move on."
168                .to_string(),
169        evidence_tasks: tasks.to_vec(),
170        evidence_count: tasks.len(),
171        kind: ProposalKind::BackboneBound,
172        confidence: Confidence::Tentative,
173        // Below harness-addressable, above nothing: base 40 + count.
174        priority: 40 + tasks.len() as u32,
175    }
176}
177
178/// Synthesize ranked durable-fix proposals from one A/B round. `report` supplies
179/// the loss set; `attribution` supplies the diagnosed patterns. Proposals are
180/// returned highest-priority first.
181pub fn synthesize_proposals(
182    _report: &AbReport,
183    attribution: &RoundAttribution,
184) -> Vec<DurableFixProposal> {
185    let mut proposals: Vec<DurableFixProposal> = attribution
186        .interventions
187        .iter()
188        .map(proposal_from_intervention)
189        .collect();
190
191    if !attribution.backbone_bound_losses.is_empty() {
192        proposals.push(backbone_bound_proposal(&attribution.backbone_bound_losses));
193    }
194
195    // Highest priority first; ties broken by evidence then title for stability.
196    proposals.sort_by(|a, b| {
197        b.priority
198            .cmp(&a.priority)
199            .then(b.evidence_count.cmp(&a.evidence_count))
200            .then(a.title.cmp(&b.title))
201    });
202    proposals
203}
204
205/// Render proposals as a human-facing report (for `car coder-ab` output / a
206/// checked-in learnings file).
207pub fn render_proposals(proposals: &[DurableFixProposal]) -> String {
208    if proposals.is_empty() {
209        return "No durable-fix proposals — no attributable coder losses this round.".to_string();
210    }
211    let mut out = format!("Durable code-fix proposals ({}):\n", proposals.len());
212    for (i, p) in proposals.iter().enumerate() {
213        out.push_str(&format!(
214            "\n{}. [{:?}, {:?}, priority {}] {}\n   pattern: {}\n   target: {} — {}\n   change: {}\n",
215            i + 1,
216            p.kind,
217            p.confidence,
218            p.priority,
219            p.title,
220            p.pattern,
221            p.target_component,
222            p.target_hint,
223            p.proposed_change,
224        ));
225        if !p.evidence_tasks.is_empty() {
226            out.push_str(&format!(
227                "   evidence: {} task(s): {}\n",
228                p.evidence_count,
229                p.evidence_tasks.join(", ")
230            ));
231        } else {
232            out.push_str(&format!("   evidence: recurrence {}\n", p.evidence_count));
233        }
234    }
235    out
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use car_eventlog::harness_adapt::HarnessIntervention;
242
243    fn iv(layer: InterventionLayer, target: &str, trigger: &str, ev: usize) -> HarnessIntervention {
244        HarnessIntervention {
245            layer,
246            target: target.into(),
247            trigger: trigger.into(),
248            intervention: "x".into(),
249            evidence_count: ev,
250        }
251    }
252
253    fn attribution(
254        interventions: Vec<HarnessIntervention>,
255        backbone: Vec<&str>,
256    ) -> RoundAttribution {
257        RoundAttribution {
258            harness_addressable_losses: vec![],
259            backbone_bound_losses: backbone.into_iter().map(String::from).collect(),
260            interventions,
261        }
262    }
263
264    // A minimal AbReport (unused by the synthesizer beyond presence).
265    fn empty_report() -> AbReport {
266        AbReport::from_cells(
267            vec![],
268            crate::coder::ab::ArmSpec::native(Some("parslee/reasoning".into())),
269            crate::coder::ab::ArmSpec::external("codex", Some("parslee/reasoning".into())),
270        )
271    }
272
273    #[test]
274    fn trajectory_retry_maps_to_repair_discipline() {
275        let attr = attribution(
276            vec![iv(
277                InterventionLayer::TrajectoryRegulation,
278                "run_command",
279                "action 'run_command' retried 4×",
280                4,
281            )],
282            vec![],
283        );
284        let ps = synthesize_proposals(&empty_report(), &attr);
285        assert_eq!(ps.len(), 1);
286        assert_eq!(ps[0].kind, ProposalKind::HarnessAddressable);
287        assert!(ps[0].target_hint.contains("native_loop.rs"));
288        assert!(ps[0].proposed_change.contains("read-the-error"));
289    }
290
291    #[test]
292    fn realization_maps_to_tool_rendering() {
293        let attr = attribution(
294            vec![iv(
295                InterventionLayer::ActionRealization,
296                "edit_file",
297                "no tool 'edit_file'",
298                3,
299            )],
300            vec![],
301        );
302        let ps = synthesize_proposals(&empty_report(), &attr);
303        assert!(
304            ps[0].target_hint.contains("render_chat_prompt")
305                || ps[0].target_hint.contains("parse_tool_calls")
306        );
307    }
308
309    #[test]
310    fn backbone_bound_cluster_targets_the_prompt_with_the_55_to_100_precedent() {
311        let attr = attribution(vec![], vec!["dedup", "sort_stable"]);
312        let ps = synthesize_proposals(&empty_report(), &attr);
313        assert_eq!(ps.len(), 1);
314        let p = &ps[0];
315        assert_eq!(p.kind, ProposalKind::BackboneBound);
316        assert_eq!(p.confidence, Confidence::Tentative);
317        assert_eq!(p.evidence_tasks, vec!["dedup", "sort_stable"]);
318        assert!(p.proposed_change.contains("55%→100%"));
319    }
320
321    #[test]
322    fn harness_addressable_outranks_backbone_bound() {
323        // A weak harness intervention (evidence 2) still outranks a big
324        // backbone cluster (5) — a diagnosable fix is more actionable.
325        let attr = attribution(
326            vec![iv(
327                InterventionLayer::TrajectoryRegulation,
328                "t",
329                "action 't' failed 2×",
330                2,
331            )],
332            vec!["a", "b", "c", "d", "e"],
333        );
334        let ps = synthesize_proposals(&empty_report(), &attr);
335        assert_eq!(ps.len(), 2);
336        assert_eq!(ps[0].kind, ProposalKind::HarnessAddressable);
337        assert_eq!(ps[1].kind, ProposalKind::BackboneBound);
338    }
339
340    #[test]
341    fn empty_attribution_yields_no_proposals() {
342        let ps = synthesize_proposals(&empty_report(), &attribution(vec![], vec![]));
343        assert!(ps.is_empty());
344        assert!(render_proposals(&ps).contains("No durable-fix proposals"));
345    }
346
347    #[test]
348    fn render_is_human_readable() {
349        let attr = attribution(
350            vec![iv(
351                InterventionLayer::EnvironmentContract,
352                "shell",
353                "action 'shell' rejected 3×",
354                3,
355            )],
356            vec!["x"],
357        );
358        let text = render_proposals(&synthesize_proposals(&empty_report(), &attr));
359        assert!(text.contains("Durable code-fix proposals (2)"));
360        assert!(text.contains("target:"));
361        assert!(text.contains("change:"));
362    }
363}