- 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>
330 lines
11 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|