Skip to main content

trustformers_debug/
dashboard.rs

1//! Interactive dashboards for real-time monitoring and analysis
2// reason: debug/profiling scaffolding — structs are constructed and their fields/methods
3// are retained for the data model, serialization completeness, and future consumers that
4// do not yet read every member. Consolidated from many item-level #[allow(dead_code)].
5#![allow(dead_code)]
6
7use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, VecDeque};
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant, SystemTime};
12use uuid::Uuid;
13
14use crate::DebugConfig;
15
16/// Real-time metrics for dashboard display
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DashboardMetrics {
19    pub timestamp: SystemTime,
20    pub loss: Option<f64>,
21    pub accuracy: Option<f64>,
22    pub learning_rate: Option<f64>,
23    pub memory_usage_mb: f64,
24    pub gpu_utilization: Option<f64>,
25    pub tokens_per_second: Option<f64>,
26    pub gradient_norm: Option<f64>,
27    pub epoch: Option<u32>,
28    pub step: Option<u64>,
29}
30
31/// Training monitor for real-time tracking
32#[derive(Debug)]
33pub struct TrainingMonitor {
34    config: DebugConfig,
35    metrics_history: VecDeque<DashboardMetrics>,
36    max_history: usize,
37    start_time: Instant,
38    alert_thresholds: AlertThresholds,
39    active_alerts: Vec<TrainingAlert>,
40}
41
42/// Alert thresholds for training monitoring
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AlertThresholds {
45    pub loss_increase_threshold: f64,
46    pub gradient_norm_max: f64,
47    pub memory_usage_max_mb: f64,
48    pub gpu_utilization_min: f64,
49    pub learning_rate_min: f64,
50    pub tokens_per_second_min: f64,
51}
52
53impl Default for AlertThresholds {
54    fn default() -> Self {
55        Self {
56            loss_increase_threshold: 1.5,
57            gradient_norm_max: 10.0,
58            memory_usage_max_mb: 8192.0,
59            gpu_utilization_min: 0.7,
60            learning_rate_min: 1e-8,
61            tokens_per_second_min: 100.0,
62        }
63    }
64}
65
66/// Training alert types
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct TrainingAlert {
69    pub alert_type: AlertType,
70    pub severity: AlertSeverity,
71    pub message: String,
72    pub timestamp: SystemTime,
73    pub metric_value: f64,
74    pub threshold: f64,
75    pub suggested_action: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub enum AlertType {
80    LossIncrease,
81    GradientExplosion,
82    MemoryOveruse,
83    LowGpuUtilization,
84    LearningRateTooLow,
85    SlowTokenProcessing,
86    ModelDivergence,
87    TrainingStalled,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub enum AlertSeverity {
92    Info,
93    Warning,
94    Critical,
95}
96
97impl TrainingMonitor {
98    /// Create a new training monitor
99    pub fn new(config: &DebugConfig) -> Self {
100        Self {
101            config: config.clone(),
102            metrics_history: VecDeque::new(),
103            max_history: 10000,
104            start_time: Instant::now(),
105            alert_thresholds: AlertThresholds::default(),
106            active_alerts: Vec::new(),
107        }
108    }
109
110    /// Update metrics and check for alerts
111    pub fn update_metrics(&mut self, metrics: DashboardMetrics) {
112        // Add to history
113        self.metrics_history.push_back(metrics.clone());
114
115        // Trim history if needed
116        if self.metrics_history.len() > self.max_history {
117            self.metrics_history.pop_front();
118        }
119
120        // Check for alerts
121        self.check_alerts(&metrics);
122    }
123
124    /// Get recent metrics for dashboard
125    pub fn get_recent_metrics(&self, count: usize) -> Vec<DashboardMetrics> {
126        self.metrics_history.iter().rev().take(count).rev().cloned().collect()
127    }
128
129    /// Get active alerts
130    pub fn get_active_alerts(&self) -> &[TrainingAlert] {
131        &self.active_alerts
132    }
133
134    /// Drop every active alert whose [`AlertType`] equals `alert_type`,
135    /// leaving alerts of any other type in place.
136    ///
137    /// The previous body was
138    /// `retain(|alert| !matches!(&alert.alert_type, _alert_type))`. In
139    /// `matches!` the second operand is a *pattern*, and a bare identifier
140    /// there is an irrefutable binding rather than a comparison against the
141    /// parameter -- so the arm always matched, the negation was always
142    /// `false`, and the call cleared **all** alerts regardless of which type
143    /// was asked for. The `_` prefix additionally silenced the
144    /// unused-variable lint that would otherwise have exposed it.
145    pub fn clear_alert(&mut self, alert_type: AlertType) {
146        self.active_alerts.retain(|alert| alert.alert_type != alert_type);
147    }
148
149    /// Set custom alert thresholds
150    pub fn set_alert_thresholds(&mut self, thresholds: AlertThresholds) {
151        self.alert_thresholds = thresholds;
152    }
153
154    /// Generate training summary
155    pub fn generate_training_summary(&self) -> TrainingSummary {
156        let total_duration = self.start_time.elapsed();
157        let total_steps = self.metrics_history.len();
158
159        let avg_loss = self.calculate_average_loss();
160        let best_accuracy = self.calculate_best_accuracy();
161        let avg_tokens_per_second = self.calculate_average_tokens_per_second();
162        let training_stability = self.calculate_training_stability();
163
164        TrainingSummary {
165            total_duration,
166            total_steps,
167            avg_loss,
168            best_accuracy,
169            avg_tokens_per_second,
170            training_stability,
171            active_alerts_count: self.active_alerts.len(),
172            convergence_status: self.assess_convergence(),
173        }
174    }
175
176    fn check_alerts(&mut self, metrics: &DashboardMetrics) {
177        // Check for loss increase
178        if let Some(current_loss) = metrics.loss {
179            if let Some(prev_metrics) =
180                self.metrics_history.get(self.metrics_history.len().saturating_sub(10))
181            {
182                if let Some(prev_loss) = prev_metrics.loss {
183                    if current_loss > prev_loss * self.alert_thresholds.loss_increase_threshold {
184                        self.add_alert(TrainingAlert {
185                            alert_type: AlertType::LossIncrease,
186                            severity: AlertSeverity::Warning,
187                            message: "Loss has increased significantly".to_string(),
188                            timestamp: SystemTime::now(),
189                            metric_value: current_loss,
190                            threshold: prev_loss * self.alert_thresholds.loss_increase_threshold,
191                            suggested_action: "Check learning rate or data quality".to_string(),
192                        });
193                    }
194                }
195            }
196        }
197
198        // Check gradient norm
199        if let Some(grad_norm) = metrics.gradient_norm {
200            if grad_norm > self.alert_thresholds.gradient_norm_max {
201                self.add_alert(TrainingAlert {
202                    alert_type: AlertType::GradientExplosion,
203                    severity: AlertSeverity::Critical,
204                    message: "Gradient explosion detected".to_string(),
205                    timestamp: SystemTime::now(),
206                    metric_value: grad_norm,
207                    threshold: self.alert_thresholds.gradient_norm_max,
208                    suggested_action: "Apply gradient clipping or reduce learning rate".to_string(),
209                });
210            }
211        }
212
213        // Check memory usage
214        if metrics.memory_usage_mb > self.alert_thresholds.memory_usage_max_mb {
215            self.add_alert(TrainingAlert {
216                alert_type: AlertType::MemoryOveruse,
217                severity: AlertSeverity::Warning,
218                message: "High memory usage detected".to_string(),
219                timestamp: SystemTime::now(),
220                metric_value: metrics.memory_usage_mb,
221                threshold: self.alert_thresholds.memory_usage_max_mb,
222                suggested_action: "Reduce batch size or enable gradient checkpointing".to_string(),
223            });
224        }
225
226        // Check GPU utilization
227        if let Some(gpu_util) = metrics.gpu_utilization {
228            if gpu_util < self.alert_thresholds.gpu_utilization_min {
229                self.add_alert(TrainingAlert {
230                    alert_type: AlertType::LowGpuUtilization,
231                    severity: AlertSeverity::Info,
232                    message: "Low GPU utilization".to_string(),
233                    timestamp: SystemTime::now(),
234                    metric_value: gpu_util,
235                    threshold: self.alert_thresholds.gpu_utilization_min,
236                    suggested_action: "Increase batch size or check data loading".to_string(),
237                });
238            }
239        }
240
241        // Check tokens per second
242        if let Some(tps) = metrics.tokens_per_second {
243            if tps < self.alert_thresholds.tokens_per_second_min {
244                self.add_alert(TrainingAlert {
245                    alert_type: AlertType::SlowTokenProcessing,
246                    severity: AlertSeverity::Warning,
247                    message: "Slow token processing detected".to_string(),
248                    timestamp: SystemTime::now(),
249                    metric_value: tps,
250                    threshold: self.alert_thresholds.tokens_per_second_min,
251                    suggested_action: "Optimize model or increase batch size".to_string(),
252                });
253            }
254        }
255    }
256
257    fn add_alert(&mut self, alert: TrainingAlert) {
258        // Avoid duplicate alerts of same type
259        if !self.active_alerts.iter().any(|a| a.alert_type == alert.alert_type) {
260            self.active_alerts.push(alert);
261        }
262    }
263
264    fn calculate_average_loss(&self) -> Option<f64> {
265        let losses: Vec<f64> = self.metrics_history.iter().filter_map(|m| m.loss).collect();
266
267        if losses.is_empty() {
268            None
269        } else {
270            Some(losses.iter().sum::<f64>() / losses.len() as f64)
271        }
272    }
273
274    fn calculate_best_accuracy(&self) -> Option<f64> {
275        self.metrics_history
276            .iter()
277            .filter_map(|m| m.accuracy)
278            .fold(None, |acc, x| match acc {
279                None => Some(x),
280                Some(y) => Some(x.max(y)),
281            })
282    }
283
284    fn calculate_average_tokens_per_second(&self) -> Option<f64> {
285        let tps_values: Vec<f64> =
286            self.metrics_history.iter().filter_map(|m| m.tokens_per_second).collect();
287
288        if tps_values.is_empty() {
289            None
290        } else {
291            Some(tps_values.iter().sum::<f64>() / tps_values.len() as f64)
292        }
293    }
294
295    fn calculate_training_stability(&self) -> TrainingStability {
296        if self.metrics_history.len() < 10 {
297            return TrainingStability::Insufficient;
298        }
299
300        let recent_losses: Vec<f64> =
301            self.metrics_history.iter().rev().take(50).filter_map(|m| m.loss).collect();
302
303        if recent_losses.len() < 10 {
304            return TrainingStability::Insufficient;
305        }
306
307        // Calculate loss variance
308        let mean_loss = recent_losses.iter().sum::<f64>() / recent_losses.len() as f64;
309        let variance = recent_losses.iter().map(|&x| (x - mean_loss).powi(2)).sum::<f64>()
310            / recent_losses.len() as f64;
311
312        let std_dev = variance.sqrt();
313        let coefficient_of_variation = if mean_loss != 0.0 { std_dev / mean_loss } else { 0.0 };
314
315        match coefficient_of_variation {
316            cv if cv < 0.1 => TrainingStability::Stable,
317            cv if cv < 0.3 => TrainingStability::Moderate,
318            _ => TrainingStability::Unstable,
319        }
320    }
321
322    fn assess_convergence(&self) -> ConvergenceStatus {
323        if self.metrics_history.len() < 50 {
324            return ConvergenceStatus::TooEarly;
325        }
326
327        let recent_losses: Vec<f64> =
328            self.metrics_history.iter().rev().take(100).filter_map(|m| m.loss).collect();
329
330        if recent_losses.len() < 50 {
331            return ConvergenceStatus::TooEarly;
332        }
333
334        // Check if loss is decreasing
335        let first_half_avg =
336            recent_losses[25..].iter().sum::<f64>() / (recent_losses.len() - 25) as f64;
337        let second_half_avg = recent_losses[..25].iter().sum::<f64>() / 25.0;
338
339        if second_half_avg < first_half_avg * 0.95 {
340            ConvergenceStatus::Converging
341        } else if (second_half_avg - first_half_avg).abs() / first_half_avg < 0.01 {
342            ConvergenceStatus::Converged
343        } else {
344            ConvergenceStatus::Diverging
345        }
346    }
347}
348
349/// Model comparison tool for A/B testing
350#[derive(Debug)]
351pub struct ModelComparator {
352    models: HashMap<String, ModelMetrics>,
353    comparison_config: ComparisonConfig,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct ModelMetrics {
358    pub model_id: String,
359    pub model_name: String,
360    pub metrics_history: Vec<DashboardMetrics>,
361    pub final_loss: Option<f64>,
362    pub final_accuracy: Option<f64>,
363    pub training_time: Duration,
364    pub parameter_count: usize,
365    pub model_size_mb: f64,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct ComparisonConfig {
370    pub primary_metric: String,
371    pub comparison_window: usize,
372    pub significance_threshold: f64,
373}
374
375impl Default for ComparisonConfig {
376    fn default() -> Self {
377        Self {
378            primary_metric: "loss".to_string(),
379            comparison_window: 100,
380            significance_threshold: 0.05,
381        }
382    }
383}
384
385impl ModelComparator {
386    /// Create new model comparator
387    pub fn new() -> Self {
388        Self {
389            models: HashMap::new(),
390            comparison_config: ComparisonConfig::default(),
391        }
392    }
393
394    /// Add model for comparison
395    pub fn add_model(&mut self, model_metrics: ModelMetrics) {
396        self.models.insert(model_metrics.model_id.clone(), model_metrics);
397    }
398
399    /// Compare models and generate report
400    pub fn compare_models(&self) -> ModelComparisonReport {
401        let mut comparisons = Vec::new();
402        let model_ids: Vec<String> = self.models.keys().cloned().collect();
403
404        for i in 0..model_ids.len() {
405            for j in (i + 1)..model_ids.len() {
406                let model_a = &self.models[&model_ids[i]];
407                let model_b = &self.models[&model_ids[j]];
408
409                let comparison = self.compare_two_models(model_a, model_b);
410                comparisons.push(comparison);
411            }
412        }
413
414        let best_model = self.find_best_model();
415        let ranking = self.rank_models();
416
417        ModelComparisonReport {
418            comparisons,
419            best_model,
420            ranking,
421            comparison_config: self.comparison_config.clone(),
422        }
423    }
424
425    fn compare_two_models(
426        &self,
427        model_a: &ModelMetrics,
428        model_b: &ModelMetrics,
429    ) -> ModelComparison {
430        let performance_diff = self.calculate_performance_difference(model_a, model_b);
431        let efficiency_diff = self.calculate_efficiency_difference(model_a, model_b);
432        let statistical_significance = self.test_statistical_significance(model_a, model_b);
433
434        ModelComparison {
435            model_a_id: model_a.model_id.clone(),
436            model_b_id: model_b.model_id.clone(),
437            performance_difference: performance_diff,
438            efficiency_difference: efficiency_diff,
439            statistical_significance,
440            recommendation: self.generate_recommendation(model_a, model_b, performance_diff),
441        }
442    }
443
444    /// Relative difference in the configured `primary_metric` between the two
445    /// models, or `None` when it cannot be computed.
446    ///
447    /// `None` is returned when either model never recorded the metric, or when
448    /// the reference model's value is `0.0` (the relative difference would be
449    /// a division by zero). This used to return a bare `0.0` in exactly those
450    /// cases, which a caller could not tell apart from the genuine "both
451    /// models scored identically" answer.
452    fn calculate_performance_difference(
453        &self,
454        model_a: &ModelMetrics,
455        model_b: &ModelMetrics,
456    ) -> Option<f64> {
457        match self.comparison_config.primary_metric.as_str() {
458            "loss" => {
459                let (loss_a, loss_b) = (model_a.final_loss?, model_b.final_loss?);
460                if loss_a == 0.0 {
461                    return None;
462                }
463                Some((loss_b - loss_a) / loss_a) // Negative means model_a is better
464            },
465            "accuracy" => {
466                let (acc_a, acc_b) = (model_a.final_accuracy?, model_b.final_accuracy?);
467                if acc_a == 0.0 {
468                    return None;
469                }
470                Some((acc_b - acc_a) / acc_a) // Positive means model_b is better
471            },
472            // An unrecognised `primary_metric` names nothing this comparator
473            // can read, so there is no difference to report.
474            _ => None,
475        }
476    }
477
478    /// Mean of the relative training-time and model-size differences, or
479    /// `None` when either reference quantity is zero.
480    ///
481    /// A `ModelMetrics` built before training has run (or before the model
482    /// size is known) carries `training_time == Duration::ZERO` /
483    /// `model_size_mb == 0.0`; dividing by those produced `inf`/`NaN`, and the
484    /// only reason the old code did not surface them is that nothing checked.
485    /// Absence is now reported as absence.
486    fn calculate_efficiency_difference(
487        &self,
488        model_a: &ModelMetrics,
489        model_b: &ModelMetrics,
490    ) -> Option<f64> {
491        let time_a = model_a.training_time.as_secs_f64();
492        if time_a == 0.0 || model_a.model_size_mb == 0.0 {
493            return None;
494        }
495
496        // Compare training time efficiency
497        let time_diff = model_b.training_time.as_secs_f64() / time_a - 1.0;
498
499        // Compare model size efficiency
500        let size_diff = model_b.model_size_mb / model_a.model_size_mb - 1.0;
501
502        // Combined efficiency score (lower is better)
503        Some((time_diff + size_diff) / 2.0)
504    }
505
506    /// Extract the recorded per-step samples of `comparison_config.primary_metric`
507    /// ("loss" or "accuracy") from a model's real `metrics_history`, dropping
508    /// steps where that metric was not recorded. An unrecognised
509    /// `primary_metric` yields no samples (matching
510    /// [`Self::calculate_performance_difference`]'s own `_ => None` arm),
511    /// never a fabricated series.
512    fn metric_samples(&self, model: &ModelMetrics) -> Vec<f64> {
513        match self.comparison_config.primary_metric.as_str() {
514            "loss" => model.metrics_history.iter().filter_map(|m| m.loss).collect(),
515            "accuracy" => model.metrics_history.iter().filter_map(|m| m.accuracy).collect(),
516            _ => Vec::new(),
517        }
518    }
519
520    /// Real two-sample Welch's t-test (unequal variances, unequal sample
521    /// sizes) between `model_a` and `model_b`'s recorded per-step
522    /// `primary_metric` samples, reusing the same
523    /// [`crate::differential_debugging::welch_t_test`] machinery that
524    /// backs `DifferentialDebugger::perform_ab_statistical_tests`. Returns
525    /// `None` --
526    /// never a fabricated `true`/`false` -- when either model has fewer than
527    /// 2 recorded samples of the metric, or when the underlying test itself
528    /// has no meaningful result (both samples degenerate constants; see
529    /// `welch_t_test`'s own doc comment).
530    fn test_statistical_significance(
531        &self,
532        model_a: &ModelMetrics,
533        model_b: &ModelMetrics,
534    ) -> Option<bool> {
535        let samples_a = self.metric_samples(model_a);
536        let samples_b = self.metric_samples(model_b);
537        crate::differential_debugging::welch_t_test(
538            &samples_a,
539            &samples_b,
540            self.comparison_config.significance_threshold,
541        )
542        .map(|result| result.is_significant)
543    }
544
545    /// Human-readable verdict derived from
546    /// [`Self::calculate_performance_difference`]. When that is `None` the
547    /// recommendation says so instead of claiming the models are equivalent
548    /// (which is what a `0.0` difference used to make it say).
549    fn generate_recommendation(
550        &self,
551        model_a: &ModelMetrics,
552        model_b: &ModelMetrics,
553        perf_diff: Option<f64>,
554    ) -> String {
555        let Some(perf_diff) = perf_diff else {
556            return format!(
557                "Cannot compare {} and {}: the '{}' metric was not recorded for both models",
558                model_a.model_name, model_b.model_name, self.comparison_config.primary_metric
559            );
560        };
561        if perf_diff.abs() < 0.01 {
562            "Models perform similarly - choose based on other factors".to_string()
563        } else if perf_diff < 0.0 {
564            format!(
565                "Model {} performs {:.1}% better",
566                model_a.model_name,
567                perf_diff.abs() * 100.0
568            )
569        } else {
570            format!(
571                "Model {} performs {:.1}% better",
572                model_b.model_name,
573                perf_diff * 100.0
574            )
575        }
576    }
577
578    fn find_best_model(&self) -> Option<String> {
579        let mut best_model = None;
580        let mut best_score = f64::NEG_INFINITY;
581
582        for model in self.models.values() {
583            let score = match self.comparison_config.primary_metric.as_str() {
584                "loss" => model.final_loss.map(|l| -l).unwrap_or(f64::NEG_INFINITY),
585                "accuracy" => model.final_accuracy.unwrap_or(0.0),
586                _ => 0.0,
587            };
588
589            if score > best_score {
590                best_score = score;
591                best_model = Some(model.model_id.clone());
592            }
593        }
594
595        best_model
596    }
597
598    fn rank_models(&self) -> Vec<ModelRanking> {
599        let mut rankings: Vec<ModelRanking> = self
600            .models
601            .values()
602            .map(|model| {
603                let score = match self.comparison_config.primary_metric.as_str() {
604                    "loss" => model.final_loss.map(|l| -l).unwrap_or(f64::NEG_INFINITY),
605                    "accuracy" => model.final_accuracy.unwrap_or(0.0),
606                    _ => 0.0,
607                };
608
609                ModelRanking {
610                    model_id: model.model_id.clone(),
611                    model_name: model.model_name.clone(),
612                    score,
613                    rank: 0, // Will be filled below
614                }
615            })
616            .collect();
617
618        rankings.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
619
620        for (i, ranking) in rankings.iter_mut().enumerate() {
621            ranking.rank = i + 1;
622        }
623
624        rankings
625    }
626}
627
628/// Hyperparameter explorer for optimization guidance
629#[derive(Debug)]
630pub struct HyperparameterExplorer {
631    experiments: HashMap<String, HyperparameterExperiment>,
632    search_space: HyperparameterSearchSpace,
633    optimization_history: Vec<OptimizationStep>,
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct HyperparameterExperiment {
638    pub experiment_id: String,
639    pub hyperparameters: HashMap<String, HyperparameterValue>,
640    pub results: ExperimentResults,
641    pub status: ExperimentStatus,
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize)]
645pub enum HyperparameterValue {
646    Float(f64),
647    Integer(i64),
648    String(String),
649    Boolean(bool),
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct ExperimentResults {
654    pub final_loss: Option<f64>,
655    pub final_accuracy: Option<f64>,
656    pub training_time: Duration,
657    pub convergence_epoch: Option<u32>,
658    pub best_validation_score: Option<f64>,
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub enum ExperimentStatus {
663    Running,
664    Completed,
665    Failed,
666    Cancelled,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct HyperparameterSearchSpace {
671    pub learning_rate: (f64, f64),
672    pub batch_size: (i64, i64),
673    pub dropout_rate: (f64, f64),
674    pub weight_decay: (f64, f64),
675    pub num_layers: (i64, i64),
676    pub hidden_size: (i64, i64),
677}
678
679impl Default for HyperparameterSearchSpace {
680    fn default() -> Self {
681        Self {
682            learning_rate: (1e-5, 1e-1),
683            batch_size: (4, 128),
684            dropout_rate: (0.0, 0.5),
685            weight_decay: (0.0, 1e-2),
686            num_layers: (1, 12),
687            hidden_size: (64, 2048),
688        }
689    }
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct OptimizationStep {
694    pub step: usize,
695    pub best_experiment_id: String,
696    pub best_score: f64,
697    pub exploration_count: usize,
698    pub exploitation_count: usize,
699}
700
701impl HyperparameterExplorer {
702    /// Create new hyperparameter explorer
703    pub fn new() -> Self {
704        Self {
705            experiments: HashMap::new(),
706            search_space: HyperparameterSearchSpace::default(),
707            optimization_history: Vec::new(),
708        }
709    }
710
711    /// Add experiment result
712    pub fn add_experiment(&mut self, experiment: HyperparameterExperiment) {
713        self.experiments.insert(experiment.experiment_id.clone(), experiment);
714    }
715
716    /// Get hyperparameter recommendations
717    pub fn get_recommendations(&self) -> HyperparameterRecommendations {
718        let best_experiments = self.find_best_experiments(5);
719        let parameter_importance = self.analyze_parameter_importance();
720        let suggested_ranges = self.suggest_search_ranges();
721        let next_experiments = self.suggest_next_experiments(3);
722
723        HyperparameterRecommendations {
724            best_experiments,
725            parameter_importance,
726            suggested_ranges,
727            next_experiments,
728            total_experiments: self.experiments.len(),
729        }
730    }
731
732    fn find_best_experiments(&self, limit: usize) -> Vec<String> {
733        let mut experiments: Vec<_> = self.experiments.values().collect();
734        experiments.sort_by(|a, b| {
735            let score_a = a.results.final_loss.unwrap_or(f64::INFINITY);
736            let score_b = b.results.final_loss.unwrap_or(f64::INFINITY);
737            score_a.partial_cmp(&score_b).unwrap_or(std::cmp::Ordering::Equal)
738        });
739
740        experiments.iter().take(limit).map(|exp| exp.experiment_id.clone()).collect()
741    }
742
743    fn analyze_parameter_importance(&self) -> HashMap<String, f64> {
744        // Simplified parameter importance analysis
745        let mut importance = HashMap::new();
746        importance.insert("learning_rate".to_string(), 0.8);
747        importance.insert("batch_size".to_string(), 0.6);
748        importance.insert("dropout_rate".to_string(), 0.4);
749        importance.insert("weight_decay".to_string(), 0.3);
750        importance
751    }
752
753    fn suggest_search_ranges(&self) -> HashMap<String, (f64, f64)> {
754        // Analyze best experiments to narrow search ranges
755        let mut ranges = HashMap::new();
756        ranges.insert("learning_rate".to_string(), (1e-4, 1e-2));
757        ranges.insert("dropout_rate".to_string(), (0.1, 0.3));
758        ranges
759    }
760
761    fn suggest_next_experiments(&self, count: usize) -> Vec<HashMap<String, HyperparameterValue>> {
762        let mut suggestions = Vec::new();
763
764        for i in 0..count {
765            let mut params = HashMap::new();
766
767            // Generate varied parameter combinations based on best results
768            params.insert(
769                "learning_rate".to_string(),
770                HyperparameterValue::Float(0.001 * (1.0 + i as f64 * 0.5)),
771            );
772            params.insert(
773                "batch_size".to_string(),
774                HyperparameterValue::Integer(32 * (1 + i as i64)),
775            );
776            params.insert(
777                "dropout_rate".to_string(),
778                HyperparameterValue::Float(0.1 + i as f64 * 0.1),
779            );
780
781            suggestions.push(params);
782        }
783
784        suggestions
785    }
786}
787
788/// Dashboard aggregator that combines all monitoring tools
789#[derive(Debug)]
790pub struct InteractiveDashboard {
791    config: DebugConfig,
792    training_monitor: TrainingMonitor,
793    model_comparator: ModelComparator,
794    hyperparameter_explorer: HyperparameterExplorer,
795    dashboard_state: DashboardState,
796    websocket_server: Option<DashboardEndpoint>,
797}
798
799#[derive(Debug, Serialize, Deserialize)]
800pub struct DashboardState {
801    pub active_session_id: Option<Uuid>,
802    pub refresh_rate_ms: u64,
803    pub auto_alerts: bool,
804    pub display_mode: DisplayMode,
805}
806
807#[derive(Debug, Clone, Serialize, Deserialize)]
808pub enum DisplayMode {
809    Overview,
810    DetailedMetrics,
811    ModelComparison,
812    HyperparameterOptimization,
813    AlertsOnly,
814}
815
816/// WebSocket server for real-time dashboard updates
817#[derive(Debug)]
818/// Endpoint configuration plus the queue of dashboard updates waiting to be
819/// delivered to clients.
820///
821/// It is **not** a WebSocket server: nothing binds `port` and no protocol
822/// handshake happens here. [`InteractiveDashboard::update`] queues each metrics
823/// snapshot; whatever transport the embedding application uses drains the queue
824/// with [`InteractiveDashboard::drain_pending_updates`] and delivers them.
825pub struct DashboardEndpoint {
826    /// Port the embedding application intends to serve on.
827    port: u16,
828    /// Client identifiers the embedding application has registered.
829    connected_clients: Arc<Mutex<Vec<String>>>,
830    /// Metrics snapshots queued since the last drain, oldest first, capped at
831    /// [`MAX_PENDING_DASHBOARD_UPDATES`].
832    pending_updates: Arc<Mutex<VecDeque<DashboardMetrics>>>,
833}
834
835impl DashboardEndpoint {
836    /// Port the embedding application intends to serve on.
837    pub fn port(&self) -> u16 {
838        self.port
839    }
840
841    /// Client identifiers registered by the embedding application.
842    pub fn connected_clients(&self) -> Vec<String> {
843        self.connected_clients.lock().map(|clients| clients.clone()).unwrap_or_default()
844    }
845}
846
847/// Most queued dashboard updates retained before the oldest are dropped.
848const MAX_PENDING_DASHBOARD_UPDATES: usize = 1000;
849
850impl InteractiveDashboard {
851    /// Create new interactive dashboard
852    pub fn new(config: &DebugConfig) -> Self {
853        Self {
854            config: config.clone(),
855            training_monitor: TrainingMonitor::new(config),
856            model_comparator: ModelComparator::new(),
857            hyperparameter_explorer: HyperparameterExplorer::new(),
858            dashboard_state: DashboardState {
859                active_session_id: None,
860                refresh_rate_ms: 1000,
861                auto_alerts: true,
862                display_mode: DisplayMode::Overview,
863            },
864            websocket_server: None,
865        }
866    }
867
868    /// Start dashboard with WebSocket server
869    pub async fn start(&mut self, port: Option<u16>) -> Result<()> {
870        let port = port.unwrap_or(8080);
871
872        self.websocket_server = Some(DashboardEndpoint {
873            port,
874            connected_clients: Arc::new(Mutex::new(Vec::new())),
875            pending_updates: Arc::new(Mutex::new(VecDeque::new())),
876        });
877
878        // Deliberately not "started on port {port}": no socket is bound here.
879        tracing::info!(
880            port,
881            "interactive dashboard activated; updates will be queued for the embedding \
882             application to deliver"
883        );
884        Ok(())
885    }
886
887    /// Take every dashboard update queued since the last call, oldest first.
888    ///
889    /// Returns an empty vector when the dashboard has not been started.
890    pub fn drain_pending_updates(&self) -> Vec<DashboardMetrics> {
891        let Some(endpoint) = self.websocket_server.as_ref() else {
892            return Vec::new();
893        };
894        endpoint
895            .pending_updates
896            .lock()
897            .map(|mut queue| queue.drain(..).collect())
898            .unwrap_or_default()
899    }
900
901    /// Update dashboard with new metrics
902    pub fn update(&mut self, metrics: DashboardMetrics) {
903        self.training_monitor.update_metrics(metrics.clone());
904
905        // Queue the snapshot for whatever transport the embedding application
906        // uses; see `drain_pending_updates`.
907        self.queue_update(metrics);
908    }
909
910    /// Get current dashboard snapshot
911    pub fn get_dashboard_snapshot(&self) -> DashboardSnapshot {
912        let training_summary = self.training_monitor.generate_training_summary();
913        let recent_metrics = self.training_monitor.get_recent_metrics(100);
914        let active_alerts = self.training_monitor.get_active_alerts().to_vec();
915        let model_comparison = self.model_comparator.compare_models();
916        let hyperparameter_recommendations = self.hyperparameter_explorer.get_recommendations();
917
918        DashboardSnapshot {
919            timestamp: SystemTime::now(),
920            training_summary,
921            recent_metrics,
922            active_alerts,
923            model_comparison,
924            hyperparameter_recommendations,
925            dashboard_state: DashboardState {
926                active_session_id: self.dashboard_state.active_session_id,
927                refresh_rate_ms: self.dashboard_state.refresh_rate_ms,
928                auto_alerts: self.dashboard_state.auto_alerts,
929                display_mode: self.dashboard_state.display_mode.clone(),
930            },
931        }
932    }
933
934    /// Export dashboard data to file
935    pub async fn export_dashboard_data(&self, path: &str) -> Result<()> {
936        let snapshot = self.get_dashboard_snapshot();
937        let json = serde_json::to_string_pretty(&snapshot)?;
938        tokio::fs::write(path, json).await?;
939        Ok(())
940    }
941
942    /// Queue one metrics snapshot for delivery, dropping the oldest once the
943    /// queue reaches [`MAX_PENDING_DASHBOARD_UPDATES`].
944    ///
945    /// This replaces `broadcast_update`, which sent nothing at all while
946    /// logging "Broadcasting dashboard update to connected clients".
947    fn queue_update(&self, metrics: DashboardMetrics) {
948        let Some(endpoint) = self.websocket_server.as_ref() else {
949            return;
950        };
951        if let Ok(mut queue) = endpoint.pending_updates.lock() {
952            queue.push_back(metrics);
953            while queue.len() > MAX_PENDING_DASHBOARD_UPDATES {
954                queue.pop_front();
955            }
956        }
957    }
958}
959
960// Supporting data structures
961
962#[derive(Debug, Clone, Serialize, Deserialize)]
963pub struct TrainingSummary {
964    pub total_duration: Duration,
965    pub total_steps: usize,
966    pub avg_loss: Option<f64>,
967    pub best_accuracy: Option<f64>,
968    pub avg_tokens_per_second: Option<f64>,
969    pub training_stability: TrainingStability,
970    pub active_alerts_count: usize,
971    pub convergence_status: ConvergenceStatus,
972}
973
974#[derive(Debug, Clone, Serialize, Deserialize)]
975pub enum TrainingStability {
976    Stable,
977    Moderate,
978    Unstable,
979    Insufficient,
980}
981
982#[derive(Debug, Clone, Serialize, Deserialize)]
983pub enum ConvergenceStatus {
984    TooEarly,
985    Converging,
986    Converged,
987    Diverging,
988}
989
990#[derive(Debug, Serialize, Deserialize)]
991pub struct ModelComparisonReport {
992    pub comparisons: Vec<ModelComparison>,
993    pub best_model: Option<String>,
994    pub ranking: Vec<ModelRanking>,
995    pub comparison_config: ComparisonConfig,
996}
997
998#[derive(Debug, Serialize, Deserialize)]
999pub struct ModelComparison {
1000    pub model_a_id: String,
1001    pub model_b_id: String,
1002    /// Relative change in the configured `primary_metric` from `model_a` to
1003    /// `model_b`, or `None` when at least one of them never recorded that
1004    /// metric (or the reference value is zero) -- never a `0.0` standing in
1005    /// for "nothing was measured".
1006    pub performance_difference: Option<f64>,
1007    /// Mean of the relative training-time and model-size changes, or `None`
1008    /// when `model_a` has no recorded training time or model size.
1009    pub efficiency_difference: Option<f64>,
1010    /// `Some(true)`/`Some(false)` from a real Welch's t-test over both
1011    /// models' recorded `primary_metric` samples (see
1012    /// `ModelComparator::test_statistical_significance`), or `None` when
1013    /// there was not enough recorded history to run the test -- never a
1014    /// fabricated constant.
1015    pub statistical_significance: Option<bool>,
1016    pub recommendation: String,
1017}
1018
1019#[derive(Debug, Serialize, Deserialize)]
1020pub struct ModelRanking {
1021    pub model_id: String,
1022    pub model_name: String,
1023    pub score: f64,
1024    pub rank: usize,
1025}
1026
1027#[derive(Debug, Serialize, Deserialize)]
1028pub struct HyperparameterRecommendations {
1029    pub best_experiments: Vec<String>,
1030    pub parameter_importance: HashMap<String, f64>,
1031    pub suggested_ranges: HashMap<String, (f64, f64)>,
1032    pub next_experiments: Vec<HashMap<String, HyperparameterValue>>,
1033    pub total_experiments: usize,
1034}
1035
1036#[derive(Debug, Serialize, Deserialize)]
1037pub struct DashboardSnapshot {
1038    pub timestamp: SystemTime,
1039    pub training_summary: TrainingSummary,
1040    pub recent_metrics: Vec<DashboardMetrics>,
1041    pub active_alerts: Vec<TrainingAlert>,
1042    pub model_comparison: ModelComparisonReport,
1043    pub hyperparameter_recommendations: HyperparameterRecommendations,
1044    pub dashboard_state: DashboardState,
1045}
1046
1047/// Dashboard report for integration with main debug system
1048#[derive(Debug, Serialize, Deserialize)]
1049pub struct DashboardReport {
1050    pub session_duration: Duration,
1051    pub total_metrics_recorded: usize,
1052    pub alerts_triggered: usize,
1053    pub models_compared: usize,
1054    pub experiments_tracked: usize,
1055    pub performance_summary: TrainingSummary,
1056    pub key_insights: Vec<String>,
1057    pub recommendations: Vec<String>,
1058}
1059
1060impl InteractiveDashboard {
1061    /// Generate comprehensive dashboard report
1062    pub async fn generate_report(&self) -> Result<DashboardReport> {
1063        let training_summary = self.training_monitor.generate_training_summary();
1064        let total_metrics = self.training_monitor.metrics_history.len();
1065        let alerts_count = self.training_monitor.active_alerts.len();
1066        let models_count = self.model_comparator.models.len();
1067        let experiments_count = self.hyperparameter_explorer.experiments.len();
1068
1069        let key_insights = self.generate_key_insights();
1070        let recommendations = self.generate_recommendations();
1071
1072        Ok(DashboardReport {
1073            session_duration: training_summary.total_duration,
1074            total_metrics_recorded: total_metrics,
1075            alerts_triggered: alerts_count,
1076            models_compared: models_count,
1077            experiments_tracked: experiments_count,
1078            performance_summary: training_summary,
1079            key_insights,
1080            recommendations,
1081        })
1082    }
1083
1084    fn generate_key_insights(&self) -> Vec<String> {
1085        let mut insights = Vec::new();
1086
1087        // Training stability insights
1088        match self.training_monitor.generate_training_summary().training_stability {
1089            TrainingStability::Stable => insights.push("Training is proceeding stably".to_string()),
1090            TrainingStability::Unstable => insights.push(
1091                "Training shows high variance - consider adjusting hyperparameters".to_string(),
1092            ),
1093            _ => {},
1094        }
1095
1096        // Model comparison insights
1097        if self.model_comparator.models.len() > 1 {
1098            let comparison = self.model_comparator.compare_models();
1099            if let Some(best_model) = comparison.best_model {
1100                insights.push(format!("Best performing model: {}", best_model));
1101            }
1102        }
1103
1104        // Alert insights
1105        let critical_alerts = self
1106            .training_monitor
1107            .active_alerts
1108            .iter()
1109            .filter(|alert| matches!(alert.severity, AlertSeverity::Critical))
1110            .count();
1111
1112        if critical_alerts > 0 {
1113            insights.push(format!(
1114                "{} critical alerts require immediate attention",
1115                critical_alerts
1116            ));
1117        }
1118
1119        insights
1120    }
1121
1122    fn generate_recommendations(&self) -> Vec<String> {
1123        let mut recommendations = Vec::new();
1124
1125        // Based on active alerts
1126        for alert in &self.training_monitor.active_alerts {
1127            if matches!(alert.severity, AlertSeverity::Critical) {
1128                recommendations.push(alert.suggested_action.clone());
1129            }
1130        }
1131
1132        // Based on hyperparameter exploration
1133        if self.hyperparameter_explorer.experiments.len() > 5 {
1134            recommendations.push(
1135                "Continue hyperparameter optimization with narrowed search ranges".to_string(),
1136            );
1137        }
1138
1139        // Based on model comparison
1140        if self.model_comparator.models.len() > 1 {
1141            recommendations
1142                .push("Focus on the best performing model architecture for production".to_string());
1143        }
1144
1145        if recommendations.is_empty() {
1146            recommendations.push("Continue monitoring training progress".to_string());
1147        }
1148
1149        recommendations
1150    }
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::*;
1156
1157    // ---- Wave 6c debug-sweep2: real update queue, no fake broadcast --------
1158
1159    #[tokio::test]
1160    async fn dashboard_updates_are_really_queued_for_delivery() {
1161        let config = DebugConfig::default();
1162        let mut dashboard = InteractiveDashboard::new(&config);
1163        // Nothing is queued before the dashboard is started.
1164        dashboard.update(make_metrics_simple());
1165        assert!(dashboard.drain_pending_updates().is_empty());
1166
1167        dashboard.start(Some(9_999)).await.expect("start");
1168        dashboard.update(make_metrics_simple());
1169        dashboard.update(make_metrics_simple());
1170
1171        let drained = dashboard.drain_pending_updates();
1172        assert_eq!(drained.len(), 2, "both updates must really be retained");
1173        assert!(
1174            dashboard.drain_pending_updates().is_empty(),
1175            "draining must consume the queue"
1176        );
1177    }
1178
1179    #[tokio::test]
1180    async fn the_pending_update_queue_is_bounded() {
1181        let config = DebugConfig::default();
1182        let mut dashboard = InteractiveDashboard::new(&config);
1183        dashboard.start(None).await.expect("start");
1184        for _ in 0..(MAX_PENDING_DASHBOARD_UPDATES + 50) {
1185            dashboard.update(make_metrics_simple());
1186        }
1187        assert_eq!(
1188            dashboard.drain_pending_updates().len(),
1189            MAX_PENDING_DASHBOARD_UPDATES,
1190            "the oldest updates must be dropped, not accumulated without bound"
1191        );
1192    }
1193
1194    fn make_config() -> DebugConfig {
1195        DebugConfig::default()
1196    }
1197
1198    fn make_metrics_with(
1199        loss: Option<f64>,
1200        accuracy: Option<f64>,
1201        memory_mb: f64,
1202    ) -> DashboardMetrics {
1203        DashboardMetrics {
1204            timestamp: SystemTime::now(),
1205            loss,
1206            accuracy,
1207            learning_rate: Some(0.001),
1208            memory_usage_mb: memory_mb,
1209            gpu_utilization: Some(0.8),
1210            tokens_per_second: Some(200.0),
1211            gradient_norm: Some(1.0),
1212            epoch: Some(1),
1213            step: Some(100),
1214        }
1215    }
1216
1217    fn make_metrics_simple() -> DashboardMetrics {
1218        make_metrics_with(Some(0.5), Some(0.85), 2048.0)
1219    }
1220
1221    // --- AlertThresholds tests ---
1222
1223    #[test]
1224    fn test_alert_thresholds_default() {
1225        let thresholds = AlertThresholds::default();
1226        assert!((thresholds.loss_increase_threshold - 1.5).abs() < 1e-9);
1227        assert!((thresholds.gradient_norm_max - 10.0).abs() < 1e-9);
1228        assert!((thresholds.memory_usage_max_mb - 8192.0).abs() < 1e-9);
1229    }
1230
1231    // --- TrainingMonitor tests ---
1232
1233    #[test]
1234    fn test_training_monitor_new() {
1235        let config = make_config();
1236        let monitor = TrainingMonitor::new(&config);
1237        assert!(monitor.metrics_history.is_empty());
1238        assert!(monitor.active_alerts.is_empty());
1239        assert_eq!(monitor.max_history, 10000);
1240    }
1241
1242    #[test]
1243    fn test_training_monitor_update_metrics() {
1244        let config = make_config();
1245        let mut monitor = TrainingMonitor::new(&config);
1246        monitor.update_metrics(make_metrics_simple());
1247        assert_eq!(monitor.metrics_history.len(), 1);
1248    }
1249
1250    #[test]
1251    fn test_training_monitor_history_limit() {
1252        let config = make_config();
1253        let mut monitor = TrainingMonitor::new(&config);
1254        monitor.max_history = 5;
1255        for _ in 0..10 {
1256            monitor.update_metrics(make_metrics_simple());
1257        }
1258        assert_eq!(monitor.metrics_history.len(), 5);
1259    }
1260
1261    #[test]
1262    fn test_training_monitor_get_recent_metrics() {
1263        let config = make_config();
1264        let mut monitor = TrainingMonitor::new(&config);
1265        for _ in 0..5 {
1266            monitor.update_metrics(make_metrics_simple());
1267        }
1268        let recent = monitor.get_recent_metrics(3);
1269        assert_eq!(recent.len(), 3);
1270    }
1271
1272    #[test]
1273    fn test_training_monitor_get_recent_metrics_more_than_available() {
1274        let config = make_config();
1275        let mut monitor = TrainingMonitor::new(&config);
1276        monitor.update_metrics(make_metrics_simple());
1277        let recent = monitor.get_recent_metrics(10);
1278        assert_eq!(recent.len(), 1);
1279    }
1280
1281    #[test]
1282    fn test_training_monitor_set_alert_thresholds() {
1283        let config = make_config();
1284        let mut monitor = TrainingMonitor::new(&config);
1285        let thresholds = AlertThresholds {
1286            loss_increase_threshold: 2.0,
1287            gradient_norm_max: 5.0,
1288            memory_usage_max_mb: 4096.0,
1289            gpu_utilization_min: 0.5,
1290            learning_rate_min: 1e-6,
1291            tokens_per_second_min: 50.0,
1292        };
1293        monitor.set_alert_thresholds(thresholds);
1294        assert!((monitor.alert_thresholds.gradient_norm_max - 5.0).abs() < 1e-9);
1295    }
1296
1297    #[test]
1298    fn test_training_monitor_gradient_explosion_alert() {
1299        let config = make_config();
1300        let mut monitor = TrainingMonitor::new(&config);
1301        let mut metrics = make_metrics_simple();
1302        metrics.gradient_norm = Some(100.0);
1303        monitor.update_metrics(metrics);
1304        assert!(monitor
1305            .active_alerts
1306            .iter()
1307            .any(|a| a.alert_type == AlertType::GradientExplosion));
1308    }
1309
1310    #[test]
1311    fn test_training_monitor_memory_overuse_alert() {
1312        let config = make_config();
1313        let mut monitor = TrainingMonitor::new(&config);
1314        let metrics = make_metrics_with(Some(0.5), Some(0.8), 10000.0);
1315        monitor.update_metrics(metrics);
1316        assert!(monitor.active_alerts.iter().any(|a| a.alert_type == AlertType::MemoryOveruse));
1317    }
1318
1319    #[test]
1320    fn test_training_monitor_low_gpu_alert() {
1321        let config = make_config();
1322        let mut monitor = TrainingMonitor::new(&config);
1323        let mut metrics = make_metrics_simple();
1324        metrics.gpu_utilization = Some(0.1);
1325        monitor.update_metrics(metrics);
1326        assert!(monitor
1327            .active_alerts
1328            .iter()
1329            .any(|a| a.alert_type == AlertType::LowGpuUtilization));
1330    }
1331
1332    #[test]
1333    fn test_training_monitor_slow_token_alert() {
1334        let config = make_config();
1335        let mut monitor = TrainingMonitor::new(&config);
1336        let mut metrics = make_metrics_simple();
1337        metrics.tokens_per_second = Some(10.0);
1338        monitor.update_metrics(metrics);
1339        assert!(monitor
1340            .active_alerts
1341            .iter()
1342            .any(|a| a.alert_type == AlertType::SlowTokenProcessing));
1343    }
1344
1345    #[test]
1346    fn test_training_monitor_no_duplicate_alerts() {
1347        let config = make_config();
1348        let mut monitor = TrainingMonitor::new(&config);
1349        let mut metrics = make_metrics_simple();
1350        metrics.gradient_norm = Some(100.0);
1351        monitor.update_metrics(metrics.clone());
1352        monitor.update_metrics(metrics);
1353        let grad_alerts = monitor
1354            .active_alerts
1355            .iter()
1356            .filter(|a| a.alert_type == AlertType::GradientExplosion)
1357            .count();
1358        assert_eq!(grad_alerts, 1);
1359    }
1360
1361    #[test]
1362    fn test_training_monitor_average_loss_none() {
1363        let config = make_config();
1364        let monitor = TrainingMonitor::new(&config);
1365        assert!(monitor.calculate_average_loss().is_none());
1366    }
1367
1368    #[test]
1369    fn test_training_monitor_average_loss() {
1370        let config = make_config();
1371        let mut monitor = TrainingMonitor::new(&config);
1372        monitor.update_metrics(make_metrics_with(Some(1.0), None, 1024.0));
1373        monitor.update_metrics(make_metrics_with(Some(2.0), None, 1024.0));
1374        let avg = monitor.calculate_average_loss();
1375        assert!(avg.is_some());
1376        assert!((avg.expect("should be some") - 1.5).abs() < 1e-9);
1377    }
1378
1379    #[test]
1380    fn test_training_monitor_best_accuracy_none() {
1381        let config = make_config();
1382        let monitor = TrainingMonitor::new(&config);
1383        assert!(monitor.calculate_best_accuracy().is_none());
1384    }
1385
1386    #[test]
1387    fn test_training_monitor_best_accuracy() {
1388        let config = make_config();
1389        let mut monitor = TrainingMonitor::new(&config);
1390        monitor.update_metrics(make_metrics_with(None, Some(0.7), 1024.0));
1391        monitor.update_metrics(make_metrics_with(None, Some(0.9), 1024.0));
1392        monitor.update_metrics(make_metrics_with(None, Some(0.8), 1024.0));
1393        let best = monitor.calculate_best_accuracy();
1394        assert!(best.is_some());
1395        assert!((best.expect("should be some") - 0.9).abs() < 1e-9);
1396    }
1397
1398    #[test]
1399    fn test_training_monitor_avg_tps_none() {
1400        let config = make_config();
1401        let monitor = TrainingMonitor::new(&config);
1402        assert!(monitor.calculate_average_tokens_per_second().is_none());
1403    }
1404
1405    #[test]
1406    fn test_training_stability_insufficient() {
1407        let config = make_config();
1408        let monitor = TrainingMonitor::new(&config);
1409        assert!(matches!(
1410            monitor.calculate_training_stability(),
1411            TrainingStability::Insufficient
1412        ));
1413    }
1414
1415    #[test]
1416    fn test_convergence_too_early() {
1417        let config = make_config();
1418        let monitor = TrainingMonitor::new(&config);
1419        assert!(matches!(
1420            monitor.assess_convergence(),
1421            ConvergenceStatus::TooEarly
1422        ));
1423    }
1424
1425    #[test]
1426    fn test_generate_training_summary() {
1427        let config = make_config();
1428        let monitor = TrainingMonitor::new(&config);
1429        let summary = monitor.generate_training_summary();
1430        assert_eq!(summary.total_steps, 0);
1431        assert!(matches!(
1432            summary.convergence_status,
1433            ConvergenceStatus::TooEarly
1434        ));
1435    }
1436
1437    // --- ModelComparator tests ---
1438
1439    #[test]
1440    fn test_model_comparator_new() {
1441        let comparator = ModelComparator::new();
1442        assert!(comparator.models.is_empty());
1443    }
1444
1445    #[test]
1446    fn test_model_comparator_add_model() {
1447        let mut comparator = ModelComparator::new();
1448        comparator.add_model(ModelMetrics {
1449            model_id: "m1".to_string(),
1450            model_name: "Model A".to_string(),
1451            metrics_history: Vec::new(),
1452            final_loss: Some(0.5),
1453            final_accuracy: Some(0.9),
1454            training_time: Duration::from_secs(100),
1455            parameter_count: 1000,
1456            model_size_mb: 10.0,
1457        });
1458        assert_eq!(comparator.models.len(), 1);
1459    }
1460
1461    #[test]
1462    fn test_model_comparator_find_best_model_empty() {
1463        let comparator = ModelComparator::new();
1464        assert!(comparator.find_best_model().is_none());
1465    }
1466
1467    #[test]
1468    fn test_model_comparator_find_best_model() {
1469        let mut comparator = ModelComparator::new();
1470        comparator.add_model(ModelMetrics {
1471            model_id: "m1".to_string(),
1472            model_name: "Model A".to_string(),
1473            metrics_history: Vec::new(),
1474            final_loss: Some(0.5),
1475            final_accuracy: Some(0.9),
1476            training_time: Duration::from_secs(100),
1477            parameter_count: 1000,
1478            model_size_mb: 10.0,
1479        });
1480        comparator.add_model(ModelMetrics {
1481            model_id: "m2".to_string(),
1482            model_name: "Model B".to_string(),
1483            metrics_history: Vec::new(),
1484            final_loss: Some(0.3),
1485            final_accuracy: Some(0.95),
1486            training_time: Duration::from_secs(200),
1487            parameter_count: 2000,
1488            model_size_mb: 20.0,
1489        });
1490        let best = comparator.find_best_model();
1491        assert!(best.is_some());
1492        assert_eq!(best.expect("should find best"), "m2");
1493    }
1494
1495    #[test]
1496    fn test_model_comparator_rank_models() {
1497        let mut comparator = ModelComparator::new();
1498        comparator.add_model(ModelMetrics {
1499            model_id: "m1".to_string(),
1500            model_name: "A".to_string(),
1501            metrics_history: Vec::new(),
1502            final_loss: Some(0.5),
1503            final_accuracy: None,
1504            training_time: Duration::from_secs(100),
1505            parameter_count: 1000,
1506            model_size_mb: 10.0,
1507        });
1508        let ranking = comparator.rank_models();
1509        assert_eq!(ranking.len(), 1);
1510        assert_eq!(ranking[0].rank, 1);
1511    }
1512
1513    #[test]
1514    fn test_model_comparator_generate_recommendation_similar() {
1515        let comparator = ModelComparator::new();
1516        let ma = ModelMetrics {
1517            model_id: "a".to_string(),
1518            model_name: "A".to_string(),
1519            metrics_history: Vec::new(),
1520            final_loss: Some(0.5),
1521            final_accuracy: None,
1522            training_time: Duration::from_secs(100),
1523            parameter_count: 1000,
1524            model_size_mb: 10.0,
1525        };
1526        let rec = comparator.generate_recommendation(&ma, &ma, Some(0.0));
1527        assert!(rec.contains("similarly"));
1528    }
1529
1530    #[test]
1531    fn test_model_comparator_recommendation_reports_missing_metric_not_similarity() {
1532        // `None` means "the primary metric was never recorded", which must not
1533        // be reported as "the two models perform similarly".
1534        let comparator = ModelComparator::new();
1535        let ma = ModelMetrics {
1536            model_id: "a".to_string(),
1537            model_name: "A".to_string(),
1538            metrics_history: Vec::new(),
1539            final_loss: None,
1540            final_accuracy: None,
1541            training_time: Duration::from_secs(100),
1542            parameter_count: 1000,
1543            model_size_mb: 10.0,
1544        };
1545        let rec = comparator.generate_recommendation(&ma, &ma, None);
1546        assert!(!rec.contains("similarly"), "got {rec}");
1547        assert!(rec.contains("not recorded"), "got {rec}");
1548    }
1549
1550    #[test]
1551    fn test_performance_difference_is_none_when_metric_never_recorded() {
1552        let comparator = ModelComparator::new();
1553        let unmeasured = ModelMetrics {
1554            model_id: "a".to_string(),
1555            model_name: "A".to_string(),
1556            metrics_history: Vec::new(),
1557            final_loss: None,
1558            final_accuracy: None,
1559            training_time: Duration::from_secs(100),
1560            parameter_count: 1000,
1561            model_size_mb: 10.0,
1562        };
1563        assert_eq!(
1564            comparator.calculate_performance_difference(&unmeasured, &unmeasured),
1565            None,
1566            "no final loss on either side: absence, not a 0.0 tie"
1567        );
1568
1569        let measured_a = model_with_loss_history("a", &[0.5]);
1570        let measured_b = model_with_loss_history("b", &[0.4]);
1571        let diff = comparator
1572            .calculate_performance_difference(&measured_a, &measured_b)
1573            .expect("both models recorded a final loss");
1574        assert!(
1575            (diff - (-0.2)).abs() < 1e-12,
1576            "(0.4 - 0.5)/0.5 = -0.2, got {diff}"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_efficiency_difference_is_none_without_a_reference_scale() {
1582        let comparator = ModelComparator::new();
1583        let zeroed = ModelMetrics {
1584            model_id: "a".to_string(),
1585            model_name: "A".to_string(),
1586            metrics_history: Vec::new(),
1587            final_loss: Some(0.5),
1588            final_accuracy: None,
1589            training_time: Duration::ZERO,
1590            parameter_count: 1000,
1591            model_size_mb: 0.0,
1592        };
1593        assert_eq!(
1594            comparator.calculate_efficiency_difference(&zeroed, &zeroed),
1595            None,
1596            "dividing by a zero reference used to yield NaN/inf, not a real ratio"
1597        );
1598
1599        let a = model_with_loss_history("a", &[0.5]);
1600        let mut b = model_with_loss_history("b", &[0.5]);
1601        b.training_time = Duration::from_secs(150);
1602        b.model_size_mb = 20.0;
1603        let diff = comparator
1604            .calculate_efficiency_difference(&a, &b)
1605            .expect("both scales are non-zero");
1606        // time 150/100 - 1 = 0.5, size 20/10 - 1 = 1.0, mean = 0.75
1607        assert!((diff - 0.75).abs() < 1e-12, "got {diff}");
1608    }
1609
1610    #[test]
1611    fn test_clear_alert_removes_only_the_requested_alert_type() {
1612        let config = DebugConfig::default();
1613        let mut monitor = TrainingMonitor::new(&config);
1614        for alert_type in [
1615            AlertType::LossIncrease,
1616            AlertType::MemoryOveruse,
1617            AlertType::TrainingStalled,
1618        ] {
1619            monitor.active_alerts.push(TrainingAlert {
1620                alert_type,
1621                severity: AlertSeverity::Warning,
1622                message: "test".to_string(),
1623                timestamp: SystemTime::now(),
1624                metric_value: 1.0,
1625                threshold: 0.5,
1626                suggested_action: "none".to_string(),
1627            });
1628        }
1629        assert_eq!(monitor.get_active_alerts().len(), 3);
1630
1631        monitor.clear_alert(AlertType::MemoryOveruse);
1632
1633        // The old `matches!(x, _alert_type)` body cleared all three.
1634        let remaining: Vec<&AlertType> =
1635            monitor.get_active_alerts().iter().map(|a| &a.alert_type).collect();
1636        assert_eq!(remaining.len(), 2, "only the MemoryOveruse alert may go");
1637        assert!(remaining.contains(&&AlertType::LossIncrease));
1638        assert!(remaining.contains(&&AlertType::TrainingStalled));
1639        assert!(!remaining.contains(&&AlertType::MemoryOveruse));
1640
1641        // Clearing a type that is not present must be a no-op.
1642        monitor.clear_alert(AlertType::GradientExplosion);
1643        assert_eq!(monitor.get_active_alerts().len(), 2);
1644    }
1645
1646    fn model_with_loss_history(model_id: &str, losses: &[f64]) -> ModelMetrics {
1647        ModelMetrics {
1648            model_id: model_id.to_string(),
1649            model_name: model_id.to_string(),
1650            metrics_history: losses
1651                .iter()
1652                .map(|&l| make_metrics_with(Some(l), None, 1024.0))
1653                .collect(),
1654            final_loss: losses.last().copied(),
1655            final_accuracy: None,
1656            training_time: Duration::from_secs(100),
1657            parameter_count: 1000,
1658            model_size_mb: 10.0,
1659        }
1660    }
1661
1662    #[test]
1663    fn test_statistical_significance_none_with_empty_history() {
1664        // No recorded `loss` samples on either side -- an honest `None`,
1665        // never a fabricated `true`.
1666        let comparator = ModelComparator::new();
1667        let ma = model_with_loss_history("a", &[]);
1668        let mb = model_with_loss_history("b", &[]);
1669        assert_eq!(comparator.test_statistical_significance(&ma, &mb), None);
1670    }
1671
1672    #[test]
1673    fn test_statistical_significance_none_with_single_sample() {
1674        // A single recorded sample per model is not enough for a real
1675        // two-sample t-test.
1676        let comparator = ModelComparator::new();
1677        let ma = model_with_loss_history("a", &[0.5]);
1678        let mb = model_with_loss_history("b", &[0.2]);
1679        assert_eq!(comparator.test_statistical_significance(&ma, &mb), None);
1680    }
1681
1682    #[test]
1683    fn test_statistical_significance_true_for_clearly_separated_models() {
1684        // Two tight, well-separated loss distributions: a real Welch's
1685        // t-test must find this significant.
1686        let comparator = ModelComparator::new();
1687        let ma = model_with_loss_history("a", &[0.50, 0.51, 0.49, 0.50, 0.52, 0.48, 0.50, 0.51]);
1688        let mb = model_with_loss_history("b", &[0.20, 0.21, 0.19, 0.20, 0.22, 0.18, 0.20, 0.21]);
1689        assert_eq!(
1690            comparator.test_statistical_significance(&ma, &mb),
1691            Some(true)
1692        );
1693    }
1694
1695    #[test]
1696    fn test_statistical_significance_false_for_overlapping_models() {
1697        // Two noisy loss distributions with (almost) the same mean and
1698        // overlapping spread: a real Welch's t-test must NOT find this
1699        // significant -- this is exactly the case the old `true //
1700        // Placeholder` got wrong for every pair.
1701        let comparator = ModelComparator::new();
1702        let ma = model_with_loss_history("a", &[0.50, 0.55, 0.45, 0.52, 0.48, 0.51, 0.49, 0.53]);
1703        let mb = model_with_loss_history("b", &[0.51, 0.46, 0.54, 0.49, 0.52, 0.47, 0.53, 0.50]);
1704        assert_eq!(
1705            comparator.test_statistical_significance(&ma, &mb),
1706            Some(false)
1707        );
1708    }
1709
1710    #[test]
1711    fn test_compare_two_models_publishes_option_significance() {
1712        // End-to-end: `compare_two_models` (called from `compare_models`,
1713        // in turn from `get_dashboard_snapshot`) must publish the same
1714        // real `Option<bool>`, not a constant.
1715        let comparator = ModelComparator::new();
1716        let ma = model_with_loss_history("a", &[0.50, 0.51, 0.49, 0.50, 0.52, 0.48, 0.50, 0.51]);
1717        let mb = model_with_loss_history("b", &[0.20, 0.21, 0.19, 0.20, 0.22, 0.18, 0.20, 0.21]);
1718        let comparison = comparator.compare_two_models(&ma, &mb);
1719        assert_eq!(comparison.statistical_significance, Some(true));
1720    }
1721
1722    // --- HyperparameterExplorer tests ---
1723
1724    #[test]
1725    fn test_hyperparameter_explorer_new() {
1726        let explorer = HyperparameterExplorer::new();
1727        assert!(explorer.experiments.is_empty());
1728    }
1729
1730    #[test]
1731    fn test_hyperparameter_explorer_add_experiment() {
1732        let mut explorer = HyperparameterExplorer::new();
1733        explorer.add_experiment(HyperparameterExperiment {
1734            experiment_id: "exp1".to_string(),
1735            hyperparameters: HashMap::new(),
1736            results: ExperimentResults {
1737                final_loss: Some(0.5),
1738                final_accuracy: Some(0.9),
1739                training_time: Duration::from_secs(100),
1740                convergence_epoch: Some(50),
1741                best_validation_score: Some(0.88),
1742            },
1743            status: ExperimentStatus::Completed,
1744        });
1745        assert_eq!(explorer.experiments.len(), 1);
1746    }
1747
1748    #[test]
1749    fn test_hyperparameter_explorer_get_recommendations() {
1750        let explorer = HyperparameterExplorer::new();
1751        let recs = explorer.get_recommendations();
1752        assert_eq!(recs.total_experiments, 0);
1753        assert!(!recs.parameter_importance.is_empty());
1754    }
1755
1756    #[test]
1757    fn test_hyperparameter_explorer_suggest_next_experiments() {
1758        let explorer = HyperparameterExplorer::new();
1759        let suggestions = explorer.suggest_next_experiments(3);
1760        assert_eq!(suggestions.len(), 3);
1761    }
1762
1763    // --- InteractiveDashboard tests ---
1764
1765    #[test]
1766    fn test_interactive_dashboard_new() {
1767        let config = make_config();
1768        let dashboard = InteractiveDashboard::new(&config);
1769        assert!(dashboard.websocket_server.is_none());
1770    }
1771
1772    #[test]
1773    fn test_interactive_dashboard_update() {
1774        let config = make_config();
1775        let mut dashboard = InteractiveDashboard::new(&config);
1776        dashboard.update(make_metrics_simple());
1777        assert_eq!(dashboard.training_monitor.metrics_history.len(), 1);
1778    }
1779
1780    #[test]
1781    fn test_interactive_dashboard_snapshot() {
1782        let config = make_config();
1783        let dashboard = InteractiveDashboard::new(&config);
1784        let snapshot = dashboard.get_dashboard_snapshot();
1785        assert!(snapshot.recent_metrics.is_empty());
1786    }
1787
1788    #[test]
1789    fn test_interactive_dashboard_generate_recommendations() {
1790        let config = make_config();
1791        let dashboard = InteractiveDashboard::new(&config);
1792        let recs = dashboard.generate_recommendations();
1793        assert!(!recs.is_empty());
1794    }
1795
1796    #[test]
1797    fn test_interactive_dashboard_generate_key_insights() {
1798        let config = make_config();
1799        let dashboard = InteractiveDashboard::new(&config);
1800        let insights = dashboard.generate_key_insights();
1801        // With no data, stability is insufficient, so minimal insights
1802        assert!(insights.is_empty() || !insights.is_empty());
1803    }
1804
1805    // --- ComparisonConfig tests ---
1806
1807    #[test]
1808    fn test_comparison_config_default() {
1809        let config = ComparisonConfig::default();
1810        assert_eq!(config.primary_metric, "loss");
1811        assert_eq!(config.comparison_window, 100);
1812    }
1813
1814    // --- HyperparameterSearchSpace tests ---
1815
1816    #[test]
1817    fn test_search_space_default() {
1818        let space = HyperparameterSearchSpace::default();
1819        assert!(space.learning_rate.0 < space.learning_rate.1);
1820        assert!(space.batch_size.0 < space.batch_size.1);
1821    }
1822}