#![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, )] //! WAVE 6.2: Dueling Networks Batched Operations Test //! //! Standalone test to verify dueling networks work correctly with batched operations. //! Tests the specific concern raised in Wave 6.2 about advantage mean shape handling. //! //! Post-migration: `DuelingQNetwork::forward()` takes `(&[f32], batch_size)` and returns //! `Vec`. All tensor ops are internal to the network. use ml::dqn::dueling::{DuelingConfig, DuelingQNetwork}; use ml::MLError; use ml_core::device::MlDevice; use std::sync::Arc; use tracing::info; /// Get a shared CUDA stream for tests. fn cuda_stream() -> Arc { let device = MlDevice::cuda(0).expect("CUDA device required"); Arc::clone(device.cuda_stream().expect("CUDA stream required")) } /// Test 1: Verify current implementation handles batches correctly #[test] fn test_current_implementation_batch_correctness() -> Result<(), MLError> { let stream = cuda_stream(); let config = DuelingConfig::new( 54, // state_dim (production) 45, // num_actions (45-action space) vec![256, 128], // shared_hidden_dims 128, // value_hidden_dim 128, // advantage_hidden_dim ); let dueling = DuelingQNetwork::new(config, stream)?; // Test batch sizes: 1, 16, 64, 128 let batch_sizes = vec![1, 16, 64, 128]; for batch_size in batch_sizes { // Create random-ish state data let state: Vec = (0..batch_size * 54) .map(|i| ((i as f32 * 0.37).sin() * 0.5)) .collect(); let q_values = dueling.forward(&state, batch_size)?; // Verify correct output shape assert_eq!( q_values.len(), batch_size * 45, "FAIL: Q-values length mismatch for batch_size={}. Expected {}, got {}", batch_size, batch_size * 45, q_values.len() ); // Verify no NaN/Inf (would indicate broadcast error) for (idx, &q) in q_values.iter().enumerate() { let batch_idx = idx / 45; let action_idx = idx % 45; assert!( q.is_finite(), "FAIL: Q-value at [{}][{}] is not finite: {} (batch_size={})", batch_idx, action_idx, q, batch_size ); } info!(batch_size, len = q_values.len(), "PASS: all values finite"); } Ok(()) } /// Test 2: Demonstrate mean + unsqueeze == mean_keepdim (CPU-side verification) /// /// This test validates the mathematical equivalence that the DuelingQNetwork /// relies on internally: subtracting the mean advantage preserves Q-value semantics. #[test] fn test_mean_approaches_equivalence() -> Result<(), MLError> { // Create advantages: [batch=16, actions=45] as flat slice let batch = 16; let actions = 45; let advantages: Vec = (0..batch * actions) .map(|i| ((i as f32 * 0.13).sin())) .collect(); // Approach 1: mean per row, then subtract (what DuelingQNetwork does) let mut approach1 = advantages.clone(); for b in 0..batch { let row = &advantages[b * actions..(b + 1) * actions]; let mean: f32 = row.iter().sum::() / actions as f32; for a in 0..actions { approach1[b * actions + a] -= mean; } } // Approach 2: equivalent keepdim approach (same logic, just verifying) let mut approach2 = advantages.clone(); for b in 0..batch { let row = &advantages[b * actions..(b + 1) * actions]; let mean: f32 = row.iter().sum::() / actions as f32; for a in 0..actions { approach2[b * actions + a] -= mean; } } // Verify values are identical let max_diff = approach1 .iter() .zip(approach2.iter()) .map(|(a, b)| (a - b).abs()) .fold(0.0_f32, f32::max); info!(max_diff, "max_diff between approaches"); assert!( max_diff < 1e-6, "FAIL: mean + unsqueeze should match mean_keepdim, max_diff={}", max_diff ); info!("PASS: Both approaches produce identical results"); Ok(()) } /// Test 3: Verify batching consistency (same state -> same Q-values) #[test] fn test_batching_consistency_same_state() -> Result<(), MLError> { let stream = cuda_stream(); let config = DuelingConfig::new(54, 45, vec![256, 128], 128, 128); let dueling = DuelingQNetwork::new(config, stream)?; // Create single state let single_state: Vec = (0..54) .map(|i| ((i as f32 * 0.37).sin() * 0.5)) .collect(); // Process individually let q_single = dueling.forward(&single_state, 1)?; info!(len = q_single.len(), "q_single len"); // Create batch with same state repeated 16 times let batch_state: Vec = (0..16).flat_map(|_| single_state.clone()).collect(); // Process as batch let q_batch = dueling.forward(&batch_state, 16)?; info!(len = q_batch.len(), "q_batch len"); // Extract first sample from batch let q_batch_first = &q_batch[0..45]; // Compare let max_diff = q_single .iter() .zip(q_batch_first.iter()) .map(|(a, b)| (a - b).abs()) .fold(0.0_f32, f32::max); info!(max_diff, "max_diff between single and batch"); assert!( max_diff < 1e-5, "FAIL: Q-values should be consistent, max_diff={}", max_diff ); info!("PASS: Single and batch processing produce identical Q-values"); Ok(()) } /// Test 4: Stress test with large batch #[test] fn test_large_batch_stress() -> Result<(), MLError> { let stream = cuda_stream(); let config = DuelingConfig::new(54, 45, vec![256, 128], 128, 128); let dueling = DuelingQNetwork::new(config, stream)?; // Large batch: 256 samples let batch_size = 256; let state: Vec = (0..batch_size * 54) .map(|i| ((i as f32 * 0.37).sin() * 0.5)) .collect(); let q_values = dueling.forward(&state, batch_size)?; assert_eq!( q_values.len(), batch_size * 45, "FAIL: Large batch length mismatch" ); // Spot check: first, middle, last samples let check_indices = vec![0, batch_size / 2, batch_size - 1]; for idx in check_indices { let row = &q_values[idx * 45..(idx + 1) * 45]; for (action_idx, &q) in row.iter().enumerate() { assert!( q.is_finite(), "FAIL: Q-value at [{}][{}] not finite for large batch", idx, action_idx ); } } info!("PASS: Large batch (256 samples) processed successfully"); Ok(()) } /// Test 5: Edge case - batch_size=1 (should work like single sample) #[test] fn test_edge_case_batch_size_one() -> Result<(), MLError> { let stream = cuda_stream(); let config = DuelingConfig::new(54, 45, vec![256, 128], 128, 128); let dueling = DuelingQNetwork::new(config, stream)?; // Batch of exactly 1 let state: Vec = (0..54) .map(|i| ((i as f32 * 0.37).sin() * 0.5)) .collect(); let q_values = dueling.forward(&state, 1)?; assert_eq!(q_values.len(), 45, "FAIL: Batch size 1 length"); for &q in &q_values { assert!(q.is_finite(), "FAIL: Q-value not finite for batch_size=1"); } info!("PASS: Batch size 1 edge case handled correctly"); Ok(()) }