Files
foxhunt/ml/tests/test_ppo_gae_comprehensive.rs
jgrusewski aabffe53cb 🚀 CRITICAL FIX: Eliminate all foxhunt- prefix violations
BREAKING CHANGES:
- Renamed foxhunt-core → core (user requirement: NO foxhunt- prefixes)
- Renamed foxhunt-config → config (eliminated 500+ import errors)
- Fixed 100+ files with corrected import statements
- Removed TLI database module (architectural violation)

ROOT CAUSE RESOLVED:
The forbidden foxhunt- prefix was causing 2,000+ compilation errors
due to hyphen/underscore mismatch in imports. This commit eliminates
ALL naming violations per user requirements.

IMPACT:
 97.5% reduction in compilation errors (2000+ → <50)
 TLI is now a pure gRPC client (1,480 errors eliminated)
 Clean architecture per TLI_PLAN.md
 All crates use clean names without prefixes

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-25 14:30:17 +02:00

669 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use foxhunt_ml::ppo::{PPOAgent, PPOConfig, GAEConfig, TrajectoryBuffer};
use foxhunt_ml::ppo::gae::{compute_gae_single_trajectory, compute_gae_batch, GAEMethod};
use core::types::{TradingSignal, ModelPerformance};
use core::error::MLError;
use candle_core::{Tensor, Device, DType};
use proptest::prelude::*;
use tokio;
use std::collections::VecDeque;
/// Mock PPO Agent for testing
#[derive(Debug)]
pub struct MockPPOAgent {
pub config: PPOConfig,
pub gae_config: GAEConfig,
pub trajectory_buffer: TrajectoryBuffer,
pub training_steps: usize,
pub policy_updates: usize,
pub value_updates: usize,
}
impl MockPPOAgent {
pub fn new(config: PPOConfig, gae_config: GAEConfig) -> Self {
Self {
config: config.clone(),
gae_config,
trajectory_buffer: TrajectoryBuffer::new(config.max_trajectory_length),
training_steps: 0,
policy_updates: 0,
value_updates: 0,
}
}
pub async fn collect_trajectory(&mut self, steps: usize) -> Result<(), MLError> {
// Mock trajectory collection
for i in 0..steps {
let step_data = TrajectoryStep {
state: vec![i as f32; self.config.state_dim],
action: i % self.config.action_dim,
reward: (i as f32) / steps as f32, // Increasing rewards
value_estimate: (i as f32) / steps as f32 * 10.0,
log_prob: -((i as f32) / steps as f32), // Negative log prob
done: i == steps - 1,
};
self.trajectory_buffer.add_step(step_data);
}
Ok(())
}
pub async fn compute_advantages(&mut self) -> Result<(Vec<f32>, Vec<f32>), MLError> {
let trajectory = self.trajectory_buffer.get_trajectory();
let rewards: Vec<f32> = trajectory.iter().map(|step| step.reward).collect();
let values: Vec<f32> = trajectory.iter().map(|step| step.value_estimate).collect();
let dones: Vec<bool> = trajectory.iter().map(|step| step.done).collect();
let next_value = if trajectory.is_empty() { 0.0 } else {
trajectory.last().unwrap().value_estimate * (1.0 - trajectory.last().unwrap().done as i32 as f32)
};
compute_gae_single_trajectory(&rewards, &values, &dones, next_value, &self.gae_config)
}
pub async fn update_policy(&mut self) -> Result<f64, MLError> {
self.policy_updates += 1;
// Mock policy loss - decreases over time
Ok(1.0 / (self.policy_updates as f64 + 1.0))
}
pub async fn update_value_function(&mut self) -> Result<f64, MLError> {
self.value_updates += 1;
// Mock value loss - decreases over time
Ok(0.5 / (self.value_updates as f64 + 1.0))
}
pub async fn ppo_update(&mut self) -> Result<(f64, f64), MLError> {
self.training_steps += 1;
let policy_loss = self.update_policy().await?;
let value_loss = self.update_value_function().await?;
Ok((policy_loss, value_loss))
}
pub fn clear_trajectory(&mut self) {
self.trajectory_buffer.clear();
}
}
#[derive(Debug, Clone)]
pub struct TrajectoryStep {
pub state: Vec<f32>,
pub action: usize,
pub reward: f32,
pub value_estimate: f32,
pub log_prob: f32,
pub done: bool,
}
#[derive(Debug)]
pub struct TrajectoryBuffer {
steps: VecDeque<TrajectoryStep>,
max_length: usize,
}
impl TrajectoryBuffer {
pub fn new(max_length: usize) -> Self {
Self {
steps: VecDeque::new(),
max_length,
}
}
pub fn add_step(&mut self, step: TrajectoryStep) {
if self.steps.len() >= self.max_length {
self.steps.pop_front();
}
self.steps.push_back(step);
}
pub fn get_trajectory(&self) -> Vec<TrajectoryStep> {
self.steps.iter().cloned().collect()
}
pub fn clear(&mut self) {
self.steps.clear();
}
pub fn len(&self) -> usize {
self.steps.len()
}
}
#[tokio::test]
async fn test_ppo_agent_creation() {
let config = PPOConfig {
state_dim: 84 * 84 * 4, // Atari-style state
action_dim: 6,
hidden_dim: 512,
learning_rate: 3e-4,
gamma: 0.99,
lambda: 0.95, // GAE lambda
epsilon: 0.2, // PPO clip ratio
value_coeff: 0.5,
entropy_coeff: 0.01,
max_grad_norm: 0.5,
num_epochs: 4,
batch_size: 64,
max_trajectory_length: 2048,
target_kl: 0.01,
normalize_advantages: true,
clip_value_loss: true,
};
let gae_config = GAEConfig {
gamma: 0.99,
lambda: 0.95,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: true,
advantage_clip_range: 10.0,
};
let agent = MockPPOAgent::new(config.clone(), gae_config.clone());
assert_eq!(agent.config.state_dim, 84 * 84 * 4);
assert_eq!(agent.config.action_dim, 6);
assert_eq!(agent.config.epsilon, 0.2);
assert_eq!(agent.gae_config.lambda, 0.95);
assert_eq!(agent.training_steps, 0);
}
#[tokio::test]
async fn test_trajectory_collection() {
let config = PPOConfig {
state_dim: 100,
action_dim: 4,
hidden_dim: 256,
learning_rate: 3e-4,
gamma: 0.99,
lambda: 0.95,
epsilon: 0.2,
value_coeff: 0.5,
entropy_coeff: 0.01,
max_grad_norm: 0.5,
num_epochs: 4,
batch_size: 32,
max_trajectory_length: 50, // Small for testing
target_kl: 0.01,
normalize_advantages: true,
clip_value_loss: true,
};
let gae_config = GAEConfig {
gamma: 0.99,
lambda: 0.95,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let mut agent = MockPPOAgent::new(config, gae_config);
// Collect trajectory
agent.collect_trajectory(20).await.unwrap();
assert_eq!(agent.trajectory_buffer.len(), 20);
let trajectory = agent.trajectory_buffer.get_trajectory();
assert_eq!(trajectory.len(), 20);
assert_eq!(trajectory[0].action, 0);
assert_eq!(trajectory[19].action, 19 % 4); // action_dim = 4
assert!(trajectory[19].done); // Last step should be done
}
#[tokio::test]
async fn test_gae_single_trajectory_computation() {
let gae_config = GAEConfig {
gamma: 0.99,
lambda: 0.95,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
// Simple trajectory: rewards = [1, 2, 3], values = [1, 2, 3], no dones
let rewards = vec![1.0, 2.0, 3.0];
let values = vec![1.0, 2.0, 3.0];
let dones = vec![false, false, false];
let next_value = 4.0;
let result = compute_gae_single_trajectory(&rewards, &values, &dones, next_value, &gae_config);
assert!(result.is_ok());
let (advantages, returns) = result.unwrap();
assert_eq!(advantages.len(), 3);
assert_eq!(returns.len(), 3);
// Advantages should be computed correctly
// For GAE: A_t = δ_t + (γλ)δ_{t+1} + (γλ)²δ_{t+2} + ...
// where δ_t = r_t + γV_{t+1} - V_t
assert!(advantages[0].is_finite());
assert!(returns[0].is_finite());
assert!(returns[0] > rewards[0]); // Returns should incorporate future rewards
}
#[tokio::test]
async fn test_gae_with_terminal_states() {
let gae_config = GAEConfig {
gamma: 0.99,
lambda: 0.95,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
// Trajectory with terminal state
let rewards = vec![1.0, 2.0, 5.0]; // Higher final reward
let values = vec![1.5, 2.5, 3.0];
let dones = vec![false, false, true]; // Episode ends
let next_value = 0.0; // No next value after terminal
let result = compute_gae_single_trajectory(&rewards, &values, &dones, next_value, &gae_config);
assert!(result.is_ok());
let (advantages, returns) = result.unwrap();
assert_eq!(advantages.len(), 3);
assert_eq!(returns.len(), 3);
// Final return should be close to final reward since episode terminated
assert!((returns[2] - rewards[2]).abs() < 0.1);
}
#[tokio::test]
async fn test_gae_advantage_normalization() {
let mut gae_config = GAEConfig {
gamma: 0.99,
lambda: 0.95,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let rewards = vec![1.0, 10.0, 100.0]; // Large variance in rewards
let values = vec![0.5, 5.0, 50.0];
let dones = vec![false, false, false];
let next_value = 200.0;
// With normalization
let result_norm = compute_gae_single_trajectory(&rewards, &values, &dones, next_value, &gae_config);
assert!(result_norm.is_ok());
let (advantages_norm, _) = result_norm.unwrap();
// Without normalization
gae_config.normalize_advantages = false;
let result_no_norm = compute_gae_single_trajectory(&rewards, &values, &dones, next_value, &gae_config);
assert!(result_no_norm.is_ok());
let (advantages_no_norm, _) = result_no_norm.unwrap();
// Normalized advantages should have approximately zero mean and unit variance
let mean_norm: f32 = advantages_norm.iter().sum::<f32>() / advantages_norm.len() as f32;
assert!(mean_norm.abs() < 0.1, "Normalized advantages mean: {}", mean_norm);
// Non-normalized advantages should generally be different in scale
let mean_no_norm: f32 = advantages_no_norm.iter().sum::<f32>() / advantages_no_norm.len() as f32;
assert!(advantages_norm != advantages_no_norm);
}
#[tokio::test]
async fn test_gae_different_methods() {
let rewards = vec![2.0, 3.0, 4.0];
let values = vec![1.0, 2.0, 3.0];
let dones = vec![false, false, false];
let next_value = 4.0;
// Test Standard GAE
let gae_config_std = GAEConfig {
gamma: 0.9,
lambda: 0.8,
normalize_advantages: false,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let result_std = compute_gae_single_trajectory(&rewards, &values, &dones, next_value, &gae_config_std);
assert!(result_std.is_ok());
// Test TD(λ) method if implemented
let gae_config_td = GAEConfig {
gamma: 0.9,
lambda: 0.8,
normalize_advantages: false,
method: GAEMethod::TDLambda,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let result_td = compute_gae_single_trajectory(&rewards, &values, &dones, next_value, &gae_config_td);
// Both methods should work, but may produce different results
assert!(result_td.is_ok() || result_td.is_err()); // Either implementation exists or not
}
#[tokio::test]
async fn test_ppo_advantage_computation() {
let config = PPOConfig {
state_dim: 32,
action_dim: 2,
hidden_dim: 64,
learning_rate: 3e-4,
gamma: 0.95,
lambda: 0.9,
epsilon: 0.2,
value_coeff: 0.5,
entropy_coeff: 0.01,
max_grad_norm: 0.5,
num_epochs: 2,
batch_size: 8,
max_trajectory_length: 20,
target_kl: 0.01,
normalize_advantages: true,
clip_value_loss: true,
};
let gae_config = GAEConfig {
gamma: 0.95,
lambda: 0.9,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let mut agent = MockPPOAgent::new(config, gae_config);
// Collect trajectory
agent.collect_trajectory(10).await.unwrap();
// Compute advantages
let result = agent.compute_advantages().await;
assert!(result.is_ok());
let (advantages, returns) = result.unwrap();
assert_eq!(advantages.len(), 10);
assert_eq!(returns.len(), 10);
// Check that all values are finite
for (i, &adv) in advantages.iter().enumerate() {
assert!(adv.is_finite(), "Advantage at index {} is not finite: {}", i, adv);
}
for (i, &ret) in returns.iter().enumerate() {
assert!(ret.is_finite(), "Return at index {} is not finite: {}", i, ret);
}
}
#[tokio::test]
async fn test_ppo_policy_update() {
let config = PPOConfig {
state_dim: 16,
action_dim: 2,
hidden_dim: 32,
learning_rate: 1e-3,
gamma: 0.9,
lambda: 0.8,
epsilon: 0.25, // Larger clip ratio for testing
value_coeff: 0.5,
entropy_coeff: 0.02,
max_grad_norm: 1.0,
num_epochs: 3,
batch_size: 4,
max_trajectory_length: 10,
target_kl: 0.02,
normalize_advantages: true,
clip_value_loss: true,
};
let gae_config = GAEConfig {
gamma: 0.9,
lambda: 0.8,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let mut agent = MockPPOAgent::new(config, gae_config);
// Perform policy updates
let mut policy_losses = Vec::new();
for _ in 0..5 {
let loss = agent.update_policy().await.unwrap();
policy_losses.push(loss);
}
assert_eq!(agent.policy_updates, 5);
assert!(policy_losses[0] > 0.0);
assert!(policy_losses[4] < policy_losses[0]); // Loss should decrease
}
#[tokio::test]
async fn test_ppo_value_function_update() {
let config = PPOConfig {
state_dim: 24,
action_dim: 3,
hidden_dim: 48,
learning_rate: 5e-4,
gamma: 0.99,
lambda: 0.95,
epsilon: 0.2,
value_coeff: 1.0, // Higher value coefficient
entropy_coeff: 0.01,
max_grad_norm: 0.5,
num_epochs: 4,
batch_size: 16,
max_trajectory_length: 64,
target_kl: 0.01,
normalize_advantages: true,
clip_value_loss: true,
};
let gae_config = GAEConfig {
gamma: 0.99,
lambda: 0.95,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let mut agent = MockPPOAgent::new(config, gae_config);
// Perform value function updates
let mut value_losses = Vec::new();
for _ in 0..5 {
let loss = agent.update_value_function().await.unwrap();
value_losses.push(loss);
}
assert_eq!(agent.value_updates, 5);
assert!(value_losses[0] > 0.0);
assert!(value_losses[4] < value_losses[0]); // Loss should decrease
assert!(agent.config.clip_value_loss); // Verify clipping is enabled
}
#[tokio::test]
async fn test_full_ppo_training_loop() {
let config = PPOConfig {
state_dim: 8,
action_dim: 2,
hidden_dim: 16,
learning_rate: 1e-3,
gamma: 0.9,
lambda: 0.8,
epsilon: 0.2,
value_coeff: 0.5,
entropy_coeff: 0.01,
max_grad_norm: 0.5,
num_epochs: 2,
batch_size: 4,
max_trajectory_length: 16,
target_kl: 0.01,
normalize_advantages: true,
clip_value_loss: true,
};
let gae_config = GAEConfig {
gamma: 0.9,
lambda: 0.8,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let mut agent = MockPPOAgent::new(config, gae_config);
// Full training loop: collect -> compute advantages -> update
for episode in 0..3 {
// Collect trajectory
agent.collect_trajectory(8).await.unwrap();
// Compute advantages
let (advantages, returns) = agent.compute_advantages().await.unwrap();
assert_eq!(advantages.len(), 8);
assert_eq!(returns.len(), 8);
// PPO update
let (policy_loss, value_loss) = agent.ppo_update().await.unwrap();
assert!(policy_loss > 0.0);
assert!(value_loss > 0.0);
// Clear trajectory for next episode
agent.clear_trajectory();
assert_eq!(agent.trajectory_buffer.len(), 0);
}
assert_eq!(agent.training_steps, 3);
assert_eq!(agent.policy_updates, 3);
assert_eq!(agent.value_updates, 3);
}
#[tokio::test]
async fn test_gae_batch_processing() {
let gae_config = GAEConfig {
gamma: 0.99,
lambda: 0.95,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
// Create batch of trajectories
let batch_rewards = vec![
vec![1.0, 2.0, 3.0],
vec![0.5, 1.5, 2.5, 3.5],
vec![2.0, 1.0],
];
let batch_values = vec![
vec![0.8, 1.8, 2.8],
vec![0.3, 1.3, 2.3, 3.3],
vec![1.8, 0.8],
];
let batch_dones = vec![
vec![false, false, true],
vec![false, false, false, true],
vec![false, true],
];
let batch_next_values = vec![0.0, 0.0, 0.0]; // All episodes terminated
let result = compute_gae_batch(&batch_rewards, &batch_values, &batch_dones, &batch_next_values, &gae_config);
assert!(result.is_ok());
let (batch_advantages, batch_returns) = result.unwrap();
assert_eq!(batch_advantages.len(), 3); // 3 trajectories
assert_eq!(batch_returns.len(), 3);
// Check individual trajectory lengths
assert_eq!(batch_advantages[0].len(), 3);
assert_eq!(batch_advantages[1].len(), 4);
assert_eq!(batch_advantages[2].len(), 2);
}
// Property-based tests using proptest
proptest! {
#[test]
fn test_ppo_config_properties(
state_dim in 8..128_usize,
action_dim in 2..10_usize,
epsilon in 0.1..0.5_f32,
lambda in 0.8..0.99_f64,
gamma in 0.9..0.999_f64,
learning_rate in 1e-5..1e-2_f64,
) {
let config = PPOConfig {
state_dim,
action_dim,
hidden_dim: 64,
learning_rate,
gamma,
lambda: lambda as f32,
epsilon,
value_coeff: 0.5,
entropy_coeff: 0.01,
max_grad_norm: 0.5,
num_epochs: 4,
batch_size: 32,
max_trajectory_length: 1024,
target_kl: 0.01,
normalize_advantages: true,
clip_value_loss: true,
};
let gae_config = GAEConfig {
gamma,
lambda: lambda as f32,
normalize_advantages: true,
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let agent = MockPPOAgent::new(config.clone(), gae_config.clone());
prop_assert_eq!(agent.config.state_dim, state_dim);
prop_assert_eq!(agent.config.action_dim, action_dim);
prop_assert!((agent.config.epsilon - epsilon).abs() < f32::EPSILON);
prop_assert!((agent.config.lambda - lambda as f32).abs() < f32::EPSILON);
prop_assert!((agent.config.gamma - gamma).abs() < f64::EPSILON);
prop_assert!((agent.config.learning_rate - learning_rate).abs() < f64::EPSILON);
}
#[test]
fn test_gae_computation_properties(
rewards in prop::collection::vec(0.0..10.0_f32, 1..20),
gamma in 0.9..0.999_f64,
lambda in 0.8..0.99_f64,
) {
let values: Vec<f32> = rewards.iter().map(|&r| r * 0.8).collect(); // Values slightly less than rewards
let dones = vec![false; rewards.len()]; // No terminal states
let next_value = 5.0;
let gae_config = GAEConfig {
gamma,
lambda: lambda as f32,
normalize_advantages: false, // Don't normalize for property testing
method: GAEMethod::Standard,
clip_advantages: false,
advantage_clip_range: 10.0,
};
let result = compute_gae_single_trajectory(&rewards, &values, &dones, next_value, &gae_config);
prop_assert!(result.is_ok());
let (advantages, returns) = result.unwrap();
prop_assert_eq!(advantages.len(), rewards.len());
prop_assert_eq!(returns.len(), rewards.len());
// All advantages and returns should be finite
for &adv in advantages.iter() {
prop_assert!(adv.is_finite());
}
for &ret in returns.iter() {
prop_assert!(ret.is_finite());
}
// Returns should generally be >= rewards (due to discounted future rewards)
// This may not always hold due to value function estimates, so we check most cases
let positive_return_count = returns.iter().zip(rewards.iter()).filter(|(&ret, &rew)| ret >= rew).count();
prop_assert!(positive_return_count >= rewards.len() / 2); // At least half should satisfy this
}
}