Files
foxhunt/crates/ml/tests/dqn_diversity_penalty_test.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- Reduce CI GPU test datasets 16x for walltime reduction
- Reduce early-stop epochs 50→10, add --test-threads=1
- Serialize all GPU lib tests to prevent cuBLAS init race
- Align state_dim to 16 for BF16 tensor core HMMA dispatch
- BF16 precision tolerance in ml-dqn tests
- Enable branching DQN + tracing subscriber in smoke tests
- Prevent min_replay_size > buffer_size deadlock in early-stop tests
- Prevent AutoReplaySizer from breaking gradient collapse warmup
- Replace racy tokio::spawn checkpoint counter with AtomicUsize
- Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests
- RealDataLoader respects TEST_DATA_DIR for CI PVC layout
- Add collapse_warmup_capacity to gpu_smoketest DQNConfig
- Drain CUDA context between test binaries
- Detached HEAD checkout prevents local branch corruption
- GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions
- OOD input handling tests use use_gpu: true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:00:13 +01:00

494 lines
16 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,
)]
//! DQN Diversity Penalty Unit Tests
//!
//! Comprehensive tests for Fix #4: Entropy-based diversity penalty in hyperopt objective function.
//!
//! This test suite validates:
//! 1. Shannon entropy calculation accuracy (uniform, extreme, moderate bias)
//! 2. Diversity penalty application (threshold at entropy < 0.5)
//! 3. Edge cases (empty distribution, boundary conditions)
//!
//! ## Context
//!
//! Fix #4 addresses the critical issue of 99.4% SELL bias discovered in DQN training.
//! The diversity penalty penalizes configurations where action entropy falls below 0.5
//! (indicating extreme action homogeneity).
//!
//! ## Penalty Formula
//!
//! ```text
//! if entropy < 0.5:
//! penalty = -10.0 (catastrophic)
//! else:
//! penalty = 0.0 (acceptable diversity)
//! ```
//!
//! ## Shannon Entropy Formula
//!
//! ```text
//! H(X) = -Σ p(x) * log2(p(x))
//! ```
//!
//! Where:
//! - p(x) = probability of action x (count / total)
//! - log2(p) = logarithm base 2
//! - H(X) = entropy in bits
//!
//! Expected ranges:
//! - Uniform (33%, 33%, 33%): H ≈ 1.585 bits (max for 3 actions)
//! - Extreme bias (100%, 0%, 0%): H = 0.0 bits (min)
//! - Moderate bias (70%, 20%, 10%): H ≈ 1.16 bits
#[cfg(test)]
mod dqn_diversity_penalty_tests {
use tracing::info;
/// Helper function to calculate Shannon entropy
///
/// This mirrors the entropy calculation used in the hyperopt objective function.
/// Calculates entropy in bits using base-2 logarithm.
///
/// # Arguments
///
/// * `action_counts` - Array of action counts [BUY, SELL, HOLD]
///
/// # Returns
///
/// Entropy in bits (0.0 = all same action, 1.585 ≈ uniform distribution)
fn calculate_action_entropy(action_counts: &[usize]) -> f64 {
let total: usize = action_counts.iter().sum();
if total == 0 {
return 0.0;
}
let mut entropy = 0.0;
for &count in action_counts {
if count > 0 {
let p = count as f64 / total as f64;
entropy -= p * p.log2();
}
}
entropy
}
/// Helper to calculate diversity penalty
///
/// # Arguments
///
/// * `entropy` - Shannon entropy of action distribution
///
/// # Returns
///
/// Penalty value (-10.0 if entropy < 0.5, else 0.0)
fn calculate_diversity_penalty(entropy: f64) -> f64 {
if entropy < 0.5 {
-10.0
} else {
0.0
}
}
// ========================================================================
// Entropy Calculation Tests
// ========================================================================
#[test]
fn test_entropy_uniform_distribution() {
// BUY=100, SELL=100, HOLD=100 (33.3% each)
let action_counts = [100, 100, 100];
let entropy = calculate_action_entropy(&action_counts);
// Uniform distribution: entropy = log2(3) ≈ 1.585
assert!(
(entropy - 1.585).abs() < 0.001,
"Expected entropy ≈ 1.585 for uniform distribution, got {:.4}",
entropy
);
}
#[test]
fn test_entropy_extreme_bias() {
// SELL=1000, BUY=0, HOLD=0 (100% SELL)
let action_counts = [0, 1000, 0];
let entropy = calculate_action_entropy(&action_counts);
// All actions the same: entropy = 0.0
assert_eq!(
entropy, 0.0,
"Expected entropy = 0.0 for 100% bias, got {:.4}",
entropy
);
}
#[test]
fn test_entropy_moderate_bias() {
// SELL=70, BUY=20, HOLD=10 (70% SELL dominance)
let action_counts = [20, 70, 10];
let entropy = calculate_action_entropy(&action_counts);
// Expected: entropy ≈ 1.157 (calculated manually)
// H = -(0.2*log2(0.2) + 0.7*log2(0.7) + 0.1*log2(0.1))
// H = -(0.2*(-2.322) + 0.7*(-0.515) + 0.1*(-3.322))
// H = -(-0.464 - 0.361 - 0.332) = 1.157
assert!(
(entropy - 1.157).abs() < 0.02,
"Expected entropy ≈ 1.16 for 70/20/10 distribution, got {:.4}",
entropy
);
}
#[test]
fn test_entropy_two_action_distribution() {
// BUY=50, SELL=50, HOLD=0 (50/50 split)
let action_counts = [50, 50, 0];
let entropy = calculate_action_entropy(&action_counts);
// Two equal actions: entropy = log2(2) = 1.0
assert!(
(entropy - 1.0).abs() < 0.001,
"Expected entropy = 1.0 for 50/50 split, got {:.4}",
entropy
);
}
// ========================================================================
// Penalty Application Tests
// ========================================================================
#[test]
fn test_penalty_balanced_trial() {
// Balanced distribution (BUY=30%, SELL=30%, HOLD=40%)
let action_counts = [30, 30, 40];
let entropy = calculate_action_entropy(&action_counts);
let penalty = calculate_diversity_penalty(entropy);
assert!(
entropy >= 0.5,
"Balanced trial should have entropy ≥ 0.5, got {:.4}",
entropy
);
assert_eq!(
penalty, 0.0,
"Balanced trial should have NO penalty (entropy={:.4})",
entropy
);
}
#[test]
fn test_penalty_moderately_biased_trial() {
// Moderate bias (SELL=60%, BUY=25%, HOLD=15%)
let action_counts = [25, 60, 15];
let entropy = calculate_action_entropy(&action_counts);
let penalty = calculate_diversity_penalty(entropy);
assert!(
entropy >= 0.5,
"Moderate bias (60%) should have entropy ≥ 0.5, got {:.4}",
entropy
);
assert_eq!(
penalty, 0.0,
"Moderate bias should have NO penalty (entropy={:.4})",
entropy
);
}
#[test]
fn test_penalty_highly_biased_trial() {
// High bias (SELL=95%, BUY=3%, HOLD=2%)
// This produces entropy ≈ 0.33, well below 0.5 threshold
let action_counts = [3, 95, 2];
let entropy = calculate_action_entropy(&action_counts);
let penalty = calculate_diversity_penalty(entropy);
assert!(
entropy < 0.5,
"High bias (95%) should have entropy < 0.5, got {:.4}",
entropy
);
assert_eq!(
penalty, -10.0,
"High bias should receive -10.0 penalty (entropy={:.4})",
entropy
);
}
#[test]
fn test_penalty_extreme_bias_trial() {
// Extreme bias (SELL=99.4%, BUY=0.6%, HOLD=0%)
// This mirrors the actual bug discovered in Wave 3-A1
let action_counts = [6, 994, 0];
let entropy = calculate_action_entropy(&action_counts);
let penalty = calculate_diversity_penalty(entropy);
assert!(
entropy < 0.5,
"Extreme bias (99.4%) should have entropy < 0.5, got {:.4}",
entropy
);
assert_eq!(
penalty, -10.0,
"Extreme bias should receive -10.0 penalty (entropy={:.4})",
entropy
);
}
// ========================================================================
// Edge Case Tests
// ========================================================================
#[test]
fn test_entropy_empty_distribution() {
// Edge case: no actions recorded
let action_counts = [0, 0, 0];
let entropy = calculate_action_entropy(&action_counts);
let penalty = calculate_diversity_penalty(entropy);
assert_eq!(entropy, 0.0, "Empty distribution should have entropy = 0.0");
assert_eq!(
penalty, -10.0,
"Empty distribution should receive -10.0 penalty"
);
}
#[test]
fn test_penalty_boundary_case() {
// Find action counts that produce entropy ≈ 0.5 (boundary)
// Through trial and error:
// SELL=92%, BUY=6%, HOLD=2% gives entropy ≈ 0.47 (below threshold)
// SELL=85%, BUY=10%, HOLD=5% gives entropy ≈ 0.71 (above threshold)
// Test below threshold
let action_counts_below = [6, 92, 2];
let entropy_below = calculate_action_entropy(&action_counts_below);
let penalty_below = calculate_diversity_penalty(entropy_below);
info!(entropy = entropy_below, penalty = penalty_below, "Boundary case (below)");
assert!(
entropy_below < 0.5,
"92/6/2 distribution should have entropy < 0.5, got {:.4}",
entropy_below
);
assert_eq!(
penalty_below, -10.0,
"Below threshold: should penalize (entropy={:.4})",
entropy_below
);
// Test above threshold
let action_counts_above = [10, 85, 5];
let entropy_above = calculate_action_entropy(&action_counts_above);
let penalty_above = calculate_diversity_penalty(entropy_above);
info!(entropy = entropy_above, penalty = penalty_above, "Boundary case (above)");
assert!(
entropy_above >= 0.5,
"85/10/5 distribution should have entropy ≥ 0.5, got {:.4}",
entropy_above
);
assert_eq!(
penalty_above, 0.0,
"At/above threshold: should not penalize (entropy={:.4})",
entropy_above
);
}
#[test]
fn test_entropy_exact_threshold() {
// Test with entropy exactly at 0.5 threshold
// We need to find action counts that produce entropy ≈ 0.5
// Through calculation: SELL=93%, BUY=5%, HOLD=2% gives entropy ≈ 0.48
// Test just below threshold (should penalize)
let action_counts_below = [5, 93, 2];
let entropy_below = calculate_action_entropy(&action_counts_below);
info!(entropy = entropy_below, "Below threshold test");
// Verify entropy is close to but below 0.5
assert!(
entropy_below < 0.5,
"Expected entropy < 0.5 for 93/5/2 distribution, got {:.4}",
entropy_below
);
let penalty_below = calculate_diversity_penalty(entropy_below);
assert_eq!(
penalty_below, -10.0,
"Just below threshold (entropy={:.4}): should penalize",
entropy_below
);
// Test just above threshold (should not penalize)
// SELL=87%, BUY=9%, HOLD=4% gives entropy ≈ 0.61
let action_counts_above = [9, 87, 4];
let entropy_above = calculate_action_entropy(&action_counts_above);
info!(entropy = entropy_above, "Above threshold test");
assert!(
entropy_above >= 0.5,
"Expected entropy >= 0.5 for 87/9/4 distribution, got {:.4}",
entropy_above
);
let penalty_above = calculate_diversity_penalty(entropy_above);
assert_eq!(
penalty_above, 0.0,
"Just above threshold (entropy={:.4}): should not penalize",
entropy_above
);
}
// ========================================================================
// Additional Validation Tests
// ========================================================================
#[test]
fn test_entropy_mathematical_properties() {
// Test 1: Entropy is symmetric (order doesn't matter)
let counts_1 = [30, 40, 30];
let counts_2 = [40, 30, 30];
let counts_3 = [30, 30, 40];
let entropy_1 = calculate_action_entropy(&counts_1);
let entropy_2 = calculate_action_entropy(&counts_2);
let entropy_3 = calculate_action_entropy(&counts_3);
assert!(
(entropy_1 - entropy_2).abs() < 1e-10,
"Entropy should be symmetric: {:.4} != {:.4}",
entropy_1,
entropy_2
);
assert!(
(entropy_1 - entropy_3).abs() < 1e-10,
"Entropy should be symmetric: {:.4} != {:.4}",
entropy_1,
entropy_3
);
// Test 2: Maximum entropy for 3 actions is log2(3) ≈ 1.585
let uniform = [100, 100, 100];
let max_entropy = calculate_action_entropy(&uniform);
assert!(
max_entropy <= 1.585 + 0.001,
"Maximum entropy for 3 actions should be ≤ log2(3) ≈ 1.585, got {:.4}",
max_entropy
);
// Test 3: Minimum entropy is 0.0 (all same action)
let extreme = [0, 1000, 0];
let min_entropy = calculate_action_entropy(&extreme);
assert_eq!(
min_entropy, 0.0,
"Minimum entropy should be 0.0 for uniform action, got {:.4}",
min_entropy
);
}
#[test]
fn test_penalty_consistency() {
// Test that penalty is consistent across different action counts with same proportions
// 90/7/3 proportions
let small_scale = [7, 90, 3];
let large_scale = [70, 900, 30];
let entropy_small = calculate_action_entropy(&small_scale);
let entropy_large = calculate_action_entropy(&large_scale);
assert!(
(entropy_small - entropy_large).abs() < 1e-10,
"Entropy should be scale-invariant: {:.4} != {:.4}",
entropy_small,
entropy_large
);
let penalty_small = calculate_diversity_penalty(entropy_small);
let penalty_large = calculate_diversity_penalty(entropy_large);
assert_eq!(
penalty_small, penalty_large,
"Penalty should be consistent across scales"
);
}
}