#![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, )] //! PPO 45-Action Network Tests (TDD Red Phase) //! //! Tests for PPO network architecture expansion from 3 -> 45 actions. //! //! Test coverage: //! 1. Policy network outputs 45 logits (not 3) //! 2. Value network still outputs 1 value (unchanged) //! 3. Softmax over 45 logits sums to 1.0 //! 4. Full forward pass with 45 actions works correctly //! //! Expected behavior (TDD Red -> Green): //! - RED: Tests fail because num_actions=3 (current implementation) //! - GREEN: Tests pass after num_actions=45 changes use anyhow::Result; // candle eliminated -- test uses native GPU APIs (cudarc + cuda_nn) #[test] fn test_policy_network_45_output() -> Result<()> { use ml::ppo::ppo::{PPOConfig, PPO}; // Create PPO with 45 actions let config = PPOConfig { num_actions: 45, // 5x3x3 factored action space state_dim: 54, // Wave 3 features (54->54) ..Default::default() }; let ppo = PPO::new(config)?; // Create dummy state (flat f32 slice, batch_size=1) let state = vec![0.0_f32; 54]; // Forward pass through policy network (returns Vec of logits) let logits = ppo.actor.forward_host(&state, 1)?; // ASSERT: Policy network outputs 45 logits (not 3) assert_eq!( logits.len(), 45, "Policy network should output 45 logits, got: {}", logits.len() ); Ok(()) } #[test] fn test_value_network_single_output() -> Result<()> { use ml::ppo::ppo::{PPOConfig, PPO}; // Create PPO with 45 actions let config = PPOConfig { num_actions: 45, // 5x3x3 factored action space state_dim: 54, // Wave 3 features (54->54) ..Default::default() }; let ppo = PPO::new(config)?; // Create dummy state (flat f32 slice, batch_size=1) let state = vec![0.0_f32; 54]; // Forward pass through value network (returns Vec) let value = ppo.critic.forward_host(&state, 1)?; // ASSERT: Value network still outputs 1 value (batch_size=1, scalar output) assert_eq!( value.len(), 1, "Value network should output single value per batch item, got: {}", value.len() ); Ok(()) } #[test] fn test_policy_network_softmax() -> Result<()> { use ml::ppo::ppo::{PPOConfig, PPO}; // Create PPO with 45 actions let config = PPOConfig { num_actions: 45, state_dim: 54, ..Default::default() }; let ppo = PPO::new(config)?; // Create dummy state (flat f32 slice, batch_size=1) let state = vec![0.0_f32; 54]; // Get action probabilities (softmax of logits) -- returns Vec let probs = match &ppo.actor { ml::ppo::ppo::ActorNetwork::MLP(net) => net.action_probabilities(&state, 1)?, ml::ppo::ppo::ActorNetwork::LSTM(_) => { panic!("Expected MLP actor, got LSTM"); } }; // ASSERT 1: Probabilities have 45 elements assert_eq!( probs.len(), 45, "Action probabilities should have 45 dimensions, got: {}", probs.len() ); // ASSERT 2: Probabilities sum to 1.0 (softmax property) let sum: f32 = probs.iter().sum(); assert!( (sum - 1.0).abs() < 1e-5, "Softmax probabilities should sum to 1.0, got: {}", sum ); // ASSERT 3: Each probability is in [0, 1] for (i, &prob) in probs.iter().enumerate() { assert!( prob >= 0.0 && prob <= 1.0, "Probability {} is out of range [0, 1]: {}", i, prob ); } Ok(()) } #[test] fn test_network_forward_pass() -> Result<()> { use ml::ppo::ppo::{PPOConfig, PPO}; // Create PPO with 45 actions let config = PPOConfig { num_actions: 45, state_dim: 54, policy_hidden_dims: vec![128, 64], value_hidden_dims: vec![256, 128, 64], ..Default::default() }; let ppo = PPO::new(config)?; // Create batch of states (batch_size=8, flat f32 slice) let batch_size = 8; let state = vec![0.0_f32; batch_size * 54]; // ASSERT 1: Policy forward pass with batch (returns batch_size * num_actions logits) let logits = ppo.actor.forward_host(&state, batch_size)?; assert_eq!( logits.len(), batch_size * 45, "Policy logits should have {} elements (batch_size * 45), got: {}", batch_size * 45, logits.len() ); // ASSERT 2: Value forward pass with batch (returns batch_size values) let values = ppo.critic.forward_host(&state, batch_size)?; assert_eq!( values.len(), batch_size, "Value network should output {} values, got: {}", batch_size, values.len() ); // ASSERT 3: Action probabilities with batch let probs = match &ppo.actor { ml::ppo::ppo::ActorNetwork::MLP(net) => net.action_probabilities(&state, batch_size)?, ml::ppo::ppo::ActorNetwork::LSTM(_) => { panic!("Expected MLP actor, got LSTM"); } }; assert_eq!( probs.len(), batch_size * 45, "Action probabilities should have {} elements, got: {}", batch_size * 45, probs.len() ); // ASSERT 4: Each batch item's probabilities sum to 1.0 for i in 0..batch_size { let row_start = i * 45; let row_end = row_start + 45; let row = &probs[row_start..row_end]; let sum: f32 = row.iter().sum(); assert!( (sum - 1.0).abs() < 1e-5, "Batch item {} probabilities should sum to 1.0, got: {}", i, sum ); } Ok(()) } #[test] fn test_backward_compatibility_3_actions() -> Result<()> { use ml::ppo::ppo::{PPOConfig, PPO}; // Test that 3-action configuration still works (backward compatibility) let config = PPOConfig { num_actions: 3, state_dim: 54, ..Default::default() }; let ppo = PPO::new(config)?; // Create dummy state (flat f32 slice, batch_size=1) let state = vec![0.0_f32; 54]; // Policy network should output 3 logits let logits = ppo.actor.forward_host(&state, 1)?; assert_eq!( logits.len(), 3, "Policy network with num_actions=3 should output 3 logits" ); // Value network should still output 1 value let value = ppo.critic.forward_host(&state, 1)?; assert_eq!( value.len(), 1, "Value network should output single value" ); Ok(()) } #[test] fn test_ppo_config_default_45_actions() -> Result<()> { use ml::ppo::ppo::PPOConfig; // ASSERT: Default config uses 45 actions (not 3) let config = PPOConfig::default(); assert_eq!( config.num_actions, 45, "Default PPOConfig should have num_actions=45, got: {}", config.num_actions ); Ok(()) } #[test] fn test_ppo_trainer_default_45_actions() -> Result<()> { use ml::trainers::ppo::PpoHyperparameters; // ASSERT: Conservative params use 45 actions let params = PpoHyperparameters::conservative(); let config: ml::ppo::ppo::PPOConfig = params.into(); assert_eq!( config.num_actions, 45, "PpoHyperparameters should default to 45 actions, got: {}", config.num_actions ); Ok(()) } #[test] fn test_hyperopt_adapter_default_45_actions() -> Result<()> { use ml::hyperopt::adapters::ppo::PPOParams; // ASSERT: PPOParams default uses 45 actions when converted to config let params = PPOParams::default(); // Create config manually (since PPOParams -> PPOConfig conversion is in PPOTrainer) // We verify the architecture matches 45-action space use ml::ppo::ppo::PPOConfig; use ml::ppo::gae::GAEConfig; let config = PPOConfig { state_dim: 54, num_actions: 45, // This is what hyperopt adapter should use policy_hidden_dims: vec![128, 64], value_hidden_dims: vec![512, 384, 256, 128, 64], policy_learning_rate: params.policy_learning_rate, value_learning_rate: params.value_learning_rate, clip_epsilon: params.clip_epsilon as f32, value_loss_coeff: params.value_loss_coeff as f32, entropy_coeff: params.entropy_coeff as f32, gae_config: GAEConfig::default(), batch_size: 2048, mini_batch_size: 512, num_epochs: 20, max_grad_norm: 0.5, early_stopping_enabled: true, early_stopping_patience: 5, early_stopping_min_delta: 1e-4, early_stopping_min_epochs: 5, max_position_absolute: 2.0, transaction_cost_bps: 0.10, cash_reserve_pct: 20.0, circuit_breaker_threshold: 5, use_lstm: false, lstm_hidden_dim: 128, lstm_num_layers: 1, lstm_sequence_length: 32, accumulation_steps: 1, clip_epsilon_high: None, use_symlog: true, use_adaptive_entropy: true, use_percentile_scaling: true, }; assert_eq!( config.num_actions, 45, "Hyperopt adapter config should use 45 actions" ); Ok(()) }