Move test files from ml/src/*/tests/ to ml/tests/. Convert crate-internal imports to public API imports. Rename files to follow naming conventions (no wave/priority prefixes). Files moved: - ml/src/dqn/tests/ -> ml/tests/ (4 files) - ml/src/trainers/dqn/tests/ -> ml/tests/ (5 files) Renames: - target_update_comprehensive_tests.rs -> target_update_tests.rs - p0_integration_tests.rs -> dqn_trainer_integration_tests.rs - p1_integration_tests.rs -> dqn_trainer_p1_tests.rs - ensemble_uncertainty_hyperopt_tests.rs -> ensemble_hyperopt_tests.rs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
369 lines
11 KiB
Rust
369 lines
11 KiB
Rust
//! 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.use_per = true; // Enable P0.2 (batch diversity) + P0.7 (staleness)
|
|
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");
|
|
}
|