swarm_engine_eval/validation/
validator.rs1use std::fmt;
6
7use crate::scenario::EvalScenario;
8use swarm_engine_core::actions::EnvironmentSpecRegistry;
9
10#[derive(Debug, Clone)]
12pub enum ValidationWarning {
13 UnsupportedAction {
15 action_name: String,
16 env_type: String,
17 suggestion: Option<String>,
18 },
19 UnknownEnvironment { env_type: String },
21 MissingRequiredParamDefinition {
23 action_name: String,
24 param_name: String,
25 },
26 ActionsWithoutEnvironment { action_count: usize },
28 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 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#[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
113pub struct ScenarioValidator {
115 registry: EnvironmentSpecRegistry,
116}
117
118impl Default for ScenarioValidator {
119 fn default() -> Self {
120 Self::new()
121 }
122}
123
124impl ScenarioValidator {
125 pub fn new() -> Self {
127 Self {
128 registry: EnvironmentSpecRegistry::new(),
129 }
130 }
131
132 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 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 return warnings;
148 }
149 };
150
151 for action_def in actions {
153 let action_name = &action_def.name;
154
155 if action_name.to_lowercase() == "continue" {
157 continue;
158 }
159
160 if !env_spec.supports_action(action_name) {
162 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 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 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 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 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 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 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 pub fn validate_scenario(scenario: &EvalScenario) -> Vec<ValidationWarning> {
234 let validator = Self::new();
235 validator.validate(scenario)
236 }
237}
238
239fn 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 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 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"], );
381 let warnings = ScenarioValidator::validate_scenario(&scenario);
382
383 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 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 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}