The accumulation_steps and clip_epsilon_high fields were added to PPOConfig but two test files with explicit struct literals were missed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
407 lines
15 KiB
Rust
407 lines
15 KiB
Rust
//! Recurrent PPO Integration Tests
|
||
//!
|
||
//! This module provides comprehensive integration tests for LSTM-enhanced PPO:
|
||
//! 1. Single episode training with LSTM
|
||
//! 2. Hidden state continuity across timesteps
|
||
//! 3. Episode boundary handling (hidden state reset)
|
||
//! 4. Recurrent vs feedforward comparison
|
||
//! 5. Checkpointing with hidden states
|
||
//!
|
||
//! These tests verify end-to-end functionality of the recurrent PPO system.
|
||
|
||
#![allow(unused_crate_dependencies)]
|
||
|
||
use candle_core::{Device, Tensor, DType};
|
||
use ml::dqn::TradingAction;
|
||
use ml::ppo::{
|
||
ppo::{PPOConfig, PPO},
|
||
trajectories::{Trajectory, TrajectoryBatch, TrajectoryStep},
|
||
gae::GAEConfig,
|
||
};
|
||
use std::path::PathBuf;
|
||
|
||
// ============================================================================
|
||
// Test 1: Single Episode Training with LSTM
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_recurrent_ppo_single_episode() {
|
||
// Test that LSTM-enhanced PPO can train on a single episode
|
||
let config = PPOConfig {
|
||
state_dim: 32,
|
||
num_actions: 5,
|
||
policy_hidden_dims: vec![64],
|
||
value_hidden_dims: vec![64],
|
||
use_lstm: true,
|
||
lstm_hidden_dim: 128,
|
||
lstm_num_layers: 1,
|
||
batch_size: 16,
|
||
mini_batch_size: 8,
|
||
num_epochs: 3,
|
||
policy_learning_rate: 1e-4,
|
||
value_learning_rate: 1e-3,
|
||
clip_epsilon: 0.2,
|
||
value_loss_coeff: 1.0,
|
||
entropy_coeff: 0.01,
|
||
gae_config: GAEConfig {
|
||
gamma: 0.99,
|
||
lambda: 0.95,
|
||
normalize_advantages: true,
|
||
},
|
||
max_grad_norm: 0.5,
|
||
early_stopping_enabled: false,
|
||
early_stopping_patience: 10,
|
||
early_stopping_min_delta: 1e-4,
|
||
early_stopping_min_epochs: 20,
|
||
max_position_absolute: 2.0,
|
||
transaction_cost_bps: 0.10,
|
||
cash_reserve_pct: 20.0,
|
||
circuit_breaker_threshold: 5,
|
||
lstm_sequence_length: 32,
|
||
accumulation_steps: 1,
|
||
clip_epsilon_high: None,
|
||
};
|
||
|
||
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
|
||
let mut ppo = PPO::with_device(config.clone(), device.clone())
|
||
.expect("Failed to create recurrent PPO");
|
||
|
||
// Verify hidden state manager is initialized
|
||
assert!(ppo.hidden_state_manager.is_some(), "Hidden state manager should be initialized when use_lstm=true");
|
||
|
||
// Create a simple trajectory (10 timesteps)
|
||
let mut trajectory = Trajectory::new();
|
||
for t in 0..10 {
|
||
let state = vec![t as f32; 32]; // Simple incrementing state
|
||
let action = TradingAction::Buy;
|
||
let log_prob = -1.0;
|
||
let value = 5.0 + t as f32;
|
||
let reward = 1.0;
|
||
let done = t == 9;
|
||
|
||
trajectory.add_step(TrajectoryStep::new(state, action, log_prob, value, reward, done));
|
||
}
|
||
|
||
// Create batch with simple advantages and returns
|
||
let advantages = vec![1.0; 10];
|
||
let returns = vec![10.0; 10];
|
||
let mut batch = TrajectoryBatch::from_trajectories(vec![trajectory], advantages, returns);
|
||
|
||
// Normalize advantages
|
||
batch.normalize_advantages().expect("Failed to normalize advantages");
|
||
|
||
// Train for 1 epoch
|
||
let (policy_loss, value_loss) = ppo.update(&mut batch)
|
||
.expect("Training should complete without errors");
|
||
|
||
// Verify losses are finite (not NaN or Inf)
|
||
assert!(policy_loss.is_finite(), "Policy loss should be finite, got {}", policy_loss);
|
||
assert!(value_loss.is_finite(), "Value loss should be finite, got {}", value_loss);
|
||
|
||
println!("✅ test_recurrent_ppo_single_episode passed: policy_loss={:.4}, value_loss={:.4}",
|
||
policy_loss, value_loss);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 2: Hidden State Continuity Across Timesteps
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_recurrent_ppo_hidden_state_continuity() {
|
||
// Test that hidden states persist and evolve across timesteps within an episode
|
||
let config = PPOConfig {
|
||
state_dim: 16,
|
||
num_actions: 3,
|
||
policy_hidden_dims: vec![32],
|
||
value_hidden_dims: vec![32],
|
||
use_lstm: true,
|
||
lstm_hidden_dim: 64,
|
||
lstm_num_layers: 1,
|
||
batch_size: 8,
|
||
mini_batch_size: 4,
|
||
num_epochs: 2,
|
||
policy_learning_rate: 1e-4,
|
||
value_learning_rate: 1e-3,
|
||
lstm_sequence_length: 32,
|
||
..PPOConfig::default()
|
||
};
|
||
|
||
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
|
||
let ppo = PPO::with_device(config.clone(), device.clone())
|
||
.expect("Failed to create recurrent PPO");
|
||
|
||
// Get hidden state manager
|
||
let manager = ppo.hidden_state_manager.as_ref()
|
||
.expect("Hidden state manager should exist");
|
||
|
||
// Extract initial hidden states (should be zeros)
|
||
let (h0_policy, _c0_policy) = manager.get_policy_state();
|
||
let (h0_value, _c0_value) = manager.get_value_state();
|
||
|
||
let h0_sum_policy = h0_policy.sum_all().expect("Sum failed").to_scalar::<f32>().expect("To scalar failed");
|
||
let h0_sum_value = h0_value.sum_all().expect("Sum failed").to_scalar::<f32>().expect("To scalar failed");
|
||
|
||
// Verify initial states are zeros
|
||
assert_eq!(h0_sum_policy, 0.0, "Initial policy hidden state should be zeros");
|
||
assert_eq!(h0_sum_value, 0.0, "Initial value hidden state should be zeros");
|
||
|
||
println!("✅ test_recurrent_ppo_hidden_state_continuity passed: States initialized correctly");
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 3: Episode Boundary Handling (Hidden State Reset)
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_recurrent_ppo_episode_boundaries() {
|
||
// Test that hidden states reset between episodes
|
||
let config = PPOConfig {
|
||
state_dim: 16,
|
||
num_actions: 3,
|
||
policy_hidden_dims: vec![32],
|
||
value_hidden_dims: vec![32],
|
||
use_lstm: true,
|
||
lstm_hidden_dim: 64,
|
||
lstm_num_layers: 1,
|
||
batch_size: 8,
|
||
mini_batch_size: 4,
|
||
num_epochs: 2,
|
||
lstm_sequence_length: 32,
|
||
..PPOConfig::default()
|
||
};
|
||
|
||
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
|
||
let mut ppo = PPO::with_device(config.clone(), device.clone())
|
||
.expect("Failed to create recurrent PPO");
|
||
|
||
// Create two episodes
|
||
let mut episode1 = Trajectory::new();
|
||
for t in 0..5 {
|
||
episode1.add_step(TrajectoryStep::new(
|
||
vec![1.0; 16],
|
||
TradingAction::Buy,
|
||
-1.0,
|
||
5.0,
|
||
1.0,
|
||
t == 4, // Done at last step
|
||
));
|
||
}
|
||
|
||
let mut episode2 = Trajectory::new();
|
||
for t in 0..5 {
|
||
episode2.add_step(TrajectoryStep::new(
|
||
vec![2.0; 16],
|
||
TradingAction::Sell,
|
||
-1.0,
|
||
5.0,
|
||
1.0,
|
||
t == 4, // Done at last step
|
||
));
|
||
}
|
||
|
||
// Create batch from both episodes
|
||
let advantages = vec![1.0; 10]; // 5 steps per episode × 2 episodes
|
||
let returns = vec![10.0; 10];
|
||
let mut batch = TrajectoryBatch::from_trajectories(
|
||
vec![episode1, episode2],
|
||
advantages,
|
||
returns,
|
||
);
|
||
|
||
batch.normalize_advantages().expect("Failed to normalize advantages");
|
||
|
||
// Train on batch (should handle episode boundaries)
|
||
let result = ppo.update(&mut batch);
|
||
assert!(result.is_ok(), "Training should complete without errors across episode boundaries");
|
||
|
||
// Verify hidden state manager can reset states
|
||
if let Some(ref mut manager) = ppo.hidden_state_manager {
|
||
// Get actual batch size from hidden state manager
|
||
let (h_policy, _) = manager.get_policy_state();
|
||
let actual_batch_size = h_policy.dims()[1]; // Shape: [num_layers, batch_size, hidden_dim]
|
||
|
||
// Create done mask: all environments done (match actual batch size)
|
||
let done_mask = Tensor::ones(&[actual_batch_size], DType::U8, &device)
|
||
.expect("Failed to create done mask");
|
||
|
||
// Reset states
|
||
manager.reset_on_done(&done_mask).expect("Reset should succeed");
|
||
|
||
// Verify states are zeros after reset
|
||
let (h_policy, _c_policy) = manager.get_policy_state();
|
||
let h_sum = h_policy.sum_all().expect("Sum failed").to_scalar::<f32>().expect("To scalar failed");
|
||
assert_eq!(h_sum, 0.0, "Hidden states should be zeros after reset");
|
||
}
|
||
|
||
println!("✅ test_recurrent_ppo_episode_boundaries passed: Episode boundaries handled correctly");
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 4: Recurrent vs Feedforward Comparison
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_recurrent_vs_feedforward_ppo() {
|
||
// Compare LSTM vs non-LSTM PPO training behavior
|
||
let base_config = PPOConfig {
|
||
state_dim: 16,
|
||
num_actions: 3,
|
||
policy_hidden_dims: vec![32],
|
||
value_hidden_dims: vec![32],
|
||
batch_size: 16,
|
||
mini_batch_size: 8,
|
||
num_epochs: 5,
|
||
policy_learning_rate: 1e-4,
|
||
value_learning_rate: 1e-3,
|
||
lstm_sequence_length: 32,
|
||
..PPOConfig::default()
|
||
};
|
||
|
||
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
|
||
|
||
// Create feedforward PPO
|
||
let mut feedforward_config = base_config.clone();
|
||
feedforward_config.use_lstm = false;
|
||
let mut ppo_feedforward = PPO::with_device(feedforward_config, device.clone())
|
||
.expect("Failed to create feedforward PPO");
|
||
|
||
// Create recurrent PPO
|
||
let mut recurrent_config = base_config.clone();
|
||
recurrent_config.use_lstm = true;
|
||
recurrent_config.lstm_hidden_dim = 128;
|
||
recurrent_config.lstm_num_layers = 1;
|
||
let mut ppo_recurrent = PPO::with_device(recurrent_config, device.clone())
|
||
.expect("Failed to create recurrent PPO");
|
||
|
||
// Verify configurations
|
||
assert!(ppo_feedforward.hidden_state_manager.is_none(), "Feedforward PPO should not have hidden state manager");
|
||
assert!(ppo_recurrent.hidden_state_manager.is_some(), "Recurrent PPO should have hidden state manager");
|
||
|
||
// Create identical trajectory for both
|
||
let mut trajectory = Trajectory::new();
|
||
for t in 0..10 {
|
||
trajectory.add_step(TrajectoryStep::new(
|
||
vec![t as f32; 16],
|
||
TradingAction::Buy,
|
||
-1.0,
|
||
5.0,
|
||
1.0,
|
||
t == 9,
|
||
));
|
||
}
|
||
|
||
let advantages = vec![1.0; 10];
|
||
let returns = vec![10.0; 10];
|
||
|
||
// Train feedforward
|
||
let mut batch_ff = TrajectoryBatch::from_trajectories(vec![trajectory.clone()], advantages.clone(), returns.clone());
|
||
batch_ff.normalize_advantages().expect("Failed to normalize");
|
||
let (ff_policy_loss, ff_value_loss) = ppo_feedforward.update(&mut batch_ff)
|
||
.expect("Feedforward training failed");
|
||
|
||
// Train recurrent
|
||
let mut batch_rec = TrajectoryBatch::from_trajectories(vec![trajectory], advantages, returns);
|
||
batch_rec.normalize_advantages().expect("Failed to normalize");
|
||
let (rec_policy_loss, rec_value_loss) = ppo_recurrent.update(&mut batch_rec)
|
||
.expect("Recurrent training failed");
|
||
|
||
// Both should produce finite losses
|
||
assert!(ff_policy_loss.is_finite(), "Feedforward policy loss should be finite");
|
||
assert!(ff_value_loss.is_finite(), "Feedforward value loss should be finite");
|
||
assert!(rec_policy_loss.is_finite(), "Recurrent policy loss should be finite");
|
||
assert!(rec_value_loss.is_finite(), "Recurrent value loss should be finite");
|
||
|
||
println!("✅ test_recurrent_vs_feedforward_ppo passed:");
|
||
println!(" Feedforward: policy_loss={:.4}, value_loss={:.4}", ff_policy_loss, ff_value_loss);
|
||
println!(" Recurrent: policy_loss={:.4}, value_loss={:.4}", rec_policy_loss, rec_value_loss);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 5: Checkpointing with Hidden States
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
#[ignore = "LSTM checkpoint loading not yet implemented (requires from_varbuilder methods in lstm_networks.rs)"]
|
||
fn test_recurrent_ppo_checkpointing() {
|
||
// Test that checkpoints can be saved and loaded with LSTM configuration
|
||
// TODO: Implement from_varbuilder methods in LSTMPolicyNetwork and LSTMValueNetwork
|
||
// to enable loading LSTM checkpoints from safetensors files
|
||
let config = PPOConfig {
|
||
state_dim: 16,
|
||
num_actions: 3,
|
||
policy_hidden_dims: vec![32],
|
||
value_hidden_dims: vec![32],
|
||
use_lstm: true,
|
||
lstm_hidden_dim: 64,
|
||
lstm_num_layers: 1,
|
||
batch_size: 16,
|
||
mini_batch_size: 8,
|
||
num_epochs: 3,
|
||
lstm_sequence_length: 32,
|
||
..PPOConfig::default()
|
||
};
|
||
|
||
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
|
||
let mut ppo = PPO::with_device(config.clone(), device.clone())
|
||
.expect("Failed to create recurrent PPO");
|
||
|
||
// Train for a few steps
|
||
let mut trajectory = Trajectory::new();
|
||
for t in 0..10 {
|
||
trajectory.add_step(TrajectoryStep::new(
|
||
vec![t as f32; 16],
|
||
TradingAction::Buy,
|
||
-1.0,
|
||
5.0,
|
||
1.0,
|
||
t == 9,
|
||
));
|
||
}
|
||
|
||
let advantages = vec![1.0; 10];
|
||
let returns = vec![10.0; 10];
|
||
let mut batch = TrajectoryBatch::from_trajectories(vec![trajectory], advantages, returns);
|
||
batch.normalize_advantages().expect("Failed to normalize");
|
||
|
||
let (_loss_before, _) = ppo.update(&mut batch)
|
||
.expect("Training failed");
|
||
|
||
// Save checkpoint
|
||
let checkpoint_dir = PathBuf::from("/tmp/ppo_recurrent_checkpoint_test");
|
||
std::fs::create_dir_all(&checkpoint_dir).expect("Failed to create checkpoint dir");
|
||
|
||
let actor_path = checkpoint_dir.join("actor_epoch_1.safetensors");
|
||
let critic_path = checkpoint_dir.join("critic_epoch_1.safetensors");
|
||
let metadata_path = checkpoint_dir.join("metadata_epoch_1.json");
|
||
|
||
let result = ppo.save_checkpoint(
|
||
actor_path.to_str().unwrap(),
|
||
critic_path.to_str().unwrap(),
|
||
metadata_path.to_str().unwrap(),
|
||
);
|
||
assert!(result.is_ok(), "Checkpoint save should succeed for recurrent PPO");
|
||
|
||
// Verify checkpoint files exist
|
||
assert!(actor_path.exists(), "Actor checkpoint should exist");
|
||
assert!(critic_path.exists(), "Critic checkpoint should exist");
|
||
assert!(metadata_path.exists(), "Metadata checkpoint should exist");
|
||
|
||
// Load checkpoint into new PPO instance
|
||
let ppo_loaded = PPO::load_checkpoint(
|
||
actor_path.to_str().unwrap(),
|
||
critic_path.to_str().unwrap(),
|
||
config.clone(),
|
||
device.clone(),
|
||
);
|
||
assert!(ppo_loaded.is_ok(), "Checkpoint load should succeed for recurrent PPO");
|
||
|
||
// Note: load_checkpoint does not restore training_steps from metadata.
|
||
// This is expected behavior - training_steps start from 0 after loading.
|
||
// The important part is that the model weights are restored correctly.
|
||
|
||
// Clean up
|
||
std::fs::remove_dir_all(&checkpoint_dir).ok();
|
||
|
||
println!("✅ test_recurrent_ppo_checkpointing passed: Checkpoints save/load correctly");
|
||
}
|