#![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, )] //! # DQN Feature Cache Integration Tests //! //! Comprehensive test suite for the DQN feature caching system that pre-computes //! 51-feature vectors (43 base + 8 OFI) once and reuses them across hyperopt trials. //! //! ## Test Coverage //! //! 1. **Cache Creation Success**: Verify cache file creation with correct metadata //! 2. **Cache Loading Matches Original**: Ensure cached features match non-cached computation //! 3. **Cache Invalidation**: Test cache key changes when input data changes //! 4. **Hyperopt Integration**: Multi-trial cache reuse verification //! 5. **Performance Benchmark**: Validate 20s → <1s loading speedup //! 6. **Edge Cases**: Missing cache, corrupted cache, multiple datasets //! //! ## Expected Performance Gains //! //! - Cache creation: ~20-30s (one-time setup) //! - Cache loading: <1s per trial //! - Time savings: 87-96% for 50-trial hyperopt //! - Cache size: 3-5 MB compressed Parquet per dataset //! //! Design reference: `/tmp/MBP10_OFI_CACHING_DESIGN.md` section 8 use anyhow::{Context, Result}; use std::fs; use std::path::Path; use std::time::{Duration, Instant}; use tempfile::TempDir; use std::ffi::OsStr; use tracing::{info, warn}; // Cache functions (to be implemented based on design doc) // These will initially fail compilation until implemented /// Calculate SHA256-based cache key from input data /// /// Components: /// - Parquet file path + modification time /// - MBP-10 directory path + file list + modification times /// - Feature extraction version (code hash) /// - Warmup period constant fn calculate_cache_key( parquet_path: &Path, mbp10_dir: &Path, warmup_period: usize, ) -> Result { use sha2::{Digest, Sha256}; use std::time::UNIX_EPOCH; let mut hasher = Sha256::new(); // 1. Parquet file path + mtime hasher.update(parquet_path.to_string_lossy().as_bytes()); let parquet_mtime = parquet_path .metadata() .context("Failed to read parquet metadata")? .modified() .context("Failed to get parquet mtime")? .duration_since(UNIX_EPOCH) .context("System time error")? .as_secs(); hasher.update(&parquet_mtime.to_le_bytes()); // 2. MBP-10 directory + all .dbn files + mtimes hasher.update(mbp10_dir.to_string_lossy().as_bytes()); if mbp10_dir.exists() { let mut dbn_files: Vec<_> = fs::read_dir(mbp10_dir) .context("Failed to read mbp10 directory")? .filter_map(|e| e.ok()) .filter(|e| e.path().extension() == Some(OsStr::new("dbn"))) .collect(); dbn_files.sort_by_key(|e| e.path()); for entry in &dbn_files { hasher.update(entry.path().to_string_lossy().as_bytes()); let mtime = entry .metadata() .context("Failed to read dbn metadata")? .modified() .context("Failed to get dbn mtime")? .duration_since(UNIX_EPOCH) .context("System time error")? .as_secs(); hasher.update(&mtime.to_le_bytes()); } } // 3. Feature extraction version (simplified - use constant for now) // In production, this would hash the extraction.rs source file let feature_version = "v1.0.0"; hasher.update(feature_version.as_bytes()); // 4. Warmup period hasher.update(&warmup_period.to_le_bytes()); Ok(format!("{:x}", hasher.finalize())) } /// Create feature cache from parquet and MBP-10 data /// /// This is a placeholder that will be implemented based on the design doc. /// Expected behavior: /// - Load data from parquet_path and mbp10_dir /// - Extract 51-feature vectors /// - Save to cache_dir as Parquet file /// - Create metadata JSON file async fn create_feature_cache( _parquet_path: &Path, _mbp10_dir: &Path, _cache_dir: &Path, _warmup_period: usize, ) -> Result { // NOTE: This is a stub that will fail until implemented // Implementation should follow design doc section 4 (Phase 1) anyhow::bail!( "create_feature_cache not yet implemented - see /tmp/MBP10_OFI_CACHING_DESIGN.md Phase 1" ) } /// Load features from cache /// /// Returns (train_data, val_data) tuples matching DQN trainer format. /// Each sample is (FeatureVector, Vec) where Vec contains target prices. async fn load_features_from_cache( _cache_dir: &Path, _parquet_path: &Path, _mbp10_dir: &Path, _warmup_period: usize, ) -> Result)>, Vec<([f64; 42], Vec)>)>> { // NOTE: This is a stub that will fail until implemented // Implementation should follow design doc section 4 (Phase 2) anyhow::bail!( "load_features_from_cache not yet implemented - see /tmp/MBP10_OFI_CACHING_DESIGN.md Phase 2" ) } // ============================================================================ // TEST 1: Cache Creation Success // ============================================================================ #[tokio::test] #[ignore] // Ignore until cache creation is implemented async fn test_cache_creation_success() -> Result<()> { // Setup let temp_dir = TempDir::new().context("Failed to create temp directory")?; let cache_dir = temp_dir.path().join("cache"); fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?; // Create cache let result = create_feature_cache( Path::new("test_data/ES_FUT_180d.parquet"), Path::new("test_data/mbp10"), &cache_dir, 50, ).await; assert!( result.is_ok(), "Cache creation should succeed: {:?}", result.err() ); // Verify cache file exists let cache_files: Vec<_> = fs::read_dir(&cache_dir) .context("Failed to read cache directory")? .filter_map(|e| e.ok()) .filter(|e| e.path().extension() == Some(OsStr::new("parquet"))) .collect(); assert_eq!( cache_files.len(), 1, "Should create exactly one cache file" ); // Verify metadata exists let metadata_path = cache_dir.join("cache_metadata.json"); assert!( metadata_path.exists(), "Metadata file should exist at {:?}", metadata_path ); // Verify file size (should be 3-5 MB compressed) let file_size = fs::metadata(cache_files[0].path()) .context("Failed to read cache file metadata")? .len(); assert!( file_size > 2_000_000, "Cache file too small: {} bytes (expected >2MB)", file_size ); assert!( file_size < 10_000_000, "Cache file too large: {} bytes (expected <10MB)", file_size ); info!(file_size_kb = file_size / 1024, "Cache created successfully"); Ok(()) } // ============================================================================ // TEST 2: Cache Loading Matches Original // ============================================================================ #[tokio::test] #[ignore] // Ignore until cache loading is implemented async fn test_cache_loading_matches_original() -> Result<()> { let temp_dir = TempDir::new().context("Failed to create temp directory")?; let cache_dir = temp_dir.path().join("cache"); fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?; // Create cache create_feature_cache( Path::new("test_data/ES_FUT_180d.parquet"), Path::new("test_data/mbp10"), &cache_dir, 50, ) .await .context("Failed to create cache")?; // Load from cache let (cached_train, cached_val) = load_features_from_cache( &cache_dir, Path::new("test_data/ES_FUT_180d.parquet"), Path::new("test_data/mbp10"), 50, ) .await .context("Failed to load from cache")? .expect("Cache should exist"); // Load original way (no cache) - this requires DQN trainer modification // For now, we'll verify the cached data structure is correct assert!( !cached_train.is_empty(), "Training data should not be empty" ); assert!( !cached_val.is_empty(), "Validation data should not be empty" ); // Verify feature dimensions let (features, targets) = &cached_train[0]; assert_eq!( features.len(), 51, "Each sample should have 51 features" ); assert_eq!( targets.len(), 4, "Each sample should have 4 target prices" ); // Verify train/val split is approximately 80/20 let total = cached_train.len() + cached_val.len(); let train_ratio = cached_train.len() as f64 / total as f64; assert!( (train_ratio - 0.8).abs() < 0.05, "Train/val split should be ~80/20, got {:.2}", train_ratio ); // Compare first 10 samples (features should be deterministic) for i in 0..10.min(cached_train.len()) { let features = &cached_train[i].0; // Verify no NaN or Inf values for (j, &value) in features.iter().enumerate() { assert!( value.is_finite(), "Feature {} in sample {} is not finite: {}", j, i, value ); } } info!(train_samples = cached_train.len(), val_samples = cached_val.len(), "Cache loaded successfully"); Ok(()) } // ============================================================================ // TEST 3: Cache Invalidation on Data Change // ============================================================================ #[tokio::test] async fn test_cache_invalidation_on_data_change() -> Result<()> { let temp_dir = TempDir::new().context("Failed to create temp directory")?; // Create a temporary parquet file let parquet_path = temp_dir.path().join("test.parquet"); fs::write(&parquet_path, b"mock parquet data v1") .context("Failed to write parquet file")?; let mbp10_dir = temp_dir.path().join("mbp10"); fs::create_dir_all(&mbp10_dir).context("Failed to create mbp10 directory")?; // Calculate initial cache key let cache_key1 = calculate_cache_key(&parquet_path, &mbp10_dir, 50) .context("Failed to calculate initial cache key")?; // Wait to ensure mtime changes (Linux has second-level granularity in most filesystems) std::thread::sleep(Duration::from_secs(2)); // Modify file content to trigger mtime update fs::write(&parquet_path, b"mock parquet data v2 - MODIFIED CONTENT") .context("Failed to modify parquet file")?; // Calculate new cache key let cache_key2 = calculate_cache_key(&parquet_path, &mbp10_dir, 50) .context("Failed to calculate new cache key")?; info!(cache_key1 = &cache_key1[..16], "Cache key 1 (original)"); info!(cache_key2 = &cache_key2[..16], "Cache key 2 (after modification)"); // Keys should differ (cache invalidated) assert_ne!( cache_key1, cache_key2, "Cache key should change when data file is modified.\nKey1: {}\nKey2: {}", cache_key1, cache_key2 ); info!(old_key = &cache_key1[..16], new_key = &cache_key2[..16], "Cache invalidation works"); Ok(()) } // ============================================================================ // TEST 4: Cache Key Stability // ============================================================================ #[tokio::test] async fn test_cache_key_stability() -> Result<()> { let temp_dir = TempDir::new().context("Failed to create temp directory")?; // Create test files let parquet_path = temp_dir.path().join("test.parquet"); fs::write(&parquet_path, b"mock parquet data") .context("Failed to write parquet file")?; let mbp10_dir = temp_dir.path().join("mbp10"); fs::create_dir_all(&mbp10_dir).context("Failed to create mbp10 directory")?; // Calculate cache key multiple times without modification let key1 = calculate_cache_key(&parquet_path, &mbp10_dir, 50)?; let key2 = calculate_cache_key(&parquet_path, &mbp10_dir, 50)?; let key3 = calculate_cache_key(&parquet_path, &mbp10_dir, 50)?; // Keys should be identical (deterministic) assert_eq!( key1, key2, "Cache key should be deterministic" ); assert_eq!( key2, key3, "Cache key should be deterministic" ); info!(key = %key1, "Cache key is stable"); Ok(()) } // ============================================================================ // TEST 5: Cache Key with Different Warmup Periods // ============================================================================ #[tokio::test] async fn test_cache_key_different_warmup() -> Result<()> { let temp_dir = TempDir::new().context("Failed to create temp directory")?; // Create test files let parquet_path = temp_dir.path().join("test.parquet"); fs::write(&parquet_path, b"mock parquet data") .context("Failed to write parquet file")?; let mbp10_dir = temp_dir.path().join("mbp10"); fs::create_dir_all(&mbp10_dir).context("Failed to create mbp10 directory")?; // Calculate cache keys with different warmup periods let key_warmup_50 = calculate_cache_key(&parquet_path, &mbp10_dir, 50)?; let key_warmup_100 = calculate_cache_key(&parquet_path, &mbp10_dir, 100)?; // Keys should differ (different warmup periods) assert_ne!( key_warmup_50, key_warmup_100, "Cache key should change when warmup period changes" ); info!(key_warmup_50 = &key_warmup_50[..16], key_warmup_100 = &key_warmup_100[..16], "Different warmup periods produce different keys"); Ok(()) } // ============================================================================ // TEST 6: Hyperopt Integration (Multi-Trial Cache Reuse) // ============================================================================ #[tokio::test] #[ignore] // Ignore until hyperopt integration is implemented async fn test_hyperopt_with_cache() -> Result<()> { let temp_dir = TempDir::new().context("Failed to create temp directory")?; let cache_dir = temp_dir.path().join("cache"); fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?; // Pre-create cache create_feature_cache( Path::new("test_data/ES_FUT_180d.parquet"), Path::new("test_data/mbp10"), &cache_dir, 50, ) .await .context("Failed to create cache")?; // Simulate 3 hyperopt trials (in production, this would use DQNHyperoptAdapter) let start = Instant::now(); let mut trial_durations = Vec::new(); for trial in 1..=3 { let trial_start = Instant::now(); // Load features from cache let (_train, _val) = load_features_from_cache( &cache_dir, Path::new("test_data/ES_FUT_180d.parquet"), Path::new("test_data/mbp10"), 50, ) .await .context("Failed to load from cache")? .expect("Cache should exist"); let trial_duration = trial_start.elapsed(); trial_durations.push(trial_duration); info!(trial, duration = ?trial_duration, "Loaded features from cache"); } let total_duration = start.elapsed(); // Verify results assert_eq!(trial_durations.len(), 3, "Should complete all 3 trials"); // Each trial should load in <2s (with cache) for (i, duration) in trial_durations.iter().enumerate() { assert!( duration < &Duration::from_secs(2), "Trial {} took too long: {:?} (expected <2s with cache)", i + 1, duration ); } // Total should be <10s (generous, in practice should be ~3s) assert!( total_duration < Duration::from_secs(10), "Total hyperopt time with cache took too long: {:?} (expected <10s)", total_duration ); info!(total_duration = ?total_duration, avg_per_trial = ?(total_duration / 3), "Hyperopt with cache completed"); Ok(()) } // ============================================================================ // TEST 7: Performance Benchmark (Cache vs No Cache) // ============================================================================ #[tokio::test] #[ignore] // Ignore by default - only run for performance validation async fn test_cache_performance_benchmark() -> Result<()> { let temp_dir = TempDir::new().context("Failed to create temp directory")?; let cache_dir = temp_dir.path().join("cache"); fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?; info!("DQN Feature Cache Performance Benchmark"); // Benchmark: Cache creation info!("Step 1: Cache creation (one-time setup)"); let start = Instant::now(); create_feature_cache( Path::new("test_data/ES_FUT_180d.parquet"), Path::new("test_data/mbp10"), &cache_dir, 50, ) .await .context("Failed to create cache")?; let creation_time = start.elapsed(); info!(creation_time = ?creation_time, "Cache creation time"); // Verify creation time is reasonable assert!( creation_time < Duration::from_secs(60), "Cache creation too slow: {:?} (expected <60s)", creation_time ); // Benchmark: Cache loading (3 trials) info!("Step 2: Cache loading (simulating hyperopt trials)"); let mut load_times = Vec::new(); for trial in 1..=3 { let start = Instant::now(); let (_train, _val) = load_features_from_cache( &cache_dir, Path::new("test_data/ES_FUT_180d.parquet"), Path::new("test_data/mbp10"), 50, ) .await .context("Failed to load from cache")? .expect("Cache should exist"); let load_time = start.elapsed(); load_times.push(load_time); info!(trial, load_time = ?load_time, "Cache load time for trial"); } let avg_load_time = load_times.iter().sum::() / load_times.len() as u32; // Summary let without_cache = Duration::from_secs(20 * 50); // 20s per trial let with_cache = creation_time + (avg_load_time * 50); let savings = without_cache.as_secs() - with_cache.as_secs(); let savings_pct = (savings as f64 / without_cache.as_secs() as f64) * 100.0; info!(creation_time = ?creation_time, avg_load_time = ?avg_load_time, savings_pct, "Performance summary"); info!(without_cache_secs = without_cache.as_secs(), with_cache_secs = with_cache.as_secs(), savings_secs = savings, "Hyperopt impact (50 trials)"); // Assertions assert!( avg_load_time < Duration::from_secs(2), "Cache loading too slow: {:?} (expected <2s)", avg_load_time ); assert!( savings_pct > 80.0, "Cache savings insufficient: {:.1}% (expected >80%)", savings_pct ); info!("Performance benchmark passed"); Ok(()) } // ============================================================================ // TEST 8: Edge Case - Missing Cache Directory // ============================================================================ #[tokio::test] #[ignore] // Ignore until cache loading is implemented async fn test_missing_cache_graceful_fallback() -> Result<()> { let temp_dir = TempDir::new().context("Failed to create temp directory")?; let nonexistent_cache = temp_dir.path().join("nonexistent_cache"); // Try to load from non-existent cache let result = load_features_from_cache( &nonexistent_cache, Path::new("test_data/ES_FUT_180d.parquet"), Path::new("test_data/mbp10"), 50, ) .await; // Should return None (cache miss) rather than error match result { Ok(None) => { info!("Missing cache handled gracefully (returns None)"); Ok(()) } Ok(Some(_)) => { anyhow::bail!("Should not find cache in non-existent directory") } Err(e) => { anyhow::bail!("Should return None for missing cache, got error: {}", e) } } } // ============================================================================ // TEST 9: Multiple Datasets (Separate Cache Files) // ============================================================================ #[tokio::test] #[ignore] // Ignore until cache creation is implemented async fn test_multiple_datasets_separate_caches() -> Result<()> { let temp_dir = TempDir::new().context("Failed to create temp directory")?; let cache_dir = temp_dir.path().join("cache"); fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?; // Create test files for two datasets let dataset1 = temp_dir.path().join("ES_FUT_180d.parquet"); let dataset2 = temp_dir.path().join("NQ_FUT_180d.parquet"); fs::write(&dataset1, b"ES futures data").context("Failed to write dataset1")?; fs::write(&dataset2, b"NQ futures data").context("Failed to write dataset2")?; let mbp10_dir = temp_dir.path().join("mbp10"); fs::create_dir_all(&mbp10_dir).context("Failed to create mbp10 directory")?; // Calculate cache keys for both datasets let key1 = calculate_cache_key(&dataset1, &mbp10_dir, 50)?; let key2 = calculate_cache_key(&dataset2, &mbp10_dir, 50)?; // Keys should differ (different datasets) assert_ne!( key1, key2, "Different datasets should produce different cache keys" ); info!(es_key = &key1[..16], nq_key = &key2[..16], "Multiple datasets produce separate cache keys"); Ok(()) }