Skip to main content

ainl_contracts/
procedure.rs

1//! Portable procedure-learning contracts.
2//!
3//! These types are intentionally host-neutral: ArmaraOS/OpenFang can render them as skills or
4//! hands, the Python AINL runtime can render executable graph source, and other hosts can keep
5//! them as JSON graph-shaped procedures.
6
7use serde::{Deserialize, Serialize};
8
9use crate::{CognitiveVitals, ContextFreshness, ImpactDecision, TrajectoryOutcome, TrajectoryStep};
10
11/// Lifecycle state for a reusable procedure artifact.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum ProcedureLifecycle {
15    #[default]
16    Draft,
17    Candidate,
18    Validated,
19    Promoted,
20    Deprecated,
21}
22
23/// Render/execution targets for a procedure artifact.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ProcedureArtifactFormat {
27    MarkdownSkill,
28    OpenFangSkill,
29    AinlGraph,
30    Hand,
31    PromptOnly,
32}
33
34/// One normalized event in an experience bundle.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct ExperienceEvent {
37    pub event_id: String,
38    pub timestamp_ms: i64,
39    pub tool_or_adapter: String,
40    pub operation: String,
41    pub success: bool,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub input_preview: Option<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub output_preview: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub error: Option<String>,
48    #[serde(default)]
49    pub duration_ms: u64,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub vitals: Option<CognitiveVitals>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub freshness_at_step: Option<ContextFreshness>,
54}
55
56impl From<&TrajectoryStep> for ExperienceEvent {
57    fn from(step: &TrajectoryStep) -> Self {
58        Self {
59            event_id: step.step_id.clone(),
60            timestamp_ms: step.timestamp_ms,
61            tool_or_adapter: step.adapter.clone(),
62            operation: step.operation.clone(),
63            success: step.success,
64            input_preview: step.inputs_preview.clone(),
65            output_preview: step.outputs_preview.clone(),
66            error: step.error.clone(),
67            duration_ms: step.duration_ms,
68            vitals: step.vitals.clone(),
69            freshness_at_step: step.freshness_at_step,
70        }
71    }
72}
73
74/// Host-neutral evidence packet used to mint or patch reusable procedures.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct ExperienceBundle {
77    pub schema_version: u32,
78    pub bundle_id: String,
79    pub agent_id: String,
80    pub intent: String,
81    pub outcome: TrajectoryOutcome,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub host_outcome: Option<String>,
84    pub observation_count: u32,
85    pub fitness: f32,
86    pub events: Vec<ExperienceEvent>,
87    #[serde(default)]
88    pub source_trajectory_ids: Vec<String>,
89    #[serde(default)]
90    pub source_failure_ids: Vec<String>,
91    pub freshness: ContextFreshness,
92    pub impact_decision: ImpactDecision,
93}
94
95/// Structured procedure step, rich enough to render to Markdown or graph forms.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case", tag = "kind")]
98pub enum ProcedureStepKind {
99    ToolCall {
100        tool: String,
101        #[serde(default)]
102        args_schema: serde_json::Value,
103    },
104    AdapterCall {
105        adapter: String,
106        op: String,
107    },
108    Validate {
109        target: String,
110    },
111    Branch {
112        condition: String,
113    },
114    HumanReview {
115        reason: String,
116    },
117    Instruction {
118        text: String,
119    },
120}
121
122/// One step in a reusable procedure.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct ProcedureStep {
125    pub step_id: String,
126    pub title: String,
127    pub kind: ProcedureStepKind,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub rationale: Option<String>,
130}
131
132/// Verification guidance attached to a procedure.
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
134pub struct ProcedureVerification {
135    #[serde(default)]
136    pub checks: Vec<String>,
137    #[serde(default)]
138    pub success_criteria: Vec<String>,
139}
140
141/// Canonical reusable procedure. Renderers produce SKILL.md, skill.toml, AINL graph skeletons, etc.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct ProcedureArtifact {
144    pub schema_version: u32,
145    pub id: String,
146    pub title: String,
147    pub intent: String,
148    pub summary: String,
149    #[serde(default)]
150    pub required_tools: Vec<String>,
151    #[serde(default)]
152    pub required_adapters: Vec<String>,
153    #[serde(default)]
154    pub inputs: Vec<String>,
155    #[serde(default)]
156    pub outputs: Vec<String>,
157    #[serde(default)]
158    pub preconditions: Vec<String>,
159    #[serde(default)]
160    pub steps: Vec<ProcedureStep>,
161    #[serde(default)]
162    pub verification: ProcedureVerification,
163    #[serde(default)]
164    pub known_failures: Vec<String>,
165    #[serde(default)]
166    pub recovery: Vec<String>,
167    #[serde(default)]
168    pub source_trajectory_ids: Vec<String>,
169    #[serde(default)]
170    pub source_failure_ids: Vec<String>,
171    pub fitness: f32,
172    pub observation_count: u32,
173    pub lifecycle: ProcedureLifecycle,
174    #[serde(default)]
175    pub render_targets: Vec<ProcedureArtifactFormat>,
176}
177
178/// Patch proposal against an existing procedure artifact.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct ProcedurePatch {
181    pub schema_version: u32,
182    pub patch_id: String,
183    pub procedure_id: String,
184    pub rationale: String,
185    #[serde(default)]
186    pub add_steps: Vec<ProcedureStep>,
187    #[serde(default)]
188    pub add_known_failures: Vec<String>,
189    #[serde(default)]
190    pub add_recovery: Vec<String>,
191    #[serde(default)]
192    pub source_failure_ids: Vec<String>,
193}
194
195/// Outcome of trying to reuse a validated procedure.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct ProcedureReuseOutcome {
198    pub procedure_id: String,
199    pub outcome: TrajectoryOutcome,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub failure_id: Option<String>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub notes: Option<String>,
204}
205
206/// Portable deterministic execution payload derived from a [`ProcedureArtifact`].
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct ProcedureExecutionPlan {
209    pub procedure_id: String,
210    pub schema_version: u32,
211    #[serde(default)]
212    pub steps: Vec<ProcedureExecutionStep>,
213    #[serde(default)]
214    pub verification: ProcedureVerification,
215}
216
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct ProcedureExecutionStep {
219    pub step_id: String,
220    pub title: String,
221    pub executor: String,
222    pub operation: String,
223    #[serde(default)]
224    pub args_schema: serde_json::Value,
225    #[serde(default)]
226    pub depends_on: Vec<String>,
227    #[serde(default)]
228    pub on_error: String,
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::{TrajectoryOutcome, LEARNER_SCHEMA_VERSION};
235
236    #[test]
237    fn procedure_artifact_roundtrips_json() {
238        let p = ProcedureArtifact {
239            schema_version: LEARNER_SCHEMA_VERSION,
240            id: "proc:demo".into(),
241            title: "Demo".into(),
242            intent: "Do a demo".into(),
243            summary: "Reusable demo flow".into(),
244            required_tools: vec!["file_read".into()],
245            required_adapters: vec![],
246            inputs: vec!["path".into()],
247            outputs: vec!["summary".into()],
248            preconditions: vec!["Workspace exists".into()],
249            steps: vec![ProcedureStep {
250                step_id: "step-1".into(),
251                title: "Read file".into(),
252                kind: ProcedureStepKind::ToolCall {
253                    tool: "file_read".into(),
254                    args_schema: serde_json::json!({"type":"object"}),
255                },
256                rationale: None,
257            }],
258            verification: ProcedureVerification {
259                checks: vec!["Confirm output is non-empty".into()],
260                success_criteria: vec!["Output summarizes file".into()],
261            },
262            known_failures: vec![],
263            recovery: vec![],
264            source_trajectory_ids: vec!["traj-1".into()],
265            source_failure_ids: vec![],
266            fitness: 0.9,
267            observation_count: 3,
268            lifecycle: ProcedureLifecycle::Candidate,
269            render_targets: vec![ProcedureArtifactFormat::MarkdownSkill],
270        };
271        let j = serde_json::to_value(&p).unwrap();
272        let back: ProcedureArtifact = serde_json::from_value(j).unwrap();
273        assert_eq!(back, p);
274    }
275
276    #[test]
277    fn experience_event_from_trajectory_step() {
278        let step = TrajectoryStep {
279            step_id: "s1".into(),
280            timestamp_ms: 10,
281            adapter: "tool".into(),
282            operation: "file_read".into(),
283            inputs_preview: Some("in".into()),
284            outputs_preview: Some("out".into()),
285            duration_ms: 5,
286            success: true,
287            error: None,
288            vitals: None,
289            freshness_at_step: None,
290            frame_vars: None,
291            tool_telemetry: None,
292        };
293        let event = ExperienceEvent::from(&step);
294        assert_eq!(event.operation, "file_read");
295        assert!(event.success);
296        assert_eq!(event.duration_ms, 5);
297    }
298
299    #[test]
300    fn reuse_outcome_serializes_failure() {
301        let outcome = ProcedureReuseOutcome {
302            procedure_id: "proc:demo".into(),
303            outcome: TrajectoryOutcome::Failure,
304            failure_id: Some("failure-1".into()),
305            notes: None,
306        };
307        let j = serde_json::to_string(&outcome).unwrap();
308        assert!(j.contains("failure"));
309    }
310}