1use crate::dag::schema::{DagDefinition, DagNodeType};
7use std::collections::{HashMap, HashSet};
8
9#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
11pub enum DagValidationError {
12 #[error("DAG 内にノードが 1 つも定義されていません。")]
14 EmptyNodes,
15
16 #[error("エントリノード '{0}' が nodes マップ内に存在しません。")]
18 EntryNodeNotFound(String),
19
20 #[error("ノード '{from_node}' から未定義のターゲットノード '{target_node}' を参照しています。")]
22 TargetNodeNotFound {
23 from_node: String,
25 target_node: String,
27 },
28
29 #[error("DAG 内に循環参照 (閉路) が検出されました: {0}。")]
31 CycleDetected(String),
32
33 #[error("ノード '{node_id}' ({node_type:?}) の定義が不正です: {reason}。")]
35 InvalidNodeSpec {
36 node_id: String,
38 node_type: DagNodeType,
40 reason: String,
42 },
43
44 #[error("エントリノードから到達不能な孤立ノードが検出されました: {0:?}。")]
46 UnreachableNodes(Vec<String>),
47}
48
49pub fn validate_dag(dag: &DagDefinition) -> Result<(), DagValidationError> {
59 if dag.nodes.is_empty() {
60 return Err(DagValidationError::EmptyNodes);
61 }
62
63 if !dag.nodes.contains_key(&dag.entry_node) {
64 return Err(DagValidationError::EntryNodeNotFound(
65 dag.entry_node.clone(),
66 ));
67 }
68
69 for (node_id, node) in &dag.nodes {
71 match node.node_type {
72 DagNodeType::Inference => {
73 if node.question.is_none() {
74 return Err(DagValidationError::InvalidNodeSpec {
75 node_id: node_id.clone(),
76 node_type: node.node_type,
77 reason: "Inference ノードには question の定義が必須です。".to_string(),
78 });
79 }
80 }
81 DagNodeType::Branch => {
82 }
84 DagNodeType::Parallel => {
85 if node.parallel_nodes.is_empty() {
86 return Err(DagValidationError::InvalidNodeSpec {
87 node_id: node_id.clone(),
88 node_type: node.node_type,
89 reason: "Parallel ノードには 1 つ以上の parallel_nodes が必要です。"
90 .to_string(),
91 });
92 }
93 let join_node =
94 node.join_node
95 .as_ref()
96 .ok_or_else(|| DagValidationError::InvalidNodeSpec {
97 node_id: node_id.clone(),
98 node_type: node.node_type,
99 reason: "Parallel ノードには join_node の定義が必須です。".to_string(),
100 })?;
101 if !dag.nodes.contains_key(join_node) {
102 return Err(DagValidationError::TargetNodeNotFound {
103 from_node: node_id.clone(),
104 target_node: join_node.clone(),
105 });
106 }
107 for p_node_id in &node.parallel_nodes {
108 let p_node = dag.nodes.get(p_node_id).ok_or_else(|| {
109 DagValidationError::TargetNodeNotFound {
110 from_node: node_id.clone(),
111 target_node: p_node_id.clone(),
112 }
113 })?;
114 if p_node.node_type != DagNodeType::Inference {
115 return Err(DagValidationError::InvalidNodeSpec {
116 node_id: p_node_id.clone(),
117 node_type: p_node.node_type,
118 reason: "Parallel ノード内のタスクノードは Inference 型である必要があります。".to_string(),
119 });
120 }
121 if !p_node.conditions.is_empty() {
122 return Err(DagValidationError::InvalidNodeSpec {
123 node_id: p_node_id.clone(),
124 node_type: p_node.node_type,
125 reason: "Parallel ノード内のタスクノードは独自の conditions を持てません(親ノードの join_node へ合流するため)。".to_string(),
126 });
127 }
128 if p_node.question.is_none() {
129 return Err(DagValidationError::InvalidNodeSpec {
130 node_id: p_node_id.clone(),
131 node_type: p_node.node_type,
132 reason:
133 "Parallel ノード内のタスクノードには question の定義が必須です。"
134 .to_string(),
135 });
136 }
137 }
138 }
139 DagNodeType::Escalate => {}
140 }
141
142 for cond in &node.conditions {
143 if !dag.nodes.contains_key(&cond.target_node) {
144 return Err(DagValidationError::TargetNodeNotFound {
145 from_node: node_id.clone(),
146 target_node: cond.target_node.clone(),
147 });
148 }
149 }
150 }
151
152 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
154 for (id, node) in &dag.nodes {
155 let mut neighbors = Vec::new();
156 match node.node_type {
157 DagNodeType::Inference | DagNodeType::Branch => {
158 for cond in &node.conditions {
159 neighbors.push(cond.target_node.as_str());
160 }
161 }
162 DagNodeType::Parallel => {
163 for p in &node.parallel_nodes {
164 neighbors.push(p.as_str());
165 }
166 if let Some(join) = &node.join_node {
167 for p in &node.parallel_nodes {
168 adj.entry(p.as_str()).or_default().push(join.as_str());
170 }
171 }
172 }
173 DagNodeType::Escalate => {}
174 }
175 adj.entry(id.as_str()).or_default().extend(neighbors);
176 }
177
178 let mut state: HashMap<&str, u8> = HashMap::new();
180 let mut path_stack = Vec::new();
181
182 fn dfs<'a>(
183 u: &'a str,
184 adj: &HashMap<&str, Vec<&'a str>>,
185 state: &mut HashMap<&'a str, u8>,
186 path_stack: &mut Vec<&'a str>,
187 ) -> Result<(), DagValidationError> {
188 state.insert(u, 1);
189 path_stack.push(u);
190
191 if let Some(neighbors) = adj.get(u) {
192 for &v in neighbors {
193 let v_state = state.get(v).copied().unwrap_or(0);
194 if v_state == 1 {
195 let cycle_start = path_stack.iter().position(|&x| x == v).unwrap_or(0);
197 let mut cycle_path = path_stack[cycle_start..].to_vec();
198 cycle_path.push(v);
199 return Err(DagValidationError::CycleDetected(cycle_path.join(" -> ")));
200 } else if v_state == 0 {
201 dfs(v, adj, state, path_stack)?;
202 }
203 }
204 }
205
206 path_stack.pop();
207 state.insert(u, 2);
208 Ok(())
209 }
210
211 for node_id in dag.nodes.keys() {
212 let node_id_str = node_id.as_str();
213 if state.get(node_id_str).copied().unwrap_or(0) == 0 {
214 dfs(node_id_str, &adj, &mut state, &mut path_stack)?;
215 }
216 }
217
218 let mut reachable = HashSet::new();
220 let mut queue = vec![dag.entry_node.as_str()];
221 reachable.insert(dag.entry_node.as_str());
222
223 while let Some(curr) = queue.pop() {
224 if let Some(neighbors) = adj.get(curr) {
225 for &nxt in neighbors {
226 if reachable.insert(nxt) {
227 queue.push(nxt);
228 }
229 }
230 }
231 }
232
233 let unreachable: Vec<String> = dag
234 .nodes
235 .keys()
236 .filter(|k| !reachable.contains(k.as_str()))
237 .cloned()
238 .collect();
239
240 if !unreachable.is_empty() {
241 tracing::warn!(
242 "DAG '{}' にエントリノードから到達不能な孤立ノードが検出されました: {:?}",
243 dag.dag_id,
244 unreachable
245 );
246 }
247
248 Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::dag::schema::{DagNode, NodeCondition, NodeConditionOp};
255 use indexmap::IndexMap;
256 use sokuto_core::schema::Question;
257
258 #[test]
259 fn test_valid_dag() {
260 let mut nodes = IndexMap::new();
261 nodes.insert(
262 "node1".to_string(),
263 DagNode {
264 node_type: DagNodeType::Inference,
265 question: Some(Question::new_noul("テスト?".to_string())),
266 conditions: vec![NodeCondition {
267 field: "answer".to_string(),
268 op: NodeConditionOp::Eq,
269 value: Some(serde_json::Value::Bool(true)),
270 target_node: "node2".to_string(),
271 }],
272 parallel_nodes: Vec::new(),
273 join_node: None,
274 reason: None,
275 },
276 );
277 nodes.insert(
278 "node2".to_string(),
279 DagNode {
280 node_type: DagNodeType::Escalate,
281 question: None,
282 conditions: Vec::new(),
283 parallel_nodes: Vec::new(),
284 join_node: None,
285 reason: Some("終了".to_string()),
286 },
287 );
288
289 let dag = DagDefinition {
290 dag_id: "valid_dag".to_string(),
291 timeout_ms: 100,
292 entry_node: "node1".to_string(),
293 nodes,
294 };
295
296 assert!(validate_dag(&dag).is_ok());
297 }
298
299 #[test]
300 fn test_cycle_detection() {
301 let mut nodes = IndexMap::new();
302 nodes.insert(
303 "node1".to_string(),
304 DagNode {
305 node_type: DagNodeType::Inference,
306 question: Some(Question::new_noul("Q1".to_string())),
307 conditions: vec![NodeCondition {
308 field: "answer".to_string(),
309 op: NodeConditionOp::Eq,
310 value: Some(serde_json::Value::Bool(true)),
311 target_node: "node2".to_string(),
312 }],
313 parallel_nodes: Vec::new(),
314 join_node: None,
315 reason: None,
316 },
317 );
318 nodes.insert(
319 "node2".to_string(),
320 DagNode {
321 node_type: DagNodeType::Inference,
322 question: Some(Question::new_noul("Q2".to_string())),
323 conditions: vec![NodeCondition {
324 field: "answer".to_string(),
325 op: NodeConditionOp::Eq,
326 value: Some(serde_json::Value::Bool(true)),
327 target_node: "node1".to_string(), }],
329 parallel_nodes: Vec::new(),
330 join_node: None,
331 reason: None,
332 },
333 );
334
335 let dag = DagDefinition {
336 dag_id: "cyclic_dag".to_string(),
337 timeout_ms: 100,
338 entry_node: "node1".to_string(),
339 nodes,
340 };
341
342 let res = validate_dag(&dag);
343 assert!(matches!(res, Err(DagValidationError::CycleDetected(_))));
344 }
345
346 #[test]
347 fn test_target_not_found() {
348 let mut nodes = IndexMap::new();
349 nodes.insert(
350 "node1".to_string(),
351 DagNode {
352 node_type: DagNodeType::Inference,
353 question: Some(Question::new_noul("Q1".to_string())),
354 conditions: vec![NodeCondition {
355 field: "answer".to_string(),
356 op: NodeConditionOp::Eq,
357 value: Some(serde_json::Value::Bool(true)),
358 target_node: "non_existent".to_string(),
359 }],
360 parallel_nodes: Vec::new(),
361 join_node: None,
362 reason: None,
363 },
364 );
365
366 let dag = DagDefinition {
367 dag_id: "broken_dag".to_string(),
368 timeout_ms: 100,
369 entry_node: "node1".to_string(),
370 nodes,
371 };
372
373 let res = validate_dag(&dag);
374 assert!(matches!(
375 res,
376 Err(DagValidationError::TargetNodeNotFound { .. })
377 ));
378 }
379}