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