clnrm_core/telemetry/live_check/
validation.rs

1//! 80/20 validation logic for Weaver conformance reports
2//!
3//! Implements validation mode filtering and coverage calculation for
4//! fast CI/CD gates (80/20 mode) while maintaining strict validation
5//! for final releases.
6
7use super::config::{Complete80_20Config, EightyTwentyConfig, ValidationConfig, ValidationMode};
8use serde::{Deserialize, Serialize};
9
10/// Validator for conformance reports with 80/20 support
11pub struct ConformanceValidator {
12    config: ValidationConfig,
13    eighty_twenty_config: Option<EightyTwentyConfig>,
14    #[allow(dead_code)]
15    complete_config: Option<Complete80_20Config>,
16}
17
18impl ConformanceValidator {
19    /// Create new validator with validation config
20    pub fn new(config: ValidationConfig) -> Self {
21        Self {
22            config,
23            eighty_twenty_config: None,
24            complete_config: None,
25        }
26    }
27
28    /// Create validator with 80/20 configuration
29    pub fn with_80_20_config(config: ValidationConfig, eighty_twenty: EightyTwentyConfig) -> Self {
30        Self {
31            config,
32            eighty_twenty_config: Some(eighty_twenty),
33            complete_config: None,
34        }
35    }
36
37    /// Create validator with complete configuration
38    pub fn with_complete_config(complete: Complete80_20Config) -> Self {
39        Self {
40            config: complete.validation_config.clone(),
41            eighty_twenty_config: None,
42            complete_config: Some(complete),
43        }
44    }
45
46    /// Validate conformance report and return result
47    pub fn validate(&self, report: &ConformanceReport) -> ValidationResult {
48        let start = std::time::Instant::now();
49
50        let result = match self.config.mode {
51            ValidationMode::Strict => self.validate_strict(report),
52            ValidationMode::Lenient => self.validate_lenient(report),
53            ValidationMode::EightyTwenty => self.validate_eighty_twenty(report),
54            ValidationMode::Minimal => self.validate_minimal(report),
55        };
56
57        let duration_ms = start.elapsed().as_millis() as u64;
58
59        // Check if validation completed within time budget
60        let within_budget = duration_ms <= self.config.max_validation_time_ms;
61
62        ValidationResult {
63            mode: self.config.mode,
64            violations: result.0,
65            coverage: result.1,
66            passed: result.2,
67            duration_ms,
68            within_time_budget: within_budget,
69        }
70    }
71
72    /// Strict mode: 100% conformance required
73    fn validate_strict(&self, report: &ConformanceReport) -> (Vec<Violation>, f64, bool) {
74        let mut violations = Vec::new();
75
76        // Check all required spans present
77        for span in &report.required_spans {
78            if !report.present_spans.contains(span) {
79                violations.push(Violation::MissingSpan(span.clone()));
80            }
81        }
82
83        // Check all required attributes present
84        for attr in &report.required_attributes {
85            if !report.present_attributes.contains(attr) {
86                violations.push(Violation::MissingAttribute(attr.clone()));
87            }
88        }
89
90        // Check optional attributes if configured
91        if self.config.fail_on_missing_optional {
92            for attr in &report.optional_attributes {
93                if !report.present_attributes.contains(attr) {
94                    violations.push(Violation::MissingOptionalAttribute(attr.clone()));
95                }
96            }
97        }
98
99        let coverage = self.calculate_coverage(report);
100        let passed = violations.is_empty() && coverage >= self.config.coverage_threshold;
101
102        (violations, coverage, passed)
103    }
104
105    /// Lenient mode: All spans + required attributes
106    fn validate_lenient(&self, report: &ConformanceReport) -> (Vec<Violation>, f64, bool) {
107        let mut violations = Vec::new();
108
109        // Check all required spans present
110        for span in &report.required_spans {
111            if !report.present_spans.contains(span) {
112                violations.push(Violation::MissingSpan(span.clone()));
113            }
114        }
115
116        // Check required attributes (not optional)
117        for attr in &report.required_attributes {
118            if !report.present_attributes.contains(attr) {
119                violations.push(Violation::MissingAttribute(attr.clone()));
120            }
121        }
122
123        // Optional attributes are optional in lenient mode
124
125        let coverage = self.calculate_coverage(report);
126        let passed = violations.is_empty() && coverage >= self.config.coverage_threshold;
127
128        (violations, coverage, passed)
129    }
130
131    /// 80/20 mode: Only check critical 20%
132    fn validate_eighty_twenty(&self, report: &ConformanceReport) -> (Vec<Violation>, f64, bool) {
133        let eighty_twenty = match &self.eighty_twenty_config {
134            Some(config) => config,
135            None => {
136                // Use default if not provided
137                &EightyTwentyConfig::default()
138            }
139        };
140
141        let mut violations = Vec::new();
142
143        // Only check critical spans (the 20%)
144        for span in &eighty_twenty.critical_spans {
145            if !report.present_spans.contains(span) {
146                violations.push(Violation::MissingCriticalSpan(span.clone()));
147            }
148        }
149
150        // Only check required attributes
151        for attr in &eighty_twenty.required_attributes {
152            if !report.present_attributes.contains(attr) {
153                violations.push(Violation::MissingCriticalAttribute(attr.clone()));
154            }
155        }
156
157        // Optional attributes are truly optional in 80/20 mode
158
159        let coverage = self.calculate_critical_coverage(report, eighty_twenty);
160        let passed = violations.is_empty() && coverage >= self.config.coverage_threshold;
161
162        (violations, coverage, passed)
163    }
164
165    /// Minimal mode: Critical spans only
166    fn validate_minimal(&self, report: &ConformanceReport) -> (Vec<Violation>, f64, bool) {
167        let eighty_twenty = match &self.eighty_twenty_config {
168            Some(config) => config,
169            None => &EightyTwentyConfig::default(),
170        };
171
172        let mut violations = Vec::new();
173
174        // Only check critical spans
175        for span in &eighty_twenty.critical_spans {
176            if !report.present_spans.contains(span) {
177                violations.push(Violation::MissingCriticalSpan(span.clone()));
178            }
179        }
180
181        // In minimal mode, check only the most critical attributes
182        let minimal_attrs = ["container.id", "test.hermetic", "test.result"];
183        for attr in &minimal_attrs {
184            if !report.present_attributes.contains(&attr.to_string()) {
185                violations.push(Violation::MissingCriticalAttribute(attr.to_string()));
186            }
187        }
188
189        let coverage = self.calculate_minimal_coverage(report, &minimal_attrs);
190        let passed = violations.is_empty() && coverage >= self.config.coverage_threshold;
191
192        (violations, coverage, passed)
193    }
194
195    /// Calculate overall coverage percentage
196    fn calculate_coverage(&self, report: &ConformanceReport) -> f64 {
197        let total_spans = report.required_spans.len();
198        let total_attrs = report.required_attributes.len();
199        let total = total_spans + total_attrs;
200
201        if total == 0 {
202            return 100.0;
203        }
204
205        let present_spans = report
206            .required_spans
207            .iter()
208            .filter(|s| report.present_spans.contains(*s))
209            .count();
210
211        let present_attrs = report
212            .required_attributes
213            .iter()
214            .filter(|a| report.present_attributes.contains(*a))
215            .count();
216
217        let present = present_spans + present_attrs;
218        (present as f64 / total as f64) * 100.0
219    }
220
221    /// Calculate critical coverage (80/20 mode)
222    fn calculate_critical_coverage(
223        &self,
224        report: &ConformanceReport,
225        eighty_twenty: &EightyTwentyConfig,
226    ) -> f64 {
227        let total_critical_spans = eighty_twenty.critical_spans.len();
228        let total_required_attrs = eighty_twenty.required_attributes.len();
229        let total = total_critical_spans + total_required_attrs;
230
231        if total == 0 {
232            return 100.0;
233        }
234
235        let present_spans = eighty_twenty
236            .critical_spans
237            .iter()
238            .filter(|s| report.present_spans.contains(*s))
239            .count();
240
241        let present_attrs = eighty_twenty
242            .required_attributes
243            .iter()
244            .filter(|a| report.present_attributes.contains(*a))
245            .count();
246
247        let present = present_spans + present_attrs;
248        (present as f64 / total as f64) * 100.0
249    }
250
251    /// Calculate minimal coverage (minimal mode)
252    fn calculate_minimal_coverage(
253        &self,
254        report: &ConformanceReport,
255        minimal_attrs: &[&str],
256    ) -> f64 {
257        let eighty_twenty = match &self.eighty_twenty_config {
258            Some(config) => config,
259            None => &EightyTwentyConfig::default(),
260        };
261
262        let total_spans = eighty_twenty.critical_spans.len();
263        let total_attrs = minimal_attrs.len();
264        let total = total_spans + total_attrs;
265
266        if total == 0 {
267            return 100.0;
268        }
269
270        let present_spans = eighty_twenty
271            .critical_spans
272            .iter()
273            .filter(|s| report.present_spans.contains(*s))
274            .count();
275
276        let present_attrs = minimal_attrs
277            .iter()
278            .filter(|a| report.present_attributes.contains(&a.to_string()))
279            .count();
280
281        let present = present_spans + present_attrs;
282        (present as f64 / total as f64) * 100.0
283    }
284
285    /// Get detailed coverage breakdown by category
286    pub fn get_coverage_breakdown(&self, report: &ConformanceReport) -> CoverageBreakdown {
287        let eighty_twenty = match &self.eighty_twenty_config {
288            Some(config) => config,
289            None => &EightyTwentyConfig::default(),
290        };
291
292        // Critical spans coverage
293        let critical_spans_total = eighty_twenty.critical_spans.len();
294        let critical_spans_present = eighty_twenty
295            .critical_spans
296            .iter()
297            .filter(|s| report.present_spans.contains(*s))
298            .count();
299        let critical_spans_coverage = if critical_spans_total > 0 {
300            (critical_spans_present as f64 / critical_spans_total as f64) * 100.0
301        } else {
302            100.0
303        };
304
305        // Required attributes coverage
306        let required_attrs_total = eighty_twenty.required_attributes.len();
307        let required_attrs_present = eighty_twenty
308            .required_attributes
309            .iter()
310            .filter(|a| report.present_attributes.contains(*a))
311            .count();
312        let required_attrs_coverage = if required_attrs_total > 0 {
313            (required_attrs_present as f64 / required_attrs_total as f64) * 100.0
314        } else {
315            100.0
316        };
317
318        // Optional attributes coverage
319        let optional_attrs_total = eighty_twenty.optional_attributes.len();
320        let optional_attrs_present = eighty_twenty
321            .optional_attributes
322            .iter()
323            .filter(|a| report.present_attributes.contains(*a))
324            .count();
325        let optional_attrs_coverage = if optional_attrs_total > 0 {
326            (optional_attrs_present as f64 / optional_attrs_total as f64) * 100.0
327        } else {
328            100.0
329        };
330
331        CoverageBreakdown {
332            critical_spans_coverage,
333            critical_spans_present,
334            critical_spans_total,
335            required_attributes_coverage: required_attrs_coverage,
336            required_attributes_present: required_attrs_present,
337            required_attributes_total: required_attrs_total,
338            optional_attributes_coverage: optional_attrs_coverage,
339            optional_attributes_present: optional_attrs_present,
340            optional_attributes_total: optional_attrs_total,
341        }
342    }
343}
344
345/// Conformance report from Weaver live-check
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ConformanceReport {
348    /// All required spans
349    pub required_spans: Vec<String>,
350    /// Spans actually present in telemetry
351    pub present_spans: Vec<String>,
352    /// All required attributes
353    pub required_attributes: Vec<String>,
354    /// Attributes actually present in telemetry
355    pub present_attributes: Vec<String>,
356    /// Optional attributes
357    pub optional_attributes: Vec<String>,
358}
359
360impl ConformanceReport {
361    /// Create a new conformance report
362    pub fn new() -> Self {
363        Self {
364            required_spans: Vec::new(),
365            present_spans: Vec::new(),
366            required_attributes: Vec::new(),
367            present_attributes: Vec::new(),
368            optional_attributes: Vec::new(),
369        }
370    }
371
372    /// Add required span
373    pub fn add_required_span(&mut self, span: String) {
374        self.required_spans.push(span);
375    }
376
377    /// Add present span
378    pub fn add_present_span(&mut self, span: String) {
379        self.present_spans.push(span);
380    }
381
382    /// Add required attribute
383    pub fn add_required_attribute(&mut self, attr: String) {
384        self.required_attributes.push(attr);
385    }
386
387    /// Add present attribute
388    pub fn add_present_attribute(&mut self, attr: String) {
389        self.present_attributes.push(attr);
390    }
391
392    /// Add optional attribute
393    pub fn add_optional_attribute(&mut self, attr: String) {
394        self.optional_attributes.push(attr);
395    }
396}
397
398impl Default for ConformanceReport {
399    fn default() -> Self {
400        Self::new()
401    }
402}
403
404/// Result of validation
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct ValidationResult {
407    /// Validation mode used
408    pub mode: ValidationMode,
409    /// List of violations found
410    pub violations: Vec<Violation>,
411    /// Coverage percentage (0.0 - 100.0)
412    pub coverage: f64,
413    /// Whether validation passed
414    pub passed: bool,
415    /// Validation duration in milliseconds
416    pub duration_ms: u64,
417    /// Whether validation completed within time budget
418    pub within_time_budget: bool,
419}
420
421impl ValidationResult {
422    /// Check if validation is successful
423    pub fn is_success(&self) -> bool {
424        self.passed && self.within_time_budget
425    }
426
427    /// Get summary message
428    pub fn summary(&self) -> String {
429        if self.passed {
430            format!(
431                "✅ Validation PASSED ({:?} mode): {:.1}% coverage in {}ms",
432                self.mode, self.coverage, self.duration_ms
433            )
434        } else {
435            format!(
436                "❌ Validation FAILED ({:?} mode): {:.1}% coverage, {} violations",
437                self.mode,
438                self.coverage,
439                self.violations.len()
440            )
441        }
442    }
443
444    /// Print detailed report
445    pub fn print_report(&self) {
446        println!("\n{}", "=".repeat(60));
447        println!("WEAVER VALIDATION REPORT ({:?} MODE)", self.mode);
448        println!("{}", "=".repeat(60));
449
450        println!(
451            "\nStatus: {}",
452            if self.passed {
453                "✅ PASSED"
454            } else {
455                "❌ FAILED"
456            }
457        );
458        println!("Coverage: {:.1}%", self.coverage);
459        println!("Duration: {}ms", self.duration_ms);
460        println!(
461            "Time Budget: {}",
462            if self.within_time_budget {
463                "✅ Met"
464            } else {
465                "⚠️ Exceeded"
466            }
467        );
468
469        if !self.violations.is_empty() {
470            println!("\n{} VIOLATIONS FOUND:", self.violations.len());
471            for (i, violation) in self.violations.iter().enumerate() {
472                println!("  {}. {}", i + 1, violation);
473            }
474        }
475
476        println!("\n{}", "=".repeat(60));
477    }
478}
479
480/// Validation violation
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub enum Violation {
483    /// Required span is missing
484    MissingSpan(String),
485    /// Required attribute is missing
486    MissingAttribute(String),
487    /// Optional attribute is missing
488    MissingOptionalAttribute(String),
489    /// Critical span is missing (80/20 mode)
490    MissingCriticalSpan(String),
491    /// Critical attribute is missing (80/20 mode)
492    MissingCriticalAttribute(String),
493}
494
495impl std::fmt::Display for Violation {
496    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497        match self {
498            Violation::MissingSpan(span) => write!(f, "Missing required span: {}", span),
499            Violation::MissingAttribute(attr) => write!(f, "Missing required attribute: {}", attr),
500            Violation::MissingOptionalAttribute(attr) => {
501                write!(f, "Missing optional attribute: {}", attr)
502            }
503            Violation::MissingCriticalSpan(span) => write!(f, "Missing CRITICAL span: {}", span),
504            Violation::MissingCriticalAttribute(attr) => {
505                write!(f, "Missing CRITICAL attribute: {}", attr)
506            }
507        }
508    }
509}
510
511/// Detailed coverage breakdown by category
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct CoverageBreakdown {
514    /// Critical spans coverage percentage
515    pub critical_spans_coverage: f64,
516    /// Number of critical spans present
517    pub critical_spans_present: usize,
518    /// Total number of critical spans
519    pub critical_spans_total: usize,
520    /// Required attributes coverage percentage
521    pub required_attributes_coverage: f64,
522    /// Number of required attributes present
523    pub required_attributes_present: usize,
524    /// Total number of required attributes
525    pub required_attributes_total: usize,
526    /// Optional attributes coverage percentage
527    pub optional_attributes_coverage: f64,
528    /// Number of optional attributes present
529    pub optional_attributes_present: usize,
530    /// Total number of optional attributes
531    pub optional_attributes_total: usize,
532}
533
534impl CoverageBreakdown {
535    /// Print coverage breakdown
536    pub fn print(&self) {
537        println!("\n{}", "=".repeat(60));
538        println!("COVERAGE BREAKDOWN");
539        println!("{}", "=".repeat(60));
540
541        println!(
542            "\nCritical Spans: {:.1}% ({}/{})",
543            self.critical_spans_coverage, self.critical_spans_present, self.critical_spans_total
544        );
545
546        println!(
547            "Required Attributes: {:.1}% ({}/{})",
548            self.required_attributes_coverage,
549            self.required_attributes_present,
550            self.required_attributes_total
551        );
552
553        println!(
554            "Optional Attributes: {:.1}% ({}/{})",
555            self.optional_attributes_coverage,
556            self.optional_attributes_present,
557            self.optional_attributes_total
558        );
559
560        println!("\n{}", "=".repeat(60));
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    fn create_test_report() -> ConformanceReport {
569        let mut report = ConformanceReport::new();
570
571        // Add required spans
572        report.add_required_span("span1".to_string());
573        report.add_required_span("span2".to_string());
574        report.add_required_span("span3".to_string());
575
576        // Add present spans (2 out of 3)
577        report.add_present_span("span1".to_string());
578        report.add_present_span("span2".to_string());
579
580        // Add required attributes
581        report.add_required_attribute("attr1".to_string());
582        report.add_required_attribute("attr2".to_string());
583
584        // Add present attributes (1 out of 2)
585        report.add_present_attribute("attr1".to_string());
586
587        report
588    }
589
590    #[test]
591    fn test_strict_mode_all_present() {
592        let config = ValidationConfig::strict();
593        let validator = ConformanceValidator::new(config);
594
595        let mut report = ConformanceReport::new();
596        report.add_required_span("span1".to_string());
597        report.add_present_span("span1".to_string());
598        report.add_required_attribute("attr1".to_string());
599        report.add_present_attribute("attr1".to_string());
600
601        let result = validator.validate(&report);
602
603        assert!(result.passed);
604        assert_eq!(result.coverage, 100.0);
605        assert!(result.violations.is_empty());
606    }
607
608    #[test]
609    fn test_strict_mode_missing_span() {
610        let config = ValidationConfig::strict();
611        let validator = ConformanceValidator::new(config);
612
613        let mut report = ConformanceReport::new();
614        report.add_required_span("span1".to_string());
615        // span1 not present
616        report.add_required_attribute("attr1".to_string());
617        report.add_present_attribute("attr1".to_string());
618
619        let result = validator.validate(&report);
620
621        assert!(!result.passed);
622        assert_eq!(result.violations.len(), 1);
623        assert!(matches!(result.violations[0], Violation::MissingSpan(_)));
624    }
625
626    #[test]
627    fn test_eighty_twenty_mode_critical_only() {
628        let config = ValidationConfig::eighty_twenty();
629        let eighty_twenty = EightyTwentyConfig {
630            critical_spans: vec!["critical_span".to_string()],
631            required_attributes: vec!["critical_attr".to_string()],
632            optional_attributes: vec![],
633        };
634
635        let validator = ConformanceValidator::with_80_20_config(config, eighty_twenty);
636
637        let mut report = ConformanceReport::new();
638        report.add_required_span("critical_span".to_string());
639        report.add_required_span("optional_span".to_string());
640        report.add_present_span("critical_span".to_string());
641        // optional_span missing - should be OK in 80/20 mode
642
643        report.add_required_attribute("critical_attr".to_string());
644        report.add_present_attribute("critical_attr".to_string());
645
646        let result = validator.validate(&report);
647
648        // Should pass because critical span/attr present
649        assert!(result.passed);
650        assert_eq!(result.coverage, 100.0); // 100% of critical items covered
651    }
652
653    #[test]
654    fn test_coverage_calculation() {
655        let config = ValidationConfig::strict();
656        let validator = ConformanceValidator::new(config);
657
658        let report = create_test_report();
659        let result = validator.validate(&report);
660
661        // Coverage = (2 spans + 1 attr) / (3 spans + 2 attrs) = 3/5 = 60%
662        assert_eq!(result.coverage, 60.0);
663    }
664
665    #[test]
666    fn test_minimal_mode() {
667        let config = ValidationConfig::minimal();
668        let validator = ConformanceValidator::new(config);
669
670        let mut report = ConformanceReport::new();
671
672        // Add all critical spans
673        for span in &[
674            "clnrm.test.execute",
675            "clnrm.container.start",
676            "clnrm.container.stop",
677            "clnrm.test.cleanup",
678            "clnrm.cli.health",
679        ] {
680            report.add_required_span(span.to_string());
681            report.add_present_span(span.to_string());
682        }
683
684        // Add minimal critical attributes
685        for attr in &["container.id", "test.hermetic", "test.result"] {
686            report.add_required_attribute(attr.to_string());
687            report.add_present_attribute(attr.to_string());
688        }
689
690        let result = validator.validate(&report);
691
692        assert!(result.passed);
693        assert_eq!(result.coverage, 100.0);
694    }
695
696    #[test]
697    fn test_coverage_breakdown() {
698        let config = ValidationConfig::eighty_twenty();
699        let eighty_twenty = EightyTwentyConfig {
700            critical_spans: vec!["span1".to_string(), "span2".to_string()],
701            required_attributes: vec!["attr1".to_string(), "attr2".to_string()],
702            optional_attributes: vec!["opt1".to_string()],
703        };
704
705        let validator = ConformanceValidator::with_80_20_config(config, eighty_twenty);
706
707        let mut report = ConformanceReport::new();
708        report.add_present_span("span1".to_string());
709        // span2 missing
710        report.add_present_attribute("attr1".to_string());
711        report.add_present_attribute("attr2".to_string());
712        // opt1 missing
713
714        let breakdown = validator.get_coverage_breakdown(&report);
715
716        assert_eq!(breakdown.critical_spans_coverage, 50.0); // 1/2
717        assert_eq!(breakdown.required_attributes_coverage, 100.0); // 2/2
718        assert_eq!(breakdown.optional_attributes_coverage, 0.0); // 0/1
719    }
720
721    #[test]
722    fn test_validation_time_budget() {
723        let config = ValidationConfig {
724            mode: ValidationMode::EightyTwenty,
725            fail_on_violation: true,
726            fail_on_missing_optional: false,
727            coverage_threshold: 80.0,
728            max_validation_time_ms: 1, // Very short time budget
729        };
730
731        let validator = ConformanceValidator::new(config);
732        let report = create_test_report();
733
734        let result = validator.validate(&report);
735
736        // Validation might exceed the 1ms budget
737        // Just check that the field is populated correctly
738        assert!(result.duration_ms >= 0);
739    }
740
741    #[test]
742    fn test_lenient_mode() {
743        let config = ValidationConfig::lenient();
744        let validator = ConformanceValidator::new(config);
745
746        let mut report = ConformanceReport::new();
747        report.add_required_span("span1".to_string());
748        report.add_present_span("span1".to_string());
749        report.add_required_attribute("attr1".to_string());
750        report.add_present_attribute("attr1".to_string());
751
752        // Optional attribute missing - should be OK in lenient mode
753        report.add_optional_attribute("optional1".to_string());
754
755        let result = validator.validate(&report);
756
757        assert!(result.passed);
758        assert_eq!(result.coverage, 100.0);
759    }
760}