Files
foxhunt/crates/ml/tests/streaming_pipeline_edge_cases.rs
jgrusewski cf91106e32 fix: migrate 44 test files from Candle to native CUDA — zero test compile errors
Complete Candle→cudarc migration for all test code. The workspace
now compiles clean with `cargo check --workspace --tests` (0 errors)
and `cargo clippy --workspace --lib -D warnings` (0 errors).

Migration patterns applied across all files:
- Tensor → GpuTensor (from_host, zeros, randn, full)
- Device → MlDevice (cuda, cuda_if_available, new_cuda)
- All GpuTensor ops now take &Arc<CudaStream>
- VarMap/VarBuilder → GpuVarStore or removed
- DType removed (everything f32)
- Candle autograd tests (Var, GradStore, backward) → #[ignore]
- Preprocessing tests → host-side Vec<f32> (CPU-side by design)
- PPO hidden state → host-side Vec<f32> slices
- UnifiedTrainable: forward_loss(&[f32], &[f32]) → f64

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:02:26 +01:00

797 lines
23 KiB
Rust

#![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<PathBuf> {
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(())
}