Files
foxhunt/crates/ml/tests/edge_cases/early_stopping_edge_cases.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- 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>
2026-03-15 12:00:13 +01:00

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