Skip to main content

sokuto_runtime/dag/
executor.rs

1//! # インプロセス DAG 実行器モジュール
2//!
3//! 非同期グラフ巡回、ゼロコピー文脈注入、および CPU 集約的推論の
4//! `tokio::task::spawn_blocking` オフロードによる超低遅延オーケストレーションを提供する。
5
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use indexmap::IndexMap;
10use sokuto_core::contract::calibration::CalibrationConfig;
11use sokuto_core::gating::DecisionRoute;
12use sokuto_core::schema::Answer;
13
14use crate::dag::interpolate::{NodeContextValue, interpolate_prompt, interpolate_question};
15use crate::dag::pool::PoolError;
16use crate::dag::schema::{
17    DagDefinition, DagExecutionResult, DagNodeType, NodeCondition, NodeConditionOp,
18    StepExecutionResult,
19};
20use crate::dag::validator::{DagValidationError, validate_dag};
21use crate::engine::InferenceEngine;
22use crate::error::RuntimeError;
23use crate::tokenizer::JevTokenizer;
24
25/// DAG 実行時に発生するエラー型。
26#[derive(Debug, thiserror::Error)]
27pub enum DagError {
28    /// 静的検証エラー。
29    #[error("DAG 検証エラー: {0}。")]
30    Validation(#[from] DagValidationError),
31
32    /// セッションプール借用エラー。
33    #[error("セッションプールエラー: {0}。")]
34    Pool(#[from] PoolError),
35
36    /// DAG 全体実行のタイムアウト。
37    #[error("DAG 実行がタイムアウトしました ({0}ms)。")]
38    Timeout(u64),
39
40    /// グラフ巡回のステップ数上限超過 (無限ループ動的防壁)。
41    #[error("DAG 実行ステップ数が上限 ({0}) を超過しました。")]
42    StepLimitExceeded(usize),
43
44    /// 指定ノードが存在しない。
45    #[error("ノード '{0}' が見つかりません。")]
46    NodeNotFound(String),
47
48    /// ランタイム推論エンジン由来のエラー。
49    #[error("推論ランタイムエラー: {0}。")]
50    Runtime(#[from] RuntimeError),
51
52    /// 並行タスクジョインエラーなど。
53    #[error("DAG 実行失敗: {0}。")]
54    ExecutionFailed(String),
55}
56
57/// インプロセス DAG 実行エンジン。
58///
59/// 単一プロンプトでの複合推論を避け、軽量モデルによる多段判定と
60/// Rust 側での決定論的ブール論理・制御フローをミリ秒台で実行する。
61pub struct InProcessDagExecutor {
62    engine: Arc<InferenceEngine>,
63    tokenizer: Arc<JevTokenizer>,
64    calib_config: CalibrationConfig,
65    max_steps: usize,
66}
67
68impl InProcessDagExecutor {
69    /// 新規 DAG 実行エンジンを構築する。
70    ///
71    /// # 引数
72    /// - `engine`: ONNX 推論エンジン。
73    /// - `tokenizer`: Jev 高速トークナイザー。
74    /// - `calib_config`: 較正温度設定。
75    pub fn new(
76        engine: Arc<InferenceEngine>,
77        tokenizer: Arc<JevTokenizer>,
78        calib_config: CalibrationConfig,
79    ) -> Self {
80        Self {
81            engine,
82            tokenizer,
83            calib_config,
84            max_steps: 50,
85        }
86    }
87
88    /// 実行ステップ数の安全上限を設定する。既定値は 50。
89    pub fn with_max_steps(mut self, max_steps: usize) -> Self {
90        self.max_steps = max_steps;
91        self
92    }
93
94    /// DAG を実行し、実行結果を返却する。
95    ///
96    /// 実行前に静的検証を行い、DAG 全体を `dag.timeout_ms` の制限時間でラップして実行する。
97    ///
98    /// # 引数
99    /// - `state`: 入力コンテキスト文。
100    /// - `dag`: 宣言的 DAG 定義。
101    pub async fn execute(
102        &self,
103        state: &str,
104        dag: &DagDefinition,
105    ) -> Result<DagExecutionResult, DagError> {
106        // 1. 静的検証 (Fast-Fail)
107        validate_dag(dag)?;
108
109        let timeout_duration = Duration::from_millis(dag.timeout_ms);
110        let start_time = Instant::now();
111
112        // 2. タイムアウト監視付きでステップループを実行
113        let exec_result = tokio::time::timeout(timeout_duration, self.run_dag(state, dag)).await;
114
115        match exec_result {
116            Ok(Ok(mut res)) => {
117                res.total_duration_ms = start_time.elapsed().as_secs_f64() * 1000.0;
118                Ok(res)
119            }
120            Ok(Err(err)) => Err(err),
121            Err(_) => Err(DagError::Timeout(dag.timeout_ms)),
122        }
123    }
124
125    /// DAG ステップ実行ループ本体。
126    async fn run_dag(
127        &self,
128        initial_state: &str,
129        dag: &DagDefinition,
130    ) -> Result<DagExecutionResult, DagError> {
131        let mut context: IndexMap<String, NodeContextValue> = IndexMap::new();
132        let mut steps: IndexMap<String, StepExecutionResult> = IndexMap::new();
133        let mut execution_path: Vec<String> = Vec::new();
134
135        // スクラッチバッファをループ外で事前確保し、各ステップで再利用する
136        let mut scratch_state = String::with_capacity(initial_state.len() + 64);
137
138        let mut curr_node_id = dag.entry_node.clone();
139        let mut step_count = 0;
140        let mut final_answer: Option<Answer> = None;
141        let mut escalated = false;
142        let mut escalation_reason = None;
143        let mut escalation_node_id = None;
144        let mut escalation_prompt = None;
145        let mut escalation_gating = None;
146
147        while step_count < self.max_steps {
148            step_count += 1;
149            let node = dag
150                .nodes
151                .get(&curr_node_id)
152                .ok_or_else(|| DagError::NodeNotFound(curr_node_id.clone()))?;
153
154            execution_path.push(curr_node_id.clone());
155            let step_start = Instant::now();
156
157            match node.node_type {
158                DagNodeType::Inference => {
159                    let raw_question = node
160                        .question
161                        .as_ref()
162                        .ok_or_else(|| DagError::NodeNotFound(curr_node_id.clone()))?;
163
164                    // ゼロアロケーション動的コンテキスト補間 (事前確保バッファの再利用)
165                    interpolate_prompt(initial_state, &context, &mut scratch_state);
166                    let interpolated_question = interpolate_question(raw_question, &context);
167
168                    // 推論処理を専用ブロッキングスレッドへオフロード
169                    let engine = self.engine.clone();
170                    let tokenizer = self.tokenizer.clone();
171                    let calib_config = self.calib_config.clone();
172                    let current_state = scratch_state.clone();
173
174                    let answer = tokio::task::spawn_blocking(move || {
175                        engine.predict_single_question(
176                            &tokenizer,
177                            &current_state,
178                            &interpolated_question,
179                            &calib_config,
180                        )
181                    })
182                    .await
183                    .map_err(|e| DagError::ExecutionFailed(format!("スレッド結合失敗: {e}。")))?
184                    .map_err(DagError::Runtime)?;
185
186                    // コンテキストに結果を反映
187                    let ctx_val = answer_to_context_value(&answer);
188                    context.insert(curr_node_id.clone(), ctx_val);
189                    final_answer = Some(answer.clone());
190
191                    // 条件評価
192                    let next_node = evaluate_conditions(&node.conditions, Some(&answer), &context);
193                    let duration_ms = step_start.elapsed().as_secs_f64() * 1000.0;
194
195                    steps.insert(
196                        curr_node_id.clone(),
197                        StepExecutionResult {
198                            node_id: curr_node_id.clone(),
199                            node_type: DagNodeType::Inference,
200                            answer: Some(answer),
201                            duration_ms,
202                            next_node: next_node.clone(),
203                            reason: node.reason.clone(),
204                        },
205                    );
206
207                    match next_node {
208                        Some(nxt) => curr_node_id = nxt,
209                        None => break,
210                    }
211                }
212                DagNodeType::Branch => {
213                    let next_node =
214                        evaluate_conditions(&node.conditions, final_answer.as_ref(), &context);
215                    let duration_ms = step_start.elapsed().as_secs_f64() * 1000.0;
216
217                    steps.insert(
218                        curr_node_id.clone(),
219                        StepExecutionResult {
220                            node_id: curr_node_id.clone(),
221                            node_type: DagNodeType::Branch,
222                            answer: None,
223                            duration_ms,
224                            next_node: next_node.clone(),
225                            reason: node.reason.clone(),
226                        },
227                    );
228
229                    match next_node {
230                        Some(nxt) => curr_node_id = nxt,
231                        None => break,
232                    }
233                }
234                DagNodeType::Parallel => {
235                    let mut tasks = Vec::with_capacity(node.parallel_nodes.len());
236
237                    for p_node_id in &node.parallel_nodes {
238                        let p_node = dag
239                            .nodes
240                            .get(p_node_id)
241                            .ok_or_else(|| DagError::NodeNotFound(p_node_id.clone()))?;
242
243                        let raw_question = p_node.question.as_ref().ok_or_else(|| {
244                            DagError::ExecutionFailed(format!(
245                                "並行ノード '{p_node_id}' に question が存在しません。"
246                            ))
247                        })?;
248
249                        // 事前確保バッファを活用してプロンプトを展開
250                        interpolate_prompt(initial_state, &context, &mut scratch_state);
251                        let interpolated_question = interpolate_question(raw_question, &context);
252
253                        let engine = self.engine.clone();
254                        let tokenizer = self.tokenizer.clone();
255                        let calib_config = self.calib_config.clone();
256                        let node_id_owned = p_node_id.clone();
257                        let state_owned = scratch_state.clone();
258
259                        let handle = tokio::task::spawn_blocking(move || {
260                            let ans = engine.predict_single_question(
261                                &tokenizer,
262                                &state_owned,
263                                &interpolated_question,
264                                &calib_config,
265                            );
266                            (node_id_owned, ans)
267                        });
268                        tasks.push(handle);
269                    }
270
271                    // 並行実行を待機
272                    let results = futures_util::future::join_all(tasks).await;
273
274                    for res in results {
275                        let (p_id, ans_res) = res.map_err(|e| {
276                            DagError::ExecutionFailed(format!("並行タスク結合失敗: {e}。"))
277                        })?;
278                        let ans = ans_res.map_err(DagError::Runtime)?;
279                        let ctx_val = answer_to_context_value(&ans);
280                        context.insert(p_id.clone(), ctx_val);
281
282                        steps.insert(
283                            p_id.clone(),
284                            StepExecutionResult {
285                                node_id: p_id,
286                                node_type: DagNodeType::Inference,
287                                answer: Some(ans),
288                                duration_ms: step_start.elapsed().as_secs_f64() * 1000.0,
289                                next_node: node.join_node.clone(),
290                                reason: None,
291                            },
292                        );
293                    }
294
295                    let duration_ms = step_start.elapsed().as_secs_f64() * 1000.0;
296                    let next_node = node.join_node.clone();
297
298                    steps.insert(
299                        curr_node_id.clone(),
300                        StepExecutionResult {
301                            node_id: curr_node_id.clone(),
302                            node_type: DagNodeType::Parallel,
303                            answer: None,
304                            duration_ms,
305                            next_node: next_node.clone(),
306                            reason: node.reason.clone(),
307                        },
308                    );
309
310                    match next_node {
311                        Some(nxt) => curr_node_id = nxt,
312                        None => break,
313                    }
314                }
315                DagNodeType::Escalate => {
316                    let duration_ms = step_start.elapsed().as_secs_f64() * 1000.0;
317                    escalated = true;
318                    escalation_reason = node.reason.clone();
319
320                    // エスカレーションをトリガーした直前ノードの特定
321                    let trigger_node_id = execution_path.iter().rev().nth(1).cloned();
322                    escalation_node_id = trigger_node_id.clone();
323
324                    // 最後の Answer から Gating メタデータを抽出
325                    escalation_gating = final_answer.as_ref().and_then(|a| a.gating.clone());
326
327                    // System 2 向けの Guided CoT プロンプト案の導出
328                    if let Some(ref g) = escalation_gating
329                        && let Some(ref esc_ctx) = g.escalation
330                        && let Some(ref tmpl) = esc_ctx.prompt_template
331                    {
332                        escalation_prompt = Some(tmpl.clone());
333                    } else if let Some(ref trig_id) = trigger_node_id
334                        && let Some(trig_node) = dag.nodes.get(trig_id)
335                        && let Some(ref q) = trig_node.question
336                        && let Some(ref ans) = final_answer
337                        && let Some(ref g) = escalation_gating
338                    {
339                        use sokuto_core::gating::CandidateProbability;
340                        let candidates: Vec<CandidateProbability> = ans
341                            .probabilities
342                            .as_ref()
343                            .map(|p| {
344                                let mut list: Vec<_> = p
345                                    .iter()
346                                    .map(|(k, &v)| CandidateProbability {
347                                        candidate: k.clone(),
348                                        probability: v,
349                                    })
350                                    .collect();
351                                list.sort_by(|a, b| {
352                                    b.probability
353                                        .partial_cmp(&a.probability)
354                                        .unwrap_or(std::cmp::Ordering::Equal)
355                                });
356                                list
357                            })
358                            .unwrap_or_default();
359
360                        use crate::engine::escalation::build_rich_escalation_prompt;
361                        escalation_prompt = Some(build_rich_escalation_prompt(
362                            Some(initial_state),
363                            Some(trig_id.as_str()),
364                            Some(q),
365                            g,
366                            &candidates,
367                        ));
368                    } else {
369                        escalation_prompt = node.reason.clone();
370                    }
371
372                    steps.insert(
373                        curr_node_id.clone(),
374                        StepExecutionResult {
375                            node_id: curr_node_id.clone(),
376                            node_type: DagNodeType::Escalate,
377                            answer: None,
378                            duration_ms,
379                            next_node: None,
380                            reason: node.reason.clone(),
381                        },
382                    );
383                    break;
384                }
385            }
386        }
387
388        if step_count >= self.max_steps {
389            return Err(DagError::StepLimitExceeded(self.max_steps));
390        }
391
392        Ok(DagExecutionResult {
393            dag_id: dag.dag_id.clone(),
394            execution_path,
395            steps,
396            total_duration_ms: 0.0,
397            escalated,
398            escalation_reason,
399            escalation_node_id,
400            escalation_prompt,
401            escalation_gating,
402            final_answer,
403        })
404    }
405}
406
407/// Answer をコンテキスト値に変換する内部ヘルパー。
408fn answer_to_context_value(answer: &Answer) -> NodeContextValue {
409    let conf = answer.confidence.unwrap_or(0.0);
410    if let Some(ref c) = answer.choice {
411        NodeContextValue::new(c.clone(), conf)
412    } else if let Some(n) = answer.noul {
413        NodeContextValue::new(if n >= 0.5 { "true" } else { "false" }, conf)
414    } else if let Some(s) = answer.score {
415        NodeContextValue::new(format!("{s}"), conf)
416    } else {
417        NodeContextValue::new("null", conf)
418    }
419}
420
421/// 条件リストを順次評価し、最初に適合した条件の `target_node` を返却する (Short-circuit)。
422pub fn evaluate_conditions(
423    conditions: &[NodeCondition],
424    answer: Option<&Answer>,
425    context: &IndexMap<String, NodeContextValue>,
426) -> Option<String> {
427    for cond in conditions {
428        if matches_condition(cond, answer, context) {
429            return Some(cond.target_node.clone());
430        }
431    }
432    None
433}
434
435/// 単一条件の適合判定を行う。
436fn matches_condition(
437    cond: &NodeCondition,
438    answer: Option<&Answer>,
439    context: &IndexMap<String, NodeContextValue>,
440) -> bool {
441    let field = cond.field.as_str();
442
443    // 1. confidence フィールドの比較
444    if field == "confidence" {
445        let conf = answer.and_then(|a| a.confidence).unwrap_or(0.0);
446        let target_val = match &cond.value {
447            Some(serde_json::Value::Number(n)) => n.as_f64().unwrap_or(0.0),
448            _ => 0.0,
449        };
450        return match cond.op {
451            NodeConditionOp::ConfidenceGte | NodeConditionOp::Gte => conf >= target_val - 1e-6,
452            NodeConditionOp::ConfidenceLt | NodeConditionOp::Lt => conf < target_val - 1e-6,
453            NodeConditionOp::Gt => conf > target_val + 1e-6,
454            NodeConditionOp::Lte => conf <= target_val + 1e-6,
455            NodeConditionOp::Eq => (conf - target_val).abs() < 1e-6,
456            NodeConditionOp::Neq => (conf - target_val).abs() >= 1e-6,
457            _ => false,
458        };
459    }
460
461    // 2. route (ゲーティングルート) フィールドの比較
462    if field == "route" {
463        let route_str = answer
464            .and_then(|a| a.gating.as_ref())
465            .map(|g| match g.route {
466                DecisionRoute::AutoExecute => "auto_execute",
467                DecisionRoute::ConfirmOrEscalate => "confirm_or_escalate",
468                DecisionRoute::Fallback => "fallback",
469            })
470            .unwrap_or("auto_execute");
471
472        let target_str = cond.value.as_ref().and_then(|v| v.as_str()).unwrap_or("");
473        return match cond.op {
474            NodeConditionOp::Eq => route_str == target_str,
475            NodeConditionOp::Neq => route_str != target_str,
476            _ => false,
477        };
478    }
479
480    // 3. answer / value フィールドの比較
481    if (field == "answer" || field == "value")
482        && let Some(ans) = answer
483    {
484        // Noul (言明真実確率値 f64: 0.0〜1.0) 型判定
485        if let Some(noul_val) = ans.noul {
486            // bool リテラルまたは文字列による判定 (true: >= 0.5, false: < 0.5)
487            let target_bool = match &cond.value {
488                Some(serde_json::Value::Bool(b)) => Some(*b),
489                Some(serde_json::Value::String(s)) => match s.as_str() {
490                    "true" => Some(true),
491                    "false" => Some(false),
492                    _ => None,
493                },
494                _ => None,
495            };
496            if let Some(tb) = target_bool {
497                let is_true = noul_val >= 0.5;
498                return match cond.op {
499                    NodeConditionOp::Eq => is_true == tb,
500                    NodeConditionOp::Neq => is_true != tb,
501                    _ => false,
502                };
503            }
504
505            // 数値による直接判定 (確率値そのものの閾値比較)
506            if let Some(target_num) = cond.value.as_ref().and_then(|v| v.as_f64()) {
507                return match cond.op {
508                    NodeConditionOp::Eq => (noul_val - target_num).abs() < 1e-6,
509                    NodeConditionOp::Neq => (noul_val - target_num).abs() >= 1e-6,
510                    NodeConditionOp::Gt => noul_val > target_num + 1e-6,
511                    NodeConditionOp::Gte => noul_val >= target_num - 1e-6,
512                    NodeConditionOp::Lt => noul_val < target_num - 1e-6,
513                    NodeConditionOp::Lte => noul_val <= target_num + 1e-6,
514                    _ => false,
515                };
516            }
517        }
518
519        // Choice (文字列) 型判定
520        if let Some(ref choice_val) = ans.choice {
521            match cond.op {
522                NodeConditionOp::Eq => {
523                    let target_str = cond.value.as_ref().and_then(|v| v.as_str()).unwrap_or("");
524                    return choice_val == target_str;
525                }
526                NodeConditionOp::Neq => {
527                    let target_str = cond.value.as_ref().and_then(|v| v.as_str()).unwrap_or("");
528                    return choice_val != target_str;
529                }
530                NodeConditionOp::In => {
531                    if let Some(serde_json::Value::Array(arr)) = &cond.value {
532                        return arr
533                            .iter()
534                            .any(|item| item.as_str().is_some_and(|s| s == choice_val));
535                    }
536                }
537                _ => {}
538            }
539        }
540
541        // Score (数値) 型判定
542        if let Some(score_val) = ans.score {
543            let target_num = cond.value.as_ref().and_then(|v| v.as_f64()).unwrap_or(0.0);
544            return match cond.op {
545                NodeConditionOp::Eq => (score_val - target_num).abs() < 1e-6,
546                NodeConditionOp::Neq => (score_val - target_num).abs() >= 1e-6,
547                NodeConditionOp::Gt => score_val > target_num + 1e-6,
548                NodeConditionOp::Gte => score_val >= target_num - 1e-6,
549                NodeConditionOp::Lt => score_val < target_num - 1e-6,
550                NodeConditionOp::Lte => score_val <= target_num + 1e-6,
551                _ => false,
552            };
553        }
554    }
555
556    // 4. コンテキスト内の先行ノード ID 直接参照 (例: `node_id.value`)
557    if let Some((node_id, prop)) = field.split_once('.')
558        && let Some(ctx_val) = context.get(node_id)
559    {
560        match prop {
561            "value" | "answer" => {
562                let target_str = cond.value.as_ref().and_then(|v| v.as_str()).unwrap_or("");
563                return match cond.op {
564                    NodeConditionOp::Eq => ctx_val.value == target_str,
565                    NodeConditionOp::Neq => ctx_val.value != target_str,
566                    _ => false,
567                };
568            }
569            "confidence" => {
570                let target_num = cond.value.as_ref().and_then(|v| v.as_f64()).unwrap_or(0.0);
571                return match cond.op {
572                    NodeConditionOp::ConfidenceGte | NodeConditionOp::Gte => {
573                        ctx_val.confidence >= target_num - 1e-6
574                    }
575                    NodeConditionOp::ConfidenceLt | NodeConditionOp::Lt => {
576                        ctx_val.confidence < target_num - 1e-6
577                    }
578                    _ => false,
579                };
580            }
581            _ => {}
582        }
583    }
584
585    false
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn test_evaluate_conditions_confidence() {
594        let answer = Answer {
595            choice: Some("transfer".to_string()),
596            confidence: Some(0.62),
597            probabilities: None,
598            score: None,
599            noul: None,
600            gating: None,
601        };
602        let context = IndexMap::new();
603
604        let conditions = vec![
605            NodeCondition {
606                field: "confidence".to_string(),
607                op: NodeConditionOp::ConfidenceLt,
608                value: Some(serde_json::json!(0.65)),
609                target_node: "escalate_low_confidence".to_string(),
610            },
611            NodeCondition {
612                field: "answer".to_string(),
613                op: NodeConditionOp::Eq,
614                value: Some(serde_json::json!("transfer")),
615                target_node: "transfer_flow".to_string(),
616            },
617        ];
618
619        let target = evaluate_conditions(&conditions, Some(&answer), &context);
620        assert_eq!(target, Some("escalate_low_confidence".to_string()));
621    }
622
623    #[test]
624    fn test_evaluate_conditions_noul() {
625        let answer = Answer {
626            choice: None,
627            confidence: Some(0.98),
628            probabilities: None,
629            score: None,
630            noul: Some(0.02),
631            gating: None,
632        };
633        let context = IndexMap::new();
634
635        let conditions = vec![
636            NodeCondition {
637                field: "answer".to_string(),
638                op: NodeConditionOp::Eq,
639                value: Some(serde_json::json!(true)),
640                target_node: "reject_flow".to_string(),
641            },
642            NodeCondition {
643                field: "answer".to_string(),
644                op: NodeConditionOp::Eq,
645                value: Some(serde_json::json!(false)),
646                target_node: "continue_flow".to_string(),
647            },
648        ];
649
650        let target = evaluate_conditions(&conditions, Some(&answer), &context);
651        assert_eq!(target, Some("continue_flow".to_string()));
652    }
653}