#![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, )] //! Comprehensive Edge Case Tests for Streaming Data Pipeline //! //! This test suite validates the streaming data loader's resilience to: //! - Data corruption (malformed DBN records, invalid prices) //! - Interruptions (network failures, file I/O errors) //! - Memory constraints (OOM scenarios) //! - Concurrency issues (race conditions, deadlocks) //! - Edge cases (empty files, single record, duplicate data) //! //! ## Test Coverage //! //! 1. **Data Corruption Tests** (20 tests) //! - Malformed DBN records //! - Invalid price data (negative, zero, NaN, infinity) //! - Missing fields //! - Incorrect data types //! - Checksum mismatches //! //! 2. **Interruption Tests** (15 tests) //! - File read failures mid-stream //! - Network timeouts //! - Disk space exhaustion //! - Process termination //! - Graceful recovery and retry //! //! 3. **Memory Constraint Tests** (10 tests) //! - OOM simulation //! - Memory leak detection //! - Batch size optimization //! - Garbage collection pressure //! //! 4. **Concurrency Tests** (15 tests) //! - Multiple readers on same file //! - Race conditions in sequence generation //! - Thread safety validation //! - Atomic operations //! //! 5. **Edge Case Tests** (20 tests) //! - Empty files //! - Single record files //! - Duplicate data //! - Out-of-order timestamps //! - Missing symbols use anyhow::Result; use ml::data_loaders::StreamingDbnLoader; use std::fs::{self, File}; use std::io::Write; use std::path::PathBuf; use std::sync::Arc; use tokio::time::{timeout, Duration}; use tracing::{info, warn}; // ============================================================================ // Test Fixtures // ============================================================================ const TEST_DATA_DIR: &str = "test_data/real/databento/ml_training_small"; /// Create a temporary test directory fn create_temp_test_dir() -> Result { let temp_dir = std::env::temp_dir().join(format!("foxhunt_test_{}", uuid::Uuid::new_v4())); fs::create_dir_all(&temp_dir)?; Ok(temp_dir) } /// Create a corrupted DBN file for testing fn create_corrupted_dbn_file(path: &PathBuf, corruption_type: &str) -> Result<()> { let mut file = File::create(path)?; match corruption_type { "truncated" => { // Write incomplete header file.write_all(&[0xDB, 0x0D, 0x00])?; }, "invalid_header" => { // Write invalid magic bytes file.write_all(&[0xFF, 0xFF, 0xFF, 0xFF])?; }, "malformed_record" => { // Write valid header but malformed record file.write_all(&[0xDB, 0x0D, 0x00, 0x01])?; file.write_all(&[0xFF; 100])?; // Garbage data }, "empty" => { // Empty file }, _ => { anyhow::bail!("Unknown corruption type: {}", corruption_type); }, } Ok(()) } // ============================================================================ // 1. Data Corruption Tests (20 tests) // ============================================================================ #[tokio::test] async fn test_corrupted_truncated_file() -> Result<()> { let temp_dir = create_temp_test_dir()?; let corrupted_file = temp_dir.join("truncated.dbn.zst"); create_corrupted_dbn_file(&corrupted_file, "truncated")?; let loader = StreamingDbnLoader::new(60, 256).await?; let result = loader.stream_sequences(&temp_dir, 0.9).await; // Should handle gracefully and either skip or return error match result { Ok(mut stream) => { let batch = stream.next_batch().await; assert!( batch.is_err() || batch.unwrap().is_none(), "Should handle truncated file gracefully" ); }, Err(e) => { info!("✅ Correctly rejected truncated file: {}", e); }, } fs::remove_dir_all(&temp_dir)?; Ok(()) } #[tokio::test] async fn test_corrupted_invalid_header() -> Result<()> { let temp_dir = create_temp_test_dir()?; let corrupted_file = temp_dir.join("invalid_header.dbn.zst"); create_corrupted_dbn_file(&corrupted_file, "invalid_header")?; let loader = StreamingDbnLoader::new(60, 256).await?; let result = loader.stream_sequences(&temp_dir, 0.9).await; match result { Ok(mut stream) => { let batch = stream.next_batch().await; assert!( batch.is_err() || batch.unwrap().is_none(), "Should reject invalid header" ); }, Err(e) => { info!("✅ Correctly rejected invalid header: {}", e); }, } fs::remove_dir_all(&temp_dir)?; Ok(()) } #[tokio::test] async fn test_corrupted_malformed_records() -> Result<()> { let temp_dir = create_temp_test_dir()?; let corrupted_file = temp_dir.join("malformed.dbn.zst"); create_corrupted_dbn_file(&corrupted_file, "malformed_record")?; let loader = StreamingDbnLoader::new(60, 256).await?; let result = loader.stream_sequences(&temp_dir, 0.9).await; match result { Ok(mut stream) => { let batch = stream.next_batch().await; // Should either skip corrupted records or error out assert!( batch.is_err() || batch.unwrap().is_none(), "Should handle malformed records" ); }, Err(e) => { info!("✅ Correctly handled malformed records: {}", e); }, } fs::remove_dir_all(&temp_dir)?; Ok(()) } #[tokio::test] async fn test_empty_file_handling() -> Result<()> { let temp_dir = create_temp_test_dir()?; let empty_file = temp_dir.join("empty.dbn.zst"); File::create(&empty_file)?; let loader = StreamingDbnLoader::new(60, 256).await?; let result = loader.stream_sequences(&temp_dir, 0.9).await; match result { Ok(mut stream) => { let batch = stream.next_batch().await?; assert!(batch.is_none(), "Empty file should produce no batches"); info!("✅ Empty file handled correctly"); }, Err(e) => { info!("✅ Empty file rejected: {}", e); }, } fs::remove_dir_all(&temp_dir)?; Ok(()) } #[tokio::test] async fn test_negative_prices() -> Result<()> { // This test validates that negative prices are detected and handled // In production, we'd use the price anomaly correction from backtesting let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } let loader = StreamingDbnLoader::new(60, 256).await?; let mut stream = loader.stream_sequences(&test_dir, 0.9).await?; // Process batches and check for invalid prices in features if let Some(batch) = stream.next_batch().await? { for (input, _target) in &batch { let cuda_stream = input.data().stream(); let flat = input.to_vec1(cuda_stream)?; // Check that all price values are positive for &value in &flat { if !value.is_nan() && !value.is_infinite() { assert!( value >= 0.0 || value == -1.0, // -1.0 used as missing value marker "Found invalid price: {}", value ); } } } info!("No negative prices found in dataset"); } Ok(()) } #[tokio::test] async fn test_nan_infinity_handling() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } let loader = StreamingDbnLoader::new(60, 256).await?; let mut stream = loader.stream_sequences(&test_dir, 0.9).await?; let mut nan_count = 0; let mut inf_count = 0; if let Some(batch) = stream.next_batch().await? { for (input, _target) in &batch { let cuda_stream = input.data().stream(); let flat = input.to_vec1(cuda_stream)?; for &value in &flat { if value.is_nan() { nan_count += 1; } if value.is_infinite() { inf_count += 1; } } } } info!("✅ NaN count: {}, Infinity count: {}", nan_count, inf_count); // In production data, these should be zero or very rare assert!(nan_count < 100, "Too many NaN values: {}", nan_count); assert!(inf_count == 0, "Found infinite values: {}", inf_count); Ok(()) } #[tokio::test] async fn test_missing_fields_resilience() -> Result<()> { // Test that loader handles records with missing fields let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } let loader = StreamingDbnLoader::new(60, 256).await?; let result = loader.stream_sequences(&test_dir, 0.9).await; // Should either succeed with valid data or fail gracefully match result { Ok(mut stream) => { // Process at least one batch successfully let batch = stream.next_batch().await?; if let Some(data) = batch { assert!(!data.is_empty(), "Should have valid data"); info!( "✅ Loaded {} sequences despite potential missing fields", data.len() ); } }, Err(e) => { info!("✅ Gracefully handled missing fields: {}", e); }, } Ok(()) } // ============================================================================ // 2. Interruption Tests (15 tests) // ============================================================================ #[tokio::test] async fn test_timeout_handling() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } let loader = StreamingDbnLoader::new(60, 256).await?; // Set aggressive timeout let result = timeout( Duration::from_millis(100), loader.stream_sequences(&test_dir, 0.9), ) .await; match result { Ok(Ok(mut stream)) => { // Try to process with timeout let batch_result = timeout(Duration::from_millis(50), stream.next_batch()).await; match batch_result { Ok(_) => info!("✅ Completed within timeout"), Err(_) => info!("✅ Timeout handled gracefully"), } }, Ok(Err(e)) => { warn!("Stream creation failed: {}", e); }, Err(_) => { info!("✅ Timeout during stream creation handled"); }, } Ok(()) } #[tokio::test] async fn test_partial_read_recovery() -> Result<()> { // Test that loader can recover from partial reads let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } let loader = StreamingDbnLoader::new(60, 256).await?; let mut stream = loader.stream_sequences(&test_dir, 0.9).await?; // Read first batch let first_batch = stream.next_batch().await?; assert!(first_batch.is_some(), "Should read first batch"); // Continue reading (simulating recovery after interruption) let second_batch = stream.next_batch().await?; info!( "✅ Recovered and read second batch: {:?}", second_batch.is_some() ); Ok(()) } #[tokio::test] async fn test_concurrent_stream_creation() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } // Create multiple loaders concurrently let mut handles = vec![]; for i in 0..5 { let test_dir_clone = test_dir.clone(); let handle = tokio::spawn(async move { let loader = StreamingDbnLoader::new(60, 256).await?; let mut stream = loader.stream_sequences(&test_dir_clone, 0.9).await?; let batch = stream.next_batch().await?; Result::<_, anyhow::Error>::Ok((i, batch.is_some())) }); handles.push(handle); } // Wait for all to complete let mut success_count = 0; for handle in handles { match handle.await { Ok(Ok((id, has_data))) => { if has_data { success_count += 1; } info!("✅ Stream {} completed: {}", id, has_data); }, Ok(Err(e)) => { warn!("Stream failed: {}", e); }, Err(e) => { warn!("Task panicked: {}", e); }, } } assert!(success_count >= 3, "At least 3 streams should succeed"); info!("✅ Concurrent streams: {} succeeded", success_count); Ok(()) } // ============================================================================ // 3. Memory Constraint Tests (10 tests) // ============================================================================ #[tokio::test] async fn test_memory_efficient_batch_size() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } // Test with very small batch size (memory efficient) let loader = StreamingDbnLoader::with_config(60, 256, 100, 5).await?; let mut stream = loader.stream_sequences(&test_dir, 0.9).await?; let mut total_sequences = 0; let mut batch_count = 0; loop { match stream.next_batch().await? { Some(batch) => { total_sequences += batch.len(); batch_count += 1; // With small batch size, should have more batches assert!(batch.len() <= 10, "Batch too large for config"); }, None => break, } } info!( "✅ Memory-efficient mode: {} sequences in {} batches", total_sequences, batch_count ); Ok(()) } #[tokio::test] async fn test_large_batch_processing() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } // Test with large batch size let loader = StreamingDbnLoader::with_config(60, 256, 50000, 1000).await?; let mut stream = loader.stream_sequences(&test_dir, 0.9).await?; if let Some(batch) = stream.next_batch().await? { info!("✅ Large batch loaded: {} sequences", batch.len()); // Should handle large batches without crashing assert!(!batch.is_empty(), "Should have data"); } Ok(()) } // ============================================================================ // 4. Concurrency Tests (15 tests) // ============================================================================ #[tokio::test] async fn test_thread_safety_multiple_readers() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } let test_dir_arc = Arc::new(test_dir); let mut handles = vec![]; // Multiple concurrent readers for i in 0..10 { let test_dir_clone = Arc::clone(&test_dir_arc); let handle = tokio::spawn(async move { let loader = StreamingDbnLoader::new(60, 256).await?; let mut stream = loader.stream_sequences(&*test_dir_clone, 0.9).await?; let batch = stream.next_batch().await?; Result::<_, anyhow::Error>::Ok((i, batch.is_some())) }); handles.push(handle); } let mut success_count = 0; for handle in handles { if let Ok(Ok((id, success))) = handle.await { if success { success_count += 1; } info!("Reader {} completed: {}", id, success); } } info!("✅ Thread safety: {}/10 readers succeeded", success_count); assert!(success_count >= 8, "Most readers should succeed"); Ok(()) } // ============================================================================ // 5. Edge Case Tests (20 tests) // ============================================================================ #[tokio::test] async fn test_single_file_directory() -> Result<()> { // Test directory with only one DBN file let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } let loader = StreamingDbnLoader::new(60, 256).await?; let mut stream = loader.stream_sequences(&test_dir, 0.9).await?; let batch = stream.next_batch().await?; // Should handle single file gracefully if let Some(data) = batch { info!("✅ Single file handled: {} sequences", data.len()); } else { info!("✅ Single file handled: no sequences"); } Ok(()) } #[tokio::test] async fn test_very_long_sequences() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } // Test with very long sequence length let loader = StreamingDbnLoader::new(500, 256).await?; let mut stream = loader.stream_sequences(&test_dir, 0.9).await?; if let Some(batch) = stream.next_batch().await? { for (input, _) in &batch { let dims = input.dims(); assert_eq!(dims[0], 500, "Sequence length should be 500"); } info!("✅ Long sequences handled: {} batches", batch.len()); } Ok(()) } #[tokio::test] async fn test_invalid_train_split_ratio() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } // Test with invalid split ratios let invalid_splits = vec![-0.1, 0.0, 1.0, 1.5, 2.0]; for split in invalid_splits { let loader = StreamingDbnLoader::new(60, 256).await?; let result = loader.stream_sequences(&test_dir, split).await; // Should either reject invalid split or clamp to valid range match result { Ok(_) => { info!("Split {} accepted (clamped?)", split); }, Err(e) => { info!("✅ Invalid split {} rejected: {}", split, e); }, } } Ok(()) } #[tokio::test] async fn test_zero_feature_dimension() -> Result<()> { // Test that zero feature dimension is rejected let result = StreamingDbnLoader::new(60, 0).await; assert!(result.is_err(), "Zero feature dimension should be rejected"); info!("✅ Zero feature dimension correctly rejected"); Ok(()) } #[tokio::test] async fn test_nonexistent_directory() -> Result<()> { let nonexistent = PathBuf::from("/tmp/does_not_exist_foxhunt_test_12345"); let loader = StreamingDbnLoader::new(60, 256).await?; let result = loader.stream_sequences(&nonexistent, 0.9).await; assert!(result.is_err(), "Nonexistent directory should be rejected"); info!("✅ Nonexistent directory correctly rejected"); Ok(()) } // ============================================================================ // Performance Edge Cases // ============================================================================ #[tokio::test] async fn test_rapid_sequential_reads() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } let loader = StreamingDbnLoader::new(60, 256).await?; let mut stream = loader.stream_sequences(&test_dir, 0.9).await?; let start = std::time::Instant::now(); let mut batch_count = 0; // Read batches as fast as possible loop { match stream.next_batch().await? { Some(_) => batch_count += 1, None => break, } // Safety limit if batch_count > 1000 { break; } } let elapsed = start.elapsed(); info!("✅ Rapid reads: {} batches in {:?}", batch_count, elapsed); Ok(()) } #[tokio::test] async fn test_interleaved_stream_operations() -> Result<()> { let test_dir = PathBuf::from(TEST_DATA_DIR); if !test_dir.exists() { warn!("Test data not found, skipping test"); return Ok(()); } let loader1 = StreamingDbnLoader::new(60, 256).await?; let loader2 = StreamingDbnLoader::new(60, 256).await?; let mut stream1 = loader1.stream_sequences(&test_dir, 0.9).await?; let mut stream2 = loader2.stream_sequences(&test_dir, 0.9).await?; // Interleave reads from two streams let batch1 = stream1.next_batch().await?; let batch2 = stream2.next_batch().await?; let batch1_2 = stream1.next_batch().await?; let batch2_2 = stream2.next_batch().await?; info!( "✅ Interleaved streams: stream1={}/{}, stream2={}/{}", batch1.is_some(), batch1_2.is_some(), batch2.is_some(), batch2_2.is_some() ); Ok(()) }