Files
foxhunt/crates/ml/tests/dqn_diagnostic_logging_test.rs
jgrusewski 04b285486e fix: migrate 4 DQN/recovery test files to GPU types — 92 errors fixed
recovery_tests: Mamba2SSM forward_with_gradients+backward+optimizer_step
gpu_kernel_parity: collect_experiences_gpu, store() not vars(), CudaSlice readback
dqn_gradient_collapse: GpuTensor::randn+to_dtype, host-side gather
dqn_diagnostic: GpuTensor::from_host, to_host for normalization check
trainable_adapter: MlDevice import gated #[cfg(test)]

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

477 lines
17 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,
)]
// P2 DIAGNOSTIC LOGGING ENHANCEMENT - TDD Test Suite
// Tests for comprehensive diagnostic logging controlled by --debug-logging flag
//
// Coverage:
// - Test 1: Action diversity calculation (BUY/SELL/HOLD percentages)
// - Test 2: Q-value distribution calculation (min/max/mean/std)
// - Test 3: TD error distribution calculation
// - Test 4: Episode length statistics
// - Test 5: Feature normalization validation
// - Test 6: Debug logging flag behavior
// - Test 7: Performance impact measurement
use anyhow::Result;
use ml_core::cuda_autograd::GpuTensor;
use ml_core::device::MlDevice;
use std::sync::Arc;
use tracing::info;
// Helper function to convert (exposure_idx, order_idx, urgency_idx) to action index
// Maps to the action space formula: action_idx = exposure_idx * 9 + order_idx * 3 + urgency_idx
fn get_action_index(exposure_idx: usize, order_idx: usize, urgency_idx: usize) -> usize {
exposure_idx * 9 + order_idx * 3 + urgency_idx
}
// ============================================================================
// Test 1: Action Diversity Calculation
// ============================================================================
#[test]
fn test_action_diversity_calculation() -> Result<()> {
// Setup: Create TrainingMonitor with known action distribution
// 45-action space: 5 exposure x 3 order x 3 urgency
// Legacy mapping: BUY (exposure Long50/Long100), SELL (exposure Short50/Short100), HOLD (exposure Flat)
// ExposureLevel mapping:
// Short100 = 0, Short50 = 1, Flat = 2, Long50 = 3, Long100 = 4
let mut action_counts = [0usize; 45];
// Simulate 100 actions:
// BUY: 40 actions (exposures Long50=3, Long100=4)
// 2 exposure levels x 3 order types x 3 urgency levels = 18 action types
// Each action type appears ~2.2 times to get 40 total BUY actions
for exp_idx in [3, 4] {
for order_idx in 0..3 {
for urgency_idx in 0..3 {
let action_idx = get_action_index(exp_idx, order_idx, urgency_idx);
action_counts[action_idx] = 2; // 2 x 18 = 36 BUY actions
}
}
}
// Add 4 more BUY actions to reach 40 total
action_counts[get_action_index(3, 0, 0)] += 2;
action_counts[get_action_index(4, 0, 0)] += 2;
// SELL: 40 actions (exposures Short50=1, Short100=0)
for exp_idx in [0, 1] {
for order_idx in 0..3 {
for urgency_idx in 0..3 {
let action_idx = get_action_index(exp_idx, order_idx, urgency_idx);
action_counts[action_idx] = 2; // 2 x 18 = 36 SELL actions
}
}
}
// Add 4 more SELL actions to reach 40 total
action_counts[get_action_index(0, 0, 0)] += 2;
action_counts[get_action_index(1, 0, 0)] += 2;
// HOLD: 20 actions (exposure Flat=2)
// 1 exposure level x 3 order types x 3 urgency levels = 9 action types
for order_idx in 0..3 {
for urgency_idx in 0..3 {
let action_idx = get_action_index(2, order_idx, urgency_idx);
action_counts[action_idx] = 2; // 2 x 9 = 18 HOLD actions
}
}
// Add 2 more to reach exactly 20 HOLD actions (100 total)
action_counts[get_action_index(2, 0, 0)] += 2;
// Calculate diversity
let (buy_pct, sell_pct, hold_pct) = calculate_action_diversity(&action_counts);
// Verify percentages
assert!((buy_pct - 0.40).abs() < 0.01, "BUY percentage should be ~40%, got {:.2}%", buy_pct * 100.0);
assert!((sell_pct - 0.40).abs() < 0.01, "SELL percentage should be ~40%, got {:.2}%", sell_pct * 100.0);
assert!((hold_pct - 0.20).abs() < 0.01, "HOLD percentage should be ~20%, got {:.2}%", hold_pct * 100.0);
// Verify sum to 100%
assert!((buy_pct + sell_pct + hold_pct - 1.0).abs() < 0.001, "Percentages should sum to 100%");
Ok(())
}
// Helper function (will be moved to TrainingMonitor impl)
fn calculate_action_diversity(action_counts: &[usize; 45]) -> (f64, f64, f64) {
let total: usize = action_counts.iter().sum();
if total == 0 {
return (0.0, 0.0, 0.0);
}
let mut buy_count = 0;
let mut sell_count = 0;
let mut hold_count = 0;
// Aggregate 45-action space into 3 legacy actions
// ExposureLevel: Short100=0, Short50=1, Flat=2, Long50=3, Long100=4
for exp_idx in 0..5 {
for order_idx in 0..3 {
for urgency_idx in 0..3 {
let action_idx = get_action_index(exp_idx, order_idx, urgency_idx);
let count = action_counts[action_idx];
// Categorize by exposure level
if exp_idx >= 3 {
// Long50 or Long100 -> BUY
buy_count += count;
} else if exp_idx <= 1 {
// Short100 or Short50 -> SELL
sell_count += count;
} else {
// Flat -> HOLD
hold_count += count;
}
}
}
}
let buy_pct = buy_count as f64 / total as f64;
let sell_pct = sell_count as f64 / total as f64;
let hold_pct = hold_count as f64 / total as f64;
(buy_pct, sell_pct, hold_pct)
}
// ============================================================================
// Test 2: Q-Value Distribution Calculation
// ============================================================================
#[test]
fn test_q_value_distribution_calculation() -> Result<()> {
// Setup: Create Q-value history with known statistics
let q_values = vec![
-2.0, -1.0, 0.0, 1.0, 2.0, // min=-2.0, max=2.0, mean=0.0, std=1.414
-1.5, -0.5, 0.5, 1.5, // Additional values for realistic distribution
];
// Calculate distribution
let (q_min, q_max, q_mean, q_std) = calculate_q_distribution(&q_values);
// Verify statistics
assert_eq!(q_min, -2.0, "Q-value min should be -2.0");
assert_eq!(q_max, 2.0, "Q-value max should be 2.0");
assert!((q_mean - 0.0).abs() < 0.01, "Q-value mean should be ~0.0, got {:.2}", q_mean);
assert!((q_std - 1.22).abs() < 0.1, "Q-value std should be ~1.22, got {:.2}", q_std);
Ok(())
}
// Helper function (will be moved to TrainingMonitor impl)
fn calculate_q_distribution(q_history: &[f64]) -> (f64, f64, f64, f64) {
if q_history.is_empty() {
return (0.0, 0.0, 0.0, 0.0);
}
let q_min = q_history.iter().cloned().fold(f64::INFINITY, f64::min);
let q_max = q_history.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let q_mean = q_history.iter().sum::<f64>() / q_history.len() as f64;
// Calculate standard deviation
let variance = q_history.iter()
.map(|&x| (x - q_mean).powi(2))
.sum::<f64>() / q_history.len() as f64;
let q_std = variance.sqrt();
(q_min, q_max, q_mean, q_std)
}
// ============================================================================
// Test 3: TD Error Distribution Calculation
// ============================================================================
#[test]
fn test_td_error_distribution_calculation() -> Result<()> {
// Setup: Create TD error history with known statistics
let td_errors = vec![
0.1, 0.2, 0.3, 0.4, 0.5, // min=0.1, max=0.5, mean=0.3, std=0.141
0.15, 0.25, 0.35, 0.45, // Additional values
];
// Calculate distribution
let (td_min, td_max, td_mean, td_std) = calculate_td_distribution(&td_errors);
// Verify statistics
assert_eq!(td_min, 0.1, "TD error min should be 0.1");
assert_eq!(td_max, 0.5, "TD error max should be 0.5");
assert!((td_mean - 0.3).abs() < 0.01, "TD error mean should be ~0.3, got {:.4}", td_mean);
assert!((td_std - 0.13).abs() < 0.03, "TD error std should be ~0.13, got {:.4}", td_std);
Ok(())
}
// Helper function (will be moved to TrainingMonitor impl)
fn calculate_td_distribution(td_history: &[f64]) -> (f64, f64, f64, f64) {
if td_history.is_empty() {
return (0.0, 0.0, 0.0, 0.0);
}
let td_min = td_history.iter().cloned().fold(f64::INFINITY, f64::min);
let td_max = td_history.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let td_mean = td_history.iter().sum::<f64>() / td_history.len() as f64;
// Calculate standard deviation
let variance = td_history.iter()
.map(|&x| (x - td_mean).powi(2))
.sum::<f64>() / td_history.len() as f64;
let td_std = variance.sqrt();
(td_min, td_max, td_mean, td_std)
}
// ============================================================================
// Test 4: Episode Length Statistics
// ============================================================================
#[test]
fn test_episode_length_statistics() -> Result<()> {
// Setup: Create episode lengths with known statistics
let episode_lengths = vec![100, 150, 200]; // min=100, max=200, mean=150
// Calculate statistics
let (ep_min, ep_max, ep_mean) = calculate_episode_stats(&episode_lengths);
// Verify statistics
assert_eq!(ep_min, 100, "Episode min length should be 100");
assert_eq!(ep_max, 200, "Episode max length should be 200");
assert!((ep_mean - 150.0).abs() < 0.01, "Episode mean length should be 150.0, got {:.1}", ep_mean);
Ok(())
}
// Helper function (will be moved to TrainingMonitor impl)
fn calculate_episode_stats(episode_lengths: &[usize]) -> (usize, usize, f64) {
if episode_lengths.is_empty() {
return (0, 0, 0.0);
}
let ep_min = *episode_lengths.iter().min().unwrap();
let ep_max = *episode_lengths.iter().max().unwrap();
let ep_mean = episode_lengths.iter().sum::<usize>() as f64 / episode_lengths.len() as f64;
(ep_min, ep_max, ep_mean)
}
// ============================================================================
// Test 5: Feature Normalization Validation
// ============================================================================
#[test]
fn test_feature_normalization_validation() -> Result<()> {
let device = MlDevice::new_cuda(0).expect("CUDA required");
let stream = device.cuda_stream().expect("CUDA stream").clone();
// Setup: Create states with 5% features outside [-3, +3] range
// TradingState has 54 features (from CLAUDE.md)
let batch_size = 100;
let num_features = 54;
let total_features = batch_size * num_features; // 5,400 features
let expected_violations = (total_features as f64 * 0.05) as usize; // ~270 violations
// Create tensor: 95% in range [-3, 3], 5% outside
let mut features = vec![0.0f32; total_features];
let mut violations_set = 0;
for i in 0..total_features {
if i % 20 == 0 {
// 5% of features (every 20th) are outside [-3, 3]
features[i] = if i % 40 == 0 { 3.5 } else { -3.5 };
violations_set += 1;
} else {
// 95% of features are within [-3, 3]
features[i] = ((i % 6) as f32 - 3.0) * 0.9; // Range: -2.7 to 2.7
}
}
// Verify we set the right number of violations
assert_eq!(violations_set, expected_violations, "Test setup error: should set {} violations", expected_violations);
let states_tensor = GpuTensor::from_host(&features, vec![batch_size, num_features], &stream)?;
// Count violations
let violations = check_feature_normalization(&states_tensor, &stream)?;
// Verify violation count (allow 1% tolerance)
let tolerance = (expected_violations as f64 * 0.01) as usize;
assert!(
(violations as i32 - expected_violations as i32).abs() <= tolerance as i32,
"Expected ~{} violations (5% of {}), got {}",
expected_violations, total_features, violations
);
Ok(())
}
// Helper function (will be moved to TrainingMonitor impl)
fn check_feature_normalization(states: &GpuTensor, stream: &Arc<cudarc::driver::CudaStream>) -> Result<usize> {
// Count features outside [-3, +3] range
// Read values to host for analysis (test-only readback)
let states_vec = states.to_host(stream)?;
let violations_count = states_vec.iter()
.filter(|&&x| x < -3.0 || x > 3.0)
.count();
Ok(violations_count)
}
// ============================================================================
// Test 6: Debug Logging Flag Behavior
// ============================================================================
#[test]
fn test_debug_logging_flag_behavior() -> Result<()> {
// This test verifies that diagnostic logs only appear when --debug-logging is enabled
// Since we can't easily test logging output in unit tests, we'll verify the flag
// is correctly passed through the training configuration
// Test with debug_logging = true
let should_log_diagnostics = true;
assert!(should_log_diagnostics, "Debug logging should be enabled");
// Test with debug_logging = false
let should_log_diagnostics = false;
assert!(!should_log_diagnostics, "Debug logging should be disabled");
// In the actual implementation (ml/src/trainers/dqn.rs), the logging block
// will be guarded by: if (epoch + 1) % 10 == 0 && debug_logging { ... }
Ok(())
}
// ============================================================================
// Test 7: Performance Impact Measurement
// ============================================================================
#[test]
fn test_performance_impact() -> Result<()> {
// This test verifies that diagnostic calculations have <1% overhead
// We'll measure the time to calculate all diagnostics on realistic data
use std::time::Instant;
// Setup: Create realistic monitoring data
let action_counts = [100usize; 45]; // 100 actions per action type
let q_values = vec![0.5; 10000]; // 10K Q-values
let td_errors = vec![0.1; 10000]; // 10K TD errors
let episode_lengths = vec![200; 50]; // 50 episodes
// Measure diagnostic calculation time
let start = Instant::now();
// Calculate all diagnostics
let _diversity = calculate_action_diversity(&action_counts);
let _q_dist = calculate_q_distribution(&q_values);
let _td_dist = calculate_td_distribution(&td_errors);
let _ep_stats = calculate_episode_stats(&episode_lengths);
let elapsed = start.elapsed();
// Verify overhead is minimal
// Target: <1ms for all calculations (negligible compared to epoch time of ~1s)
assert!(
elapsed.as_micros() < 1000,
"Diagnostic calculations took {}us, should be <1ms (1000us)",
elapsed.as_micros()
);
info!(elapsed_us = elapsed.as_micros(), "Diagnostic calculations completed (<1% overhead)");
Ok(())
}
// ============================================================================
// Integration Test: Full Diagnostic Logging Flow
// ============================================================================
#[test]
#[ignore] // Run with: cargo test --test dqn_diagnostic_logging_test -- --ignored
fn test_full_diagnostic_logging_integration() -> Result<()> {
// This integration test verifies the complete diagnostic logging flow
// during actual training (requires training data and GPU)
// NOTE: This would require:
// 1. Loading actual training data
// 2. Creating DQNTrainer with debug_logging=true
// 3. Running 10+ epochs
// 4. Verifying diagnostic logs appear every 10 epochs
// 5. Verifying logs contain all expected metrics
// Placeholder for integration test
// Actual implementation would go here
Ok(())
}