Skip to main content

agm_core/model/
node.rs

1//! AGM node type (spec S11, S12).
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::code::CodeBlock;
8use super::context::AgentContext;
9use super::execution::ExecutionStatus;
10use super::fields::{Confidence, FieldValue, NodeStatus, NodeType, Priority, Span, Stability};
11use super::memory::MemoryEntry;
12use super::orchestration::ParallelGroup;
13use super::verify::VerifyCheck;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct Node {
17    #[serde(rename = "node")]
18    pub id: String,
19    #[serde(rename = "type")]
20    pub node_type: NodeType,
21    pub summary: String,
22
23    // Control fields
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub priority: Option<Priority>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub stability: Option<Stability>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub confidence: Option<Confidence>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub status: Option<NodeStatus>,
32
33    // Relationship fields
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub depends: Option<Vec<String>>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub related_to: Option<Vec<String>>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub replaces: Option<Vec<String>>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub conflicts: Option<Vec<String>>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub see_also: Option<Vec<String>>,
44
45    // Operational fields
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub items: Option<Vec<String>>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub steps: Option<Vec<String>>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub fields: Option<Vec<String>>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub input: Option<Vec<String>>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub output: Option<Vec<String>>,
56
57    // Explanatory fields
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub detail: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub rationale: Option<Vec<String>>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub tradeoffs: Option<Vec<String>>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub resolution: Option<Vec<String>>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub examples: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub notes: Option<String>,
70
71    // Executable fields
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub code: Option<CodeBlock>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub code_blocks: Option<Vec<CodeBlock>>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub verify: Option<Vec<VerifyCheck>>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub agent_context: Option<AgentContext>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub target: Option<String>,
82
83    // Execution state fields
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub execution_status: Option<ExecutionStatus>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub executed_by: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub executed_at: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub execution_log: Option<String>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub retry_count: Option<u32>,
94
95    // Orchestration fields
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub parallel_groups: Option<Vec<ParallelGroup>>,
98
99    // Memory fields
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub memory: Option<Vec<MemoryEntry>>,
102
103    // Context fields
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub scope: Option<Vec<String>>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub applies_when: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub valid_from: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub valid_until: Option<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub tags: Option<Vec<String>>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub aliases: Option<Vec<String>>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub keywords: Option<Vec<String>>,
118
119    // Extension fields
120    #[serde(flatten)]
121    pub extra_fields: BTreeMap<String, FieldValue>,
122
123    // Source span (not serialized)
124    #[serde(skip)]
125    pub span: Span,
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn minimal_node() -> Node {
133        Node {
134            id: "test.node".to_owned(),
135            node_type: NodeType::Facts,
136            summary: "a test node".to_owned(),
137            priority: None,
138            stability: None,
139            confidence: None,
140            status: None,
141            depends: None,
142            related_to: None,
143            replaces: None,
144            conflicts: None,
145            see_also: None,
146            items: None,
147            steps: None,
148            fields: None,
149            input: None,
150            output: None,
151            detail: None,
152            rationale: None,
153            tradeoffs: None,
154            resolution: None,
155            examples: None,
156            notes: None,
157            code: None,
158            code_blocks: None,
159            verify: None,
160            agent_context: None,
161            target: None,
162            execution_status: None,
163            executed_by: None,
164            executed_at: None,
165            execution_log: None,
166            retry_count: None,
167            parallel_groups: None,
168            memory: None,
169            scope: None,
170            applies_when: None,
171            valid_from: None,
172            valid_until: None,
173            tags: None,
174            aliases: None,
175            keywords: None,
176            extra_fields: BTreeMap::new(),
177            span: Span::new(1, 1),
178        }
179    }
180
181    #[test]
182    fn test_node_minimal_serde_roundtrip() {
183        let node = minimal_node();
184        let json = serde_json::to_string(&node).unwrap();
185        let back: Node = serde_json::from_str(&json).unwrap();
186        assert_eq!(node.id, back.id);
187        assert_eq!(node.node_type, back.node_type);
188        assert_eq!(node.summary, back.summary);
189    }
190
191    #[test]
192    fn test_node_json_uses_spec_field_names() {
193        let node = minimal_node();
194        let json = serde_json::to_string(&node).unwrap();
195        assert!(json.contains("\"node\""));
196        assert!(json.contains("\"type\""));
197        assert!(!json.contains("\"id\""));
198        assert!(!json.contains("\"node_type\""));
199    }
200
201    #[test]
202    fn test_node_optional_fields_absent_in_json() {
203        let node = minimal_node();
204        let json = serde_json::to_string(&node).unwrap();
205        assert!(!json.contains("priority"));
206        assert!(!json.contains("depends"));
207        assert!(!json.contains("steps"));
208        assert!(!json.contains("code"));
209        assert!(!json.contains("execution_status"));
210        assert!(!json.contains("memory"));
211    }
212
213    #[test]
214    fn test_node_with_relationships_serde() {
215        let mut node = minimal_node();
216        node.depends = Some(vec!["auth.constraints".to_owned()]);
217        node.related_to = Some(vec!["auth.session".to_owned()]);
218        let json = serde_json::to_string(&node).unwrap();
219        let back: Node = serde_json::from_str(&json).unwrap();
220        assert_eq!(node.depends, back.depends);
221        assert_eq!(node.related_to, back.related_to);
222    }
223
224    #[test]
225    fn test_node_with_execution_state_serde() {
226        let mut node = minimal_node();
227        node.execution_status = Some(ExecutionStatus::Completed);
228        node.executed_by = Some("agent-01".to_owned());
229        node.executed_at = Some("2026-04-03T14:32:00Z".to_owned());
230        node.retry_count = Some(0);
231        let json = serde_json::to_string(&node).unwrap();
232        let back: Node = serde_json::from_str(&json).unwrap();
233        assert_eq!(node.execution_status, back.execution_status);
234        assert_eq!(node.executed_by, back.executed_by);
235    }
236
237    #[test]
238    fn test_node_extra_fields_preserved() {
239        let mut node = minimal_node();
240        node.extra_fields.insert(
241            "custom_field".to_owned(),
242            FieldValue::Scalar("value".to_owned()),
243        );
244        let json = serde_json::to_string(&node).unwrap();
245        assert!(json.contains("custom_field"));
246        let back: Node = serde_json::from_str(&json).unwrap();
247        assert_eq!(
248            back.extra_fields.get("custom_field"),
249            Some(&FieldValue::Scalar("value".to_owned()))
250        );
251    }
252
253    #[test]
254    fn test_node_span_not_serialized() {
255        let mut node = minimal_node();
256        node.span = Span::new(5, 20);
257        let json = serde_json::to_string(&node).unwrap();
258        assert!(!json.contains("start_line"));
259        assert!(!json.contains("end_line"));
260        assert!(!json.contains("span"));
261    }
262
263    #[test]
264    fn test_node_deserialize_from_spec_appendix_b() {
265        let json = r#"{
266            "node": "auth.login",
267            "type": "workflow",
268            "stability": "medium",
269            "priority": "critical",
270            "depends": ["auth.constraints", "auth.session"],
271            "input": ["host", "return_url"],
272            "output": ["redirect_url", "sid_cookie"],
273            "summary": "resolve tenant -> redirect -> callback -> create sid",
274            "steps": ["resolve tenant", "redirect to provider"],
275            "execution_status": "pending",
276            "retry_count": 0
277        }"#;
278        let node: Node = serde_json::from_str(json).unwrap();
279        assert_eq!(node.id, "auth.login");
280        assert_eq!(node.node_type, NodeType::Workflow);
281        assert_eq!(node.priority, Some(Priority::Critical));
282        assert_eq!(node.stability, Some(Stability::Medium));
283        assert_eq!(node.depends.as_ref().unwrap().len(), 2);
284        assert_eq!(node.steps.as_ref().unwrap().len(), 2);
285        assert_eq!(node.execution_status, Some(ExecutionStatus::Pending));
286        assert_eq!(node.retry_count, Some(0));
287    }
288}