- Reduce CI GPU test datasets 16x for walltime reduction - Reduce early-stop epochs 50→10, add --test-threads=1 - Serialize all GPU lib tests to prevent cuBLAS init race - Align state_dim to 16 for BF16 tensor core HMMA dispatch - BF16 precision tolerance in ml-dqn tests - Enable branching DQN + tracing subscriber in smoke tests - Prevent min_replay_size > buffer_size deadlock in early-stop tests - Prevent AutoReplaySizer from breaking gradient collapse warmup - Replace racy tokio::spawn checkpoint counter with AtomicUsize - Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests - RealDataLoader respects TEST_DATA_DIR for CI PVC layout - Add collapse_warmup_capacity to gpu_smoketest DQNConfig - Drain CUDA context between test binaries - Detached HEAD checkout prevents local branch corruption - GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions - OOD input handling tests use use_gpu: true Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
442 lines
14 KiB
Rust
442 lines
14 KiB
Rust
//! Edge Case Tests for Early Stopping
|
|
//!
|
|
//! This module tests extreme and unusual scenarios that early stopping must handle:
|
|
//! 1. Zero variance data
|
|
//! 2. NaN/Inf handling
|
|
//! 3. Single epoch training
|
|
//! 4. Concurrent trial execution
|
|
//! 5. Extreme parameter values
|
|
//! 6. Memory/resource limits
|
|
|
|
use tracing::info;
|
|
|
|
// ============================================================================
|
|
// ZERO VARIANCE TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_early_stopping_constant_loss() {
|
|
// Training stuck at constant loss (model not learning)
|
|
let constant_losses = vec![1.5; 50];
|
|
|
|
let window = 10;
|
|
let min_improvement = 0.1;
|
|
|
|
// Should detect plateau immediately after 2*window epochs
|
|
let epoch = 25;
|
|
let recent_avg = constant_losses[epoch-window..epoch].iter().sum::<f64>() / window as f64;
|
|
let older_avg = constant_losses[epoch-2*window..epoch-window].iter().sum::<f64>() / window as f64;
|
|
|
|
let improvement = ((older_avg - recent_avg) / older_avg * 100.0).abs();
|
|
|
|
info!(loss = constant_losses[0], improvement, "Constant loss test");
|
|
|
|
assert!(improvement < min_improvement, "Should detect constant loss as plateau");
|
|
assert_eq!(recent_avg, older_avg, "Averages should be identical");
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_near_zero_loss() {
|
|
// Training converged to near-perfect loss
|
|
let near_zero_losses = vec![1e-10; 20];
|
|
|
|
// Should still apply plateau detection
|
|
let window = 5;
|
|
let recent_avg = near_zero_losses[15..20].iter().sum::<f64>() / window as f64;
|
|
let older_avg = near_zero_losses[10..15].iter().sum::<f64>() / window as f64;
|
|
|
|
let improvement = if older_avg > 0.0 {
|
|
((older_avg - recent_avg) / older_avg * 100.0).abs()
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
info!(loss = near_zero_losses[0], improvement, "Near-zero loss test");
|
|
|
|
// Should detect as plateau
|
|
assert!(improvement < 0.1);
|
|
}
|
|
|
|
// ============================================================================
|
|
// NaN/INF HANDLING TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_early_stopping_nan_detection() {
|
|
let losses_with_nan = vec![1.0, 0.9, 0.8, f64::NAN, 0.6];
|
|
|
|
// Verify NaN detection
|
|
let has_nan = losses_with_nan.iter().any(|l| l.is_nan());
|
|
assert!(has_nan, "Should detect NaN");
|
|
|
|
// Find NaN location
|
|
let nan_index = losses_with_nan.iter()
|
|
.position(|l| l.is_nan())
|
|
.expect("Should find NaN");
|
|
|
|
info!(nan_index, "NaN detected at epoch");
|
|
assert_eq!(nan_index, 3);
|
|
|
|
// In production, this would mark trial as FAILED
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_inf_detection() {
|
|
let losses_with_inf = vec![1.0, 0.9, f64::INFINITY, 0.7];
|
|
|
|
// Verify Inf detection
|
|
let has_inf = losses_with_inf.iter().any(|l| l.is_infinite());
|
|
assert!(has_inf, "Should detect Inf");
|
|
|
|
let inf_index = losses_with_inf.iter()
|
|
.position(|l| l.is_infinite())
|
|
.expect("Should find Inf");
|
|
|
|
info!(inf_index, "Inf detected at epoch");
|
|
assert_eq!(inf_index, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_negative_inf() {
|
|
let losses = vec![1.0, 0.5, f64::NEG_INFINITY, 0.3];
|
|
|
|
let has_neg_inf = losses.iter().any(|l| l.is_infinite() && l.is_sign_negative());
|
|
assert!(has_neg_inf, "Should detect negative infinity");
|
|
|
|
// Negative infinity is also invalid
|
|
for (i, &loss) in losses.iter().enumerate() {
|
|
if loss.is_infinite() {
|
|
info!(epoch = i, loss, "Invalid loss at epoch");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// SINGLE EPOCH TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_early_stopping_single_epoch_training() {
|
|
let config = ml::trainers::dqn::DQNHyperparameters {
|
|
epochs: 1,
|
|
min_epochs_before_stopping: 50,
|
|
..Default::default()
|
|
};
|
|
|
|
// With only 1 epoch, early stopping should never trigger
|
|
assert!(config.epochs < config.min_epochs_before_stopping);
|
|
|
|
info!(epochs = config.epochs, min_epochs_before_stopping = config.min_epochs_before_stopping, "Single epoch config: early stopping will NOT trigger");
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_two_epochs() {
|
|
// With 2 epochs, cannot calculate plateau (need 2*window)
|
|
let losses = vec![1.0, 0.9];
|
|
let window = 5;
|
|
|
|
// Cannot calculate plateau with < 2*window epochs
|
|
assert!(losses.len() < window * 2, "Not enough epochs for plateau detection");
|
|
|
|
info!(losses = ?losses, window, need = window * 2, "Two epoch test: insufficient epochs for plateau detection");
|
|
}
|
|
|
|
// ============================================================================
|
|
// CONCURRENT EXECUTION TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_early_stopping_thread_safety_simulation() {
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread;
|
|
|
|
// Simulate multiple trials running concurrently
|
|
#[derive(Debug, Clone)]
|
|
struct TrialState {
|
|
trial_id: usize,
|
|
best_loss: f64,
|
|
patience_counter: usize,
|
|
stopped: bool,
|
|
}
|
|
|
|
let trials = Arc::new(Mutex::new(vec![
|
|
TrialState { trial_id: 0, best_loss: f64::INFINITY, patience_counter: 0, stopped: false },
|
|
TrialState { trial_id: 1, best_loss: f64::INFINITY, patience_counter: 0, stopped: false },
|
|
TrialState { trial_id: 2, best_loss: f64::INFINITY, patience_counter: 0, stopped: false },
|
|
]));
|
|
|
|
let mut handles = vec![];
|
|
|
|
// Spawn threads to update trials concurrently
|
|
for i in 0..3 {
|
|
let trials_clone = Arc::clone(&trials);
|
|
let handle = thread::spawn(move || {
|
|
let new_loss = 1.0 - (i as f64 * 0.1);
|
|
|
|
let mut trials = trials_clone.lock().unwrap();
|
|
trials[i].best_loss = new_loss;
|
|
trials[i].patience_counter = 0;
|
|
|
|
info!(trial = i, new_loss, "Trial updated");
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// Wait for all threads
|
|
for handle in handles {
|
|
handle.join().unwrap();
|
|
}
|
|
|
|
// Verify state isolation (no race conditions)
|
|
let final_trials = trials.lock().unwrap();
|
|
for trial in final_trials.iter() {
|
|
info!(trial_id = trial.trial_id, best_loss = trial.best_loss, stopped = trial.stopped, "Trial final state");
|
|
assert!(trial.best_loss < f64::INFINITY, "Trial {} should have been updated", trial.trial_id);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// EXTREME PARAMETER TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_early_stopping_zero_patience() {
|
|
// Patience = 0 means stop immediately on first non-improvement
|
|
let patience = 0;
|
|
let losses = vec![1.0, 0.9, 0.95]; // Improvement, then worse
|
|
|
|
let mut no_improvement_count = 0;
|
|
let mut best_loss = f64::INFINITY;
|
|
|
|
for (epoch, &loss) in losses.iter().enumerate() {
|
|
if loss < best_loss {
|
|
best_loss = loss;
|
|
no_improvement_count = 0;
|
|
} else {
|
|
no_improvement_count += 1;
|
|
}
|
|
|
|
if no_improvement_count > patience {
|
|
info!(epoch, "Stopped at epoch with zero patience");
|
|
assert_eq!(epoch, 2, "Should stop immediately at first non-improvement");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_infinite_patience() {
|
|
// Infinite patience means never stop
|
|
let patience = usize::MAX;
|
|
let losses = vec![1.0, 1.1, 1.2, 1.3, 1.4]; // Always getting worse
|
|
|
|
let mut no_improvement_count = 0;
|
|
let mut best_loss = f64::INFINITY;
|
|
|
|
for (epoch, &loss) in losses.iter().enumerate() {
|
|
if loss < best_loss {
|
|
best_loss = loss;
|
|
no_improvement_count = 0;
|
|
} else {
|
|
no_improvement_count += 1;
|
|
}
|
|
|
|
assert!(no_improvement_count < patience, "Should never exhaust infinite patience");
|
|
info!(epoch, no_improvement_count, "Epoch no_improvement count");
|
|
}
|
|
|
|
assert_eq!(no_improvement_count, 4, "Counted 4 non-improvements but didn't stop");
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_window_size_one() {
|
|
// Window size 1 means compare consecutive epochs
|
|
let losses = vec![1.0, 0.9, 0.89, 0.88, 0.87, 0.86];
|
|
let window = 1;
|
|
let min_improvement = 5.0; // 5%
|
|
|
|
for epoch in 2..losses.len() {
|
|
let recent = losses[epoch];
|
|
let older = losses[epoch-1];
|
|
let improvement = ((older - recent) / older * 100.0).abs();
|
|
|
|
info!(epoch, improvement, "Epoch improvement");
|
|
if improvement < min_improvement {
|
|
info!(min_improvement, "Plateau detected");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_very_large_window() {
|
|
// Window size = total epochs means very conservative stopping
|
|
let losses = vec![1.0; 100];
|
|
let window = 50; // Half of total epochs
|
|
let min_improvement = 0.1;
|
|
|
|
// Can only check plateau after 2*window epochs
|
|
let check_epoch = 100;
|
|
if check_epoch >= window * 2 {
|
|
let recent_avg = losses[check_epoch-window..check_epoch].iter().sum::<f64>() / window as f64;
|
|
let older_avg = losses[check_epoch-2*window..check_epoch-window].iter().sum::<f64>() / window as f64;
|
|
let improvement = ((older_avg - recent_avg) / older_avg * 100.0).abs();
|
|
|
|
info!(window, improvement, "Large window test");
|
|
assert!(improvement < min_improvement);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MEMORY/RESOURCE LIMIT TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_early_stopping_loss_history_memory() {
|
|
// Test memory usage with very long training
|
|
let num_epochs = 10_000;
|
|
let mut loss_history: Vec<f64> = Vec::with_capacity(num_epochs);
|
|
|
|
// Simulate training
|
|
for epoch in 0..num_epochs {
|
|
let loss = 1.0 / (epoch as f64 + 1.0);
|
|
loss_history.push(loss);
|
|
}
|
|
|
|
let memory_bytes = loss_history.len() * std::mem::size_of::<f64>();
|
|
info!(num_epochs, memory_bytes, memory_kb = memory_bytes as f64 / 1024.0, "Loss history memory usage");
|
|
|
|
// Verify reasonable memory usage
|
|
assert!(memory_bytes < 1_000_000, "Should use < 1 MB for 10k epochs");
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_with_batch_size_zero() {
|
|
// Batch size 0 should be rejected before training starts
|
|
let config = ml::trainers::dqn::DQNHyperparameters {
|
|
batch_size: 0,
|
|
..Default::default()
|
|
};
|
|
|
|
// In production, this would be caught by validation
|
|
info!(batch_size = config.batch_size, "Invalid batch size");
|
|
assert_eq!(config.batch_size, 0);
|
|
// Note: actual validation would happen in DQNTrainer::new()
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_max_batch_size() {
|
|
// Maximum batch size for RTX 3050 Ti
|
|
let config = ml::trainers::dqn::DQNHyperparameters {
|
|
batch_size: 230, // Max for 4GB GPU
|
|
..Default::default()
|
|
};
|
|
|
|
info!(batch_size = config.batch_size, gpu = "RTX 3050 Ti (4GB)", "Max batch size config");
|
|
|
|
assert_eq!(config.batch_size, 230);
|
|
}
|
|
|
|
// ============================================================================
|
|
// NUMERICAL STABILITY TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_early_stopping_very_small_losses() {
|
|
// Test with losses near machine epsilon
|
|
let epsilon = f64::EPSILON;
|
|
let losses = vec![
|
|
epsilon * 10.0,
|
|
epsilon * 9.0,
|
|
epsilon * 8.0,
|
|
epsilon * 7.0,
|
|
];
|
|
|
|
info!("Very small losses (near epsilon)");
|
|
for (i, &loss) in losses.iter().enumerate() {
|
|
info!(epoch = i, loss, "Epoch loss");
|
|
}
|
|
|
|
// Should still calculate improvements correctly
|
|
let improvement = (losses[0] - losses[3]) / losses[0];
|
|
info!(improvement_pct = improvement * 100.0, "Total improvement");
|
|
|
|
assert!(improvement > 0.0, "Should detect improvement even for tiny losses");
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_very_large_losses() {
|
|
// Test with very large losses
|
|
let losses = vec![1e10, 9e9, 8e9, 7e9];
|
|
|
|
info!("Very large losses");
|
|
for (i, &loss) in losses.iter().enumerate() {
|
|
info!(epoch = i, loss, "Epoch loss");
|
|
}
|
|
|
|
// Should handle large numbers without overflow
|
|
let improvement = (losses[0] - losses[3]) / losses[0] * 100.0;
|
|
info!(improvement_pct = improvement, "Total improvement");
|
|
|
|
assert!(improvement > 0.0 && improvement < 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_loss_precision() {
|
|
// Test floating point precision in improvement calculation
|
|
let loss1 = 1.0;
|
|
let loss2 = 1.0 + 1e-15; // Tiny difference
|
|
|
|
let improvement = ((loss1 - loss2).abs() / loss1 * 100.0).abs();
|
|
|
|
info!(loss1, loss2, improvement, "Precision test");
|
|
|
|
// Should handle near-identical values
|
|
assert!(improvement < 1e-10, "Should recognize near-identical losses");
|
|
}
|
|
|
|
// ============================================================================
|
|
// BOUNDARY CONDITION TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_early_stopping_at_min_epochs_boundary() {
|
|
// Test behavior exactly at min_epochs threshold
|
|
let min_epochs = 50;
|
|
let epoch = 50;
|
|
|
|
// At min_epochs, early stopping should be allowed
|
|
assert!(epoch >= min_epochs, "Should allow early stopping at min_epochs");
|
|
|
|
// One epoch before, should NOT be allowed
|
|
let epoch_before = 49;
|
|
assert!(epoch_before < min_epochs, "Should NOT allow stopping before min_epochs");
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_at_window_boundary() {
|
|
// Test plateau detection exactly at 2*window epochs
|
|
let window = 10;
|
|
let losses = vec![1.0; 20]; // Exactly 2*window epochs
|
|
|
|
assert_eq!(losses.len(), window * 2);
|
|
|
|
// Should be able to detect plateau
|
|
let recent_avg = losses[window..].iter().sum::<f64>() / window as f64;
|
|
let older_avg = losses[..window].iter().sum::<f64>() / window as f64;
|
|
|
|
assert_eq!(recent_avg, older_avg);
|
|
info!("Plateau detection at exact window boundary: success");
|
|
}
|
|
|
|
#[test]
|
|
fn test_early_stopping_one_epoch_before_window() {
|
|
// Test with exactly 2*window - 1 epochs (cannot detect plateau)
|
|
let window = 10;
|
|
let losses = vec![1.0; 19]; // One less than 2*window
|
|
|
|
assert_eq!(losses.len(), window * 2 - 1);
|
|
|
|
// Cannot calculate plateau (not enough epochs)
|
|
info!(epochs = losses.len(), need = window * 2, "Cannot detect plateau: insufficient epochs");
|
|
}
|