Files
foxhunt/crates/ml/tests/dqn_action_collapse_fix_test.rs
jgrusewski 8de65265ee refactor(dqn): strip dead use_* flags + tighten count_bonus API
Atomic removal of 5 boolean feature flags from DQNConfig per
`feedback_no_feature_flags.md`, all of which gated dead or redundant code.
(use_iqn already removed in da632446c.)

- `use_dueling`, `use_distributional`, `use_noisy_nets`, `use_branching`:
  pure-cosmetic always-on flags. Only metadata strings remained.
  4 `if true /* always on */` blocks in dqn.rs collapsed to live arms;
  dead else arms (legacy non-branching code paths, 5-flat ExposureLevel)
  deleted.
- `use_count_bonus`: redundant with `count_bonus_coefficient` (the live
  numeric kill-switch). Recording made unconditional; coefficient is the
  single dial.
- `use_cvar_action_selection`: dead post-use_iqn cleanup (only consumers
  were inside the deleted IQN arms). cvar_alpha retained for cuda_pipeline
  CVaR loss usage.

count_bonus.rs API tightened: `bonuses_branched_f32(&self,
exp_out: &mut [f32], ord_out: &mut [f32], urg_out: &mut [f32])` write-into
API alongside the existing Vec<f64> form (kept for tests). Production
DQN::get_count_bonuses_branched() returns fixed `([f32; 4], [f32; 3],
[f32; 3])` arrays — allocation-free, f32 throughout, fed directly to GPU
action selector via stack-allocated buffers.

Test 0.F outputs bit-identical vs pre-cleanup (sigma_C51, argmax,
Thompson counts) — confirms legacy use_* arms were unreachable as
expected.

`docs/dqn-wire-up-audit.md` updated per Invariant 7.

Precondition for the MoE regime redesign per
`docs/superpowers/specs/2026-04-27-moe-regime-redesign-design.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:52:23 +02:00

324 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
);
// (legacy `use_iqn` flag removed — IQN now runs unconditionally inside the
// cuda_pipeline dual head; the prior "gradient dead zone" pathology is
// structurally impossible because the standalone `iqn_network` field that
// ran in parallel and was never read at inference time has been deleted.)
// 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();
// CQL alpha defaults to 1.0 (strong conservatism for offline RL)
let alpha_diff = (hp.cql_alpha - 1.0).abs();
assert!(
alpha_diff < 1e-6,
"cql_alpha should default to 1.0, 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_14d_search_space() {
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::traits::ParameterSpace;
let bounds = DQNParams::continuous_bounds();
assert_eq!(
bounds.len(), 14,
"Search space should be 14D (3 breakouts + 5 core + 6 gen families), 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 fixed at TOML default (not in search space)
assert!(
!names.contains(&"cql_alpha"),
"cql_alpha should be fixed (not in 14D search space)"
);
}
#[test]
fn test_hyperopt_architecture_intensity_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 now absorbed into the architecture family. Verify architecture_intensity exists.
let arch_idx = names.iter().position(|&n| n == "architecture_intensity")
.expect("architecture_intensity should be in param_names");
let (lo, hi) = bounds[arch_idx];
assert_eq!(lo, 0.0, "architecture_intensity lower bound should be 0.0");
assert_eq!(hi, 2.0, "architecture_intensity upper bound should be 2.0");
}
#[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 (iqn_lambda > 0)");
}
#[test]
fn test_hyperopt_14d_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");
// gamma should survive round-trip
let diff = (reconstructed.gamma - params.gamma).abs();
assert!(
diff < 0.01,
"gamma should preserve default: expected {}, got {} (diff={})",
params.gamma, reconstructed.gamma, 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.num_actions = 45; // Factored action space (non-branching)
config.hidden_dims = vec![32, 16];
config.batch_size = 8;
config.min_replay_size = 8;
config.warmup_steps = 0;
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
);
}
}