Skip to main content

ainl_failure_learning/
procedure_patch.rs

1//! Procedure patch candidates derived from recurrent failure evidence.
2
3use 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}