Skip to main content

dasein_agentic_core/distributed/
validator.rs

1//! Validator - Rule-based output quality checks.
2//!
3//! Fast, heuristic validation for any output type. Use this for:
4//! - Text validation (not empty, no errors, length limits)
5//! - JSON validation
6//! - Quick syntax checks
7//! - Content filtering (no secrets, no TODOs)
8//!
9//! For **code validation with real compilation/tests**, use
10//! [`SandboxValidator`](super::SandboxValidator) instead.
11//!
12//! # Quick Start
13//!
14//! ```rust
15//! use dasein_agentic_core::distributed::{Validator, ValidationRule};
16//!
17//! let validator = Validator::new("val-001", "sup-001")
18//!     .rule(ValidationRule::OutputNotEmpty)
19//!     .rule(ValidationRule::NoErrors)
20//!     .rule(ValidationRule::NoSecrets)
21//!     .build();
22//!
23//! let result = validator.validate("Some LLM output", 0);
24//! assert!(result.passed);
25//! ```
26//!
27//! # Validator vs SandboxValidator
28//!
29//! | Aspect | Validator | SandboxValidator |
30//! |--------|-----------|------------------|
31//! | Speed | Fast (~1ms) | Slower (~1-5s) |
32//! | Accuracy | Heuristic | Ground truth |
33//! | Use for | Text, JSON | Code |
34//! | Feedback | Rule-based | Real errors |
35
36use serde::{Deserialize, Serialize};
37
38use super::config::ValidatorConfig;
39
40/// Predefined validation rules.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum ValidationRule {
44    /// Output must not be empty
45    OutputNotEmpty,
46    /// No error in output
47    NoErrors,
48    /// Output must be valid JSON
49    ValidJson,
50    /// Code must compile (requires language)
51    CodeCompiles { language: String },
52    /// No TODO/FIXME in code
53    NoTodos,
54    /// Must have tests
55    HasTests,
56    /// No secrets/API keys
57    NoSecrets,
58    /// Max output length
59    MaxLength { chars: usize },
60    /// Min output length
61    MinLength { chars: usize },
62    /// Must contain pattern
63    Contains { pattern: String },
64    /// Must not contain pattern
65    NotContains { pattern: String },
66    /// Custom rule (name only, logic in validator)
67    Custom { name: String },
68}
69
70impl ValidationRule {
71    /// Get rule name.
72    pub fn name(&self) -> &str {
73        match self {
74            Self::OutputNotEmpty => "output_not_empty",
75            Self::NoErrors => "no_errors",
76            Self::ValidJson => "valid_json",
77            Self::CodeCompiles { .. } => "code_compiles",
78            Self::NoTodos => "no_todos",
79            Self::HasTests => "has_tests",
80            Self::NoSecrets => "no_secrets",
81            Self::MaxLength { .. } => "max_length",
82            Self::MinLength { .. } => "min_length",
83            Self::Contains { .. } => "contains",
84            Self::NotContains { .. } => "not_contains",
85            Self::Custom { name } => name,
86        }
87    }
88}
89
90/// Result of validation.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ValidationResult {
93    /// Overall pass/fail
94    pub passed: bool,
95    /// Results per rule
96    pub rule_results: Vec<RuleResult>,
97    /// Overall score (0-100)
98    pub score: u32,
99    /// Feedback if failed
100    pub feedback: Option<String>,
101    /// Recommended action
102    pub action: ValidationAction,
103}
104
105/// Result of a single rule check.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct RuleResult {
108    pub rule: String,
109    pub passed: bool,
110    pub message: Option<String>,
111}
112
113/// Action to take after validation.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum ValidationAction {
117    /// Accept the output
118    Accept,
119    /// Retry with feedback
120    Retry { feedback: String, attempt: u32 },
121    /// Reject definitively
122    Reject { reason: String },
123}
124
125/// Validator that checks output quality.
126#[derive(Debug)]
127pub struct Validator {
128    /// Unique identifier
129    pub id: String,
130    /// Associated supervisor
131    pub supervisor: String,
132    /// Validation rules
133    rules: Vec<ValidationRule>,
134    /// Max retries on failure
135    max_retries: u32,
136}
137
138impl Validator {
139    /// Create a new validator.
140    ///
141    /// # Example
142    ///
143    /// ```rust
144    /// let val = Validator::new("val-001", "sup-001")
145    ///     .rule(ValidationRule::OutputNotEmpty)
146    ///     .build();
147    /// ```
148    pub fn new(id: impl Into<String>, supervisor: impl Into<String>) -> ValidatorBuilder {
149        ValidatorBuilder::new(id.into(), supervisor.into())
150    }
151
152    /// Create from config.
153    pub fn from_config(config: ValidatorConfig) -> Self {
154        let rules = config
155            .rules
156            .iter()
157            .map(|r| match r.as_str() {
158                "output_not_empty" => ValidationRule::OutputNotEmpty,
159                "no_errors" => ValidationRule::NoErrors,
160                "valid_json" => ValidationRule::ValidJson,
161                "no_todos" => ValidationRule::NoTodos,
162                "has_tests" => ValidationRule::HasTests,
163                "no_secrets" => ValidationRule::NoSecrets,
164                _ => ValidationRule::Custom { name: r.clone() },
165            })
166            .collect();
167
168        Self {
169            id: config.id,
170            supervisor: config.supervisor,
171            rules,
172            max_retries: config.max_retries,
173        }
174    }
175
176    /// Validate output.
177    pub fn validate(&self, output: &str, attempt: u32) -> ValidationResult {
178        let mut rule_results = Vec::new();
179        let mut all_passed = true;
180        let mut feedback_parts = Vec::new();
181
182        for rule in &self.rules {
183            let (passed, message) = self.check_rule(rule, output);
184
185            if !passed {
186                all_passed = false;
187                if let Some(ref msg) = message {
188                    feedback_parts.push(format!("{}: {}", rule.name(), msg));
189                }
190            }
191
192            rule_results.push(RuleResult {
193                rule: rule.name().to_string(),
194                passed,
195                message,
196            });
197        }
198
199        let passed_count = rule_results.iter().filter(|r| r.passed).count();
200        let score = if self.rules.is_empty() {
201            100
202        } else {
203            ((passed_count as f32 / self.rules.len() as f32) * 100.0) as u32
204        };
205
206        let action = if all_passed {
207            ValidationAction::Accept
208        } else if attempt < self.max_retries {
209            ValidationAction::Retry {
210                feedback: feedback_parts.join("; "),
211                attempt: attempt + 1,
212            }
213        } else {
214            ValidationAction::Reject {
215                reason: feedback_parts.join("; "),
216            }
217        };
218
219        let feedback = if all_passed {
220            None
221        } else {
222            Some(feedback_parts.join("; "))
223        };
224
225        ValidationResult {
226            passed: all_passed,
227            rule_results,
228            score,
229            feedback,
230            action,
231        }
232    }
233
234    /// Check a single rule.
235    fn check_rule(&self, rule: &ValidationRule, output: &str) -> (bool, Option<String>) {
236        match rule {
237            ValidationRule::OutputNotEmpty => {
238                let passed = !output.trim().is_empty();
239                (
240                    passed,
241                    if passed {
242                        None
243                    } else {
244                        Some("Output is empty".into())
245                    },
246                )
247            }
248            ValidationRule::NoErrors => {
249                let has_error = output.to_lowercase().contains("error:");
250                (
251                    !has_error,
252                    if has_error {
253                        Some("Output contains errors".into())
254                    } else {
255                        None
256                    },
257                )
258            }
259            ValidationRule::ValidJson => match serde_json::from_str::<serde_json::Value>(output) {
260                Ok(_) => (true, None),
261                Err(e) => (false, Some(format!("Invalid JSON: {}", e))),
262            },
263            ValidationRule::NoTodos => {
264                let has_todo = output.contains("TODO") || output.contains("FIXME");
265                (
266                    !has_todo,
267                    if has_todo {
268                        Some("Contains TODO/FIXME".into())
269                    } else {
270                        None
271                    },
272                )
273            }
274            ValidationRule::HasTests => {
275                let has_tests = output.contains("#[test]") || output.contains("fn test_");
276                (
277                    has_tests,
278                    if has_tests {
279                        None
280                    } else {
281                        Some("No tests found".into())
282                    },
283                )
284            }
285            ValidationRule::NoSecrets => {
286                let patterns = ["api_key", "secret", "password", "token"];
287                let has_secret = patterns.iter().any(|p| output.to_lowercase().contains(p));
288                (
289                    !has_secret,
290                    if has_secret {
291                        Some("Potential secret detected".into())
292                    } else {
293                        None
294                    },
295                )
296            }
297            ValidationRule::MaxLength { chars } => {
298                let passed = output.len() <= *chars;
299                (
300                    passed,
301                    if passed {
302                        None
303                    } else {
304                        Some(format!("Output too long: {} > {}", output.len(), chars))
305                    },
306                )
307            }
308            ValidationRule::MinLength { chars } => {
309                let passed = output.len() >= *chars;
310                (
311                    passed,
312                    if passed {
313                        None
314                    } else {
315                        Some(format!("Output too short: {} < {}", output.len(), chars))
316                    },
317                )
318            }
319            ValidationRule::Contains { pattern } => {
320                let passed = output.contains(pattern);
321                (
322                    passed,
323                    if passed {
324                        None
325                    } else {
326                        Some(format!("Missing required pattern: {}", pattern))
327                    },
328                )
329            }
330            ValidationRule::NotContains { pattern } => {
331                let passed = !output.contains(pattern);
332                (
333                    passed,
334                    if passed {
335                        None
336                    } else {
337                        Some(format!("Contains forbidden pattern: {}", pattern))
338                    },
339                )
340            }
341            ValidationRule::CodeCompiles { language: _ } => {
342                // Placeholder - real implementation would compile
343                (true, None)
344            }
345            ValidationRule::Custom { name: _ } => {
346                // Custom rules always pass by default
347                (true, None)
348            }
349        }
350    }
351}
352
353/// Builder for Validator.
354pub struct ValidatorBuilder {
355    id: String,
356    supervisor: String,
357    rules: Vec<ValidationRule>,
358    max_retries: u32,
359}
360
361impl ValidatorBuilder {
362    fn new(id: String, supervisor: String) -> Self {
363        Self {
364            id,
365            supervisor,
366            rules: Vec::new(),
367            max_retries: 2,
368        }
369    }
370
371    /// Add a validation rule.
372    pub fn rule(mut self, rule: ValidationRule) -> Self {
373        self.rules.push(rule);
374        self
375    }
376
377    /// Add multiple rules.
378    pub fn rules(mut self, rules: impl IntoIterator<Item = ValidationRule>) -> Self {
379        self.rules.extend(rules);
380        self
381    }
382
383    /// Use default rules (output_not_empty, no_errors).
384    pub fn default_rules(self) -> Self {
385        self.rule(ValidationRule::OutputNotEmpty)
386            .rule(ValidationRule::NoErrors)
387    }
388
389    /// Use strict rules for code.
390    pub fn strict_code_rules(self) -> Self {
391        self.rule(ValidationRule::OutputNotEmpty)
392            .rule(ValidationRule::NoErrors)
393            .rule(ValidationRule::NoTodos)
394            .rule(ValidationRule::NoSecrets)
395    }
396
397    /// Set max retries.
398    pub fn max_retries(mut self, n: u32) -> Self {
399        self.max_retries = n;
400        self
401    }
402
403    /// Build the validator.
404    pub fn build(self) -> Validator {
405        let rules = if self.rules.is_empty() {
406            vec![ValidationRule::OutputNotEmpty]
407        } else {
408            self.rules
409        };
410
411        Validator {
412            id: self.id,
413            supervisor: self.supervisor,
414            rules,
415            max_retries: self.max_retries,
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_validator_pass() {
426        let val = Validator::new("val-001", "sup-001")
427            .rule(ValidationRule::OutputNotEmpty)
428            .rule(ValidationRule::NoErrors)
429            .build();
430
431        let result = val.validate("Hello world", 0);
432        assert!(result.passed);
433        assert_eq!(result.score, 100);
434    }
435
436    #[test]
437    fn test_validator_fail_empty() {
438        let val = Validator::new("val-001", "sup-001")
439            .rule(ValidationRule::OutputNotEmpty)
440            .build();
441
442        let result = val.validate("", 0);
443        assert!(!result.passed);
444        assert!(matches!(result.action, ValidationAction::Retry { .. }));
445    }
446
447    #[test]
448    fn test_validator_fail_max_retries() {
449        let val = Validator::new("val-001", "sup-001")
450            .rule(ValidationRule::OutputNotEmpty)
451            .max_retries(2)
452            .build();
453
454        let result = val.validate("", 2);
455        assert!(!result.passed);
456        assert!(matches!(result.action, ValidationAction::Reject { .. }));
457    }
458}