#![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, )] //! Production Training Integration Smoke Test //! //! Validates that all production training pipeline components added in the //! production-training worktree work together correctly: //! //! 2. 26D parameter space with round-trip preservation //! 3. EarlyStoppingConfig with SuccessiveHalving //! 4. EarlyStoppingConfig with Hyperband (rung-based pruning) //! 5. ObjectiveMode equality comparison //! 6. QR-DQN training on real data (graceful skip if unavailable) #![allow(unused_crate_dependencies)] use anyhow::{Context, Result}; use ml::hyperopt::adapters::dqn::{DQNParams, ObjectiveMode}; use ml::hyperopt::early_stopping::{ EarlyStoppingConfig, EarlyStoppingObserver, EarlyStoppingStrategy, EpochMetrics, ObserverDecision, TrialObserver, }; use ml::hyperopt::traits::ParameterSpace; use tracing::{info, warn}; /// Test 1: Verify DQNParams::default() has IQN disabled and CQL alpha=0.1 /// (IQN disabled to fix train/inference network mismatch, CQL reduced from 1.0) #[test] fn test_qr_dqn_defaults() -> Result<()> { let params = DQNParams::default(); // IQN is always enabled by default assert!(params.iqn_lambda > 0.0, "IQN should be enabled by default"); assert_eq!( params.num_quantiles, 32, "DQNParams::default() should have num_quantiles: 32, got {}", params.num_quantiles ); let cql_diff = (params.cql_alpha - 0.1).abs(); assert!( cql_diff < 1e-10, "DQNParams::default() should have cql_alpha: 0.1, got {}", params.cql_alpha ); info!( cql_alpha = params.cql_alpha, num_quantiles = params.num_quantiles, "Test 1 PASSED: DQN defaults verified" ); Ok(()) } /// Test 2: Verify 28D parameter space and round-trip preservation of CQL alpha #[test] fn test_28d_parameter_space_round_trip() -> Result<()> { // Verify dimensionality (28D search space including sharpe_weight) let bounds = DQNParams::continuous_bounds(); assert_eq!( bounds.len(), 28, "DQNParams::continuous_bounds() should return 28 dimensions, got {}", bounds.len() ); // Verify all bounds are valid (min < max) for (i, (lo, hi)) in bounds.iter().enumerate() { assert!( lo < hi, "Bound {} has invalid range: [{}, {}]", i, lo, hi ); } // Round-trip: default -> continuous -> from_continuous let original = DQNParams::default(); let continuous = original.to_continuous(); assert_eq!( continuous.len(), 28, "to_continuous() should return 28 values, got {}", continuous.len() ); let reconstructed = DQNParams::from_continuous(&continuous) .map_err(|e| anyhow::anyhow!("from_continuous failed: {}", e))?; // Verify CQL alpha survives round trip let cql_diff = (reconstructed.cql_alpha - original.cql_alpha).abs(); assert!( cql_diff < 0.01, "Round-trip should preserve cql_alpha: expected {}, got {} (diff={})", original.cql_alpha, reconstructed.cql_alpha, cql_diff ); // Verify other key params survive the round trip let lr_diff = (reconstructed.learning_rate - original.learning_rate).abs(); assert!( lr_diff < 1e-8, "Round-trip should preserve learning_rate: expected {}, got {}", original.learning_rate, reconstructed.learning_rate ); info!( dimensions = bounds.len(), cql_alpha = reconstructed.cql_alpha, "Test 2 PASSED: 26D parameter space round-trip verified" ); Ok(()) } /// Test 3: EarlyStoppingConfig with SuccessiveHalving /// /// Verifies that EarlyStoppingObserver with SHA strategy can be constructed /// and returns Continue for a good initial trial (no history to prune against). #[test] fn test_early_stopping_successive_halving() -> Result<()> { let config = EarlyStoppingConfig { patience_epochs: 10, min_delta: 1e-4, min_epochs: 0, // Allow early decisions for testing strategy: EarlyStoppingStrategy::SuccessiveHalving { reduction_factor: 3, }, ..Default::default() }; let mut observer = EarlyStoppingObserver::new(config); // Start a trial with no prior history -- should not prune observer.on_trial_start(0, "sha_test_trial_0"); let metrics = EpochMetrics { epoch: 1, train_loss: 0.3, val_loss: 0.3, timestamp: 1.0, }; let decision = observer.on_epoch_complete(0, 1, &metrics); assert_eq!( decision, ObserverDecision::Continue, "SHA should not prune first trial with no history (got {:?})", decision ); // Complete a few trials to build history, then verify pruning works for trial in 0..6 { observer.on_trial_start(trial, &format!("sha_trial_{}", trial)); let loss = (trial as f64 + 1.0) * 0.1; // 0.1, 0.2, ..., 0.6 observer.on_trial_complete(trial, loss); } // New trial with bad loss should be pruned (6 trials, keep top 1/3 = 2, threshold ~ 0.2) observer.on_trial_start(7, "sha_bad_trial"); let bad_metrics = EpochMetrics { epoch: 1, train_loss: 0.9, val_loss: 0.9, timestamp: 2.0, }; let bad_decision = observer.on_epoch_complete(7, 1, &bad_metrics); assert_eq!( bad_decision, ObserverDecision::StopTrial, "SHA should prune trial with val_loss=0.9 when threshold is ~0.2" ); info!("Test 3 PASSED: SuccessiveHalving early stopping verified (good trial: Continue, bad trial: StopTrial)"); Ok(()) } /// Test 4: EarlyStoppingConfig with Hyperband /// /// Verifies that Hyperband does NOT prune between rungs (e.g. epoch 5) /// but DOES prune at rung epochs (e.g. epoch 27) for a bad trial. #[test] fn test_early_stopping_hyperband_rung_behavior() -> Result<()> { // max_resource=81, reduction_factor=3 // Rung epochs: 81/3=27, 81/9=9, 81/27=3, 81/81=1 let config = EarlyStoppingConfig { patience_epochs: 100, // High patience so only Hyperband logic matters min_delta: 1e-4, min_epochs: 0, // Allow early decisions strategy: EarlyStoppingStrategy::Hyperband { max_resource: 81, reduction_factor: 3, }, ..Default::default() }; let mut observer = EarlyStoppingObserver::new(config); // Seed with completed trials so pruning has history for trial in 0..9 { observer.on_trial_start(trial, &format!("hb_trial_{}", trial)); observer.on_trial_complete(trial, (trial as f64 + 1.0) * 0.1); } // Test: epoch 5 is NOT a rung -- should NOT prune even with bad loss observer.on_trial_start(10, "hb_non_rung_trial"); let non_rung_metrics = EpochMetrics { epoch: 5, train_loss: 0.95, val_loss: 0.95, timestamp: 5.0, }; let non_rung_decision = observer.on_epoch_complete(10, 5, &non_rung_metrics); assert_eq!( non_rung_decision, ObserverDecision::Continue, "Hyperband should NOT prune at non-rung epoch 5 (got {:?})", non_rung_decision ); // Test: epoch 27 IS a rung (81/3=27) -- should prune bad trial observer.on_trial_start(11, "hb_rung_trial"); let rung_metrics = EpochMetrics { epoch: 27, train_loss: 0.95, val_loss: 0.95, timestamp: 27.0, }; let rung_decision = observer.on_epoch_complete(11, 27, &rung_metrics); assert_eq!( rung_decision, ObserverDecision::StopTrial, "Hyperband should prune bad trial at rung epoch 27 (got {:?})", rung_decision ); info!("Test 4 PASSED: Hyperband rung-based pruning verified (non-rung epoch 5: Continue, rung epoch 27: StopTrial)"); Ok(()) } /// Test 5: ObjectiveMode equality comparison #[test] fn test_objective_mode_equality() -> Result<()> { let mode_a = ObjectiveMode::EpisodeReward; let mode_b = ObjectiveMode::Sharpe; let mode_c = ObjectiveMode::EpisodeReward; // Same variant should be equal assert_eq!( mode_a, mode_c, "EpisodeReward should equal EpisodeReward" ); // Different variants should not be equal assert_ne!( mode_a, mode_b, "EpisodeReward should not equal Sharpe" ); // Default should be Sharpe let default_mode = ObjectiveMode::default(); assert_eq!( default_mode, mode_b, "ObjectiveMode::default() should be Sharpe" ); info!("Test 5 PASSED: ObjectiveMode PartialEq verified"); Ok(()) } /// Test 6: QR-DQN training on real data /// /// Gracefully skips if test data is not available. #[tokio::test] async fn test_qr_dqn_training_real_data() -> Result<()> { use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer}; use std::path::PathBuf; // Locate test data (same pattern as dqn_training_smoke_test.rs) let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .ancestors() .find(|p| p.join("test_data").exists()) .context("Failed to find workspace root with test_data/")? .to_path_buf(); let data_dir = workspace_root.join("test_data/real/databento"); if !data_dir.exists() { warn!(path = %data_dir.display(), "Skipping test: data not found"); return Ok(()); } let data_dir_str = data_dir.to_string_lossy().to_string(); // Configure hyperparameters with QR-DQN enabled let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.epochs = 5; hyperparams.batch_size = 64; hyperparams.learning_rate = 0.0001; hyperparams.epsilon_start = 1.0; hyperparams.epsilon_end = 0.05; hyperparams.epsilon_decay = 0.90; hyperparams.early_stopping_enabled = false; // No early stopping for 5 epochs hyperparams.num_quantiles = 32; hyperparams.qr_kappa = 1.0; // Train let checkpoint_dir = tempfile::tempdir()?; let mut trainer = DQNTrainer::new(hyperparams)?; let metrics = trainer .train(&data_dir_str, |epoch, checkpoint_data, is_best| { let name = if is_best { "qrdqn_best.safetensors".to_string() } else { format!("qrdqn_epoch_{}.safetensors", epoch) }; let path = checkpoint_dir.path().join(&name); std::fs::write(&path, &checkpoint_data)?; Ok(path.to_string_lossy().to_string()) }) .await?; // Verify epochs completed assert!( metrics.epochs_trained >= 1, "Should complete at least 1 epoch, got {}", metrics.epochs_trained ); // Verify losses are finite and non-zero let loss_history = trainer.loss_history(); assert!( !loss_history.is_empty(), "Loss history should not be empty after training" ); for (i, loss) in loss_history.iter().enumerate() { assert!( loss.is_finite(), "Loss at epoch {} should be finite, got {}", i, loss ); assert!( *loss > 0.0, "Loss at epoch {} should be non-zero, got {}", i, loss ); } info!( epochs_trained = metrics.epochs_trained, loss_history = ?loss_history, training_time_secs = metrics.training_time_seconds, "Test 6 PASSED: QR-DQN training on real data verified" ); Ok(()) }