- 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>
531 lines
18 KiB
Rust
531 lines
18 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,
|
||
)]
|
||
//! P1 Episode Boundaries Fix - Comprehensive Test Suite
|
||
//!
|
||
//! This test suite validates the episode boundary segmentation fix that addresses
|
||
//! the missing episode termination logic causing improper value bootstrapping.
|
||
//!
|
||
//! **Problem**: Entire 18,000-sample dataset treated as SINGLE episode
|
||
//! **Fix**: 200-sample episodes with portfolio resets at boundaries
|
||
//! **Impact**: 1 → 90 terminal states per epoch (90x improvement)
|
||
|
||
use ml::dqn::portfolio_tracker::PortfolioTracker;
|
||
use ml::dqn::agent::TradingAction;
|
||
use tracing::info;
|
||
|
||
/// Episode length constant (must match dqn.rs)
|
||
const EPISODE_LENGTH: usize = 200;
|
||
|
||
#[test]
|
||
fn test_episode_boundary_detection() {
|
||
// Test 1: Verify episode boundaries occur at correct sample indices
|
||
//
|
||
// For 18,000 samples with EPISODE_LENGTH=200:
|
||
// Expected boundaries: 200, 400, 600, ..., 17800, 18000
|
||
// Total boundaries: 90
|
||
|
||
let dataset_size = 18_000;
|
||
let expected_boundaries = 90;
|
||
|
||
let mut boundary_count = 0;
|
||
let mut boundary_indices = Vec::new();
|
||
|
||
for i in 0..dataset_size {
|
||
let done = (i + 1) % EPISODE_LENGTH == 0 || i + 1 >= dataset_size;
|
||
if done {
|
||
boundary_count += 1;
|
||
boundary_indices.push(i + 1);
|
||
}
|
||
}
|
||
|
||
assert_eq!(boundary_count, expected_boundaries,
|
||
"Expected {} episode boundaries, got {}", expected_boundaries, boundary_count);
|
||
|
||
// Verify first few boundaries
|
||
assert_eq!(boundary_indices[0], 200, "First boundary should be at sample 200");
|
||
assert_eq!(boundary_indices[1], 400, "Second boundary should be at sample 400");
|
||
assert_eq!(boundary_indices[2], 600, "Third boundary should be at sample 600");
|
||
|
||
// Verify last boundary
|
||
assert_eq!(boundary_indices[boundary_indices.len() - 1], 18_000,
|
||
"Last boundary should be at final sample");
|
||
|
||
info!(boundary_count, "Test 1 PASSED: Episode boundaries detected correctly");
|
||
}
|
||
|
||
#[test]
|
||
fn test_portfolio_reset_at_boundaries() {
|
||
// Test 2: Verify portfolio state resets at episode boundaries
|
||
//
|
||
// Portfolio should reset to:
|
||
// - position_size = 0.0 (flat)
|
||
// - cash = initial_capital
|
||
// - entry_price = 0.0
|
||
|
||
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
|
||
|
||
// Simulate trading activity (open position)
|
||
tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0);
|
||
|
||
// Verify position is open
|
||
assert_eq!(tracker.current_position(), 10.0, "Position should be 10.0 before reset");
|
||
assert_eq!(tracker.cash_balance(), 9_000.0, "Cash should be 9000.0 before reset");
|
||
|
||
// Reset at episode boundary
|
||
tracker.reset();
|
||
|
||
// Verify portfolio reset correctly
|
||
assert_eq!(tracker.current_position(), 0.0, "Position should be 0.0 after reset");
|
||
assert_eq!(tracker.cash_balance(), 10_000.0, "Cash should be 10000.0 after reset");
|
||
assert_eq!(tracker.average_entry_price(), 0.0, "Entry price should be 0.0 after reset");
|
||
|
||
info!("Test 2 PASSED: Portfolio resets correctly at episode boundaries");
|
||
}
|
||
|
||
#[test]
|
||
fn test_done_flag_correctness() {
|
||
// Test 3: Verify done flag is true ONLY at episode boundaries
|
||
//
|
||
// done should be:
|
||
// - false at samples 1, 2, ..., 199
|
||
// - true at sample 200
|
||
// - false at samples 201, 202, ..., 399
|
||
// - true at sample 400
|
||
// - etc.
|
||
|
||
let dataset_size = 1_000; // Use smaller dataset for speed
|
||
let expected_boundaries = 5; // 200, 400, 600, 800, 1000
|
||
|
||
let mut done_count = 0;
|
||
let mut last_done_index = 0;
|
||
|
||
for i in 0..dataset_size {
|
||
let done = (i + 1) % EPISODE_LENGTH == 0 || i + 1 >= dataset_size;
|
||
|
||
if done {
|
||
done_count += 1;
|
||
|
||
// Verify spacing between boundaries (should be EPISODE_LENGTH)
|
||
if done_count > 1 {
|
||
let spacing = (i + 1) - last_done_index;
|
||
assert_eq!(spacing, EPISODE_LENGTH,
|
||
"Episode spacing should be {} samples, got {}", EPISODE_LENGTH, spacing);
|
||
}
|
||
|
||
last_done_index = i + 1;
|
||
} else {
|
||
// Verify done is false between boundaries
|
||
let next_boundary = ((i / EPISODE_LENGTH) + 1) * EPISODE_LENGTH;
|
||
assert!(i + 1 < next_boundary,
|
||
"Sample {} should have done=false (next boundary at {})", i + 1, next_boundary);
|
||
}
|
||
}
|
||
|
||
assert_eq!(done_count, expected_boundaries,
|
||
"Expected {} terminal states, got {}", expected_boundaries, done_count);
|
||
|
||
info!(done_count, "Test 3 PASSED: Done flags marked correctly");
|
||
}
|
||
|
||
#[test]
|
||
fn test_bellman_equation_respects_boundaries() {
|
||
// Test 4: Verify Bellman equation doesn't bootstrap when done=true
|
||
//
|
||
// TD Target calculation:
|
||
// - When done=false: TD_target = reward + γ × Q(next_state)
|
||
// - When done=true: TD_target = reward (no bootstrap)
|
||
//
|
||
// This test simulates the TD target calculation logic.
|
||
|
||
let gamma = 0.99;
|
||
let reward = 1.0;
|
||
let next_q_value = 10.0;
|
||
|
||
// Case 1: Mid-episode (done=false) - should bootstrap
|
||
let done_false = false;
|
||
let td_target_bootstrap = if done_false {
|
||
reward
|
||
} else {
|
||
reward + gamma * next_q_value
|
||
};
|
||
|
||
assert_eq!(td_target_bootstrap, reward + gamma * next_q_value,
|
||
"Mid-episode should bootstrap from next Q-value");
|
||
|
||
// Case 2: Episode boundary (done=true) - should NOT bootstrap
|
||
let done_true = true;
|
||
let td_target_terminal = if done_true {
|
||
reward
|
||
} else {
|
||
reward + gamma * next_q_value
|
||
};
|
||
|
||
assert_eq!(td_target_terminal, reward,
|
||
"Episode boundary should use reward only (no bootstrap)");
|
||
|
||
// Verify the difference
|
||
let bootstrap_contribution: f64 = td_target_bootstrap - td_target_terminal;
|
||
let expected_contribution: f64 = gamma * next_q_value;
|
||
|
||
assert!((bootstrap_contribution - expected_contribution).abs() < 1e-6f64,
|
||
"Bootstrap contribution should be γ × Q(next) = {}, got {}",
|
||
expected_contribution, bootstrap_contribution);
|
||
|
||
info!("Test 4 PASSED: Bellman equation respects episode boundaries");
|
||
}
|
||
|
||
#[test]
|
||
fn test_multi_epoch_episode_counting() {
|
||
// Test 5: Verify episode counting across multiple epochs
|
||
//
|
||
// Each epoch should have:
|
||
// - 90 episodes (18000 / 200 = 90)
|
||
// - Episodes reset each epoch (not cumulative)
|
||
|
||
let dataset_size = 18_000;
|
||
let num_epochs = 3;
|
||
let expected_episodes_per_epoch = 90;
|
||
|
||
let mut total_episodes = 0;
|
||
|
||
for epoch in 0..num_epochs {
|
||
let mut epoch_episodes = 0;
|
||
|
||
for i in 0..dataset_size {
|
||
let done = (i + 1) % EPISODE_LENGTH == 0 || i + 1 >= dataset_size;
|
||
if done {
|
||
epoch_episodes += 1;
|
||
}
|
||
}
|
||
|
||
assert_eq!(epoch_episodes, expected_episodes_per_epoch,
|
||
"Epoch {} should have {} episodes, got {}",
|
||
epoch + 1, expected_episodes_per_epoch, epoch_episodes);
|
||
|
||
total_episodes += epoch_episodes;
|
||
}
|
||
|
||
assert_eq!(total_episodes, num_epochs * expected_episodes_per_epoch,
|
||
"Total episodes across {} epochs should be {}, got {}",
|
||
num_epochs, num_epochs * expected_episodes_per_epoch, total_episodes);
|
||
|
||
info!(num_epochs, total_episodes, "Test 5 PASSED: Episode counting correct across epochs");
|
||
}
|
||
|
||
#[test]
|
||
fn test_partial_episode_at_epoch_end() {
|
||
// Test 6: Edge case - Partial episode when dataset not divisible by EPISODE_LENGTH
|
||
//
|
||
// Example: 18,100 samples
|
||
// - Episodes 1-90: 200 samples each (17,800 total)
|
||
// - Episode 91: 300 samples (partial, 18,000-18,100)
|
||
|
||
let dataset_size = 18_100;
|
||
let expected_full_episodes = 90; // 18,000 / 200
|
||
let expected_total_episodes = 91; // 90 full + 1 partial
|
||
|
||
let mut boundary_count = 0;
|
||
let mut episode_sizes = Vec::new();
|
||
let mut last_boundary = 0;
|
||
|
||
for i in 0..dataset_size {
|
||
let done = (i + 1) % EPISODE_LENGTH == 0 || i + 1 >= dataset_size;
|
||
if done {
|
||
boundary_count += 1;
|
||
let episode_size = (i + 1) - last_boundary;
|
||
episode_sizes.push(episode_size);
|
||
last_boundary = i + 1;
|
||
}
|
||
}
|
||
|
||
assert_eq!(boundary_count, expected_total_episodes,
|
||
"Expected {} episodes (90 full + 1 partial), got {}",
|
||
expected_total_episodes, boundary_count);
|
||
|
||
// Verify first 90 episodes are full size
|
||
for i in 0..expected_full_episodes {
|
||
assert_eq!(episode_sizes[i], EPISODE_LENGTH,
|
||
"Episode {} should be {} samples, got {}",
|
||
i + 1, EPISODE_LENGTH, episode_sizes[i]);
|
||
}
|
||
|
||
// Verify last episode is partial
|
||
let last_episode_size = episode_sizes[episode_sizes.len() - 1];
|
||
let expected_partial_size = dataset_size % EPISODE_LENGTH;
|
||
assert_eq!(last_episode_size, expected_partial_size,
|
||
"Last episode should be {} samples (partial), got {}",
|
||
expected_partial_size, last_episode_size);
|
||
|
||
info!(last_episode_size, "Test 6 PASSED: Partial episode handled correctly");
|
||
}
|
||
|
||
#[test]
|
||
fn test_episode_boundary_logging() {
|
||
// Test 7: Verify episode boundary debug logging format
|
||
//
|
||
// Expected log format:
|
||
// "Episode boundary at sample {i+1}/{total}, portfolio reset (episode {ep}/{total_ep})"
|
||
|
||
let dataset_size = 1_000; // Small dataset for speed
|
||
let _expected_boundaries = 5; // 200, 400, 600, 800, 1000
|
||
|
||
for i in 0..dataset_size {
|
||
let done = (i + 1) % EPISODE_LENGTH == 0 || i + 1 >= dataset_size;
|
||
|
||
if done {
|
||
let sample_num = i + 1;
|
||
// Episode number: For sample 200 → episode 1, sample 400 → episode 2, etc.
|
||
let episode_num = if sample_num % EPISODE_LENGTH == 0 {
|
||
sample_num / EPISODE_LENGTH
|
||
} else {
|
||
(sample_num / EPISODE_LENGTH) + 1
|
||
};
|
||
let _total_episodes = (dataset_size + EPISODE_LENGTH - 1) / EPISODE_LENGTH;
|
||
|
||
// Verify episode numbering matches the logic used in dqn.rs
|
||
let expected_episode = if sample_num % EPISODE_LENGTH == 0 {
|
||
sample_num / EPISODE_LENGTH
|
||
} else {
|
||
(sample_num / EPISODE_LENGTH) + 1
|
||
};
|
||
|
||
assert_eq!(episode_num, expected_episode,
|
||
"Episode number mismatch at sample {}: expected {}, got {}",
|
||
sample_num, expected_episode, episode_num);
|
||
}
|
||
}
|
||
|
||
info!("Test 7 PASSED: Episode boundary logging format correct");
|
||
}
|
||
|
||
#[test]
|
||
fn test_short_dataset_edge_case() {
|
||
// Test 8: Edge case - Dataset smaller than EPISODE_LENGTH
|
||
//
|
||
// Example: 50 samples with EPISODE_LENGTH=200
|
||
// Expected: 1 episode (partial) with terminal state at sample 50
|
||
|
||
let dataset_size = 50;
|
||
let expected_episodes = 1;
|
||
|
||
let mut boundary_count = 0;
|
||
|
||
for i in 0..dataset_size {
|
||
let done = (i + 1) % EPISODE_LENGTH == 0 || i + 1 >= dataset_size;
|
||
if done {
|
||
boundary_count += 1;
|
||
|
||
// Verify this is the last sample
|
||
assert_eq!(i + 1, dataset_size,
|
||
"Short dataset should have terminal state only at last sample");
|
||
}
|
||
}
|
||
|
||
assert_eq!(boundary_count, expected_episodes,
|
||
"Short dataset ({} samples) should have {} episode, got {}",
|
||
dataset_size, expected_episodes, boundary_count);
|
||
|
||
info!("Test 8 PASSED: Short dataset edge case handled correctly");
|
||
}
|
||
|
||
#[test]
|
||
fn test_exact_multiple_dataset() {
|
||
// Test 9: Edge case - Dataset size exactly divisible by EPISODE_LENGTH
|
||
//
|
||
// Example: 1,000 samples with EPISODE_LENGTH=200
|
||
// Expected: 5 episodes (200, 400, 600, 800, 1000)
|
||
|
||
let dataset_size = 1_000; // Exactly 5 episodes
|
||
let expected_episodes = 5;
|
||
|
||
let mut boundary_count = 0;
|
||
let mut boundary_indices = Vec::new();
|
||
|
||
for i in 0..dataset_size {
|
||
let done = (i + 1) % EPISODE_LENGTH == 0 || i + 1 >= dataset_size;
|
||
if done {
|
||
boundary_count += 1;
|
||
boundary_indices.push(i + 1);
|
||
}
|
||
}
|
||
|
||
assert_eq!(boundary_count, expected_episodes,
|
||
"Dataset of {} samples should have exactly {} episodes",
|
||
dataset_size, expected_episodes);
|
||
|
||
// Verify all boundaries are at exact multiples
|
||
for (idx, &boundary) in boundary_indices.iter().enumerate() {
|
||
let expected_boundary = (idx + 1) * EPISODE_LENGTH;
|
||
assert_eq!(boundary, expected_boundary,
|
||
"Episode {} boundary should be at sample {}, got {}",
|
||
idx + 1, expected_boundary, boundary);
|
||
}
|
||
|
||
info!("Test 9 PASSED: Exact multiple dataset handled correctly");
|
||
}
|
||
|
||
#[test]
|
||
fn test_no_position_leakage_between_episodes() {
|
||
// Test 10: Verify no position state leaks between episodes
|
||
//
|
||
// Simulates 2 episodes:
|
||
// - Episode 1: Open long position, end with done=true
|
||
// - Episode 2: Should start with clean portfolio (no position)
|
||
|
||
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
|
||
|
||
// Episode 1: Open position
|
||
tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0);
|
||
assert_eq!(tracker.current_position(), 10.0, "Episode 1 should have position");
|
||
|
||
// Episode boundary: Reset portfolio
|
||
tracker.reset();
|
||
|
||
// Episode 2: Verify clean start
|
||
assert_eq!(tracker.current_position(), 0.0, "Episode 2 should start flat");
|
||
assert_eq!(tracker.cash_balance(), 10_000.0, "Episode 2 should have initial capital");
|
||
assert_eq!(tracker.realized_pnl(), 0.0, "Episode 2 should have zero realized PnL");
|
||
|
||
// Verify unrealized PnL is also zero
|
||
let unrealized = tracker.unrealized_pnl(100.0);
|
||
assert_eq!(unrealized, 0.0, "Episode 2 should have zero unrealized PnL");
|
||
|
||
info!("Test 10 PASSED: No position leakage between episodes");
|
||
}
|
||
|
||
// Integration test helper to count terminal states in a mock training loop
|
||
fn count_terminal_states_in_epoch(dataset_size: usize) -> usize {
|
||
let mut terminal_count = 0;
|
||
|
||
for i in 0..dataset_size {
|
||
let done = (i + 1) % EPISODE_LENGTH == 0 || i + 1 >= dataset_size;
|
||
if done {
|
||
terminal_count += 1;
|
||
}
|
||
}
|
||
|
||
terminal_count
|
||
}
|
||
|
||
#[test]
|
||
fn test_terminal_state_count_18k_dataset() {
|
||
// Test 11: Comprehensive test - 18,000 sample dataset
|
||
//
|
||
// Verify:
|
||
// - 90 terminal states per epoch (18000 / 200 = 90)
|
||
// - Terminal states at samples: 200, 400, ..., 17800, 18000
|
||
|
||
let dataset_size = 18_000;
|
||
let expected_terminals = 90;
|
||
|
||
let terminal_count = count_terminal_states_in_epoch(dataset_size);
|
||
|
||
assert_eq!(terminal_count, expected_terminals,
|
||
"18K dataset should have {} terminal states, got {}",
|
||
expected_terminals, terminal_count);
|
||
|
||
// Calculate improvement over old implementation
|
||
let old_terminals = 1; // Before fix: only final sample
|
||
let improvement_factor = terminal_count / old_terminals;
|
||
|
||
info!(terminal_count, old_terminals, improvement_factor, "Test 11 PASSED: Terminal state count correct for 18K dataset");
|
||
}
|
||
|
||
#[test]
|
||
fn test_episode_boundary_consistency() {
|
||
// Test 12: Verify episode boundaries are consistent across iterations
|
||
//
|
||
// Run boundary detection 100 times, verify same results
|
||
|
||
let dataset_size = 1_000;
|
||
let iterations = 100;
|
||
|
||
let mut all_boundary_counts = Vec::new();
|
||
|
||
for _ in 0..iterations {
|
||
let count = count_terminal_states_in_epoch(dataset_size);
|
||
all_boundary_counts.push(count);
|
||
}
|
||
|
||
// Verify all counts are identical
|
||
let first_count = all_boundary_counts[0];
|
||
for &count in &all_boundary_counts {
|
||
assert_eq!(count, first_count,
|
||
"Episode boundary count should be consistent across iterations");
|
||
}
|
||
|
||
info!(iterations, "Test 12 PASSED: Episode boundaries consistent across iterations");
|
||
}
|