Files
foxhunt/ml/tests/dqn_trainer_integration_tests.rs
jgrusewski ac0a83e4f7 refactor(ml): reorganize tests — move integration tests to ml/tests/
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>
2026-02-20 14:07:22 +01:00

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");
}