#![allow( clippy::assertions_on_constants, clippy::assertions_on_result_states, clippy::clone_on_copy, clippy::decimal_literal_representation, clippy::doc_markdown, clippy::empty_line_after_doc_comments, clippy::field_reassign_with_default, clippy::get_unwrap, clippy::identity_op, clippy::inconsistent_digit_grouping, clippy::indexing_slicing, clippy::integer_division, clippy::len_zero, clippy::let_underscore_must_use, clippy::manual_div_ceil, clippy::manual_let_else, clippy::manual_range_contains, clippy::modulo_arithmetic, clippy::needless_range_loop, clippy::non_ascii_literal, clippy::redundant_clone, clippy::shadow_reuse, clippy::shadow_same, clippy::shadow_unrelated, clippy::single_match_else, clippy::str_to_string, clippy::string_slice, clippy::tests_outside_test_module, clippy::too_many_lines, clippy::unnecessary_wraps, clippy::unseparated_literal_suffix, clippy::use_debug, clippy::useless_vec, clippy::wildcard_enum_match_arm, clippy::else_if_without_else, clippy::expect_used, clippy::missing_const_for_fn, clippy::similar_names, clippy::type_complexity, clippy::collapsible_else_if, clippy::doc_lazy_continuation, clippy::items_after_test_module, clippy::map_clone, clippy::multiple_unsafe_ops_per_block, clippy::unwrap_or_default, clippy::assign_op_pattern, clippy::needless_borrow, clippy::println_empty_string, clippy::unnecessary_cast, clippy::used_underscore_binding, clippy::create_dir, clippy::implicit_saturating_sub, clippy::exit, clippy::expect_fun_call, clippy::too_many_arguments, clippy::unnecessary_map_or, clippy::unwrap_used, dead_code, unused_imports, unused_variables, clippy::cloned_ref_to_slice_refs, clippy::neg_multiply, clippy::while_let_loop, clippy::bool_assert_comparison, clippy::excessive_precision, clippy::trivially_copy_pass_by_ref, clippy::op_ref, clippy::redundant_closure, clippy::unnecessary_lazy_evaluations, clippy::if_then_some_else_none, clippy::unnecessary_to_owned, clippy::single_component_path_imports, )] //! TDD Tests for PPO Action Masking //! //! These tests are written BEFORE implementation to ensure correct behavior. //! All tests should fail initially, then pass after implementation. //! //! Test coverage: //! 1. Action masking at max position (+2.0) //! 2. Action masking at min position (-2.0) //! 3. Action masking at flat position (0.0) //! 4. Action masking at partial position (+1.0) //! 5. Logit application (masking sets invalid actions to -inf) // candle eliminated -- test uses native Vec APIs use ml::ppo::action_masking::{apply_mask_to_logits_vec, create_action_mask}; #[test] fn test_action_mask_at_max_position() { // At max position (+2.0), BUY actions should be masked // Actions: 0=BUY, 1=SELL, 2=HOLD let current_position = 2.0; let max_position = 2.0; let num_actions = 3; let mask = create_action_mask(current_position, max_position, num_actions); assert_eq!(mask.len(), 3, "Mask should have 3 elements"); assert_eq!(mask[0], false, "BUY should be masked at max position"); assert_eq!(mask[1], true, "SELL should be valid at max position"); assert_eq!(mask[2], true, "HOLD should be valid at max position"); } #[test] fn test_action_mask_at_min_position() { // At min position (-2.0), SELL actions should be masked // Actions: 0=BUY, 1=SELL, 2=HOLD let current_position = -2.0; let max_position = 2.0; let num_actions = 3; let mask = create_action_mask(current_position, max_position, num_actions); assert_eq!(mask.len(), 3, "Mask should have 3 elements"); assert_eq!(mask[0], true, "BUY should be valid at min position"); assert_eq!(mask[1], false, "SELL should be masked at min position"); assert_eq!(mask[2], true, "HOLD should be valid at min position"); } #[test] fn test_action_mask_flat_position() { // At flat position (0.0), all actions should be valid // Actions: 0=BUY, 1=SELL, 2=HOLD let current_position = 0.0; let max_position = 2.0; let num_actions = 3; let mask = create_action_mask(current_position, max_position, num_actions); assert_eq!(mask.len(), 3, "Mask should have 3 elements"); assert_eq!(mask[0], true, "BUY should be valid at flat position"); assert_eq!(mask[1], true, "SELL should be valid at flat position"); assert_eq!(mask[2], true, "HOLD should be valid at flat position"); } #[test] fn test_action_mask_partial_position() { // At partial long position (+1.0), all actions should be valid // (we're at +1.0, max is +2.0, so we can still buy) // Actions: 0=BUY, 1=SELL, 2=HOLD let current_position = 1.0; let max_position = 2.0; let num_actions = 3; let mask = create_action_mask(current_position, max_position, num_actions); assert_eq!(mask.len(), 3, "Mask should have 3 elements"); assert_eq!(mask[0], true, "BUY should be valid at +1.0 (max is +2.0)"); assert_eq!(mask[1], true, "SELL should be valid at +1.0"); assert_eq!(mask[2], true, "HOLD should be valid at +1.0"); } #[test] fn test_action_mask_logit_application() { // Test that masked logits are set to -inf (effectively -1e9 for numerical stability) // GPU-native: logits and mask are host-side Vec / Vec // Create sample logits [0.5, 0.3, 0.2] let logits = vec![0.5_f32, 0.3_f32, 0.2_f32]; // Mask BUY action (first action) let mask = vec![false, true, true]; // Apply mask (returns Vec) let masked_logits = apply_mask_to_logits_vec(&logits, &mask); assert_eq!(masked_logits.len(), 3, "Masked logits should have 3 elements"); // Check masked action (index 0) has very negative logit let masked_val = masked_logits[0]; assert!( masked_val < -1e8, "Masked action should have very negative logit (got {})", masked_val ); // Check unmasked actions retain original values assert!( (masked_logits[1] - 0.3).abs() < 1e-6, "Unmasked action SELL should retain value 0.3 (got {})", masked_logits[1] ); assert!( (masked_logits[2] - 0.2).abs() < 1e-6, "Unmasked action HOLD should retain value 0.2 (got {})", masked_logits[2] ); } #[test] fn test_action_mask_near_max_position() { // At position +1.99, we're very close to max but not quite there // BUY should still be masked if action would exceed limit let current_position = 1.99; let max_position = 2.0; let num_actions = 3; let mask = create_action_mask(current_position, max_position, num_actions); assert_eq!(mask.len(), 3, "Mask should have 3 elements"); // Note: Current simple implementation assumes action changes position by +/-1.0 // So BUY would take us from 1.99 -> 2.99, which exceeds 2.0 // This test documents expected behavior assert_eq!( mask[0], false, "BUY should be masked near max position (would exceed limit)" ); assert_eq!(mask[1], true, "SELL should be valid near max position"); assert_eq!(mask[2], true, "HOLD should be valid near max position"); } #[test] fn test_action_mask_batch_logits() { // Test mask application to batch of logits (host-side Vec) // Batch of 2 logits: [[0.5, 0.3, 0.2], [0.1, 0.6, 0.3]] let logits_0 = vec![0.5_f32, 0.3_f32, 0.2_f32]; let logits_1 = vec![0.1_f32, 0.6_f32, 0.3_f32]; // Mask BUY action for both samples let mask = vec![false, true, true]; // Apply mask to first sample let masked_0 = apply_mask_to_logits_vec(&logits_0, &mask); // Verify masked position (index 0) for sample 0 assert!( masked_0[0] < -1e8, "First sample BUY should be masked (got {})", masked_0[0] ); assert!( (masked_0[1] - 0.3).abs() < 1e-6, "First sample SELL should retain value 0.3 (got {})", masked_0[1] ); // Apply mask to second sample let masked_1 = apply_mask_to_logits_vec(&logits_1, &mask); // Verify masked position (index 0) for sample 1 assert!( masked_1[0] < -1e8, "Second sample BUY should be masked (got {})", masked_1[0] ); assert!( (masked_1[1] - 0.6).abs() < 1e-6, "Second sample SELL should retain value 0.6 (got {})", masked_1[1] ); }