Skip to main content

swarm_engine_eval/validation/
validator.rs

1//! Scenario Validator
2//!
3//! シナリオ定義の整合性をチェック。
4
5use std::fmt;
6
7use crate::scenario::EvalScenario;
8use swarm_engine_core::actions::EnvironmentSpecRegistry;
9
10/// バリデーション警告
11#[derive(Debug, Clone)]
12pub enum ValidationWarning {
13    /// サポートされていないアクション
14    UnsupportedAction {
15        action_name: String,
16        env_type: String,
17        suggestion: Option<String>,
18    },
19    /// 不明な Environment タイプ
20    UnknownEnvironment { env_type: String },
21    /// 必須パラメータの定義が不足
22    MissingRequiredParamDefinition {
23        action_name: String,
24        param_name: String,
25    },
26    /// アクションが定義されているが Environment がない
27    ActionsWithoutEnvironment { action_count: usize },
28    /// Continue アクションは特殊なので警告しない(情報のみ)
29    ContinueActionNote,
30}
31
32impl fmt::Display for ValidationWarning {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::UnsupportedAction {
36                action_name,
37                env_type,
38                suggestion,
39            } => {
40                write!(
41                    f,
42                    "Action '{}' is not supported by environment '{}'",
43                    action_name, env_type
44                )?;
45                if let Some(s) = suggestion {
46                    write!(f, ". Did you mean '{}'?", s)?;
47                }
48                Ok(())
49            }
50            Self::UnknownEnvironment { env_type } => {
51                write!(f, "Unknown environment type: '{}'", env_type)
52            }
53            Self::MissingRequiredParamDefinition {
54                action_name,
55                param_name,
56            } => {
57                write!(
58                    f,
59                    "Action '{}' has required parameter '{}' but it's not defined in scenario",
60                    action_name, param_name
61                )
62            }
63            Self::ActionsWithoutEnvironment { action_count } => {
64                write!(
65                    f,
66                    "{} actions defined but no environment specified",
67                    action_count
68                )
69            }
70            Self::ContinueActionNote => {
71                write!(
72                    f,
73                    "'Continue' action is a special no-op action, always supported"
74                )
75            }
76        }
77    }
78}
79
80impl ValidationWarning {
81    /// 警告の重大度を取得
82    pub fn severity(&self) -> WarningSeverity {
83        match self {
84            Self::UnsupportedAction { .. } => WarningSeverity::High,
85            Self::UnknownEnvironment { .. } => WarningSeverity::High,
86            Self::MissingRequiredParamDefinition { .. } => WarningSeverity::Medium,
87            Self::ActionsWithoutEnvironment { .. } => WarningSeverity::Low,
88            Self::ContinueActionNote => WarningSeverity::Info,
89        }
90    }
91}
92
93/// 警告の重大度
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
95pub enum WarningSeverity {
96    Info,
97    Low,
98    Medium,
99    High,
100}
101
102impl fmt::Display for WarningSeverity {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::Info => write!(f, "INFO"),
106            Self::Low => write!(f, "LOW"),
107            Self::Medium => write!(f, "MEDIUM"),
108            Self::High => write!(f, "HIGH"),
109        }
110    }
111}
112
113/// シナリオバリデータ
114pub struct ScenarioValidator {
115    registry: EnvironmentSpecRegistry,
116}
117
118impl Default for ScenarioValidator {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl ScenarioValidator {
125    /// 新しいバリデータを作成
126    pub fn new() -> Self {
127        Self {
128            registry: EnvironmentSpecRegistry::new(),
129        }
130    }
131
132    /// シナリオをバリデート
133    pub fn validate(&self, scenario: &EvalScenario) -> Vec<ValidationWarning> {
134        let mut warnings = Vec::new();
135
136        let env_type = &scenario.environment.env_type;
137        let actions = &scenario.actions.actions;
138
139        // Environment タイプのチェック
140        let env_spec = match self.registry.get(env_type) {
141            Some(spec) => spec,
142            None => {
143                warnings.push(ValidationWarning::UnknownEnvironment {
144                    env_type: env_type.clone(),
145                });
146                // Environment が不明な場合、アクションのチェックはスキップ
147                return warnings;
148            }
149        };
150
151        // アクションのチェック
152        for action_def in actions {
153            let action_name = &action_def.name;
154
155            // Continue は特殊なアクション(常にサポート)
156            if action_name.to_lowercase() == "continue" {
157                continue;
158            }
159
160            // Environment でサポートされているかチェック
161            if !env_spec.supports_action(action_name) {
162                // 類似アクションを提案
163                let suggestion = self.find_similar_action(env_spec, action_name);
164                warnings.push(ValidationWarning::UnsupportedAction {
165                    action_name: action_name.clone(),
166                    env_type: env_type.clone(),
167                    suggestion,
168                });
169                continue;
170            }
171
172            // 必須パラメータのチェック
173            if let Some(action_spec) = env_spec.get_action(action_name) {
174                for param_spec in &action_spec.params {
175                    if param_spec.required {
176                        // シナリオのアクション定義に必須パラメータが含まれているか確認
177                        let has_param = action_def
178                            .params
179                            .iter()
180                            .any(|p| p.name.to_lowercase() == param_spec.name.to_lowercase());
181
182                        if !has_param {
183                            warnings.push(ValidationWarning::MissingRequiredParamDefinition {
184                                action_name: action_name.clone(),
185                                param_name: param_spec.name.clone(),
186                            });
187                        }
188                    }
189                }
190            }
191        }
192
193        warnings
194    }
195
196    /// 類似アクションを検索
197    fn find_similar_action(
198        &self,
199        env_spec: &swarm_engine_core::actions::EnvironmentSpec,
200        name: &str,
201    ) -> Option<String> {
202        let name_lower = name.to_lowercase();
203
204        // 編集距離が小さいアクションを探す
205        let mut best_match: Option<(String, usize)> = None;
206
207        for action in &env_spec.actions {
208            let canonical_lower = action.canonical_name.to_lowercase();
209            let distance = levenshtein_distance(&name_lower, &canonical_lower);
210
211            // 閾値は3文字以内
212            if distance <= 3 && (best_match.is_none() || distance < best_match.as_ref().unwrap().1)
213            {
214                best_match = Some((action.canonical_name.clone(), distance));
215            }
216
217            // エイリアスもチェック
218            for alias in &action.aliases {
219                let alias_lower = alias.to_lowercase();
220                let alias_distance = levenshtein_distance(&name_lower, &alias_lower);
221                if alias_distance <= 3
222                    && (best_match.is_none() || alias_distance < best_match.as_ref().unwrap().1)
223                {
224                    best_match = Some((action.canonical_name.clone(), alias_distance));
225                }
226            }
227        }
228
229        best_match.map(|(name, _)| name)
230    }
231
232    /// 静的メソッドとしてバリデート(便利メソッド)
233    pub fn validate_scenario(scenario: &EvalScenario) -> Vec<ValidationWarning> {
234        let validator = Self::new();
235        validator.validate(scenario)
236    }
237}
238
239/// レーベンシュタイン距離を計算
240fn levenshtein_distance(s1: &str, s2: &str) -> usize {
241    let len1 = s1.chars().count();
242    let len2 = s2.chars().count();
243
244    if len1 == 0 {
245        return len2;
246    }
247    if len2 == 0 {
248        return len1;
249    }
250
251    let s1_chars: Vec<char> = s1.chars().collect();
252    let s2_chars: Vec<char> = s2.chars().collect();
253
254    let mut matrix = vec![vec![0usize; len2 + 1]; len1 + 1];
255
256    for (i, row) in matrix.iter_mut().enumerate().take(len1 + 1) {
257        row[0] = i;
258    }
259    for (j, cell) in matrix[0].iter_mut().enumerate().take(len2 + 1) {
260        *cell = j;
261    }
262
263    for (i, c1) in s1_chars.iter().enumerate() {
264        for (j, c2) in s2_chars.iter().enumerate() {
265            let cost = if c1 == c2 { 0 } else { 1 };
266
267            matrix[i + 1][j + 1] = (matrix[i][j + 1] + 1)
268                .min(matrix[i + 1][j] + 1)
269                .min(matrix[i][j] + cost);
270        }
271    }
272
273    matrix[len1][len2]
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::scenario::actions::{ScenarioActionCategory, ScenarioActionDef};
280    use crate::scenario::llm::LlmConfig;
281    use crate::scenario::{
282        AgentsConfig, AppConfigTemplate, EnvironmentConfig, EvalConditions, EvalScenario,
283        ScenarioActions, ScenarioId, ScenarioMeta, TaskConfig,
284    };
285
286    fn make_test_scenario(env_type: &str, action_names: Vec<&str>) -> EvalScenario {
287        EvalScenario {
288            meta: ScenarioMeta {
289                name: "Test".to_string(),
290                version: "1.0.0".to_string(),
291                id: ScenarioId::new("test:test:v1"),
292                description: String::new(),
293                tags: vec![],
294            },
295            task: TaskConfig::default(),
296            llm: LlmConfig::default(),
297            manager: Default::default(),
298            batch_processor: Default::default(),
299            dependency_graph: None,
300            actions: ScenarioActions {
301                actions: action_names
302                    .into_iter()
303                    .map(|name| ScenarioActionDef {
304                        name: name.to_string(),
305                        description: "Test action".to_string(),
306                        params: vec![],
307                        category: ScenarioActionCategory::default(),
308                        example: None,
309                    })
310                    .collect(),
311            },
312            app_config: AppConfigTemplate::default(),
313            environment: EnvironmentConfig {
314                env_type: env_type.to_string(),
315                params: Default::default(),
316                initial_state: None,
317            },
318            agents: AgentsConfig::default(),
319            conditions: EvalConditions::default(),
320            milestones: vec![],
321            variants: vec![],
322        }
323    }
324
325    #[test]
326    fn test_levenshtein_distance() {
327        assert_eq!(levenshtein_distance("", ""), 0);
328        assert_eq!(levenshtein_distance("a", ""), 1);
329        assert_eq!(levenshtein_distance("", "a"), 1);
330        assert_eq!(levenshtein_distance("abc", "abc"), 0);
331        assert_eq!(levenshtein_distance("abc", "abd"), 1);
332        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
333    }
334
335    #[test]
336    fn test_warning_severity_ordering() {
337        assert!(WarningSeverity::Info < WarningSeverity::Low);
338        assert!(WarningSeverity::Low < WarningSeverity::Medium);
339        assert!(WarningSeverity::Medium < WarningSeverity::High);
340    }
341
342    #[test]
343    fn test_validate_valid_scenario() {
344        let scenario = make_test_scenario(
345            "troubleshooting",
346            vec!["CheckStatus", "ReadLogs", "Diagnose", "Restart"],
347        );
348        let warnings = ScenarioValidator::validate_scenario(&scenario);
349
350        // 有効なシナリオなので警告なし
351        let high_warnings: Vec<_> = warnings
352            .iter()
353            .filter(|w| w.severity() >= WarningSeverity::High)
354            .collect();
355        assert!(
356            high_warnings.is_empty(),
357            "Unexpected warnings: {:?}",
358            high_warnings
359        );
360    }
361
362    #[test]
363    fn test_validate_unknown_action() {
364        let scenario = make_test_scenario("troubleshooting", vec!["CheckStatus", "UnknownAction"]);
365        let warnings = ScenarioValidator::validate_scenario(&scenario);
366
367        // UnknownAction は警告が出るはず
368        let unsupported: Vec<_> = warnings
369            .iter()
370            .filter(|w| matches!(w, ValidationWarning::UnsupportedAction { .. }))
371            .collect();
372        assert_eq!(unsupported.len(), 1);
373    }
374
375    #[test]
376    fn test_validate_similar_action_suggestion() {
377        let scenario = make_test_scenario(
378            "troubleshooting",
379            vec!["ChckStatus"], // typo: CheckStatus
380        );
381        let warnings = ScenarioValidator::validate_scenario(&scenario);
382
383        // 類似アクションが提案されるはず
384        let unsupported = warnings.iter().find(|w| {
385            matches!(
386                w,
387                ValidationWarning::UnsupportedAction {
388                    suggestion: Some(_),
389                    ..
390                }
391            )
392        });
393        assert!(unsupported.is_some(), "Expected suggestion for typo");
394    }
395
396    #[test]
397    fn test_validate_unknown_environment() {
398        let scenario = make_test_scenario("unknown_env", vec!["SomeAction"]);
399        let warnings = ScenarioValidator::validate_scenario(&scenario);
400
401        // 不明な環境タイプの警告が出るはず
402        let unknown_env: Vec<_> = warnings
403            .iter()
404            .filter(|w| matches!(w, ValidationWarning::UnknownEnvironment { .. }))
405            .collect();
406        assert_eq!(unknown_env.len(), 1);
407    }
408
409    #[test]
410    fn test_validate_continue_action_ignored() {
411        let scenario = make_test_scenario("troubleshooting", vec!["CheckStatus", "Continue"]);
412        let warnings = ScenarioValidator::validate_scenario(&scenario);
413
414        // Continue は特殊アクションなので警告なし
415        let unsupported: Vec<_> = warnings
416            .iter()
417            .filter(|w| matches!(w, ValidationWarning::UnsupportedAction { action_name, .. } if action_name == "Continue"))
418            .collect();
419        assert!(unsupported.is_empty());
420    }
421}