//! PPO Hyperopt Backward Compatibility Tests //! //! This test suite ensures that the addition of `minibatch_size` as the 6th parameter //! to PPOParams does not break existing functionality. It covers: //! //! 1. Default value validation (minibatch_size = 128) //! 2. Full serialization/deserialization roundtrip //! 3. Graceful handling of missing fields (Serde Default trait) //! 4. Old 5-parameter continuous arrays rejected with clear errors //! 5. New 6-parameter continuous arrays accepted and validated //! //! ## Migration Path //! //! - **Old checkpoints**: Use Default trait fallback (minibatch_size = 128) //! - **Old hyperopt results**: Must be re-run with 6 parameters //! - **Production configs**: Update TOML/JSON to include minibatch_size use ml::hyperopt::adapters::ppo::PPOParams; use ml::hyperopt::traits::ParameterSpace; #[test] fn test_default_ppo_params_has_reasonable_minibatch_size() { let params = PPOParams::default(); // Verify default minibatch_size is production-tested value assert_eq!( params.minibatch_size, 128, "Default minibatch_size should be 128 (production-tested)" ); // Verify it's within VRAM bounds (64-230 for RTX 3050 Ti) assert!( params.minibatch_size >= 64, "minibatch_size {} should be >= 64 (VRAM lower bound)", params.minibatch_size ); assert!( params.minibatch_size <= 230, "minibatch_size {} should be <= 230 (VRAM upper bound)", params.minibatch_size ); } #[test] fn test_ppo_params_serialization_includes_all_fields() { let params = PPOParams { policy_learning_rate: 1e-6, value_learning_rate: 0.001, clip_epsilon: 0.2, value_loss_coeff: 1.0, entropy_coeff: 0.05, minibatch_size: 128, }; // Serialize to JSON let json = serde_json::to_string(¶ms).expect("Serialization should succeed"); // Verify JSON contains minibatch_size field assert!( json.contains("minibatch_size"), "Serialized JSON should contain 'minibatch_size' field. Got: {}", json ); assert!( json.contains("128"), "Serialized JSON should contain minibatch_size value (128). Got: {}", json ); // Verify all 6 fields are present let field_count = json.matches("\":").count(); assert_eq!( field_count, 6, "Serialized JSON should have 6 fields (5 old + 1 new). Got: {}", field_count ); } #[test] fn test_ppo_params_deserialization_roundtrip() { let original = PPOParams { policy_learning_rate: 1e-6, value_learning_rate: 0.001, clip_epsilon: 0.1126, value_loss_coeff: 0.5, entropy_coeff: 0.006142, minibatch_size: 192, }; // Serialize to JSON let json = serde_json::to_string(&original).expect("Serialization should succeed"); // Deserialize back let deserialized: PPOParams = serde_json::from_str(&json).expect("Deserialization should succeed"); // Verify all fields match (within floating-point tolerance) assert!( (deserialized.policy_learning_rate - original.policy_learning_rate).abs() < 1e-10, "policy_learning_rate mismatch" ); assert!( (deserialized.value_learning_rate - original.value_learning_rate).abs() < 1e-10, "value_learning_rate mismatch" ); assert!( (deserialized.clip_epsilon - original.clip_epsilon).abs() < 1e-10, "clip_epsilon mismatch" ); assert!( (deserialized.value_loss_coeff - original.value_loss_coeff).abs() < 1e-10, "value_loss_coeff mismatch" ); assert!( (deserialized.entropy_coeff - original.entropy_coeff).abs() < 1e-10, "entropy_coeff mismatch" ); assert_eq!( deserialized.minibatch_size, original.minibatch_size, "minibatch_size mismatch" ); } #[test] fn test_ppo_params_handles_missing_minibatch_size_gracefully() { // Simulate old JSON format (5 fields, missing minibatch_size) let old_json = r#"{ "policy_learning_rate": 0.000001, "value_learning_rate": 0.001, "clip_epsilon": 0.2, "value_loss_coeff": 1.0, "entropy_coeff": 0.05 }"#; // Should deserialize successfully using Default trait let params: PPOParams = serde_json::from_str(old_json) .expect("Deserialization should succeed with missing minibatch_size field"); // Verify missing field uses default value (128) assert_eq!( params.minibatch_size, 128, "Missing minibatch_size should default to 128" ); // Verify other fields are correct assert!((params.policy_learning_rate - 1e-6).abs() < 1e-10); assert!((params.value_learning_rate - 0.001).abs() < 1e-10); assert!((params.clip_epsilon - 0.2).abs() < 1e-10); assert!((params.value_loss_coeff - 1.0).abs() < 1e-10); assert!((params.entropy_coeff - 0.05).abs() < 1e-10); } #[test] fn test_old_5_parameter_continuous_array_rejected() { // Old hyperopt results with 5 parameters (pre-minibatch_size) let old_continuous = vec![ 1e-6_f64.ln(), // policy_learning_rate (log scale) 0.001_f64.ln(), // value_learning_rate (log scale) 0.2, // clip_epsilon 1.0, // value_loss_coeff 0.05_f64.ln(), // entropy_coeff (log scale) ]; // Should fail with clear error message let result = PPOParams::from_continuous(&old_continuous); assert!( result.is_err(), "Old 5-parameter array should be rejected, but got: {:?}", result ); // Verify error message is clear let error_msg = result.unwrap_err().to_string(); assert!( error_msg.contains("Expected 6 parameters") || error_msg.contains("got 5"), "Error message should clearly state version mismatch. Got: {}", error_msg ); } #[test] fn test_new_6_parameter_continuous_array_accepted() { // New hyperopt results with 6 parameters (includes minibatch_size) let new_continuous = vec![ 1e-6_f64.ln(), // policy_learning_rate (log scale) 0.001_f64.ln(), // value_learning_rate (log scale) 0.2, // clip_epsilon 1.0, // value_loss_coeff 0.05_f64.ln(), // entropy_coeff (log scale) 128.0, // minibatch_size (linear scale) ]; // Should succeed let result = PPOParams::from_continuous(&new_continuous); assert!( result.is_ok(), "New 6-parameter array should be accepted, but got error: {:?}", result.err() ); let params = result.unwrap(); // Verify all parameters are correctly extracted assert!( (params.policy_learning_rate - 1e-6).abs() < 1e-9, "policy_learning_rate mismatch: expected 1e-6, got {}", params.policy_learning_rate ); assert!( (params.value_learning_rate - 0.001).abs() < 1e-9, "value_learning_rate mismatch: expected 0.001, got {}", params.value_learning_rate ); assert!( (params.clip_epsilon - 0.2).abs() < 1e-9, "clip_epsilon mismatch: expected 0.2, got {}", params.clip_epsilon ); assert!( (params.value_loss_coeff - 1.0).abs() < 1e-9, "value_loss_coeff mismatch: expected 1.0, got {}", params.value_loss_coeff ); assert!( (params.entropy_coeff - 0.05).abs() < 1e-9, "entropy_coeff mismatch: expected 0.05, got {}", params.entropy_coeff ); assert_eq!( params.minibatch_size, 128, "minibatch_size mismatch: expected 128, got {}", params.minibatch_size ); } #[test] fn test_minibatch_size_boundary_values() { // Test lower bound (64) let lower_bound = vec![ 1e-6_f64.ln(), // policy_learning_rate 0.001_f64.ln(), // value_learning_rate 0.2, // clip_epsilon 1.0, // value_loss_coeff 0.05_f64.ln(), // entropy_coeff 64.0, // minibatch_size (lower bound) ]; let params_lower = PPOParams::from_continuous(&lower_bound).unwrap(); assert_eq!( params_lower.minibatch_size, 64, "Lower bound (64) should be preserved" ); // Test upper bound (230) let upper_bound = vec![ 1e-6_f64.ln(), // policy_learning_rate 0.001_f64.ln(), // value_learning_rate 0.2, // clip_epsilon 1.0, // value_loss_coeff 0.05_f64.ln(), // entropy_coeff 230.0, // minibatch_size (upper bound) ]; let params_upper = PPOParams::from_continuous(&upper_bound).unwrap(); assert_eq!( params_upper.minibatch_size, 230, "Upper bound (230) should be preserved" ); } #[test] fn test_minibatch_size_clamping() { // Test below lower bound (should clamp to 64) let below_bound = vec![ 1e-6_f64.ln(), // policy_learning_rate 0.001_f64.ln(), // value_learning_rate 0.2, // clip_epsilon 1.0, // value_loss_coeff 0.05_f64.ln(), // entropy_coeff 32.0, // minibatch_size (below lower bound) ]; let params_below = PPOParams::from_continuous(&below_bound).unwrap(); assert_eq!( params_below.minibatch_size, 64, "Values below 64 should clamp to 64" ); // Test above upper bound (should clamp to 230) let above_bound = vec![ 1e-6_f64.ln(), // policy_learning_rate 0.001_f64.ln(), // value_learning_rate 0.2, // clip_epsilon 1.0, // value_loss_coeff 0.05_f64.ln(), // entropy_coeff 512.0, // minibatch_size (above upper bound) ]; let params_above = PPOParams::from_continuous(&above_bound).unwrap(); assert_eq!( params_above.minibatch_size, 230, "Values above 230 should clamp to 230" ); } #[test] fn test_to_continuous_roundtrip() { let original = PPOParams { policy_learning_rate: 1e-6, value_learning_rate: 0.001, clip_epsilon: 0.1126, value_loss_coeff: 0.5, entropy_coeff: 0.006142, minibatch_size: 192, }; // Convert to continuous array let continuous = original.to_continuous(); // Verify length is 6 assert_eq!( continuous.len(), 6, "to_continuous() should return 6 values" ); // Convert back let roundtrip = PPOParams::from_continuous(&continuous).unwrap(); // Verify all fields match (within tolerance) assert!( (roundtrip.policy_learning_rate - original.policy_learning_rate).abs() < 1e-9, "policy_learning_rate roundtrip failed" ); assert!( (roundtrip.value_learning_rate - original.value_learning_rate).abs() < 1e-9, "value_learning_rate roundtrip failed" ); assert!( (roundtrip.clip_epsilon - original.clip_epsilon).abs() < 1e-9, "clip_epsilon roundtrip failed" ); assert!( (roundtrip.value_loss_coeff - original.value_loss_coeff).abs() < 1e-9, "value_loss_coeff roundtrip failed" ); assert!( (roundtrip.entropy_coeff - original.entropy_coeff).abs() < 1e-9, "entropy_coeff roundtrip failed" ); assert_eq!( roundtrip.minibatch_size, original.minibatch_size, "minibatch_size roundtrip failed" ); } #[test] fn test_continuous_array_wrong_length_errors() { // Test empty array let empty: Vec = vec![]; let result_empty = PPOParams::from_continuous(&empty); assert!(result_empty.is_err(), "Empty array should be rejected"); // Test 7-parameter array let too_long = vec![ 1e-6_f64.ln(), // policy_learning_rate 0.001_f64.ln(), // value_learning_rate 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_long = PPOParams::from_continuous(&too_long); assert!(result_long.is_err(), "7-parameter array should be rejected"); }