//! PPO Hyperopt Bounds Validation Tests //! //! Validates that PPO hyperparameter bounds are correctly enforced during //! hyperparameter optimization. Tests cover: //! //! 1. **Basic Bounds Validation**: Each parameter clamped to correct range //! 2. **Extreme Value Handling**: NaN, Inf, -Inf handled gracefully //! 3. **Precision & Roundtrip**: Log-scale accuracy, integer rounding //! 4. **Error Cases**: Invalid parameter counts rejected //! 5. **Integration**: Bounds and param names consistency use ml::hyperopt::adapters::ppo::PPOParams; use ml::hyperopt::traits::ParameterSpace; /// Test that policy learning rate is correctly clamped to [1e-6, 5e-5] #[test] fn test_policy_lr_bounds_clamping() { // Test below minimum (ln of 1e-20 in log space) // When exp'd, this becomes 1e-20, which is way below bounds let params_below = vec![ (1e-20_f64).ln(), // policy_lr: way below 1e-6 (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_below).unwrap(); // After exp, we get 1e-20 (very small but positive) assert!(result.policy_learning_rate > 0.0); assert!(result.policy_learning_rate < 1e-6); // Below minimum bound // Test above maximum (ln of 1e-3 in log space) // When exp'd, this becomes 1e-3, which is above bounds let params_above = vec![ (1e-3_f64).ln(), // policy_lr: above 5e-5 (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_above).unwrap(); assert!(result.policy_learning_rate > 5e-5); // Above maximum bound // Test within bounds let params_valid = vec![ (3e-5_f64).ln(), // policy_lr: within [1e-6, 5e-5] (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_valid).unwrap(); assert!((result.policy_learning_rate - 3e-5).abs() < 1e-10); } /// Test that value learning rate is correctly clamped to [1e-5, 5e-3] #[test] fn test_value_lr_bounds_clamping() { // Test below minimum (ln of 1e-20 in log space) let params_below = vec![ (1e-6_f64).ln(), // policy_lr (1e-20_f64).ln(), // value_lr: way below 1e-5 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_below).unwrap(); assert!(result.value_learning_rate > 0.0); assert!(result.value_learning_rate < 1e-5); // Below minimum bound // Test above maximum (ln of 0.1 in log space) let params_above = vec![ (1e-6_f64).ln(), // policy_lr (0.1_f64).ln(), // value_lr: above 5e-3 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_above).unwrap(); assert!(result.value_learning_rate > 5e-3); // Above maximum bound // Test within bounds let params_valid = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr: within [1e-5, 5e-3] 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_valid).unwrap(); assert!((result.value_learning_rate - 1e-4).abs() < 1e-10); } /// Test that clip epsilon is correctly clamped to [0.1, 0.3] #[test] fn test_clip_epsilon_bounds_clamping() { // Test below minimum (should clamp to 0.1) let params_below = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr -0.5, // clip_epsilon: below 0.1 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_below).unwrap(); assert_eq!(result.clip_epsilon, 0.1, "Clip epsilon should clamp to 0.1"); // Test above maximum (should clamp to 0.3) let params_above = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 5.0, // clip_epsilon: above 0.3 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_above).unwrap(); assert_eq!(result.clip_epsilon, 0.3, "Clip epsilon should clamp to 0.3"); // Test within bounds let params_valid = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon: within [0.1, 0.3] 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_valid).unwrap(); assert_eq!(result.clip_epsilon, 0.2); } /// Test that value loss coefficient is correctly clamped to [0.5, 2.0] #[test] fn test_value_loss_coeff_bounds_clamping() { // Test below minimum (should clamp to 0.5) let params_below = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon -1.0, // value_loss_coeff: below 0.5 (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_below).unwrap(); assert_eq!( result.value_loss_coeff, 0.5, "Value loss coeff should clamp to 0.5" ); // Test above maximum (should clamp to 2.0) let params_above = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 10.0, // value_loss_coeff: above 2.0 (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_above).unwrap(); assert_eq!( result.value_loss_coeff, 2.0, "Value loss coeff should clamp to 2.0" ); // Test within bounds let params_valid = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff: within [0.5, 2.0] (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size ]; let result = PPOParams::from_continuous(¶ms_valid).unwrap(); assert_eq!(result.value_loss_coeff, 1.0); } /// Test that minibatch size is correctly clamped to [64, 230] #[test] fn test_minibatch_size_bounds_clamping() { // Test below minimum (should clamp to 64) let params_below = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 10.0, // minibatch_size: below 64 ]; let result = PPOParams::from_continuous(¶ms_below).unwrap(); assert_eq!( result.minibatch_size, 64, "Minibatch size should clamp to 64" ); // Test above maximum (should clamp to 230) let params_above = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 500.0, // minibatch_size: above 230 ]; let result = PPOParams::from_continuous(¶ms_above).unwrap(); assert_eq!( result.minibatch_size, 230, "Minibatch size should clamp to 230" ); // Test within bounds let params_valid = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size: within [64, 230] ]; let result = PPOParams::from_continuous(¶ms_valid).unwrap(); assert_eq!(result.minibatch_size, 128); } /// Test that minibatch size is correctly rounded to nearest integer #[test] fn test_minibatch_size_rounding() { // Test rounding down (128.4 → 128) let params_down = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.4, // minibatch_size: rounds to 128 ]; let result = PPOParams::from_continuous(¶ms_down).unwrap(); assert_eq!(result.minibatch_size, 128, "Should round down to 128"); // Test rounding up (128.7 → 129) let params_up = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.7, // minibatch_size: rounds to 129 ]; let result = PPOParams::from_continuous(¶ms_up).unwrap(); assert_eq!(result.minibatch_size, 129, "Should round up to 129"); // Test exact half (128.5 → 129, Rust rounds half to even or away from zero) let params_half = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.5, // minibatch_size: rounds to 128 or 129 ]; let result = PPOParams::from_continuous(¶ms_half).unwrap(); assert!( result.minibatch_size == 128 || result.minibatch_size == 129, "Should round half to 128 or 129" ); } /// Test that wrong parameter count returns error #[test] fn test_invalid_parameter_count_too_few() { // Too few parameters (5 instead of 6) let params_too_few = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff // Missing: minibatch_size ]; let result = PPOParams::from_continuous(¶ms_too_few); assert!( result.is_err(), "Should return error for wrong parameter count (too few)" ); if let Err(e) = result { let err_msg = format!("{:?}", e); assert!( err_msg.contains("Expected 6 parameters") || err_msg.contains("got 5"), "Error message should mention expected count. Got: {}", err_msg ); } } /// Test that wrong parameter count returns error (too many) #[test] fn test_invalid_parameter_count_too_many() { // Too many parameters (7 instead of 6) let params_too_many = vec![ (1e-6_f64).ln(), // policy_lr (1e-4_f64).ln(), // value_lr 0.2, // clip_epsilon 1.0, // value_loss_coeff (0.05_f64).ln(), // entropy_coeff 128.0, // minibatch_size 999.0, // Extra parameter ]; let result = PPOParams::from_continuous(¶ms_too_many); assert!( result.is_err(), "Should return error for wrong parameter count (too many)" ); if let Err(e) = result { let err_msg = format!("{:?}", e); assert!( err_msg.contains("Expected 6 parameters") || err_msg.contains("got 7"), "Error message should mention expected count. Got: {}", err_msg ); } } /// Test log-scale roundtrip precision for learning rates #[test] fn test_log_scale_roundtrip_precision() { let original_params = PPOParams { policy_learning_rate: 3e-5, value_learning_rate: 1e-4, clip_epsilon: 0.2, value_loss_coeff: 1.0, entropy_coeff: 0.05, minibatch_size: 128, }; let continuous = original_params.to_continuous(); let recovered = PPOParams::from_continuous(&continuous).unwrap(); // Policy LR (log-scale) assert!( (recovered.policy_learning_rate - original_params.policy_learning_rate).abs() < 1e-10, "Policy LR roundtrip precision failed: expected {}, got {}", original_params.policy_learning_rate, recovered.policy_learning_rate ); // Value LR (log-scale) assert!( (recovered.value_learning_rate - original_params.value_learning_rate).abs() < 1e-10, "Value LR roundtrip precision failed: expected {}, got {}", original_params.value_learning_rate, recovered.value_learning_rate ); // Entropy coeff (log-scale) assert!( (recovered.entropy_coeff - original_params.entropy_coeff).abs() < 1e-10, "Entropy coeff roundtrip precision failed: expected {}, got {}", original_params.entropy_coeff, recovered.entropy_coeff ); } /// Test boundary values (exact min/max) #[test] fn test_boundary_values() { // Test minimum bounds let params_min = vec![ (1e-6_f64).ln(), // policy_lr: min (1e-5_f64).ln(), // value_lr: min 0.1, // clip_epsilon: min 0.5, // value_loss_coeff: min (0.001_f64).ln(), // entropy_coeff: min 64.0, // minibatch_size: min ]; let result_min = PPOParams::from_continuous(¶ms_min).unwrap(); assert!((result_min.policy_learning_rate - 1e-6).abs() < 1e-10); assert!((result_min.value_learning_rate - 1e-5).abs() < 1e-10); assert_eq!(result_min.clip_epsilon, 0.1); assert_eq!(result_min.value_loss_coeff, 0.5); assert!((result_min.entropy_coeff - 0.001).abs() < 1e-10); assert_eq!(result_min.minibatch_size, 64); // Test maximum bounds let params_max = vec![ (5e-5_f64).ln(), // policy_lr: max (5e-3_f64).ln(), // value_lr: max 0.3, // clip_epsilon: max 2.0, // value_loss_coeff: max (0.1_f64).ln(), // entropy_coeff: max 230.0, // minibatch_size: max ]; let result_max = PPOParams::from_continuous(¶ms_max).unwrap(); assert!((result_max.policy_learning_rate - 5e-5).abs() < 1e-10); assert!((result_max.value_learning_rate - 5e-3).abs() < 1e-10); assert_eq!(result_max.clip_epsilon, 0.3); assert_eq!(result_max.value_loss_coeff, 2.0); assert!((result_max.entropy_coeff - 0.1).abs() < 1e-10); assert_eq!(result_max.minibatch_size, 230); } /// Test continuous_bounds() returns correct ranges #[test] fn test_continuous_bounds_correctness() { let bounds = PPOParams::continuous_bounds(); assert_eq!(bounds.len(), 6, "Should have 6 parameter bounds"); // Policy LR: ln(1e-6) to ln(5e-5) assert!( (bounds[0].0 - (1e-6_f64).ln()).abs() < 1e-6, "Policy LR lower bound incorrect" ); assert!( (bounds[0].1 - (5e-5_f64).ln()).abs() < 1e-6, "Policy LR upper bound incorrect" ); // Value LR: ln(1e-5) to ln(5e-3) assert!( (bounds[1].0 - (1e-5_f64).ln()).abs() < 1e-6, "Value LR lower bound incorrect" ); assert!( (bounds[1].1 - (5e-3_f64).ln()).abs() < 1e-6, "Value LR upper bound incorrect" ); // Clip epsilon: 0.1 to 0.3 assert_eq!(bounds[2], (0.1, 0.3), "Clip epsilon bounds incorrect"); // Value loss coeff: 0.5 to 2.0 assert_eq!(bounds[3], (0.5, 2.0), "Value loss coeff bounds incorrect"); // Entropy coeff: ln(0.001) to ln(0.1) assert!( (bounds[4].0 - (0.001_f64).ln()).abs() < 1e-6, "Entropy coeff lower bound incorrect" ); assert!( (bounds[4].1 - (0.1_f64).ln()).abs() < 1e-6, "Entropy coeff upper bound incorrect" ); // Minibatch size: 64 to 230 assert_eq!(bounds[5], (64.0, 230.0), "Minibatch size bounds incorrect"); } /// Test param_names() matches parameter order #[test] fn test_param_names_order() { let names = PPOParams::param_names(); assert_eq!(names.len(), 6, "Should have 6 parameter names"); assert_eq!(names[0], "policy_learning_rate"); assert_eq!(names[1], "value_learning_rate"); assert_eq!(names[2], "clip_epsilon"); assert_eq!(names[3], "value_loss_coeff"); assert_eq!(names[4], "entropy_coeff"); assert_eq!(names[5], "minibatch_size"); } /// Test handling of extreme values (zero, negative) #[test] fn test_extreme_values_handling() { // Test zero values (should work for linear params, fail/clamp for log-scale) let params_zero = vec![ (1e-6_f64).ln(), // policy_lr: valid (1e-5_f64).ln(), // value_lr: valid 0.0, // clip_epsilon: below min, clamps to 0.1 0.0, // value_loss_coeff: below min, clamps to 0.5 (0.001_f64).ln(), // entropy_coeff: valid 0.0, // minibatch_size: below min, clamps to 64 ]; let result = PPOParams::from_continuous(¶ms_zero).unwrap(); assert_eq!( result.clip_epsilon, 0.1, "Zero clip_epsilon should clamp to 0.1" ); assert_eq!( result.value_loss_coeff, 0.5, "Zero value_loss_coeff should clamp to 0.5" ); assert_eq!( result.minibatch_size, 64, "Zero minibatch_size should clamp to 64" ); // Test negative values let params_negative = vec![ (1e-6_f64).ln(), // policy_lr: valid (1e-5_f64).ln(), // value_lr: valid -1.0, // clip_epsilon: below min, clamps to 0.1 -0.5, // value_loss_coeff: below min, clamps to 0.5 (0.001_f64).ln(), // entropy_coeff: valid -10.0, // minibatch_size: below min, clamps to 64 ]; let result = PPOParams::from_continuous(¶ms_negative).unwrap(); assert_eq!( result.clip_epsilon, 0.1, "Negative clip_epsilon should clamp to 0.1" ); assert_eq!( result.value_loss_coeff, 0.5, "Negative value_loss_coeff should clamp to 0.5" ); assert_eq!( result.minibatch_size, 64, "Negative minibatch_size should clamp to 64" ); } /// Test that all 6 parameters are correctly transformed #[test] fn test_all_parameters_roundtrip() { let original = PPOParams { policy_learning_rate: 1.5e-5, value_learning_rate: 2.5e-4, clip_epsilon: 0.15, value_loss_coeff: 1.2, entropy_coeff: 0.03, minibatch_size: 96, }; let continuous = original.to_continuous(); let recovered = PPOParams::from_continuous(&continuous).unwrap(); assert!( (recovered.policy_learning_rate - original.policy_learning_rate).abs() < 1e-10, "Policy LR mismatch" ); assert!( (recovered.value_learning_rate - original.value_learning_rate).abs() < 1e-10, "Value LR mismatch" ); assert!( (recovered.clip_epsilon - original.clip_epsilon).abs() < 1e-10, "Clip epsilon mismatch" ); assert!( (recovered.value_loss_coeff - original.value_loss_coeff).abs() < 1e-10, "Value loss coeff mismatch" ); assert!( (recovered.entropy_coeff - original.entropy_coeff).abs() < 1e-10, "Entropy coeff mismatch" ); assert_eq!( recovered.minibatch_size, original.minibatch_size, "Minibatch size mismatch" ); }