Files
foxhunt/crates/ml/tests/dqn_action_collapse_fix_test.rs
jgrusewski 5e4fc2bcb1 fix: integration tests for 22D search space + cql_alpha default mismatch
- Update 3 integration tests in dqn_action_collapse_fix_test.rs that
  expected the old 46D search space (cql_alpha tunable, phase-specific
  v_range bounds). Now expect 22D with fixed cql_alpha.
- Fix cql_alpha: from_continuous hardcoded 0.5, Default uses 0.1.
  Aligned to 0.1 (same pattern as dd_threshold fix earlier).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:57:59 +01:00

330 lines
11 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 Action Collapse Fix Verification Tests
//!
//! These tests verify the behavioral changes from the DQN action collapse fix
//! (2026-03-04). They would FAIL on the old code and PASS on the fixed code.
//!
//! Root causes fixed:
//! 1. IQN/C51 train-test mismatch (IQN disabled by default)
//! 2. CQL over-regularization (alpha 1.0 → 0.1, configurable)
//! 3. Hold reward bias (hold_reward zeroed, /1000 divisor removed)
//! 4. v_min/v_max too narrow (-2/+2 → -10/+10)
//! 5. Count bonus dead code in batch selection (now wired)
//! 6. CQL alpha in 26D hyperopt search space
#![allow(unused_crate_dependencies)]
use anyhow::Result;
use tracing::info;
// ── 1. DQNConfig defaults reflect the fix ──────────────────────────────────
#[test]
fn test_dqnconfig_defaults_post_fix() {
use ml::dqn::DQNConfig;
let config = DQNConfig::default();
// v_min/v_max widened from -2/+2 to -10/+10
assert!(
config.v_min <= -10.0,
"v_min should be <= -10.0 (was -2.0), got {}",
config.v_min
);
assert!(
config.v_max >= 10.0,
"v_max should be >= 10.0 (was 2.0), got {}",
config.v_max
);
// IQN disabled (was true — caused train/inference mismatch)
assert!(
!config.use_iqn,
"use_iqn should be false (was true, caused gradient dead zone)"
);
// CQL alpha reduced from 1.0 to 0.1
let cql_diff = (config.cql_alpha - 0.1).abs();
assert!(
cql_diff < 1e-6,
"cql_alpha should be 0.1 (was 1.0, added ~3.8 loss penalty), got {}",
config.cql_alpha
);
// Entropy coefficient: must be ≤ reward magnitude (~0.001) to prevent entropy dominating learning.
// Old 0.1 was 100x reward → entropy dominated C51 gradient → model maximized entropy, not returns.
assert!(
config.entropy_coefficient <= 0.01,
"entropy_coefficient should be <= 0.01 (was 0.1, 100x reward magnitude), got {}",
config.entropy_coefficient
);
}
// ── 2. CQL configurable via DQNHyperparameters ─────────────────────────────
#[test]
fn test_cql_configurable_via_hyperparameters() {
use ml::trainers::dqn::DQNHyperparameters;
let hp = DQNHyperparameters::default();
// use_cql and cql_alpha should exist and have correct defaults
let alpha_diff = (hp.cql_alpha - 0.1).abs();
assert!(
alpha_diff < 1e-6,
"cql_alpha should default to 0.1, got {}",
hp.cql_alpha
);
// Should be overridable
let mut hp2 = DQNHyperparameters::default();
hp2.cql_alpha = 0.0;
assert!(
hp2.cql_alpha.abs() < 1e-10,
"cql_alpha should be overridable to 0.0"
);
}
#[tokio::test]
async fn test_cql_alpha_wired_through_trainer() -> Result<()> {
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer};
// Create trainer with custom CQL alpha
let mut hp = DQNHyperparameters::default();
hp.cql_alpha = 0.05; // Custom value
hp.epochs = 1;
let trainer = DQNTrainer::new(hp)?;
// The trainer should construct without error and use our CQL config
// (Previously hardcoded: use_cql: true, cql_alpha: 1.0)
assert!(trainer.get_current_lr() > 0.0, "Trainer should initialize with valid LR");
Ok(())
}
// ── 3. Trainer sets hold_reward=ZERO, hold penalty has no /1000 divisor ────
#[tokio::test]
async fn test_trainer_sets_hold_reward_zero() -> Result<()> {
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer};
// The trainer constructs RewardConfig with hold_reward = Decimal::ZERO
// (was +0.001 which was 20x trade PnL, biasing toward holding)
let mut hp = DQNHyperparameters::default();
hp.epochs = 1;
let _trainer = DQNTrainer::new(hp)?;
// If we get here, the trainer constructed successfully with hold_reward=ZERO
// The actual assertion is in trainer.rs line 492: hold_reward: Decimal::ZERO
Ok(())
}
#[test]
fn test_idle_penalty_in_gpu_composite_reward() {
use ml::trainers::dqn::DQNHyperparameters;
// hold_penalty_weight was removed from RewardConfig. Idle penalty is now
// handled by w_idle in the GPU composite reward kernel. Verify w_idle is
// a meaningful default in DQNHyperparameters.
let hp = DQNHyperparameters::default();
assert!(
hp.w_idle > 0.0,
"w_idle should be > 0 (meaningful idle penalty), got {}",
hp.w_idle
);
// w_idle goes directly to the GPU kernel launch args — no CPU reward path.
}
// ── 4. Hyperopt 26D search space includes cql_alpha ────────────────────────
#[test]
fn test_hyperopt_22d_search_space() {
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::traits::ParameterSpace;
let bounds = DQNParams::continuous_bounds();
assert_eq!(
bounds.len(), 22,
"Search space should be 22D (consolidated from 46D), got {}D",
bounds.len()
);
let names = DQNParams::param_names();
assert_eq!(
names.len(),
bounds.len(),
"param_names length should match continuous_bounds length: {} vs {}",
names.len(), bounds.len()
);
// cql_alpha is now FIXED at default (not in search space)
assert!(
!names.contains(&"cql_alpha"),
"cql_alpha should be fixed (not in 22D search space)"
);
}
#[test]
fn test_hyperopt_v_max_in_search_space() {
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::traits::ParameterSpace;
let bounds = DQNParams::continuous_bounds();
let names = DQNParams::param_names();
// v_max is at index 14 in the 22D space with range [5, 300]
let v_max_idx = names.iter().position(|&n| n == "v_max")
.expect("v_max should be in param_names");
let (lo, hi) = bounds[v_max_idx];
assert!(lo >= 1.0 && hi <= 500.0,
"v_max bounds should be reasonable, got ({}, {})", lo, hi);
assert!(lo < hi, "v_max should have a range, got ({}, {})", lo, hi);
}
#[test]
fn test_hyperopt_iqn_always_enabled() {
use ml::hyperopt::adapters::dqn::DQNParams;
let params = DQNParams::default();
// IQN is always enabled (iqn_lambda > 0 by default)
assert!(params.iqn_lambda > 0.0, "IQN should be enabled by default");
assert!(params.num_quantiles >= 32, "Should have at least 32 quantiles");
}
#[test]
fn test_hyperopt_22d_round_trip() {
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::traits::ParameterSpace;
let params = DQNParams::default();
let continuous = params.to_continuous();
let bounds = DQNParams::continuous_bounds();
assert_eq!(continuous.len(), bounds.len(),
"to_continuous length ({}) should match bounds ({})", continuous.len(), bounds.len());
let reconstructed =
DQNParams::from_continuous(&continuous).expect("from_continuous should succeed");
// cql_alpha is fixed at default in 22D space
let diff = (reconstructed.cql_alpha - params.cql_alpha).abs();
assert!(
diff < 0.01,
"cql_alpha should preserve default: expected {}, got {} (diff={})",
params.cql_alpha, reconstructed.cql_alpha, diff
);
}
// ── 5. DQN agent exposes count bonus for batch selection ───────────────────
#[test]
fn test_count_bonus_accessible() {
use ml::dqn::DQNConfig;
use ml::dqn::DQN;
let mut config = DQNConfig::default();
config.state_dim = 8;
config.num_actions = 45; // Factored action space (non-branching)
config.hidden_dims = vec![32, 16];
config.use_iqn = false;
config.batch_size = 8;
config.min_replay_size = 8;
config.warmup_steps = 0;
config.use_count_bonus = true;
config.count_bonus_coefficient = 0.1;
let dqn = DQN::new(config).expect("DQN should create with count bonus enabled");
// Non-branching: CountBonus tracks all num_actions (45) flat actions
let bonuses = dqn.get_count_bonuses();
assert_eq!(
bonuses.len(),
45,
"get_count_bonuses() should return 45 entries (flat non-branching), got {}",
bonuses.len()
);
// Initially all bonuses should be equal (no actions taken yet)
let first = bonuses.first().copied().unwrap_or(0.0);
for (i, &b) in bonuses.iter().enumerate() {
let diff = (b - first).abs();
assert!(
diff < 1e-6,
"Initial bonuses should be uniform, but index {} differs: {} vs {}",
i, b, first
);
}
}