Skip to main content

probador/
statistics.rs

1//! Statistical Analysis Module (PROBAR-SPEC-006 Section I)
2//!
3//! Implements variance decomposition, Apdex scoring, knee detection,
4//! and quantile regression for load test analysis.
5//!
6//! Based on research:
7//! - [C8] VProfiler variance trees
8//! - [C9] Treadmill tail latency attribution
9//! - [C12] Tail at Scale methodology
10
11#![allow(clippy::must_use_candidate)]
12#![allow(clippy::missing_panics_doc)]
13#![allow(clippy::missing_errors_doc)]
14#![allow(clippy::module_name_repetitions)]
15#![allow(clippy::missing_const_for_fn)]
16#![allow(clippy::cast_possible_truncation)]
17#![allow(clippy::cast_precision_loss)]
18#![allow(clippy::cast_lossless)]
19#![allow(clippy::cast_sign_loss)]
20#![allow(clippy::suboptimal_flops)]
21#![allow(clippy::format_push_string)]
22#![allow(clippy::uninlined_format_args)]
23#![allow(clippy::doc_markdown)]
24#![allow(clippy::use_self)]
25#![allow(clippy::unwrap_used)]
26#![allow(clippy::useless_format)]
27
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30
31// =============================================================================
32// I.2 Variance Tree (following [C8] VProfiler methodology)
33// =============================================================================
34
35/// Hierarchical variance decomposition
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct VarianceTree {
38    /// Total variance across all components
39    pub total_variance: f64,
40    /// Root-level components
41    pub components: Vec<VarianceComponent>,
42}
43
44impl VarianceTree {
45    /// Create a new variance tree
46    pub fn new() -> Self {
47        Self {
48            total_variance: 0.0,
49            components: Vec::new(),
50        }
51    }
52
53    /// Add a component
54    pub fn add_component(&mut self, component: VarianceComponent) {
55        self.total_variance += component.variance;
56        self.components.push(component);
57    }
58
59    /// Recalculate percentages based on total variance
60    pub fn recalculate_percentages(&mut self) {
61        if self.total_variance > 0.0 {
62            for comp in &mut self.components {
63                comp.percentage = (comp.variance / self.total_variance) * 100.0;
64                comp.recalculate_percentages(self.total_variance);
65            }
66        }
67    }
68
69    /// Build from latency samples with component attribution
70    pub fn from_samples(samples: &[LatencySample]) -> Self {
71        let mut tree = Self::new();
72
73        // Group by component
74        let mut component_samples: HashMap<String, Vec<f64>> = HashMap::new();
75        for sample in samples {
76            for (component, latency) in &sample.components {
77                component_samples
78                    .entry(component.clone())
79                    .or_default()
80                    .push(*latency);
81            }
82        }
83
84        // Calculate variance for each component
85        for (name, values) in component_samples {
86            let variance = calculate_variance(&values);
87            tree.add_component(VarianceComponent {
88                name,
89                variance,
90                percentage: 0.0,
91                children: Vec::new(),
92            });
93        }
94
95        tree.recalculate_percentages();
96        tree
97    }
98}
99
100impl Default for VarianceTree {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106/// A component in the variance tree
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct VarianceComponent {
109    /// Component name (e.g., "Network I/O", "WASM Execution")
110    pub name: String,
111    /// Variance contribution in ms²
112    pub variance: f64,
113    /// Percentage of total variance
114    pub percentage: f64,
115    /// Child components
116    pub children: Vec<VarianceComponent>,
117}
118
119impl VarianceComponent {
120    /// Create a new component
121    pub fn new(name: &str, variance: f64) -> Self {
122        Self {
123            name: name.to_string(),
124            variance,
125            percentage: 0.0,
126            children: Vec::new(),
127        }
128    }
129
130    /// Add a child component
131    pub fn add_child(&mut self, child: VarianceComponent) {
132        self.children.push(child);
133    }
134
135    /// Recalculate child percentages
136    fn recalculate_percentages(&mut self, total: f64) {
137        for child in &mut self.children {
138            child.percentage = (child.variance / total) * 100.0;
139            child.recalculate_percentages(total);
140        }
141    }
142}
143
144/// Latency sample with component breakdown
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct LatencySample {
147    /// Total latency in ms
148    pub total_ms: f64,
149    /// Breakdown by component
150    pub components: HashMap<String, f64>,
151    /// Timestamp
152    pub timestamp_ms: u64,
153}
154
155// =============================================================================
156// I.2 Apdex Score
157// =============================================================================
158
159/// Apdex (Application Performance Index) calculator
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ApdexCalculator {
162    /// Satisfied threshold in ms (requests below this are "satisfied")
163    pub satisfied_threshold_ms: u64,
164    /// Tolerating threshold in ms (requests below this are "tolerating")
165    pub tolerating_threshold_ms: u64,
166    /// Count of satisfied requests
167    satisfied_count: u64,
168    /// Count of tolerating requests
169    tolerating_count: u64,
170    /// Count of frustrated requests
171    frustrated_count: u64,
172}
173
174impl ApdexCalculator {
175    /// Create a new Apdex calculator
176    /// Default: T = 100ms (satisfied), 4T = 400ms (tolerating)
177    pub fn new(satisfied_ms: u64, tolerating_ms: u64) -> Self {
178        Self {
179            satisfied_threshold_ms: satisfied_ms,
180            tolerating_threshold_ms: tolerating_ms,
181            satisfied_count: 0,
182            tolerating_count: 0,
183            frustrated_count: 0,
184        }
185    }
186
187    /// Record a latency sample
188    pub fn record(&mut self, latency_ms: u64) {
189        if latency_ms <= self.satisfied_threshold_ms {
190            self.satisfied_count += 1;
191        } else if latency_ms <= self.tolerating_threshold_ms {
192            self.tolerating_count += 1;
193        } else {
194            self.frustrated_count += 1;
195        }
196    }
197
198    /// Calculate Apdex score (0.0 to 1.0)
199    pub fn score(&self) -> f64 {
200        let total = self.total_count();
201        if total == 0 {
202            return 1.0; // No data = perfect
203        }
204        (self.satisfied_count as f64 + self.tolerating_count as f64 / 2.0) / total as f64
205    }
206
207    /// Get total count
208    pub fn total_count(&self) -> u64 {
209        self.satisfied_count + self.tolerating_count + self.frustrated_count
210    }
211
212    /// Get satisfied count
213    pub fn satisfied(&self) -> u64 {
214        self.satisfied_count
215    }
216
217    /// Get tolerating count
218    pub fn tolerating(&self) -> u64 {
219        self.tolerating_count
220    }
221
222    /// Get frustrated count
223    pub fn frustrated(&self) -> u64 {
224        self.frustrated_count
225    }
226
227    /// Get rating based on score
228    pub fn rating(&self) -> ApdexRating {
229        let score = self.score();
230        if score >= 0.94 {
231            ApdexRating::Excellent
232        } else if score >= 0.85 {
233            ApdexRating::Good
234        } else if score >= 0.70 {
235            ApdexRating::Fair
236        } else if score >= 0.50 {
237            ApdexRating::Poor
238        } else {
239            ApdexRating::Unacceptable
240        }
241    }
242
243    /// Reset calculator
244    pub fn reset(&mut self) {
245        self.satisfied_count = 0;
246        self.tolerating_count = 0;
247        self.frustrated_count = 0;
248    }
249}
250
251impl Default for ApdexCalculator {
252    fn default() -> Self {
253        Self::new(100, 400) // T=100ms, 4T=400ms
254    }
255}
256
257/// Apdex rating levels
258#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
259pub enum ApdexRating {
260    /// 0.94 - 1.00
261    Excellent,
262    /// 0.85 - 0.93
263    Good,
264    /// 0.70 - 0.84
265    Fair,
266    /// 0.50 - 0.69
267    Poor,
268    /// 0.00 - 0.49
269    Unacceptable,
270}
271
272impl ApdexRating {
273    /// Get display string
274    pub fn as_str(&self) -> &'static str {
275        match self {
276            Self::Excellent => "Excellent",
277            Self::Good => "Good",
278            Self::Fair => "Fair",
279            Self::Poor => "Poor",
280            Self::Unacceptable => "Unacceptable",
281        }
282    }
283}
284
285// =============================================================================
286// I.2 Throughput Knee Detection (following [C12] Tail at Scale)
287// =============================================================================
288
289/// Knee detector for finding throughput inflection point
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct KneeDetector {
292    /// Data points (load, latency)
293    points: Vec<(f64, f64)>,
294    /// Detected knee point (load, latency)
295    pub knee_point: Option<(f64, f64)>,
296    /// Recommended capacity (80% of knee)
297    pub recommended_capacity: Option<f64>,
298}
299
300impl KneeDetector {
301    /// Create a new knee detector
302    pub fn new() -> Self {
303        Self {
304            points: Vec::new(),
305            knee_point: None,
306            recommended_capacity: None,
307        }
308    }
309
310    /// Add a data point (load level, latency)
311    pub fn add_point(&mut self, load: f64, latency: f64) {
312        self.points.push((load, latency));
313    }
314
315    /// Detect the knee point using second derivative
316    pub fn detect(&mut self) {
317        if self.points.len() < 3 {
318            return;
319        }
320
321        // Sort by load
322        self.points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
323
324        // Calculate second derivative (approximation)
325        let mut max_curvature = 0.0;
326        let mut knee_idx = 0;
327
328        for i in 1..self.points.len() - 1 {
329            let (x0, y0) = self.points[i - 1];
330            let (x1, y1) = self.points[i];
331            let (x2, y2) = self.points[i + 1];
332
333            // First derivatives
334            let dy1 = (y1 - y0) / (x1 - x0);
335            let dy2 = (y2 - y1) / (x2 - x1);
336
337            // Second derivative (curvature approximation)
338            let d2y = (dy2 - dy1) / ((x2 - x0) / 2.0);
339
340            if d2y > max_curvature {
341                max_curvature = d2y;
342                knee_idx = i;
343            }
344        }
345
346        if max_curvature > 0.0 {
347            self.knee_point = Some(self.points[knee_idx]);
348            self.recommended_capacity = Some(self.points[knee_idx].0 * 0.8);
349        }
350    }
351
352    /// Get points
353    pub fn points(&self) -> &[(f64, f64)] {
354        &self.points
355    }
356}
357
358impl Default for KneeDetector {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364// =============================================================================
365// I.2 Tail Latency Attribution (following [C9] Treadmill)
366// =============================================================================
367
368/// Tail latency attribution
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct TailAttribution {
371    /// Percentile (e.g., 99)
372    pub percentile: u8,
373    /// Latency value at this percentile
374    pub latency_ms: u64,
375    /// Primary cause
376    pub primary_cause: String,
377    /// Contributing factors with weights
378    pub contributing_factors: Vec<(String, f64)>,
379}
380
381impl TailAttribution {
382    /// Create a new attribution
383    pub fn new(percentile: u8, latency_ms: u64, primary_cause: &str) -> Self {
384        Self {
385            percentile,
386            latency_ms,
387            primary_cause: primary_cause.to_string(),
388            contributing_factors: Vec::new(),
389        }
390    }
391
392    /// Add a contributing factor
393    pub fn add_factor(&mut self, factor: &str, weight: f64) {
394        self.contributing_factors.push((factor.to_string(), weight));
395    }
396}
397
398/// Quantile regression results
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct QuantileRegression {
401    /// Quantiles analyzed
402    pub quantiles: Vec<f64>,
403    /// Attributions for each quantile
404    pub attributions: Vec<TailAttribution>,
405}
406
407impl QuantileRegression {
408    /// Create new quantile regression
409    pub fn new() -> Self {
410        Self {
411            quantiles: vec![0.5, 0.9, 0.95, 0.99, 0.999],
412            attributions: Vec::new(),
413        }
414    }
415
416    /// Add an attribution
417    pub fn add_attribution(&mut self, attr: TailAttribution) {
418        self.attributions.push(attr);
419    }
420}
421
422impl Default for QuantileRegression {
423    fn default() -> Self {
424        Self::new()
425    }
426}
427
428// =============================================================================
429// I.3 Statistical Analysis Report
430// =============================================================================
431
432/// Complete statistical analysis
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct StatisticalAnalysis {
435    /// Scenario name
436    pub scenario_name: String,
437    /// Variance tree
438    pub variance_tree: VarianceTree,
439    /// Apdex calculator
440    pub apdex: ApdexCalculator,
441    /// Knee detector
442    pub knee_detector: KneeDetector,
443    /// Quantile regression
444    pub quantile_regression: QuantileRegression,
445    /// Coefficient of variation (σ/μ)
446    pub coefficient_of_variation: f64,
447}
448
449impl StatisticalAnalysis {
450    /// Create new analysis
451    pub fn new(scenario_name: &str) -> Self {
452        Self {
453            scenario_name: scenario_name.to_string(),
454            variance_tree: VarianceTree::new(),
455            apdex: ApdexCalculator::default(),
456            knee_detector: KneeDetector::new(),
457            quantile_regression: QuantileRegression::new(),
458            coefficient_of_variation: 0.0,
459        }
460    }
461}
462
463// =============================================================================
464// Rendering
465// =============================================================================
466
467/// Render statistical analysis as TUI
468pub fn render_statistical_report(analysis: &StatisticalAnalysis) -> String {
469    let mut out = String::new();
470
471    out.push_str(&format!(
472        "STATISTICAL ANALYSIS: {}\n",
473        analysis.scenario_name
474    ));
475    out.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
476
477    // Variance decomposition
478    out.push_str("VARIANCE DECOMPOSITION\n");
479    out.push_str("┌───────────────────────────────────────────────────────────────┐\n");
480    out.push_str(&format!(
481        "│ Total Latency Variance: {:.0} ms²                              │\n",
482        analysis.variance_tree.total_variance
483    ));
484    out.push_str("│                                                               │\n");
485
486    for comp in &analysis.variance_tree.components {
487        let bar_len = (comp.percentage / 5.0) as usize;
488        let bar: String = "█".repeat(bar_len.min(20));
489        out.push_str(&format!(
490            "│ ├── {:<12}: {:>6.0} ms² ({:>4.1}%)  {:20} │\n",
491            truncate(&comp.name, 12),
492            comp.variance,
493            comp.percentage,
494            bar
495        ));
496    }
497    out.push_str("└───────────────────────────────────────────────────────────────┘\n\n");
498
499    // Apdex
500    out.push_str("APDEX SCORE\n");
501    out.push_str("┌───────────────────────────────────────────────────────────────┐\n");
502    out.push_str(&format!(
503        "│ Target: {}ms (Satisfied), {}ms (Tolerating)               │\n",
504        analysis.apdex.satisfied_threshold_ms, analysis.apdex.tolerating_threshold_ms
505    ));
506    out.push_str(&format!(
507        "│                                                               │\n"
508    ));
509    out.push_str(&format!(
510        "│ Satisfied:  {:>6} requests ({:>4.1}%)                        │\n",
511        analysis.apdex.satisfied(),
512        (analysis.apdex.satisfied() as f64 / analysis.apdex.total_count().max(1) as f64) * 100.0
513    ));
514    out.push_str(&format!(
515        "│ Tolerating: {:>6} requests ({:>4.1}%)                        │\n",
516        analysis.apdex.tolerating(),
517        (analysis.apdex.tolerating() as f64 / analysis.apdex.total_count().max(1) as f64) * 100.0
518    ));
519    out.push_str(&format!(
520        "│ Frustrated: {:>6} requests ({:>4.1}%)                        │\n",
521        analysis.apdex.frustrated(),
522        (analysis.apdex.frustrated() as f64 / analysis.apdex.total_count().max(1) as f64) * 100.0
523    ));
524    out.push_str(&format!(
525        "│                                                               │\n"
526    ));
527    out.push_str(&format!(
528        "│ Apdex Score: {:.2} ({})                                     │\n",
529        analysis.apdex.score(),
530        analysis.apdex.rating().as_str()
531    ));
532    out.push_str("└───────────────────────────────────────────────────────────────┘\n\n");
533
534    // Knee detection
535    if let Some((load, latency)) = analysis.knee_detector.knee_point {
536        out.push_str("THROUGHPUT KNEE DETECTION\n");
537        out.push_str("┌───────────────────────────────────────────────────────────────┐\n");
538        out.push_str(&format!(
539            "│ Knee detected at: {:.0} concurrent users                      │\n",
540            load
541        ));
542        out.push_str(&format!(
543            "│ Latency at knee: {:.0}ms                                       │\n",
544            latency
545        ));
546        if let Some(rec) = analysis.knee_detector.recommended_capacity {
547            out.push_str(&format!(
548                "│ Recommended capacity: {:.0} users (80% of knee)               │\n",
549                rec
550            ));
551        }
552        out.push_str("└───────────────────────────────────────────────────────────────┘\n\n");
553    }
554
555    out
556}
557
558/// Render as JSON
559pub fn render_statistical_json(analysis: &StatisticalAnalysis) -> String {
560    serde_json::to_string_pretty(analysis).unwrap_or_else(|_| "{}".to_string())
561}
562
563// =============================================================================
564// Helper functions
565// =============================================================================
566
567/// Calculate variance of a slice
568fn calculate_variance(values: &[f64]) -> f64 {
569    if values.is_empty() {
570        return 0.0;
571    }
572    let mean = values.iter().sum::<f64>() / values.len() as f64;
573    let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
574    variance
575}
576
577/// Truncate string
578fn truncate(s: &str, max: usize) -> String {
579    if s.len() <= max {
580        s.to_string()
581    } else {
582        format!("{}…", &s[..max - 1])
583    }
584}
585
586// =============================================================================
587// Tests
588// =============================================================================
589
590#[cfg(test)]
591#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn test_variance_component() {
597        let comp = VarianceComponent::new("Network", 1000.0);
598        assert_eq!(comp.name, "Network");
599        assert_eq!(comp.variance, 1000.0);
600    }
601
602    #[test]
603    fn test_variance_tree() {
604        let mut tree = VarianceTree::new();
605        tree.add_component(VarianceComponent::new("Network", 600.0));
606        tree.add_component(VarianceComponent::new("WASM", 400.0));
607        tree.recalculate_percentages();
608
609        assert_eq!(tree.total_variance, 1000.0);
610        assert_eq!(tree.components[0].percentage, 60.0);
611        assert_eq!(tree.components[1].percentage, 40.0);
612    }
613
614    #[test]
615    fn test_apdex_calculator() {
616        let mut apdex = ApdexCalculator::new(100, 400);
617
618        // 8 satisfied, 2 tolerating
619        for _ in 0..8 {
620            apdex.record(50);
621        }
622        for _ in 0..2 {
623            apdex.record(200);
624        }
625
626        assert_eq!(apdex.satisfied(), 8);
627        assert_eq!(apdex.tolerating(), 2);
628        assert_eq!(apdex.frustrated(), 0);
629        // Score = (8 + 2/2) / 10 = 0.9
630        assert!((apdex.score() - 0.9).abs() < 0.001);
631        assert_eq!(apdex.rating(), ApdexRating::Good);
632    }
633
634    #[test]
635    fn test_apdex_frustrated() {
636        let mut apdex = ApdexCalculator::new(100, 400);
637        apdex.record(500); // Frustrated
638        apdex.record(1000); // Frustrated
639
640        assert_eq!(apdex.frustrated(), 2);
641        assert_eq!(apdex.score(), 0.0);
642        assert_eq!(apdex.rating(), ApdexRating::Unacceptable);
643    }
644
645    #[test]
646    fn test_apdex_rating() {
647        assert_eq!(ApdexRating::Excellent.as_str(), "Excellent");
648        assert_eq!(ApdexRating::Poor.as_str(), "Poor");
649    }
650
651    #[test]
652    fn test_knee_detector() {
653        let mut detector = KneeDetector::new();
654
655        // Simulate linear then exponential growth
656        detector.add_point(10.0, 50.0);
657        detector.add_point(20.0, 55.0);
658        detector.add_point(30.0, 60.0);
659        detector.add_point(40.0, 70.0);
660        detector.add_point(50.0, 100.0);
661        detector.add_point(60.0, 200.0);
662        detector.add_point(70.0, 400.0);
663
664        detector.detect();
665
666        assert!(detector.knee_point.is_some());
667        assert!(detector.recommended_capacity.is_some());
668    }
669
670    #[test]
671    fn test_tail_attribution() {
672        let mut attr = TailAttribution::new(99, 456, "Network congestion");
673        attr.add_factor("DNS", 0.15);
674        attr.add_factor("TLS handshake", 0.25);
675
676        assert_eq!(attr.percentile, 99);
677        assert_eq!(attr.contributing_factors.len(), 2);
678    }
679
680    #[test]
681    fn test_quantile_regression() {
682        let mut qr = QuantileRegression::new();
683        qr.add_attribution(TailAttribution::new(50, 78, "Typical case"));
684        qr.add_attribution(TailAttribution::new(99, 456, "Network"));
685
686        assert_eq!(qr.attributions.len(), 2);
687    }
688
689    #[test]
690    fn test_calculate_variance() {
691        let values = vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
692        let variance = calculate_variance(&values);
693        // Mean = 5, Variance = 4
694        assert!((variance - 4.0).abs() < 0.001);
695    }
696
697    #[test]
698    fn test_statistical_analysis() {
699        let analysis = StatisticalAnalysis::new("Test Scenario");
700        assert_eq!(analysis.scenario_name, "Test Scenario");
701    }
702
703    #[test]
704    fn test_render_statistical_report() {
705        let mut analysis = StatisticalAnalysis::new("WASM Boot");
706        analysis
707            .variance_tree
708            .add_component(VarianceComponent::new("Network", 500.0));
709        analysis.variance_tree.recalculate_percentages();
710        analysis.apdex.record(50);
711        analysis.apdex.record(100);
712
713        let report = render_statistical_report(&analysis);
714        assert!(report.contains("WASM Boot"));
715        assert!(report.contains("VARIANCE"));
716        assert!(report.contains("APDEX"));
717    }
718
719    #[test]
720    fn test_render_statistical_json() {
721        let analysis = StatisticalAnalysis::new("JSON Test");
722        let json = render_statistical_json(&analysis);
723        assert!(json.contains("JSON Test"));
724    }
725
726    #[test]
727    fn test_variance_tree_default() {
728        let tree = VarianceTree::default();
729        assert_eq!(tree.total_variance, 0.0);
730        assert!(tree.components.is_empty());
731    }
732
733    #[test]
734    fn test_variance_tree_from_samples() {
735        let mut samples = Vec::new();
736
737        // Create sample 1
738        let mut components1 = HashMap::new();
739        components1.insert("network".to_string(), 50.0);
740        components1.insert("compute".to_string(), 20.0);
741        samples.push(LatencySample {
742            total_ms: 70.0,
743            components: components1,
744            timestamp_ms: 1000,
745        });
746
747        // Create sample 2
748        let mut components2 = HashMap::new();
749        components2.insert("network".to_string(), 60.0);
750        components2.insert("compute".to_string(), 25.0);
751        samples.push(LatencySample {
752            total_ms: 85.0,
753            components: components2,
754            timestamp_ms: 2000,
755        });
756
757        // Create sample 3
758        let mut components3 = HashMap::new();
759        components3.insert("network".to_string(), 55.0);
760        components3.insert("compute".to_string(), 22.0);
761        samples.push(LatencySample {
762            total_ms: 77.0,
763            components: components3,
764            timestamp_ms: 3000,
765        });
766
767        let tree = VarianceTree::from_samples(&samples);
768
769        // Should have 2 components (network and compute)
770        assert_eq!(tree.components.len(), 2);
771        assert!(tree.total_variance > 0.0);
772
773        // Each component should have a percentage
774        for comp in &tree.components {
775            assert!(comp.percentage >= 0.0);
776            assert!(comp.percentage <= 100.0);
777        }
778    }
779
780    #[test]
781    fn test_variance_tree_recalculate_empty() {
782        let mut tree = VarianceTree::new();
783        // Should not panic with empty tree
784        tree.recalculate_percentages();
785        assert_eq!(tree.total_variance, 0.0);
786    }
787
788    #[test]
789    fn test_variance_tree_recalculate_with_children() {
790        let mut tree = VarianceTree::new();
791
792        // Add parent component with children
793        let mut parent = VarianceComponent::new("parent", 100.0);
794        parent.add_child(VarianceComponent::new("child1", 60.0));
795        parent.add_child(VarianceComponent::new("child2", 40.0));
796
797        tree.add_component(parent);
798        tree.recalculate_percentages();
799
800        // Parent should be 100% of tree
801        assert!((tree.components[0].percentage - 100.0).abs() < 0.001);
802    }
803
804    #[test]
805    fn test_latency_sample() {
806        let mut components = HashMap::new();
807        components.insert("dns".to_string(), 10.0);
808        components.insert("connect".to_string(), 20.0);
809
810        let sample = LatencySample {
811            total_ms: 30.0,
812            components,
813            timestamp_ms: 1234567890,
814        };
815
816        assert_eq!(sample.total_ms, 30.0);
817        assert_eq!(sample.components.len(), 2);
818    }
819
820    #[test]
821    fn test_apdex_reset() {
822        let mut apdex = ApdexCalculator::new(100, 400);
823        apdex.record(50);
824        apdex.record(200);
825        apdex.record(500);
826
827        assert_eq!(apdex.total_count(), 3);
828
829        apdex.reset();
830        assert_eq!(apdex.total_count(), 0);
831        assert_eq!(apdex.satisfied(), 0);
832        assert_eq!(apdex.tolerating(), 0);
833        assert_eq!(apdex.frustrated(), 0);
834    }
835
836    #[test]
837    fn test_apdex_default() {
838        let apdex = ApdexCalculator::default();
839        assert_eq!(apdex.satisfied_threshold_ms, 100);
840        assert_eq!(apdex.tolerating_threshold_ms, 400);
841    }
842
843    #[test]
844    fn test_apdex_rating_fair() {
845        let mut apdex = ApdexCalculator::new(100, 400);
846        // Need score between 0.70 and 0.84
847        // 7 satisfied + 1 tolerating + 2 frustrated = (7 + 0.5) / 10 = 0.75
848        for _ in 0..7 {
849            apdex.record(50); // satisfied
850        }
851        apdex.record(200); // tolerating
852        apdex.record(500); // frustrated
853        apdex.record(600); // frustrated
854
855        assert_eq!(apdex.rating(), ApdexRating::Fair);
856    }
857
858    #[test]
859    fn test_apdex_rating_excellent() {
860        let mut apdex = ApdexCalculator::new(100, 400);
861        // Need score >= 0.94
862        for _ in 0..10 {
863            apdex.record(50); // all satisfied
864        }
865        assert_eq!(apdex.rating(), ApdexRating::Excellent);
866    }
867
868    #[test]
869    fn test_apdex_empty_score() {
870        let apdex = ApdexCalculator::new(100, 400);
871        // Empty = perfect score
872        assert_eq!(apdex.score(), 1.0);
873    }
874
875    #[test]
876    fn test_apdex_rating_all_variants() {
877        assert_eq!(ApdexRating::Excellent.as_str(), "Excellent");
878        assert_eq!(ApdexRating::Good.as_str(), "Good");
879        assert_eq!(ApdexRating::Fair.as_str(), "Fair");
880        assert_eq!(ApdexRating::Poor.as_str(), "Poor");
881        assert_eq!(ApdexRating::Unacceptable.as_str(), "Unacceptable");
882    }
883
884    #[test]
885    fn test_knee_detector_insufficient_points() {
886        let mut detector = KneeDetector::new();
887        detector.add_point(10.0, 50.0);
888        detector.add_point(20.0, 60.0);
889        // Only 2 points, need at least 3
890        detector.detect();
891        assert!(detector.knee_point.is_none());
892    }
893
894    #[test]
895    fn test_knee_detector_default() {
896        let detector = KneeDetector::default();
897        assert!(detector.knee_point.is_none());
898        assert!(detector.recommended_capacity.is_none());
899    }
900
901    #[test]
902    fn test_calculate_variance_empty() {
903        let empty: Vec<f64> = vec![];
904        assert_eq!(calculate_variance(&empty), 0.0);
905    }
906
907    #[test]
908    fn test_calculate_variance_single() {
909        let single = vec![5.0];
910        assert_eq!(calculate_variance(&single), 0.0);
911    }
912
913    #[test]
914    fn test_quantile_regression_default() {
915        let qr = QuantileRegression::default();
916        assert!(qr.attributions.is_empty());
917    }
918
919    #[test]
920    fn test_variance_component_with_child_percentages() {
921        let mut parent = VarianceComponent::new("parent", 100.0);
922        let mut child1 = VarianceComponent::new("child1", 60.0);
923        child1.percentage = 60.0;
924        let mut child2 = VarianceComponent::new("child2", 40.0);
925        child2.percentage = 40.0;
926        parent.add_child(child1);
927        parent.add_child(child2);
928
929        // Recalculate based on total_variance=200.0
930        parent.recalculate_percentages(200.0);
931
932        // Children percentages should be recalculated relative to 200
933        assert_eq!(parent.children[0].percentage, 30.0); // 60/200 * 100
934        assert_eq!(parent.children[1].percentage, 20.0); // 40/200 * 100
935    }
936
937    #[test]
938    fn test_statistical_analysis_all_fields() {
939        let mut analysis = StatisticalAnalysis::new("Full Test");
940
941        // Add variance data
942        analysis
943            .variance_tree
944            .add_component(VarianceComponent::new("A", 100.0));
945        analysis.variance_tree.recalculate_percentages();
946
947        // Add apdex data
948        analysis.apdex.record(50);
949        analysis.apdex.record(200);
950
951        // Add knee detection
952        analysis.knee_detector.add_point(10.0, 50.0);
953        analysis.knee_detector.add_point(20.0, 55.0);
954        analysis.knee_detector.add_point(30.0, 80.0);
955        analysis.knee_detector.detect();
956
957        // Add quantile regression
958        analysis
959            .quantile_regression
960            .add_attribution(TailAttribution::new(95, 200, "Network latency"));
961
962        // Render should include all sections
963        let report = render_statistical_report(&analysis);
964        assert!(report.contains("Full Test"));
965        assert!(report.contains("VARIANCE"));
966        assert!(report.contains("APDEX"));
967    }
968
969    #[test]
970    fn test_tail_attribution_multiple_factors() {
971        let mut attr = TailAttribution::new(99, 500, "High tail latency");
972        attr.add_factor("GC pause", 0.45);
973        attr.add_factor("Network", 0.30);
974        attr.add_factor("Disk I/O", 0.25);
975
976        assert_eq!(attr.contributing_factors.len(), 3);
977        assert_eq!(attr.contributing_factors[0].0, "GC pause");
978        assert!((attr.contributing_factors[0].1 - 0.45).abs() < 0.01);
979    }
980}