1use ainl_contracts::{
4 ProcedureArtifact, ProcedurePatch, ProcedureStep, ProcedureStepKind, LEARNER_SCHEMA_VERSION,
5};
6use uuid::Uuid;
7
8#[derive(Debug, Clone, PartialEq)]
9pub struct ProcedureFailureEvidence {
10 pub failure_id: String,
11 pub summary: String,
12 pub recurrence_count: u32,
13}
14
15#[derive(Debug, Clone)]
16pub struct ProcedurePatchPolicy {
17 pub min_recurrence_count: u32,
18}
19
20impl Default for ProcedurePatchPolicy {
21 fn default() -> Self {
22 Self {
23 min_recurrence_count: 2,
24 }
25 }
26}
27
28#[must_use]
29pub fn failure_patch_candidates(
30 artifact: &ProcedureArtifact,
31 failures: &[ProcedureFailureEvidence],
32 policy: &ProcedurePatchPolicy,
33) -> Vec<ProcedurePatch> {
34 failures
35 .iter()
36 .filter(|failure| failure.recurrence_count >= policy.min_recurrence_count)
37 .map(|failure| ProcedurePatch {
38 schema_version: LEARNER_SCHEMA_VERSION,
39 patch_id: format!(
40 "patch:{}",
41 Uuid::new_v5(
42 &Uuid::NAMESPACE_OID,
43 format!("{}:{}:{}", artifact.id, failure.failure_id, failure.summary)
44 .as_bytes(),
45 )
46 ),
47 procedure_id: artifact.id.clone(),
48 rationale: format!(
49 "Recurring failure observed {} times: {}",
50 failure.recurrence_count, failure.summary
51 ),
52 add_steps: structured_steps_for_failure(&failure.summary),
53 add_known_failures: vec![failure.summary.clone()],
54 add_recovery: recovery_for_failure(&failure.summary),
55 source_failure_ids: vec![failure.failure_id.clone()],
56 })
57 .collect()
58}
59
60fn structured_steps_for_failure(summary: &str) -> Vec<ProcedureStep> {
61 let lower = summary.to_ascii_lowercase();
62 let mut steps = vec![ProcedureStep {
63 step_id: "failure-precheck".into(),
64 title: "Check recurring failure before continuing".into(),
65 kind: ProcedureStepKind::Validate {
66 target: summary.to_string(),
67 },
68 rationale: Some("Added automatically from failure recurrence.".into()),
69 }];
70 if lower.contains("syntax") || lower.contains("validat") {
71 steps.push(ProcedureStep {
72 step_id: "validation-gate".into(),
73 title: "Validate output before downstream actions".into(),
74 kind: ProcedureStepKind::Validate {
75 target: "tool response has semantic success, not just transport success".into(),
76 },
77 rationale: Some(
78 "Blocks the anti-pattern of continuing after failed validation.".into(),
79 ),
80 });
81 }
82 if lower.contains("timeout") || lower.contains("rate") {
83 steps.push(ProcedureStep {
84 step_id: "retry-budget-gate".into(),
85 title: "Apply bounded retry or backoff before changing strategy".into(),
86 kind: ProcedureStepKind::Branch {
87 condition: "transient timeout/rate limit and retry budget remains".into(),
88 },
89 rationale: Some("Avoids repeated unchanged calls during transient failures.".into()),
90 });
91 }
92 if lower.contains("permission") || lower.contains("denied") || lower.contains("policy") {
93 steps.push(ProcedureStep {
94 step_id: "human-review-policy".into(),
95 title: "Escalate policy-sensitive recovery".into(),
96 kind: ProcedureStepKind::HumanReview {
97 reason: "permission or policy failure recurred".into(),
98 },
99 rationale: Some("Prevents unsafe automatic retries after policy blocks.".into()),
100 });
101 }
102 steps
103}
104
105fn recovery_for_failure(summary: &str) -> Vec<String> {
106 let lower = summary.to_ascii_lowercase();
107 let mut recovery = vec![
108 "Change inputs or repair the failed precondition before retrying.".into(),
109 "Do not continue downstream from a failed validation or blocked tool result.".into(),
110 ];
111 if lower.contains("syntax") || lower.contains("validat") {
112 recovery.push(
113 "Re-run validation and require semantic success before claiming completion.".into(),
114 );
115 }
116 if lower.contains("timeout") || lower.contains("rate") {
117 recovery.push(
118 "Use bounded retry/backoff, then switch strategy instead of repeating the same call."
119 .into(),
120 );
121 }
122 recovery
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use ainl_contracts::{ProcedureArtifactFormat, ProcedureLifecycle, ProcedureVerification};
129
130 fn artifact() -> ProcedureArtifact {
131 ProcedureArtifact {
132 schema_version: LEARNER_SCHEMA_VERSION,
133 id: "proc:test".into(),
134 title: "Test".into(),
135 intent: "test".into(),
136 summary: "summary".into(),
137 required_tools: vec![],
138 required_adapters: vec![],
139 inputs: vec![],
140 outputs: vec![],
141 preconditions: vec![],
142 steps: vec![],
143 verification: ProcedureVerification::default(),
144 known_failures: vec![],
145 recovery: vec![],
146 source_trajectory_ids: vec![],
147 source_failure_ids: vec![],
148 fitness: 0.8,
149 observation_count: 3,
150 lifecycle: ProcedureLifecycle::Candidate,
151 render_targets: vec![ProcedureArtifactFormat::PromptOnly],
152 }
153 }
154
155 #[test]
156 fn emits_patch_only_above_recurrence_threshold() {
157 let patches = failure_patch_candidates(
158 &artifact(),
159 &[
160 ProcedureFailureEvidence {
161 failure_id: "f1".into(),
162 summary: "timeout".into(),
163 recurrence_count: 1,
164 },
165 ProcedureFailureEvidence {
166 failure_id: "f2".into(),
167 summary: "syntax invalid".into(),
168 recurrence_count: 2,
169 },
170 ],
171 &ProcedurePatchPolicy::default(),
172 );
173 assert_eq!(patches.len(), 1);
174 assert_eq!(patches[0].source_failure_ids, vec!["f2"]);
175 assert!(patches[0].add_steps.len() >= 2);
176 assert!(patches[0]
177 .add_recovery
178 .iter()
179 .any(|r| r.contains("validation")));
180 }
181}