//! Unit Tests for Early Stopping Components //! //! This module contains comprehensive unit tests for early stopping functionality //! across all ML adapters (PPO, TFT, MAMBA-2, DQN). //! //! Test Categories: //! 1. Configuration Tests - Default values, custom configs, validation //! 2. State Management Tests - Loss tracking, patience counters, history //! 3. Strategy Tests - Plateau detection, pruning algorithms //! 4. Edge Cases - Zero variance, NaN/Inf handling, boundary conditions use std::collections::HashMap; use tracing::info; // ============================================================================ // TEST CATEGORY 1: CONFIGURATION TESTS // ============================================================================ /// Test that default early stopping configuration values are sensible #[test] fn test_default_early_stopping_config() { // DQN early stopping defaults let dqn_config = crate::trainers::dqn::DQNHyperparameters::conservative(); assert!(dqn_config.early_stopping_enabled, "Early stopping should be enabled by default"); assert_eq!(dqn_config.min_epochs_before_stopping, 50, "Minimum epochs should be 50"); assert_eq!(dqn_config.plateau_window, 30, "Plateau window should be 30"); assert_eq!(dqn_config.q_value_floor, 0.5, "Q-value floor should be 0.5"); assert_eq!(dqn_config.min_loss_improvement_pct, 2.0, "Min loss improvement should be 2%"); // Verify minimum epochs prevents premature stopping assert!(dqn_config.min_epochs_before_stopping >= 20, "Must allow at least 20 epochs for model to warm up"); // Verify plateau window is reasonable assert!(dqn_config.plateau_window >= 10 && dqn_config.plateau_window <= 50, "Plateau window should be between 10-50 epochs"); } /// Test that custom early stopping configurations validate correctly #[test] fn test_custom_early_stopping_config_validation() { // Valid custom configuration let custom_config = crate::trainers::dqn::DQNHyperparameters { early_stopping_enabled: true, min_epochs_before_stopping: 100, plateau_window: 20, q_value_floor: 0.3, min_loss_improvement_pct: 1.0, ..Default::default() }; assert_eq!(custom_config.min_epochs_before_stopping, 100); assert_eq!(custom_config.plateau_window, 20); assert_eq!(custom_config.q_value_floor, 0.3); assert_eq!(custom_config.min_loss_improvement_pct, 1.0); } /// Test that early stopping can be disabled #[test] fn test_early_stopping_disable() { let config = crate::trainers::dqn::DQNHyperparameters { early_stopping_enabled: false, ..Default::default() }; assert!(!config.early_stopping_enabled, "Early stopping should be disabled"); } /// Test configuration boundaries and constraints #[test] fn test_early_stopping_config_boundaries() { // Test minimum values let min_config = crate::trainers::dqn::DQNHyperparameters { min_epochs_before_stopping: 1, plateau_window: 1, q_value_floor: 0.0, min_loss_improvement_pct: 0.0, ..Default::default() }; assert_eq!(min_config.min_epochs_before_stopping, 1); assert_eq!(min_config.plateau_window, 1); assert_eq!(min_config.q_value_floor, 0.0); assert_eq!(min_config.min_loss_improvement_pct, 0.0); // Test maximum values let max_config = crate::trainers::dqn::DQNHyperparameters { min_epochs_before_stopping: 500, plateau_window: 100, q_value_floor: 10.0, min_loss_improvement_pct: 50.0, ..Default::default() }; assert_eq!(max_config.min_epochs_before_stopping, 500); assert_eq!(max_config.plateau_window, 100); assert_eq!(max_config.q_value_floor, 10.0); assert_eq!(max_config.min_loss_improvement_pct, 50.0); } // ============================================================================ // TEST CATEGORY 2: STATE MANAGEMENT TESTS // ============================================================================ /// Test loss history tracking #[test] fn test_loss_history_tracking() { let losses = vec![1.0, 0.9, 0.8, 0.7, 0.6, 0.5]; // Verify history length assert_eq!(losses.len(), 6); // Verify monotonic decrease (ideal case) for i in 1..losses.len() { assert!(losses[i] < losses[i-1], "Loss should decrease over time"); } // Calculate rolling average (window=3) let window_size = 3; let recent_avg: f64 = losses[losses.len()-window_size..].iter().sum::() / window_size as f64; let older_avg: f64 = losses[losses.len()-2*window_size..losses.len()-window_size].iter().sum::() / window_size as f64; assert!(recent_avg < older_avg, "Recent average should be lower than older average"); } /// Test loss history with noise #[test] fn test_loss_history_with_noise() { // Realistic loss history with noise let losses = vec![ 1.0, 0.95, 1.02, 0.90, 0.88, 0.93, // Early training: high variance 0.85, 0.82, 0.84, 0.80, 0.78, 0.81, // Mid training: decreasing with noise 0.75, 0.74, 0.76, 0.73, 0.74, 0.75, // Late training: plateau with noise ]; // Calculate trend (simple linear regression) let n = losses.len() as f64; let sum_x: f64 = (0..losses.len()).map(|i| i as f64).sum(); let sum_y: f64 = losses.iter().sum(); let sum_xy: f64 = losses.iter().enumerate().map(|(i, &y)| i as f64 * y).sum(); let sum_x2: f64 = (0..losses.len()).map(|i| (i * i) as f64).sum(); let slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x); // Overall trend should be decreasing (negative slope) assert!(slope < 0.0, "Overall trend should be decreasing despite noise"); // Check variance in different phases let early_variance = calculate_variance(&losses[0..6]); let late_variance = calculate_variance(&losses[12..18]); // Early training should have higher variance assert!(early_variance > late_variance * 0.5, "Early training variance should be higher"); } fn calculate_variance(values: &[f64]) -> f64 { let mean = values.iter().sum::() / values.len() as f64; let variance = values.iter().map(|v| (v - mean).powi(2)).sum::() / values.len() as f64; variance } /// Test patience counter increment and reset #[test] fn test_patience_counter_behavior() { struct PatienceState { counter: usize, best_loss: f64, patience_limit: usize, } impl PatienceState { fn new(patience: usize) -> Self { Self { counter: 0, best_loss: f64::INFINITY, patience_limit: patience, } } fn update(&mut self, loss: f64) -> bool { if loss < self.best_loss { // Improvement: reset counter self.best_loss = loss; self.counter = 0; false } else { // No improvement: increment counter self.counter += 1; self.counter >= self.patience_limit } } } let mut state = PatienceState::new(3); // Test improvement resets counter assert!(!state.update(1.0)); // Best: 1.0, counter: 0 assert!(!state.update(0.9)); // Best: 0.9, counter: 0 (reset) assert!(!state.update(0.8)); // Best: 0.8, counter: 0 (reset) // Test no improvement increments counter assert!(!state.update(0.9)); // No improvement, counter: 1 assert!(!state.update(1.0)); // No improvement, counter: 2 assert!(state.update(1.1)); // No improvement, counter: 3 -> trigger stop // Test partial improvement let mut state2 = PatienceState::new(5); assert!(!state2.update(1.0)); // Best: 1.0 assert!(!state2.update(1.1)); // Counter: 1 assert!(!state2.update(1.2)); // Counter: 2 assert!(!state2.update(0.95)); // Best: 0.95, counter: 0 (reset) assert!(!state2.update(1.0)); // Counter: 1 assert!(!state2.update(1.0)); // Counter: 2 assert!(!state2.update(1.0)); // Counter: 3 assert!(!state2.update(1.0)); // Counter: 4 assert!(state2.update(1.0)); // Counter: 5 -> trigger stop } /// Test best loss tracking with floating point precision #[test] fn test_best_loss_tracking_precision() { let mut best_loss = f64::INFINITY; let epsilon = 1e-10; // Test significant improvement let new_loss_1 = 1.0; assert!(new_loss_1 < best_loss); best_loss = new_loss_1; // Test tiny improvement (below epsilon) let new_loss_2 = best_loss - epsilon / 10.0; assert!(new_loss_2 < best_loss, "Even tiny improvements should be detected"); best_loss = new_loss_2; // Test no improvement (within epsilon) let new_loss_3 = best_loss + epsilon / 10.0; assert!(new_loss_3 > best_loss, "Tiny increases should be detected"); } /// Test epoch history storage #[test] fn test_epoch_history_storage() { #[derive(Debug, Clone)] struct EpochHistory { epoch: usize, train_loss: f64, val_loss: f64, metrics: HashMap, } let mut history = Vec::new(); // Simulate 10 epochs for epoch in 0..10 { let mut metrics = HashMap::new(); metrics.insert("q_value".to_string(), 0.5 + epoch as f64 * 0.05); metrics.insert("grad_norm".to_string(), 1.0 - epoch as f64 * 0.05); history.push(EpochHistory { epoch, train_loss: 1.0 - epoch as f64 * 0.08, val_loss: 1.05 - epoch as f64 * 0.09, metrics, }); } // Verify history length assert_eq!(history.len(), 10); // Verify chronological order for i in 1..history.len() { assert!(history[i].epoch > history[i-1].epoch); } // Verify metrics are stored correctly assert_eq!(history[5].metrics.get("q_value"), Some(&0.75)); assert!(history[5].metrics.contains_key("grad_norm")); } // ============================================================================ // TEST CATEGORY 3: STRATEGY TESTS // ============================================================================ /// Test plateau detection algorithm accuracy #[test] fn test_plateau_detection_accuracy() { // Case 1: Clear plateau (constant loss) let plateau_losses = vec![0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]; assert!(is_plateau(&plateau_losses, 4, 0.1), "Should detect constant loss as plateau"); // Case 2: Slight improvement (not a plateau) let improving_losses = vec![0.5, 0.48, 0.46, 0.44, 0.42, 0.40, 0.38, 0.36]; assert!(!is_plateau(&improving_losses, 4, 2.0), "Should not detect improving loss as plateau"); // Case 3: Noisy plateau (small oscillations around mean) let noisy_plateau = vec![0.50, 0.51, 0.49, 0.50, 0.51, 0.49, 0.50, 0.51]; assert!(is_plateau(&noisy_plateau, 4, 2.0), "Should detect noisy plateau"); // Case 4: Degrading performance (getting worse) let degrading_losses = vec![0.5, 0.52, 0.54, 0.56, 0.58, 0.60, 0.62, 0.64]; assert!(is_plateau(°rading_losses, 4, 2.0), "Should detect degrading performance as plateau (no improvement)"); } fn is_plateau(losses: &[f64], window: usize, min_improvement_pct: f64) -> bool { if losses.len() < window * 2 { return false; } let recent_avg = losses[losses.len()-window..].iter().sum::() / window as f64; let older_avg = losses[losses.len()-2*window..losses.len()-window].iter().sum::() / window as f64; let improvement_pct = if older_avg > 0.0 { (older_avg - recent_avg) / older_avg * 100.0 } else { 0.0 }; improvement_pct < min_improvement_pct } /// Test median pruner correctness #[test] fn test_median_pruner_strategy() { // Simulate 5 concurrent trials let trial_losses = vec![ vec![1.0, 0.9, 0.8, 0.7], // Trial 0: Good vec![1.0, 0.95, 0.92, 0.90], // Trial 1: Below median vec![1.0, 0.85, 0.75, 0.65], // Trial 2: Best vec![1.0, 0.98, 0.97, 0.96], // Trial 3: Worst vec![1.0, 0.93, 0.88, 0.83], // Trial 4: Median ]; // At epoch 3, calculate median loss let epoch_3_losses: Vec = trial_losses.iter().map(|t| t[3]).collect(); let mut sorted_losses = epoch_3_losses.clone(); sorted_losses.sort_by(|a, b| a.partial_cmp(b).unwrap()); let median = sorted_losses[sorted_losses.len() / 2]; assert_eq!(median, 0.83, "Median should be trial 4's loss"); // Trials below median should be pruned for (i, trial) in trial_losses.iter().enumerate() { let loss = trial[3]; if loss > median { // Trial 1 (0.90) and Trial 3 (0.96) should be pruned assert!(i == 1 || i == 3, "Trial {} with loss {} should be pruned", i, loss); } } } /// Test percentile pruner correctness #[test] fn test_percentile_pruner_strategy() { // Simulate 10 trials let trial_losses = vec![ 0.95, 0.90, 0.85, 0.80, 0.75, 0.70, 0.65, 0.60, 0.55, 0.50 ]; // Keep top 50% (prune bottom 50%) let percentile = 50; let cutoff_index = trial_losses.len() * percentile / 100; let mut sorted = trial_losses.clone(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); let cutoff_value = sorted[cutoff_index]; // Trials with loss > 0.75 should be pruned let pruned: Vec = trial_losses.iter().filter(|&&l| l > cutoff_value).copied().collect(); let kept: Vec = trial_losses.iter().filter(|&&l| l <= cutoff_value).copied().collect(); assert_eq!(kept.len(), 5, "Should keep top 50%"); assert_eq!(pruned.len(), 5, "Should prune bottom 50%"); } /// Test successive halving algorithm #[test] fn test_successive_halving_strategy() { let initial_trials = 16; let min_trials = 1; let epochs_per_rung = 10; // Successive halving schedule let mut trials_remaining = initial_trials; let mut rung = 0; let mut schedule = Vec::new(); while trials_remaining > min_trials { schedule.push((rung, trials_remaining, rung * epochs_per_rung)); trials_remaining /= 2; rung += 1; } schedule.push((rung, trials_remaining, rung * epochs_per_rung)); // Verify schedule assert_eq!(schedule.len(), 5, "Should have 5 rungs"); assert_eq!(schedule[0], (0, 16, 0)); // Rung 0: 16 trials, 0 epochs assert_eq!(schedule[1], (1, 8, 10)); // Rung 1: 8 trials, 10 epochs assert_eq!(schedule[2], (2, 4, 20)); // Rung 2: 4 trials, 20 epochs assert_eq!(schedule[3], (3, 2, 30)); // Rung 3: 2 trials, 30 epochs assert_eq!(schedule[4], (4, 1, 40)); // Rung 4: 1 trial, 40 epochs } /// Test hyperband bracket logic #[test] fn test_hyperband_bracket_logic() { let max_iter = 81; // Maximum epochs per trial let eta = 3; // Reduction factor // Calculate number of brackets (s_max + 1) let s_max = (max_iter as f64).log(eta as f64).floor() as usize; assert_eq!(s_max, 4, "Should have 5 brackets (s_max=4)"); // For each bracket, calculate resource allocation for s in (0..=s_max).rev() { let n = ((s_max + 1) as f64 / (s + 1) as f64 * eta.pow(s as u32) as f64).ceil() as usize; let r = max_iter / eta.pow(s as u32) as usize; info!(bracket = s, trials = n, epochs_per_trial = r, "Bracket resource allocation"); // Verify reasonable resource allocation assert!(n >= 1, "Should have at least 1 trial"); assert!(r >= 1, "Should have at least 1 epoch"); assert!(n * r <= max_iter * eta.pow(s_max as u32), "Total resources should be bounded"); } } // ============================================================================ // TEST CATEGORY 4: EDGE CASES // ============================================================================ /// Test early stopping with zero variance data (constant loss) #[test] fn test_early_stopping_zero_variance() { let constant_losses = vec![0.5; 100]; // 100 epochs, all same loss let window = 10; let min_improvement = 0.1; // 0.1% improvement required // Should detect plateau immediately after 2*window epochs assert!(constant_losses.len() >= window * 2); assert!(is_plateau(&constant_losses[..window*2], window, min_improvement)); } /// Test early stopping with NaN loss #[test] fn test_early_stopping_nan_loss() { let losses = vec![1.0, 0.9, 0.8, f64::NAN, 0.6]; // Check if NaN is present let has_nan = losses.iter().any(|&l| l.is_nan()); assert!(has_nan, "Should detect NaN in loss history"); // NaN should cause immediate failure (not counted as improvement) for &loss in &losses { if loss.is_nan() { // In production, this would trigger trial failure assert!(loss.is_nan()); } } } /// Test early stopping with Inf loss #[test] fn test_early_stopping_inf_loss() { let losses = vec![1.0, 0.9, 0.8, f64::INFINITY, 0.6]; // Check if Inf is present let has_inf = losses.iter().any(|&l| l.is_infinite()); assert!(has_inf, "Should detect Inf in loss history"); // Inf should cause immediate failure for &loss in &losses { if loss.is_infinite() { assert!(loss.is_infinite()); } } } /// Test early stopping with single epoch training #[test] fn test_early_stopping_single_epoch() { let config = crate::trainers::dqn::DQNHyperparameters { epochs: 1, min_epochs_before_stopping: 50, ..Default::default() }; // Should not trigger early stopping (epochs < min_epochs) assert!(config.epochs < config.min_epochs_before_stopping); } /// Test early stopping with very small improvements #[test] fn test_early_stopping_tiny_improvements() { let epsilon = 1e-8; let losses = vec![ 1.0, 1.0 - epsilon, 1.0 - 2.0 * epsilon, 1.0 - 3.0 * epsilon, 1.0 - 4.0 * epsilon, ]; // Calculate improvement percentage let window = 2; let recent_avg = losses[losses.len()-window..].iter().sum::() / window as f64; let older_avg = losses[losses.len()-2*window..losses.len()-window].iter().sum::() / window as f64; let improvement_pct = ((older_avg - recent_avg) / older_avg * 100.0).abs(); // Improvement should be very small (< 0.001%) assert!(improvement_pct < 0.001, "Improvement should be negligible"); } /// Test early stopping with large batch size edge case #[test] fn test_early_stopping_large_batch_size() { // Test that batch size doesn't affect early stopping logic let config_small_batch = crate::trainers::dqn::DQNHyperparameters { batch_size: 32, ..Default::default() }; let config_large_batch = crate::trainers::dqn::DQNHyperparameters { batch_size: 230, // Max for RTX 3050 Ti ..Default::default() }; // Early stopping parameters should be independent of batch size assert_eq!(config_small_batch.min_epochs_before_stopping, config_large_batch.min_epochs_before_stopping); assert_eq!(config_small_batch.plateau_window, config_large_batch.plateau_window); } /// Test early stopping with extreme learning rates #[test] fn test_early_stopping_extreme_learning_rates() { // Very low learning rate (slow convergence) let config_low_lr = crate::trainers::dqn::DQNHyperparameters { learning_rate: 1e-6, ..Default::default() }; // Very high learning rate (unstable training) let config_high_lr = crate::trainers::dqn::DQNHyperparameters { learning_rate: 1e-1, ..Default::default() }; // Early stopping should be same regardless of LR assert_eq!(config_low_lr.plateau_window, config_high_lr.plateau_window); } // ============================================================================ // HELPER FUNCTIONS // ============================================================================ /// Helper: Calculate improvement percentage between two values fn calculate_improvement_pct(old_value: f64, new_value: f64) -> f64 { if old_value > 0.0 { (old_value - new_value) / old_value * 100.0 } else { 0.0 } } /// Helper: Check if value is valid (not NaN or Inf) fn is_valid_loss(loss: f64) -> bool { !loss.is_nan() && !loss.is_infinite() } #[test] fn test_helpers() { assert_eq!(calculate_improvement_pct(1.0, 0.9), 10.0); assert_eq!(calculate_improvement_pct(1.0, 1.1), -10.0); assert_eq!(calculate_improvement_pct(0.0, 0.5), 0.0); assert!(is_valid_loss(1.0)); assert!(is_valid_loss(0.0)); assert!(!is_valid_loss(f64::NAN)); assert!(!is_valid_loss(f64::INFINITY)); }