Files
foxhunt/ml/tests/ppo_action_space_tests.rs
jgrusewski c645e6222d Wave 11: Rainbow DQN integration + 23/23 tests passing
CRITICAL FINDINGS from 3-trial validation:
- 85,120 gradient clipping warnings (81.6% of logs) - REGRESSION
- Rainbow features DISABLED: use_dueling=false, use_distributional=false, use_noisy_nets=false
- Negative Q-values confirmed: HOLD -1000 to -3250
- Performance: Sharpe 0.29 (target 0.77)

Changes:
- Fixed N-Step compilation (7/7 tests passing)
- Fixed Distributional compilation (6/6 tests passing)
- Fixed Dueling CUDA errors (10/10 tests passing)
- Added TDD validation for state_dim=225
- Total: 23/23 Wave 11 tests passing (100%)

Issues requiring investigation:
1. Why are Dueling/Distributional/Noisy disabled in hyperopt?
2. Why gradient explosion despite previous fixes?
3. Test coverage gaps - unit tests pass but integration fails

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 13:53:59 +01:00

324 lines
9.7 KiB
Rust

//! Tests for PPO Action Space Abstraction
//!
//! This test suite validates the unified action space interface for both
//! discrete and continuous PPO implementations.
use candle_core::Device;
use ml::ppo::{
ActionSpace, ActionType, ContinuousPPOConfig, ExposureLevel, FactoredAction, OrderType,
PPOConfig, Urgency, ContinuousPolicyConfig, UnifiedPPO, UnifiedPPOConfig,
};
use ml::MLError;
#[test]
fn test_action_space_discrete_creation() -> Result<(), MLError> {
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action_space = ActionSpace::discrete(action);
assert_eq!(action_space.action_type(), ActionType::Discrete);
assert!(action_space.as_discrete().is_some());
assert!(action_space.as_continuous().is_none());
Ok(())
}
#[test]
fn test_action_space_continuous_creation() -> Result<(), MLError> {
use ml::ppo::ContinuousAction;
let action = ContinuousAction::new(0.75);
let action_space = ActionSpace::continuous(action);
assert_eq!(action_space.action_type(), ActionType::Continuous);
assert!(action_space.as_continuous().is_some());
assert!(action_space.as_discrete().is_none());
Ok(())
}
#[test]
fn test_action_space_tensor_conversion_discrete() -> Result<(), MLError> {
let device = Device::Cpu;
let action = FactoredAction::new(ExposureLevel::Long50, OrderType::LimitMaker, Urgency::Aggressive);
let action_space = ActionSpace::discrete(action);
// Convert to tensor
let tensor = action_space.to_tensor(&device)?;
// Convert back
let recovered = ActionSpace::from_tensor(&tensor, ActionType::Discrete)?;
assert_eq!(action_space, recovered);
Ok(())
}
#[test]
fn test_action_space_tensor_conversion_continuous() -> Result<(), MLError> {
use ml::ppo::ContinuousAction;
let device = Device::Cpu;
let action = ContinuousAction::new(0.65);
let action_space = ActionSpace::continuous(action);
// Convert to tensor
let tensor = action_space.to_tensor(&device)?;
// Convert back
let recovered = ActionSpace::from_tensor(&tensor, ActionType::Continuous)?;
// Check values match (allowing for floating point error)
let original_pos = action_space.as_continuous().unwrap().position_size();
let recovered_pos = recovered.as_continuous().unwrap().position_size();
assert!((original_pos - recovered_pos).abs() < 1e-6);
Ok(())
}
#[test]
fn test_unified_ppo_discrete_creation() -> Result<(), MLError> {
let config = PPOConfig {
state_dim: 16,
num_actions: 45,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
..Default::default()
};
let device = Device::Cpu;
let ppo = UnifiedPPO::with_device(UnifiedPPOConfig::Discrete(config), device)?;
assert_eq!(ppo.action_type(), ActionType::Discrete);
Ok(())
}
#[test]
fn test_unified_ppo_continuous_creation() -> Result<(), MLError> {
let config = ContinuousPPOConfig {
state_dim: 16,
policy_config: ContinuousPolicyConfig {
state_dim: 16,
hidden_dims: vec![32],
..Default::default()
},
..Default::default()
};
let device = Device::Cpu;
let ppo = UnifiedPPO::with_device(UnifiedPPOConfig::Continuous(config), device)?;
assert_eq!(ppo.action_type(), ActionType::Continuous);
Ok(())
}
#[test]
fn test_unified_ppo_discrete_action_sampling() -> Result<(), MLError> {
let config = PPOConfig {
state_dim: 16,
num_actions: 45,
..Default::default()
};
let device = Device::Cpu;
let ppo = UnifiedPPO::with_device(UnifiedPPOConfig::Discrete(config), device.clone())?;
// Create dummy state
let state = candle_core::Tensor::zeros((1, 16), candle_core::DType::F32, &device)?;
// Sample action
let action = ppo.act(&state)?;
assert_eq!(action.action_type(), ActionType::Discrete);
assert!(action.as_discrete().is_some());
assert!(action.is_valid());
Ok(())
}
#[test]
fn test_unified_ppo_continuous_action_sampling() -> Result<(), MLError> {
let config = ContinuousPPOConfig {
state_dim: 16,
policy_config: ContinuousPolicyConfig {
state_dim: 16,
..Default::default()
},
..Default::default()
};
let device = Device::Cpu;
let ppo = UnifiedPPO::with_device(UnifiedPPOConfig::Continuous(config), device.clone())?;
// Create dummy state
let state = candle_core::Tensor::zeros((1, 16), candle_core::DType::F32, &device)?;
// Sample action
let action = ppo.act(&state)?;
assert_eq!(action.action_type(), ActionType::Continuous);
assert!(action.as_continuous().is_some());
assert!(action.is_valid());
Ok(())
}
#[test]
fn test_unified_ppo_config_switching() -> Result<(), MLError> {
let device = Device::Cpu;
// Create discrete PPO
let discrete_config = PPOConfig {
state_dim: 16,
num_actions: 45,
..Default::default()
};
let discrete_ppo = UnifiedPPO::with_device(
UnifiedPPOConfig::Discrete(discrete_config),
device.clone(),
)?;
assert_eq!(discrete_ppo.action_type(), ActionType::Discrete);
// Create continuous PPO
let continuous_config = ContinuousPPOConfig {
state_dim: 16,
policy_config: ContinuousPolicyConfig {
state_dim: 16,
..Default::default()
},
..Default::default()
};
let continuous_ppo = UnifiedPPO::with_device(
UnifiedPPOConfig::Continuous(continuous_config),
device,
)?;
assert_eq!(continuous_ppo.action_type(), ActionType::Continuous);
Ok(())
}
#[test]
fn test_action_space_validation() {
// Valid discrete action
let valid_discrete = ActionSpace::discrete(FactoredAction::from_index(0).unwrap());
assert!(valid_discrete.is_valid());
// All 45 discrete actions should be valid
for idx in 0..45 {
let action = ActionSpace::discrete(FactoredAction::from_index(idx).unwrap());
assert!(action.is_valid());
}
// Valid continuous action
use ml::ppo::ContinuousAction;
let valid_continuous = ActionSpace::continuous(ContinuousAction::new(0.5));
assert!(valid_continuous.is_valid());
// Clamped continuous action (still valid)
let clamped_continuous = ActionSpace::continuous(ContinuousAction::new(1.5));
assert!(clamped_continuous.is_valid());
}
#[test]
fn test_action_space_description() {
let discrete = ActionSpace::discrete(FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Aggressive,
));
let desc = discrete.description();
assert!(desc.contains("Long100"));
use ml::ppo::ContinuousAction;
let continuous = ActionSpace::continuous(ContinuousAction::new(0.75));
let desc = continuous.description();
assert!(desc.contains("75"));
}
#[test]
fn test_unified_ppo_save_discrete() -> Result<(), MLError> {
let config = PPOConfig {
state_dim: 16,
num_actions: 45,
..Default::default()
};
let device = Device::Cpu;
let ppo = UnifiedPPO::with_device(UnifiedPPOConfig::Discrete(config), device)?;
// Save checkpoint
let checkpoint_path = "/tmp/test_unified_ppo_discrete";
ppo.save(checkpoint_path)?;
// Verify files exist
assert!(std::path::Path::new(&format!("{}_actor.safetensors", checkpoint_path)).exists());
assert!(std::path::Path::new(&format!("{}_critic.safetensors", checkpoint_path)).exists());
assert!(std::path::Path::new(&format!("{}_metadata.json", checkpoint_path)).exists());
// Clean up
std::fs::remove_file(format!("{}_actor.safetensors", checkpoint_path)).ok();
std::fs::remove_file(format!("{}_critic.safetensors", checkpoint_path)).ok();
std::fs::remove_file(format!("{}_metadata.json", checkpoint_path)).ok();
Ok(())
}
#[test]
fn test_unified_ppo_save_continuous() -> Result<(), MLError> {
let config = ContinuousPPOConfig {
state_dim: 16,
policy_config: ContinuousPolicyConfig {
state_dim: 16,
..Default::default()
},
..Default::default()
};
let device = Device::Cpu;
let ppo = UnifiedPPO::with_device(UnifiedPPOConfig::Continuous(config), device)?;
// Save checkpoint
let checkpoint_path = "/tmp/test_unified_ppo_continuous";
ppo.save(checkpoint_path)?;
// Verify files exist
assert!(std::path::Path::new(&format!("{}_policy.safetensors", checkpoint_path)).exists());
assert!(std::path::Path::new(&format!("{}_value.safetensors", checkpoint_path)).exists());
assert!(std::path::Path::new(&format!("{}_metadata.json", checkpoint_path)).exists());
// Clean up
std::fs::remove_file(format!("{}_policy.safetensors", checkpoint_path)).ok();
std::fs::remove_file(format!("{}_value.safetensors", checkpoint_path)).ok();
std::fs::remove_file(format!("{}_metadata.json", checkpoint_path)).ok();
Ok(())
}
#[test]
fn test_unified_ppo_load_discrete() -> Result<(), MLError> {
let config = PPOConfig {
state_dim: 16,
num_actions: 45,
..Default::default()
};
let device = Device::Cpu;
let ppo = UnifiedPPO::with_device(UnifiedPPOConfig::Discrete(config), device)?;
// Save checkpoint
let checkpoint_path = "/tmp/test_unified_ppo_load_discrete";
ppo.save(checkpoint_path)?;
// Load checkpoint
let loaded_ppo = UnifiedPPO::load(checkpoint_path)?;
assert_eq!(loaded_ppo.action_type(), ActionType::Discrete);
// Clean up
std::fs::remove_file(format!("{}_actor.safetensors", checkpoint_path)).ok();
std::fs::remove_file(format!("{}_critic.safetensors", checkpoint_path)).ok();
std::fs::remove_file(format!("{}_metadata.json", checkpoint_path)).ok();
Ok(())
}