1use car_eventlog::harness_adapt::{HarnessIntervention, InterventionLayer};
20use serde::{Deserialize, Serialize};
21
22use super::ab::{AbReport, RoundAttribution};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ProposalKind {
32 HarnessAddressable,
33 BackboneBound,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum Confidence {
40 Strong,
42 Tentative,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DurableFixProposal {
49 pub title: String,
51 pub pattern: String,
53 pub target_component: String,
55 pub target_hint: String,
57 pub proposed_change: String,
59 pub evidence_tasks: Vec<String>,
61 pub evidence_count: usize,
63 pub kind: ProposalKind,
64 pub confidence: Confidence,
65 pub priority: u32,
67}
68
69fn 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(), evidence_count,
140 kind: ProposalKind::HarnessAddressable,
141 confidence: Confidence::Strong,
142 priority: 100 + evidence_count as u32,
144 }
145}
146
147fn 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 priority: 40 + tasks.len() as u32,
175 }
176}
177
178pub 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 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
205pub 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 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 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}