//! DQN Hyperopt Multi-Objective Function Tests //! //! This module tests the multi-objective optimization function that combines: //! - Reward component (80% weight): Primary metric - episode reward //! - Diversity penalty (10% weight): Action distribution entropy //! - Stability penalty (5% weight): Q-value health and gradient norms //! - Completion penalty (5% weight): Epochs completed vs target //! //! ## Test Philosophy //! //! These tests validate the objective function BEFORE implementation (TDD). //! They will fail initially and pass once the implementation is complete. //! //! ## Expected Behavior //! //! 1. **Hard Constraints** (trial rejection → objective = +1e6): //! - Q-value floor: > -10.0 //! - Training loss ceiling: < 1000.0 //! - Minimum epochs: ≥ 20 //! //! 2. **Soft Penalties** (weighted composite): //! - Reward: -avg_episode_reward (negated for minimization) //! - Diversity: Entropy-based action distribution penalty //! - Stability: Q-value range + gradient norm penalties //! - Completion: Penalty for early stopping //! //! ## Note on DQNMetrics Structure //! //! This test file uses the PROPOSED DQNMetrics structure with added fields: //! - `action_distribution: [f64; 3]` - [BUY%, SELL%, HOLD%] //! - `avg_gradient_norm: f64` - Average gradient norm during training //! //! These fields need to be added to `ml/src/hyperopt/adapters/dqn.rs` before //! the tests can compile. See `DQN_HYPEROPT_OBJECTIVE_ANALYSIS.md` Section 6. // NOTE: This will not compile until DQNMetrics is extended with: // - action_distribution: [f64; 3] // - avg_gradient_norm: f64 // // Temporary struct definition for test-driven development #[derive(Debug, Clone)] struct DQNMetrics { pub train_loss: f64, pub val_loss: f64, pub avg_q_value: f64, pub final_epsilon: f64, pub epochs_completed: usize, pub avg_episode_reward: f64, pub action_distribution: [f64; 3], // ✅ NEW: [BUY%, SELL%, HOLD%] pub avg_gradient_norm: f64, // ✅ NEW: Average gradient norm } // ============================================================================ // Test Helpers // ============================================================================ /// Create test DQNMetrics with default "healthy" values fn create_healthy_metrics() -> DQNMetrics { DQNMetrics { train_loss: 0.1, val_loss: 0.15, avg_q_value: 5.0, // Healthy Q-value (0.5 to 10.0) final_epsilon: 0.01, epochs_completed: 50, // Full training avg_episode_reward: 0.001, // Positive reward action_distribution: [0.33, 0.33, 0.34], // Balanced actions avg_gradient_norm: 2.0, // Healthy gradient norm (< 10.0) } } /// Create test DQNMetrics with Q-value collapse fn create_q_collapse_metrics() -> DQNMetrics { DQNMetrics { train_loss: 0.1, val_loss: 0.15, avg_q_value: -681.92, // CATASTROPHIC collapse final_epsilon: 0.01, epochs_completed: 10, // Early stop avg_episode_reward: 0.001, action_distribution: [0.994, 0.003, 0.003], // Degenerate (99.4% BUY) avg_gradient_norm: 0.01, } } /// Create test DQNMetrics with high loss (numerical explosion) fn create_high_loss_metrics() -> DQNMetrics { DQNMetrics { train_loss: 5000.0, // EXPLOSION val_loss: 5500.0, avg_q_value: 5.0, final_epsilon: 0.01, epochs_completed: 5, // Early stop due to explosion avg_episode_reward: 0.0001, action_distribution: [0.50, 0.30, 0.20], avg_gradient_norm: 150.0, // Gradient explosion } } /// Create test DQNMetrics with early stopping fn create_early_stop_metrics() -> DQNMetrics { DQNMetrics { train_loss: 0.1, val_loss: 0.15, avg_q_value: 5.0, final_epsilon: 0.50, // High epsilon (not enough decay) epochs_completed: 15, // Stopped at 15/50 epochs avg_episode_reward: 0.0005, action_distribution: [0.40, 0.35, 0.25], avg_gradient_norm: 3.0, } } /// Create test DQNMetrics with extreme HOLD bias fn create_hold_bias_metrics() -> DQNMetrics { DQNMetrics { train_loss: 0.05, val_loss: 0.06, avg_q_value: 3.0, final_epsilon: 0.01, epochs_completed: 50, avg_episode_reward: 0.0003, // Low reward (agent not acting) action_distribution: [0.05, 0.05, 0.90], // 90% HOLD avg_gradient_norm: 1.5, } } // ============================================================================ // Component Tests (6 tests) // ============================================================================ #[test] fn test_reward_component_normalization() { // Test that reward component is correctly negated and scaled let metrics = create_healthy_metrics(); // Reward component should be -avg_episode_reward // For avg_episode_reward = 0.001, reward_component = -0.001 let reward_component = calculate_reward_component(&metrics); assert_eq!( reward_component, -0.001, "Reward component should be negated avg_episode_reward" ); // Test with higher reward let high_reward_metrics = DQNMetrics { avg_episode_reward: 0.005, ..create_healthy_metrics() }; let high_reward_component = calculate_reward_component(&high_reward_metrics); assert_eq!( high_reward_component, -0.005, "Higher reward should result in more negative component (better for minimization)" ); } #[test] fn test_diversity_penalty_balanced_actions() { // Test that balanced action distribution (33/33/33) results in zero/low penalty let metrics = create_healthy_metrics(); let penalty = calculate_diversity_penalty(&metrics); // Balanced distribution should have entropy close to max_entropy = ln(3) ≈ 1.099 // Diversity score = entropy / max_entropy ≈ 1.0 // Penalty = 1.0 - diversity_score ≈ 0.0 assert!( penalty < 0.05, "Balanced action distribution should have low penalty: {}", penalty ); } #[test] fn test_diversity_penalty_extreme_hold() { // Test that extreme HOLD bias (5/5/90) results in high penalty let metrics = create_hold_bias_metrics(); let penalty = calculate_diversity_penalty(&metrics); // Degenerate distribution (90% HOLD) should have low entropy // Diversity score should be low // Penalty should be high (close to 1.0) assert!( penalty > 0.5, "Extreme HOLD bias should have high penalty: {}", penalty ); } #[test] fn test_stability_penalty_good_qvalues() { // Test that Q-values in healthy range (0.5 to 10.0) result in zero penalty let metrics = create_healthy_metrics(); let penalty = calculate_stability_penalty(&metrics); // Q-value = 5.0 is in healthy range [0.5, 10.0] // Gradient norm = 2.0 is below threshold (10.0) // Penalty should be zero or very low assert!( penalty < 0.1, "Healthy Q-values and gradients should have low penalty: {}", penalty ); } #[test] fn test_stability_penalty_gradient_explosion() { // Test that gradient explosion (grad_norm > 10) results in high penalty let metrics = create_high_loss_metrics(); let penalty = calculate_stability_penalty(&metrics); // Gradient norm = 150.0 >> 10.0 threshold // Should result in significant penalty assert!( penalty > 5.0, "Gradient explosion should have high penalty: {}", penalty ); } #[test] fn test_completion_penalty_early_stop() { // Test that early stopping (30/100 epochs) results in penalty let metrics = create_early_stop_metrics(); let penalty = calculate_completion_penalty(&metrics, 50); // Completed 15/50 epochs = 30% completion // Should have penalty proportional to missing epochs assert!( penalty > 0.5, "Early stopping should have significant penalty: {}", penalty ); } // ============================================================================ // Integration Tests (3 tests) // ============================================================================ #[test] fn test_multiobjective_optimal_trial() { // Test that a "perfect" trial gets a good (low) objective score let metrics = create_healthy_metrics(); let objective = calculate_multiobjective(&metrics, 50); // Expected calculation: // reward_component = -0.001 // diversity_penalty = ~0.0 (balanced actions) // stability_penalty = ~0.0 (healthy Q-values) // completion_penalty = ~0.0 (50/50 epochs) // // objective = 0.8 * (-0.001) + 0.1 * 0.0 + 0.05 * 0.0 + 0.05 * 0.0 // = -0.0008 assert!( objective < -0.0005, "Optimal trial should have low (negative) objective: {}", objective ); // Verify all constraints pass assert!( !violates_hard_constraints(&metrics), "Optimal trial should not violate hard constraints" ); } #[test] fn test_multiobjective_broken_trial() { // Test that a broken trial (99% HOLD) gets a bad (high) objective score let metrics = create_hold_bias_metrics(); let objective = calculate_multiobjective(&metrics, 50); // Expected calculation: // reward_component = -0.0003 (low reward due to inaction) // diversity_penalty = ~0.6 (90% HOLD bias) // stability_penalty = ~0.0 (Q-values OK) // completion_penalty = ~0.0 (50/50 epochs) // // objective = 0.8 * (-0.0003) + 0.1 * 0.6 + 0.05 * 0.0 + 0.05 * 0.0 // = -0.00024 + 0.06 // = +0.05976 assert!( objective > 0.03, "Broken trial (HOLD bias) should have high (positive) objective: {}", objective ); } #[test] fn test_weight_sensitivity() { // Test that weight changes affect trial ranking correctly let good_metrics = create_healthy_metrics(); let hold_bias_metrics = create_hold_bias_metrics(); // Calculate objectives with standard weights (reward=80%, diversity=10%, stability=5%, completion=5%) let good_objective = calculate_multiobjective(&good_metrics, 50); let hold_objective = calculate_multiobjective(&hold_bias_metrics, 50); // Good trial should have LOWER objective (minimization) assert!( good_objective < hold_objective, "Good trial (balanced actions) should rank better than HOLD-biased trial: {} vs {}", good_objective, hold_objective ); // Verify ranking is correct let ranking_correct = good_objective < hold_objective; assert!( ranking_correct, "Weight sensitivity failed: good={}, hold={}", good_objective, hold_objective ); } // ============================================================================ // Hard Constraint Tests (3 tests) // ============================================================================ #[test] fn test_hard_constraint_q_value_floor() { // Test that Q-value < -10.0 triggers hard constraint violation let metrics = create_q_collapse_metrics(); let objective = calculate_multiobjective(&metrics, 50); // Hard constraint violation should return penalty = +1e6 assert_eq!( objective, 1e6, "Q-value floor violation should return penalty objective" ); } #[test] fn test_hard_constraint_training_loss_ceiling() { // Test that training loss > 1000.0 triggers hard constraint violation let metrics = create_high_loss_metrics(); let objective = calculate_multiobjective(&metrics, 50); // Hard constraint violation should return penalty = +1e6 assert_eq!( objective, 1e6, "Training loss ceiling violation should return penalty objective" ); } #[test] fn test_hard_constraint_minimum_epochs() { // Test that epochs_completed < 20 triggers hard constraint violation let metrics = DQNMetrics { epochs_completed: 15, // Below minimum of 20 ..create_healthy_metrics() }; let objective = calculate_multiobjective(&metrics, 50); // Hard constraint violation should return penalty = +1e6 assert_eq!( objective, 1e6, "Minimum epochs violation should return penalty objective" ); } // ============================================================================ // Edge Case Tests (3 additional tests) // ============================================================================ #[test] fn test_edge_case_zero_entropy() { // Test that 100% single action (zero entropy) results in maximum penalty let metrics = DQNMetrics { train_loss: 0.1, val_loss: 0.15, avg_q_value: 5.0, final_epsilon: 0.01, epochs_completed: 50, avg_episode_reward: 0.001, action_distribution: [1.0, 0.0, 0.0], // 100% BUY (degenerate) avg_gradient_norm: 2.0, }; let penalty = calculate_diversity_penalty(&metrics); // Zero entropy → diversity_score = 0 → penalty = 1.0 (maximum) assert!( penalty > 0.95, "Zero entropy should have maximum penalty: {}", penalty ); } #[test] fn test_edge_case_q_value_boundaries() { // Test Q-value penalty at exact boundaries (0.5 and 10.0) // Test 1: Q-value = 0.5 (lower boundary of healthy range) let metrics_lower = DQNMetrics { avg_q_value: 0.5, avg_gradient_norm: 2.0, ..create_healthy_metrics() }; let penalty_lower = calculate_stability_penalty(&metrics_lower); assert!( penalty_lower < 0.01, "Q-value at lower boundary (0.5) should have minimal penalty: {}", penalty_lower ); // Test 2: Q-value = 10.0 (upper boundary of healthy range) let metrics_upper = DQNMetrics { avg_q_value: 10.0, avg_gradient_norm: 2.0, ..create_healthy_metrics() }; let penalty_upper = calculate_stability_penalty(&metrics_upper); assert!( penalty_upper < 0.01, "Q-value at upper boundary (10.0) should have minimal penalty: {}", penalty_upper ); // Test 3: Q-value = 0.49 (just below lower boundary) let metrics_below = DQNMetrics { avg_q_value: 0.49, avg_gradient_norm: 2.0, ..create_healthy_metrics() }; let penalty_below = calculate_stability_penalty(&metrics_below); assert!( penalty_below > 0.0, "Q-value below lower boundary (0.49) should have penalty: {}", penalty_below ); // Test 4: Q-value = 10.01 (just above upper boundary) let metrics_above = DQNMetrics { avg_q_value: 10.01, avg_gradient_norm: 2.0, ..create_healthy_metrics() }; let penalty_above = calculate_stability_penalty(&metrics_above); assert!( penalty_above > 0.0, "Q-value above upper boundary (10.01) should have penalty: {}", penalty_above ); } #[test] fn test_edge_case_gradient_norm_threshold() { // Test gradient norm penalty at exact threshold (10.0) // Test 1: grad_norm = 10.0 (at threshold) let metrics_at = DQNMetrics { avg_q_value: 5.0, avg_gradient_norm: 10.0, ..create_healthy_metrics() }; let penalty_at = calculate_stability_penalty(&metrics_at); assert!( penalty_at < 0.1, "Gradient norm at threshold (10.0) should have minimal penalty: {}", penalty_at ); // Test 2: grad_norm = 9.99 (just below threshold) let metrics_below = DQNMetrics { avg_q_value: 5.0, avg_gradient_norm: 9.99, ..create_healthy_metrics() }; let penalty_below = calculate_stability_penalty(&metrics_below); assert!( penalty_below < 0.1, "Gradient norm below threshold (9.99) should have minimal penalty: {}", penalty_below ); // Test 3: grad_norm = 10.01 (just above threshold) let metrics_above = DQNMetrics { avg_q_value: 5.0, avg_gradient_norm: 10.01, ..create_healthy_metrics() }; let penalty_above = calculate_stability_penalty(&metrics_above); assert!( penalty_above > 0.0, "Gradient norm above threshold (10.01) should have penalty: {}", penalty_above ); } // ============================================================================ // Component Helper Functions (NOT IMPLEMENTED YET) // ============================================================================ /// Calculate reward component (negated for minimization) /// /// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation. /// It will be implemented in `ml/src/hyperopt/adapters/dqn.rs` as part of the multi-objective function. fn calculate_reward_component(_metrics: &DQNMetrics) -> f64 { panic!("calculate_reward_component() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs"); } /// Calculate diversity penalty using entropy /// /// Entropy = -Σ(p_i * ln(p_i)) for i in [BUY, SELL, HOLD] /// Max entropy = ln(3) ≈ 1.099 (uniform distribution) /// Diversity score = entropy / max_entropy /// Penalty = 1.0 - diversity_score /// /// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation. fn calculate_diversity_penalty(_metrics: &DQNMetrics) -> f64 { panic!("calculate_diversity_penalty() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs"); } /// Calculate stability penalty from Q-value health and gradient norms /// /// Q-value penalty: /// - If Q < 0.5: penalty = |0.5 - Q| /// - If Q > 10.0: penalty = (Q - 10.0) * 0.1 /// - Else: penalty = 0.0 /// /// Gradient penalty: /// - If grad_norm > 10.0: penalty = (grad_norm - 10.0) * 0.5 /// - Else: penalty = 0.0 /// /// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation. fn calculate_stability_penalty(_metrics: &DQNMetrics) -> f64 { panic!("calculate_stability_penalty() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs"); } /// Calculate completion penalty from epochs completed vs target /// /// Penalty = (target_epochs - completed_epochs) / target_epochs /// /// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation. fn calculate_completion_penalty(_metrics: &DQNMetrics, _target_epochs: usize) -> f64 { panic!("calculate_completion_penalty() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs"); } /// Check if metrics violate any hard constraints /// /// Hard constraints: /// 1. Q-value floor: avg_q_value > -10.0 /// 2. Training loss ceiling: train_loss < 1000.0 /// 3. Minimum epochs: epochs_completed >= 20 /// /// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation. fn violates_hard_constraints(_metrics: &DQNMetrics) -> bool { panic!("violates_hard_constraints() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs"); } /// Calculate multi-objective function /// /// If hard constraints violated: return +1e6 /// Else: return weighted composite of components /// /// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation. fn calculate_multiobjective(_metrics: &DQNMetrics, _target_epochs: usize) -> f64 { panic!("calculate_multiobjective() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs"); }