Remove use_double_dqn, use_dueling, use_per, use_branching,
use_distributional, use_noisy_nets, use_huber_loss, and use_cql
from DQNConfig, DQNHyperparameters, and DqnParams structs.
These features are always enabled (Rainbow DQN standard). The boolean
flags were dead code — every constructor set them to true, and the
only code paths that set them to false were in tests that disabled
features for simplicity. With the fields removed, the features are
unconditionally active, eliminating ~490 lines of dead configuration.
Key changes:
- Struct field declarations removed from 3 core config structs
- Conditional branches (if use_X { ... } else { ... }) simplified:
dueling/branching/PER network creation is now unconditional
- Checkpoint metadata hardcodes "true" for backward compatibility
- Hyperopt search space index 11 (use_branching) fixed at 1.0
- TOML/YAML config files cleaned of removed fields
- Tests that toggled these flags updated or rewritten
45 files changed, -487 net lines. Zero new test failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
442 lines
14 KiB
Rust
442 lines
14 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,
|
|
)]
|
|
//! WAVE 26 P0 Features Integration Tests
|
|
//!
|
|
//! Comprehensive integration tests verifying all P0 features work together:
|
|
//! - P0.1: TD-error clamping (1e6 threshold)
|
|
//! - P0.2: Batch diversity enforcement (no duplicate sampling)
|
|
//! - P0.6: LR scheduler (exponential decay)
|
|
//! - P0.7: Priority staleness tracking (automatic decay)
|
|
//!
|
|
//! Each test validates the feature in isolation and in combination.
|
|
|
|
use ml::dqn::prioritized_replay::{PrioritizedReplayBuffer, PrioritizedReplayConfig, PrioritizationStrategy};
|
|
use ml::trainers::dqn::lr_scheduler::{LRScheduler, LRDecayType};
|
|
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer};
|
|
use ml::dqn::Experience;
|
|
use anyhow::Result;
|
|
use std::sync::Arc;
|
|
|
|
/// P0.6: Test LR scheduler decay over training epochs
|
|
#[tokio::test]
|
|
async fn test_p0_lr_scheduler_decay() -> Result<()> {
|
|
// Create LR scheduler with exponential decay
|
|
let initial_lr = 0.001;
|
|
let decay_rate = 0.95;
|
|
let min_lr = 1e-6;
|
|
|
|
let mut scheduler = LRScheduler::new(
|
|
initial_lr,
|
|
0, // warmup_steps (0 = no warmup)
|
|
LRDecayType::Exponential {
|
|
decay_rate,
|
|
min_lr,
|
|
},
|
|
);
|
|
|
|
// Verify initial LR
|
|
assert_eq!(scheduler.get_lr(), initial_lr);
|
|
assert_eq!(scheduler.get_initial_lr(), initial_lr);
|
|
|
|
// Step through 300 iterations
|
|
let mut last_lr = initial_lr;
|
|
for step in 1..=300 {
|
|
scheduler.step();
|
|
let current_lr = scheduler.get_lr();
|
|
|
|
// Verify LR is decreasing (or at minimum)
|
|
assert!(
|
|
current_lr <= last_lr || current_lr == min_lr,
|
|
"LR should decrease or reach minimum at step {}",
|
|
step
|
|
);
|
|
|
|
// Verify exponential decay formula: lr = initial_lr * decay_rate^step
|
|
let expected_lr = (initial_lr * decay_rate.powf(step as f64)).max(min_lr);
|
|
assert!(
|
|
(current_lr - expected_lr).abs() < 1e-8,
|
|
"LR at step {}: expected {:.6e}, got {:.6e}",
|
|
step,
|
|
expected_lr,
|
|
current_lr
|
|
);
|
|
|
|
last_lr = current_lr;
|
|
}
|
|
|
|
// Verify minimum LR is respected
|
|
assert!(scheduler.get_lr() >= min_lr);
|
|
|
|
// Verify LR has decayed from initial value
|
|
assert!(scheduler.get_lr() < initial_lr);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// P0.7: Test priority staleness decay mechanism
|
|
/// NOTE: The staleness decay feature is not yet implemented in PrioritizedReplayBuffer.
|
|
/// This test validates the basic priority tracking mechanism as a foundation.
|
|
#[tokio::test]
|
|
async fn test_p0_priority_staleness_decay() -> Result<()> {
|
|
// Create prioritized replay buffer with PER enabled
|
|
let buffer = Arc::new(PrioritizedReplayBuffer::new(PrioritizedReplayConfig {
|
|
capacity: 1000,
|
|
alpha: 0.6,
|
|
beta: 0.4,
|
|
initial_priority: 1.0,
|
|
min_priority: 1e-6,
|
|
strategy: PrioritizationStrategy::Proportional,
|
|
beta_max: 1.0,
|
|
beta_annealing_steps: 100000,
|
|
})?);
|
|
|
|
// Push 50 experiences at training step 0
|
|
buffer.set_training_step(0);
|
|
for i in 0..50 {
|
|
let state = vec![0.0_f32; 54];
|
|
let next_state = vec![0.0_f32; 54];
|
|
|
|
let exp = Experience::new(
|
|
state,
|
|
(i % 45) as u8, // FactoredAction space
|
|
0.1,
|
|
next_state,
|
|
false,
|
|
);
|
|
buffer.push(exp)?;
|
|
}
|
|
|
|
// Verify all experiences are stored
|
|
assert_eq!(buffer.len(), 50);
|
|
|
|
// Advance training step to 15,000
|
|
buffer.set_training_step(15000);
|
|
assert_eq!(buffer.training_step(), 15000);
|
|
|
|
// Update priority of experience 0 (simulate manual priority update)
|
|
buffer.update_priorities(&[0], &[2.0])?;
|
|
|
|
// Advance to step 20,000
|
|
buffer.set_training_step(20000);
|
|
assert_eq!(buffer.training_step(), 20000);
|
|
|
|
// Update another experience to verify update mechanism works
|
|
buffer.update_priorities(&[1], &[1.5])?;
|
|
|
|
// Verify buffer metrics reflect updates
|
|
let metrics = buffer.get_metrics();
|
|
assert!(metrics.priority_updates > 0, "Priority updates should be tracked");
|
|
assert!(metrics.max_priority > 0.0, "Max priority should be positive");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// P0.2: Test batch diversity enforcement (no duplicate sampling)
|
|
#[tokio::test]
|
|
async fn test_p0_batch_diversity_no_duplicates() -> Result<()> {
|
|
use std::collections::HashSet;
|
|
|
|
let buffer = Arc::new(PrioritizedReplayBuffer::new(PrioritizedReplayConfig {
|
|
capacity: 200,
|
|
alpha: 0.6,
|
|
beta: 0.4,
|
|
initial_priority: 1.0,
|
|
min_priority: 1e-6,
|
|
strategy: PrioritizationStrategy::Proportional,
|
|
beta_max: 1.0,
|
|
beta_annealing_steps: 100000,
|
|
})?);
|
|
|
|
// Fill buffer with 100 experiences
|
|
for i in 0..100 {
|
|
let state = vec![0.0_f32; 54];
|
|
let next_state = vec![0.0_f32; 54];
|
|
|
|
let exp = Experience::new(
|
|
state,
|
|
(i % 45) as u8,
|
|
0.1,
|
|
next_state,
|
|
false,
|
|
);
|
|
buffer.push(exp)?;
|
|
}
|
|
|
|
// Sample first batch of 32
|
|
let (_, _, indices1) = buffer.sample(32)?;
|
|
let set1: HashSet<usize> = indices1.iter().copied().collect();
|
|
|
|
// Sample second batch of 32
|
|
let (_, _, indices2) = buffer.sample(32)?;
|
|
let set2: HashSet<usize> = indices2.iter().copied().collect();
|
|
|
|
// Verify batches are disjoint (no overlap)
|
|
let intersection: HashSet<_> = set1.intersection(&set2).collect();
|
|
assert!(
|
|
intersection.is_empty(),
|
|
"Consecutive batches should have no duplicate indices, found {} overlaps",
|
|
intersection.len()
|
|
);
|
|
|
|
// Verify each batch has unique elements
|
|
assert_eq!(
|
|
set1.len(),
|
|
32,
|
|
"First batch should have 32 unique indices"
|
|
);
|
|
assert_eq!(
|
|
set2.len(),
|
|
32,
|
|
"Second batch should have 32 unique indices"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// P0.2: Test batch diversity cooldown mechanism
|
|
#[tokio::test]
|
|
async fn test_p0_batch_diversity_cooldown() -> Result<()> {
|
|
use std::collections::HashSet;
|
|
|
|
let buffer = Arc::new(PrioritizedReplayBuffer::new(PrioritizedReplayConfig {
|
|
capacity: 500,
|
|
alpha: 0.6,
|
|
beta: 0.4,
|
|
initial_priority: 1.0,
|
|
min_priority: 1e-6,
|
|
strategy: PrioritizationStrategy::Proportional,
|
|
beta_max: 1.0,
|
|
beta_annealing_steps: 100000,
|
|
})?);
|
|
|
|
// Fill buffer with 500 experiences
|
|
for i in 0..500 {
|
|
let state = vec![0.0_f32; 54];
|
|
let next_state = vec![0.0_f32; 54];
|
|
|
|
let exp = Experience::new(
|
|
state,
|
|
(i % 45) as u8,
|
|
0.1,
|
|
next_state,
|
|
false,
|
|
);
|
|
buffer.push(exp)?;
|
|
}
|
|
|
|
// Track all sampled indices across 50 batches
|
|
let mut all_indices = HashSet::new();
|
|
|
|
// Sample 50 batches of size 10 each
|
|
for _ in 0..50 {
|
|
let (_, _, indices) = buffer.sample(10)?;
|
|
for &idx in &indices {
|
|
all_indices.insert(idx);
|
|
}
|
|
}
|
|
|
|
// With batch diversity enforcement, cooldown is cleared at batch 49 (sample_counter % 50 == 49).
|
|
// Additionally, the fallback mechanism (max 100 attempts) may clear cooldown earlier
|
|
// when there are few unique experiences left to sample from.
|
|
// We allow for some variation due to these mechanisms.
|
|
assert!(
|
|
all_indices.len() >= 400,
|
|
"First 50 batches should have at least 400 unique indices, got {}",
|
|
all_indices.len()
|
|
);
|
|
|
|
// Sample 51st batch (cooldown should be cleared)
|
|
let (_, _, indices_51) = buffer.sample(10)?;
|
|
|
|
// This batch can now reuse indices from earlier batches
|
|
// We can't assert exact overlap, but we can verify it samples successfully
|
|
assert_eq!(
|
|
indices_51.len(),
|
|
10,
|
|
"51st batch should sample 10 experiences"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// P0 Integration: Test all features working together
|
|
#[tokio::test]
|
|
async fn test_p0_all_features_together() -> Result<()> {
|
|
// Create hyperparameters with all P0 features enabled
|
|
let mut hyperparams = DQNHyperparameters::default();
|
|
hyperparams.learning_rate = 0.001;
|
|
hyperparams.lr_decay_rate = 0.95; // Enable P0.6 (LR scheduler)
|
|
hyperparams.lr_decay_steps = 100;
|
|
hyperparams.lr_min = 1e-6;
|
|
hyperparams.epochs = 2; // Just 2 epochs for integration test
|
|
hyperparams.batch_size = 32;
|
|
hyperparams.buffer_size = 1000;
|
|
|
|
// Create trainer
|
|
let trainer = DQNTrainer::new(hyperparams)?;
|
|
|
|
// Verify LR scheduler is initialized
|
|
assert_eq!(trainer.get_current_lr(), 0.001);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// P0.1: Verify TD-error clamping threshold exists
|
|
#[test]
|
|
fn test_p0_td_error_clamping_threshold() {
|
|
// This is a compile-time test to verify the clamp exists
|
|
const MAX_LOSS_THRESHOLD: f32 = 1e6;
|
|
assert_eq!(MAX_LOSS_THRESHOLD, 1_000_000.0);
|
|
|
|
// Verify clamp logic
|
|
let test_losses = vec![
|
|
(100.0, 100.0), // Normal loss
|
|
(1000.0, 1000.0), // High but acceptable
|
|
(1e6, 1e6), // At threshold
|
|
(1e7, 1e6), // Above threshold - should clamp
|
|
(f32::INFINITY, 1e6), // Extreme case
|
|
];
|
|
|
|
for (input, expected) in test_losses {
|
|
let clamped = if input > 1e6 { 1e6 } else { input };
|
|
assert_eq!(clamped, expected, "Loss clamping failed for input {}", input);
|
|
}
|
|
}
|
|
|
|
/// Integration test: Verify DQNTrainer has LR scheduler access
|
|
#[tokio::test]
|
|
async fn test_p0_trainer_lr_scheduler_access() -> Result<()> {
|
|
let mut hyperparams = DQNHyperparameters::default();
|
|
hyperparams.learning_rate = 0.001;
|
|
hyperparams.lr_decay_rate = 0.9;
|
|
hyperparams.lr_decay_steps = 50;
|
|
hyperparams.lr_min = 1e-5;
|
|
hyperparams.epochs = 1;
|
|
|
|
let trainer = DQNTrainer::new(hyperparams)?;
|
|
|
|
// Verify initial LR
|
|
let initial_lr = trainer.get_current_lr();
|
|
assert_eq!(initial_lr, 0.001);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Test helper: Verify PrioritizedReplayBuffer has training step tracking
|
|
/// NOTE: Staleness decay is not yet implemented - this test verifies the foundation
|
|
#[test]
|
|
fn test_p0_staleness_tracking_exists() {
|
|
// This test verifies the training step tracking exists as foundation for staleness
|
|
let buffer = PrioritizedReplayBuffer::new(PrioritizedReplayConfig {
|
|
capacity: 100,
|
|
alpha: 0.6,
|
|
beta: 0.4,
|
|
initial_priority: 1.0,
|
|
min_priority: 1e-6,
|
|
strategy: PrioritizationStrategy::Proportional,
|
|
beta_max: 1.0,
|
|
beta_annealing_steps: 100000,
|
|
})
|
|
.expect("Buffer creation should succeed");
|
|
|
|
// Verify training step tracking works
|
|
buffer.set_training_step(1000);
|
|
assert_eq!(buffer.training_step(), 1000, "Training step should be settable");
|
|
|
|
buffer.step();
|
|
assert_eq!(buffer.training_step(), 1001, "Training step should increment");
|
|
}
|
|
|
|
/// Test helper: Verify batch diversity tracking exists
|
|
#[test]
|
|
fn test_p0_batch_diversity_exists() {
|
|
// This test verifies the batch diversity mechanism exists
|
|
let buffer = PrioritizedReplayBuffer::new(PrioritizedReplayConfig {
|
|
capacity: 100,
|
|
alpha: 0.6,
|
|
beta: 0.4,
|
|
initial_priority: 1.0,
|
|
min_priority: 1e-6,
|
|
strategy: PrioritizationStrategy::Proportional,
|
|
beta_max: 1.0,
|
|
beta_annealing_steps: 100000,
|
|
})
|
|
.expect("Buffer creation should succeed");
|
|
|
|
// Verify recently_sampled field exists (accessed via sample() method)
|
|
// The HashSet tracking is internal, so we verify via successful buffer creation
|
|
assert_eq!(buffer.len(), 0, "New buffer should be empty");
|
|
}
|