Skip to main content

sokuto_runtime/dag/
validator.rs

1//! # DAG 静的検証モジュール
2//!
3//! 実行前の DAG 定義に対し、循環参照 (閉路)、未定義ノード参照、
4//! 型契約違反、到達不能ノードを Fast-Fail で静的検証する。
5
6use crate::dag::schema::{DagDefinition, DagNodeType};
7use std::collections::{HashMap, HashSet};
8
9/// DAG 静的検証で発生するエラー型。
10#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
11pub enum DagValidationError {
12    /// ノードマップが空。
13    #[error("DAG 内にノードが 1 つも定義されていません。")]
14    EmptyNodes,
15
16    /// エントリノードが未定義。
17    #[error("エントリノード '{0}' が nodes マップ内に存在しません。")]
18    EntryNodeNotFound(String),
19
20    /// 参照先ノードが未定義。
21    #[error("ノード '{from_node}' から未定義のターゲットノード '{target_node}' を参照しています。")]
22    TargetNodeNotFound {
23        /// 参照元ノード ID。
24        from_node: String,
25        /// 未定義の参照先ノード ID。
26        target_node: String,
27    },
28
29    /// 有向グラフに循環 (サイクル) が検出された。
30    #[error("DAG 内に循環参照 (閉路) が検出されました: {0}。")]
31    CycleDetected(String),
32
33    /// ノード種別に対する必須プロパティの欠落。
34    #[error("ノード '{node_id}' ({node_type:?}) の定義が不正です: {reason}。")]
35    InvalidNodeSpec {
36        /// 不正なノード ID。
37        node_id: String,
38        /// ノード種別。
39        node_type: DagNodeType,
40        /// 理由説明文。
41        reason: String,
42    },
43
44    /// エントリノードから到達不能な孤立ノードが存在する。
45    #[error("エントリノードから到達不能な孤立ノードが検出されました: {0:?}。")]
46    UnreachableNodes(Vec<String>),
47}
48
49/// DAG 定義の整合性と無閉路性を静的に検証する。
50///
51/// # 検査項目
52/// 1. `nodes` が 1 つ以上のノードを含むこと。
53/// 2. `entry_node` が `nodes` に実在すること。
54/// 3. 各ノードのプロパティ検証 (推論ノードの質問、並行ノードの合流先など)。
55/// 4. すべての遷移先 (`target_node`, `parallel_nodes`, `join_node`) が実在すること。
56/// 5. DFS による有向グラフの閉路 (サイクル) 検出。
57/// 6. `entry_node` から全ノードへの到達性確認。
58pub 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    // 1. ノード種別整合性および参照先実在チェック
70    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                // conditions が空の場合は終端ノードとして扱われ、次ノードなしで正常終了する。
83            }
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    // 2. 有向隣接リストの構築
153    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                        // 並行ノード群から合流ノードへのエッジも考慮
169                        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    // 3. DFS によるサイクル検出 (0: 未訪問, 1: 訪問中, 2: 完了)
179    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                    // 訪問中ノードへの遷移 = サイクル検出
196                    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    // 4. 到達性チェック (entry_node から到達可能か)
219    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(), // 閉路
328                }],
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}