//! DQN Extended Validation System Tests //! //! Test-Driven Development (TDD) suite for comprehensive DQN validation. //! This suite ensures training validates: //! - Overfitting detection (train/val divergence) //! - Action distribution monitoring (HOLD collapse) //! - Q-value stability (explosion detection) //! - Policy entropy (exploration collapse) //! - Early stopping (5 failure modes) //! - Production readiness (profitability, diversity, stability) use ml::trainers::validation_metrics::{EarlyStopCriteria, ValidationMetrics}; #[cfg(test)] mod validation_metrics_tests { use super::*; /// Module 1: Validation Metrics (8 tests) #[test] fn test_validation_loss_calculated_on_holdout() { // Test that validation metrics distinguish between train and val loss let metrics = ValidationMetrics::new( 1, 1.5, // train_loss 2.0, // val_loss (higher = using separate holdout set) 5.0, 0.5, [0.3, 0.3, 0.4], 0.8, 0.55, 1.8, 0.5, ); assert!( metrics.val_loss > metrics.train_loss, "Validation loss should be tracked separately" ); } #[test] fn test_train_val_loss_divergence_detected() { // Test detection when training loss decreases but validation loss increases let history = vec![ ValidationMetrics::new(1, 2.0, 2.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(2, 1.8, 2.1, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(3, 1.6, 2.2, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(4, 1.4, 2.3, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(5, 1.2, 2.4, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ]; let current = ValidationMetrics::new(6, 1.0, 2.5, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5); assert!( current.is_overfitting(&history), "Should detect train↓ val↑ divergence" ); } #[test] fn test_q_value_distribution_tracked() { // Test Q-value mean/std tracked per epoch let metrics = ValidationMetrics::new( 10, 1.0, 2.0, 15.5, // q_value_mean 3.2, // q_value_std [0.3, 0.3, 0.4], 0.8, 0.55, 1.8, 0.5, ); assert_eq!(metrics.q_value_mean, 15.5, "Q-value mean should be tracked"); assert_eq!(metrics.q_value_std, 3.2, "Q-value std should be tracked"); } #[test] fn test_action_distribution_tracked() { // Test action distribution [BUY%, SELL%, HOLD%] tracked per epoch let metrics = ValidationMetrics::new( 10, 1.0, 2.0, 5.0, 0.5, [0.25, 0.35, 0.40], // BUY=25%, SELL=35%, HOLD=40% 0.8, 0.55, 1.8, 0.5, ); assert_eq!( metrics.action_distribution[0], 0.25, "BUY% should be tracked" ); assert_eq!( metrics.action_distribution[1], 0.35, "SELL% should be tracked" ); assert_eq!( metrics.action_distribution[2], 0.40, "HOLD% should be tracked" ); } #[test] fn test_policy_entropy_tracked() { // Test Shannon entropy H = -Σ p_i log(p_i) tracked per epoch let metrics = ValidationMetrics::new( 10, 1.0, 2.0, 5.0, 0.5, [0.3, 0.3, 0.4], 0.95, // policy_entropy 0.55, 1.8, 0.5, ); assert_eq!( metrics.policy_entropy, 0.95, "Policy entropy should be tracked" ); assert!( metrics.policy_entropy > 0.0 && metrics.policy_entropy <= 1.099, "Entropy in valid range" ); } #[test] fn test_win_rate_estimated() { // Test win rate (% profitable actions) estimated on validation set let metrics = ValidationMetrics::new( 10, 1.0, 2.0, 5.0, 0.5, [0.3, 0.3, 0.4], 0.8, 0.62, // win_rate = 62% 1.8, 0.5, ); assert_eq!(metrics.win_rate, 0.62, "Win rate should be estimated"); assert!( metrics.win_rate >= 0.0 && metrics.win_rate <= 1.0, "Win rate in [0, 1]" ); } #[test] fn test_sharpe_ratio_estimated() { // Test Sharpe ratio (reward mean / reward std) estimated on validation set let metrics = ValidationMetrics::new( 10, 1.0, 2.0, 5.0, 0.5, [0.3, 0.3, 0.4], 0.8, 0.55, 2.3, // sharpe_ratio 0.5, ); assert_eq!( metrics.sharpe_ratio, 2.3, "Sharpe ratio should be estimated" ); } #[test] fn test_metrics_saved_to_checkpoint() { // Test all validation metrics can be serialized (for checkpoint metadata) let metrics = ValidationMetrics::new(10, 1.0, 2.0, 5.0, 0.5, [0.3, 0.3, 0.4], 0.8, 0.55, 1.8, 0.5); // Test serialization let json = serde_json::to_string(&metrics).expect("Should serialize to JSON"); assert!(json.contains("epoch"), "JSON should contain epoch"); assert!(json.contains("val_loss"), "JSON should contain val_loss"); // Test deserialization let _deserialized: ValidationMetrics = serde_json::from_str(&json).expect("Should deserialize from JSON"); } } #[cfg(test)] mod early_stopping_tests { use super::*; /// Module 2: Early Stopping (6 tests) #[test] fn test_stop_when_val_loss_increases_5_epochs() { // Test early stopping triggers when validation loss increases for 5 epochs let history = vec![ ValidationMetrics::new(1, 1.0, 2.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(2, 1.0, 2.1, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(3, 1.0, 2.2, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(4, 1.0, 2.3, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(5, 1.0, 2.4, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ]; let current = ValidationMetrics::new(6, 1.0, 2.5, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5); let criteria = EarlyStopCriteria::ValidationLossIncrease { patience: 5 }; let result = criteria.should_stop(¤t, &history); assert!(result.is_some(), "Should trigger early stopping"); assert!( result.unwrap().contains("Validation loss increased"), "Should cite validation loss" ); } #[test] fn test_stop_when_hold_over_90_percent() { // Test early stopping when HOLD action > 90% for 10 epochs let history: Vec = (1..=10) .map(|i| { ValidationMetrics::new( i, 1.0, 2.0, 1.0, 0.1, [0.05, 0.05, 0.92], // HOLD > 90% 0.5, 0.6, 1.8, 0.5, ) }) .collect(); let current = ValidationMetrics::new( 11, 1.0, 2.0, 1.0, 0.1, [0.05, 0.05, 0.92], 0.5, 0.6, 1.8, 0.5, ); let criteria = EarlyStopCriteria::ActionCollapse { hold_threshold: 0.9, patience: 10, }; let result = criteria.should_stop(¤t, &history); assert!( result.is_some(), "Should trigger early stopping due to action collapse" ); assert!( result.unwrap().contains("Action collapse"), "Should cite action collapse" ); } #[test] fn test_stop_when_entropy_below_threshold() { // Test early stopping when policy entropy < 0.1 for 10 epochs let history: Vec = (1..=10) .map(|i| { ValidationMetrics::new( i, 1.0, 2.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.05, // entropy < 0.1 0.6, 1.8, 0.5, ) }) .collect(); let current = ValidationMetrics::new(11, 1.0, 2.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.05, 0.6, 1.8, 0.5); let criteria = EarlyStopCriteria::EntropyCollapse { threshold: 0.1, patience: 10, }; let result = criteria.should_stop(¤t, &history); assert!( result.is_some(), "Should trigger early stopping due to entropy collapse" ); assert!( result.unwrap().contains("Entropy collapse"), "Should cite entropy collapse" ); } #[test] fn test_stop_when_q_values_explode() { // Test early stopping when Q-values > 10,000 let history = vec![]; let current = ValidationMetrics::new( 10, 1.0, 2.0, 15_000.0, // Q-value explosion 1.0, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5, ); let criteria = EarlyStopCriteria::QValueExplosion { threshold: 10_000.0, }; let result = criteria.should_stop(¤t, &history); assert!( result.is_some(), "Should trigger early stopping due to Q-value explosion" ); assert!( result.unwrap().contains("Q-value explosion"), "Should cite Q-value explosion" ); } #[test] fn test_stop_when_gradients_explode() { // Test early stopping when gradient norm > 100 let history = vec![]; let current = ValidationMetrics::new( 10, 1.0, 2.0, 5.0, 0.5, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 150.0, // gradient_norm > 100 ); let criteria = EarlyStopCriteria::GradientExplosion { threshold: 100.0 }; let result = criteria.should_stop(¤t, &history); assert!( result.is_some(), "Should trigger early stopping due to gradient explosion" ); assert!( result.unwrap().contains("Gradient explosion"), "Should cite gradient explosion" ); } #[test] fn test_best_model_saved_before_stopping() { // Test that early stopping system provides stopping reason (model save is trainer responsibility) let history = vec![ ValidationMetrics::new(1, 2.0, 2.5, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(2, 1.8, 2.3, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(3, 1.5, 2.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), // Best val_loss=2.0 ]; let current = ValidationMetrics::new(4, 1.0, 2.5, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5); // Find epoch with best validation loss let best_epoch = history .iter() .min_by(|a, b| a.val_loss.partial_cmp(&b.val_loss).unwrap()) .map(|m| m.epoch); assert_eq!(best_epoch, Some(3), "Should identify epoch 3 as best"); } } #[cfg(test)] mod overfitting_detection_tests { use super::*; /// Module 3: Overfitting Detection (5 tests) #[test] fn test_train_val_ratio_over_2_is_overfitting() { // Test overfitting detected when train_loss / val_loss > 2.0 let history = vec![]; let current = ValidationMetrics::new( 10, 1.0, // train_loss 3.5, // val_loss (ratio = 3.5) 5.0, 0.5, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5, ); assert!( current.is_overfitting(&history), "Should detect overfitting from high train/val ratio" ); assert!( current.train_val_ratio() > 2.0, "Train/val ratio should exceed 2.0" ); } #[test] fn test_val_loss_increasing_train_decreasing() { // Test overfitting when train_loss trends down but val_loss trends up over 5 epochs let history = vec![ ValidationMetrics::new(1, 2.0, 2.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(2, 1.8, 2.2, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(3, 1.6, 2.4, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(4, 1.4, 2.6, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(5, 1.2, 2.8, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ]; let current = ValidationMetrics::new(6, 1.0, 3.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5); assert!( current.is_overfitting(&history), "Should detect train↓ val↑ divergence" ); } #[test] fn test_action_distribution_validation_mismatch() { // Test that action distribution differences can be detected let train_dist = [0.3f32, 0.3, 0.4]; let val_dist = [0.1f32, 0.1, 0.8]; // Significant mismatch let mismatch: f32 = train_dist .iter() .zip(val_dist.iter()) .map(|(t, v)| (t - v).abs()) .sum(); assert!( mismatch > 0.3, "Should detect > 30% action distribution mismatch" ); } #[test] fn test_q_values_out_of_range_on_validation() { // Test Q-value divergence detection let train_q = 5.0f32; let val_q = 18.0f32; // 3.6x training Q-values let ratio = val_q / train_q; assert!( ratio > 3.0, "Should detect validation Q-values > 3x training" ); } #[test] fn test_regularization_triggered_on_overfitting() { // Test that overfitting detection provides actionable signal let history = vec![ ValidationMetrics::new(1, 2.0, 2.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(2, 1.5, 2.5, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(3, 1.0, 3.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(4, 0.8, 3.5, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ValidationMetrics::new(5, 0.6, 4.0, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5), ]; let current = ValidationMetrics::new(6, 0.5, 4.5, 1.0, 0.1, [0.3, 0.3, 0.4], 0.5, 0.6, 1.8, 0.5); if current.is_overfitting(&history) { // Regularization would be triggered here (logged as warning in trainer) assert!(true, "Overfitting signal triggers regularization warning"); } } } #[cfg(test)] mod production_readiness_tests { use super::*; /// Module 4: Production Readiness (6 tests) #[test] fn test_model_passes_profitability_check() { // Test model passes profitability check (Sharpe > 1.5, Win Rate > 50%) let metrics = ValidationMetrics::new( 50, 0.8, 1.5, 8.0, 1.2, [0.3, 0.3, 0.4], 0.9, 0.58, // win_rate > 0.5 ✓ 2.1, // sharpe > 1.5 ✓ 0.5, ); assert!(metrics.win_rate > 0.5, "Win rate should exceed 50%"); assert!(metrics.sharpe_ratio > 1.5, "Sharpe ratio should exceed 1.5"); } #[test] fn test_model_passes_action_diversity_check() { // Test model passes diversity check (HOLD < 70%) let metrics = ValidationMetrics::new( 50, 0.8, 1.5, 8.0, 1.2, [0.3, 0.35, 0.35], // HOLD = 35% < 70% ✓ 0.9, 0.58, 2.1, 0.5, ); assert!( metrics.action_distribution[2] < 0.7, "HOLD % should be < 70%" ); } #[test] fn test_model_passes_stability_check() { // Test model passes stability check (Q-values finite and bounded) let metrics = ValidationMetrics::new( 50, 0.8, 1.5, 12.5, // |q_mean| < 1000 ✓ 2.0, [0.3, 0.3, 0.4], 0.9, 0.58, 2.1, 0.5, ); assert!( metrics.q_value_mean.is_finite(), "Q-values should be finite" ); assert!( metrics.q_value_mean.abs() < 1000.0, "Q-values should be bounded" ); } #[test] fn test_model_passes_performance_check() { // Test inference latency check (< 1ms is handled by separate benchmarks) // This test validates that metrics don't indicate training instability let metrics = ValidationMetrics::new( 50, 0.8, 1.5, 8.0, 1.2, [0.3, 0.3, 0.4], 0.9, 0.58, 2.1, 0.5, // gradient_norm < 100 (stable) ); assert!( !metrics.has_gradient_explosion(), "Gradients should be stable" ); } #[test] fn test_model_passes_robustness_check() { // Test model handles edge cases (NaN detection) let metrics = ValidationMetrics::new(50, 0.8, 1.5, 8.0, 1.2, [0.3, 0.3, 0.4], 0.9, 0.58, 2.1, 0.5); // Verify no NaN values in metrics assert!(!metrics.train_loss.is_nan(), "train_loss should not be NaN"); assert!(!metrics.val_loss.is_nan(), "val_loss should not be NaN"); assert!( !metrics.q_value_mean.is_nan(), "q_value_mean should not be NaN" ); assert!( !metrics.policy_entropy.is_nan(), "policy_entropy should not be NaN" ); } #[test] fn test_all_checks_bundled_in_is_production_ready() { // Test comprehensive production readiness check let good_metrics = ValidationMetrics::new( 100, 0.9, // train_loss 1.2, // val_loss < 5.0 ✓ 10.0, // q_value_mean (finite, |x| < 1000) ✓ 2.0, // q_value_std [0.32, 0.35, 0.33], // HOLD = 33% < 70% ✓ 0.95, // policy_entropy > 0.1 ✓ 0.62, // win_rate > 0.5 ✓ 2.5, // sharpe_ratio > 1.5 ✓ 0.8, // gradient_norm ); assert!( good_metrics.is_production_ready(), "Should pass all production criteria" ); // Test failure case: high HOLD let bad_metrics = ValidationMetrics::new( 100, 0.9, 1.2, 10.0, 2.0, [0.1, 0.1, 0.8], // HOLD = 80% > 70% ✗ 0.95, 0.62, 2.5, 0.8, ); assert!( !bad_metrics.is_production_ready(), "Should fail due to high HOLD %" ); } }