Files
foxhunt/crates/ml/tests/ppo_45_action_network_tests.rs
jgrusewski 448b61d095 refactor: collapse 9-level to 7-level ExposureLevel — eliminate degenerate Flat variants
The 4-branch DQN (direction x magnitude) had 3 degenerate variants
(Short25, Flat, Long25) that all mapped to 0.0 target exposure when
direction=Flat, causing 82% Flat collapse. Collapse these into a
single Flat variant, giving 7 levels (ShortSmall/Half/Full, Flat,
LongSmall/Half/Full) and 63 total factored actions (7x3x3).

- ExposureLevel enum: 9 variants -> 7 (add direction/magnitude/from_dir_mag)
- FactoredAction: 81 -> 63 total actions, from_index/to_index updated
- DQN epsilon-greedy: use from_dir_mag() instead of dir*3+mag indexing
- DQN config: num_actions default 9 -> 7
- PPO action space: 45 -> 63 actions, action masking updated
- Signal adapter CUDA kernel: 5-bin -> 7-bin exposure aggregation
- All tests updated for new variant names and index ranges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:54:09 +02:00

396 lines
11 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,
)]
//! PPO 45-Action Network Tests (TDD Red Phase)
//!
//! Tests for PPO network architecture expansion from 3 -> 45 actions.
//!
//! Test coverage:
//! 1. Policy network outputs 45 logits (not 3)
//! 2. Value network still outputs 1 value (unchanged)
//! 3. Softmax over 45 logits sums to 1.0
//! 4. Full forward pass with 45 actions works correctly
//!
//! Expected behavior (TDD Red -> Green):
//! - RED: Tests fail because num_actions=3 (current implementation)
//! - GREEN: Tests pass after num_actions=45 changes
use anyhow::Result;
// candle eliminated -- test uses native GPU APIs (cudarc + cuda_nn)
#[test]
fn test_policy_network_45_output() -> Result<()> {
use ml::ppo::ppo::{PPOConfig, PPO};
// Create PPO with 45 actions
let config = PPOConfig {
num_actions: 63, // 5x3x3 factored action space
state_dim: 54, // Wave 3 features (54->54)
..Default::default()
};
let ppo = PPO::new(config)?;
// Create dummy state (flat f32 slice, batch_size=1)
let state = vec![0.0_f32; 54];
// Forward pass through policy network (returns Vec<f32> of logits)
let logits = ppo.actor.forward_host(&state, 1)?;
// ASSERT: Policy network outputs 45 logits (not 3)
assert_eq!(
logits.len(),
45,
"Policy network should output 45 logits, got: {}",
logits.len()
);
Ok(())
}
#[test]
fn test_value_network_single_output() -> Result<()> {
use ml::ppo::ppo::{PPOConfig, PPO};
// Create PPO with 45 actions
let config = PPOConfig {
num_actions: 63, // 5x3x3 factored action space
state_dim: 54, // Wave 3 features (54->54)
..Default::default()
};
let ppo = PPO::new(config)?;
// Create dummy state (flat f32 slice, batch_size=1)
let state = vec![0.0_f32; 54];
// Forward pass through value network (returns Vec<f32>)
let value = ppo.critic.forward_host(&state, 1)?;
// ASSERT: Value network still outputs 1 value (batch_size=1, scalar output)
assert_eq!(
value.len(),
1,
"Value network should output single value per batch item, got: {}",
value.len()
);
Ok(())
}
#[test]
fn test_policy_network_softmax() -> Result<()> {
use ml::ppo::ppo::{PPOConfig, PPO};
// Create PPO with 45 actions
let config = PPOConfig {
num_actions: 63,
state_dim: 54,
..Default::default()
};
let ppo = PPO::new(config)?;
// Create dummy state (flat f32 slice, batch_size=1)
let state = vec![0.0_f32; 54];
// Get action probabilities (softmax of logits) -- returns Vec<f32>
let probs = match &ppo.actor {
ml::ppo::ppo::ActorNetwork::MLP(net) => net.action_probabilities(&state, 1)?,
ml::ppo::ppo::ActorNetwork::LSTM(_) => {
panic!("Expected MLP actor, got LSTM");
}
};
// ASSERT 1: Probabilities have 45 elements
assert_eq!(
probs.len(),
45,
"Action probabilities should have 45 dimensions, got: {}",
probs.len()
);
// ASSERT 2: Probabilities sum to 1.0 (softmax property)
let sum: f32 = probs.iter().sum();
assert!(
(sum - 1.0).abs() < 1e-5,
"Softmax probabilities should sum to 1.0, got: {}",
sum
);
// ASSERT 3: Each probability is in [0, 1]
for (i, &prob) in probs.iter().enumerate() {
assert!(
prob >= 0.0 && prob <= 1.0,
"Probability {} is out of range [0, 1]: {}",
i,
prob
);
}
Ok(())
}
#[test]
fn test_network_forward_pass() -> Result<()> {
use ml::ppo::ppo::{PPOConfig, PPO};
// Create PPO with 45 actions
let config = PPOConfig {
num_actions: 63,
state_dim: 54,
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![256, 128, 64],
..Default::default()
};
let ppo = PPO::new(config)?;
// Create batch of states (batch_size=8, flat f32 slice)
let batch_size = 8;
let state = vec![0.0_f32; batch_size * 54];
// ASSERT 1: Policy forward pass with batch (returns batch_size * num_actions logits)
let logits = ppo.actor.forward_host(&state, batch_size)?;
assert_eq!(
logits.len(),
batch_size * 45,
"Policy logits should have {} elements (batch_size * 45), got: {}",
batch_size * 45,
logits.len()
);
// ASSERT 2: Value forward pass with batch (returns batch_size values)
let values = ppo.critic.forward_host(&state, batch_size)?;
assert_eq!(
values.len(),
batch_size,
"Value network should output {} values, got: {}",
batch_size,
values.len()
);
// ASSERT 3: Action probabilities with batch
let probs = match &ppo.actor {
ml::ppo::ppo::ActorNetwork::MLP(net) => net.action_probabilities(&state, batch_size)?,
ml::ppo::ppo::ActorNetwork::LSTM(_) => {
panic!("Expected MLP actor, got LSTM");
}
};
assert_eq!(
probs.len(),
batch_size * 45,
"Action probabilities should have {} elements, got: {}",
batch_size * 45,
probs.len()
);
// ASSERT 4: Each batch item's probabilities sum to 1.0
for i in 0..batch_size {
let row_start = i * 45;
let row_end = row_start + 45;
let row = &probs[row_start..row_end];
let sum: f32 = row.iter().sum();
assert!(
(sum - 1.0).abs() < 1e-5,
"Batch item {} probabilities should sum to 1.0, got: {}",
i,
sum
);
}
Ok(())
}
#[test]
fn test_backward_compatibility_3_actions() -> Result<()> {
use ml::ppo::ppo::{PPOConfig, PPO};
// Test that 3-action configuration still works (backward compatibility)
let config = PPOConfig {
num_actions: 3,
state_dim: 54,
..Default::default()
};
let ppo = PPO::new(config)?;
// Create dummy state (flat f32 slice, batch_size=1)
let state = vec![0.0_f32; 54];
// Policy network should output 3 logits
let logits = ppo.actor.forward_host(&state, 1)?;
assert_eq!(
logits.len(),
3,
"Policy network with num_actions=3 should output 3 logits"
);
// Value network should still output 1 value
let value = ppo.critic.forward_host(&state, 1)?;
assert_eq!(
value.len(),
1,
"Value network should output single value"
);
Ok(())
}
#[test]
fn test_ppo_config_default_45_actions() -> Result<()> {
use ml::ppo::ppo::PPOConfig;
// ASSERT: Default config uses 45 actions (not 3)
let config = PPOConfig::default();
assert_eq!(
config.num_actions, 45,
"Default PPOConfig should have num_actions=45, got: {}",
config.num_actions
);
Ok(())
}
#[test]
fn test_ppo_trainer_default_45_actions() -> Result<()> {
use ml::trainers::ppo::PpoHyperparameters;
// ASSERT: Conservative params use 45 actions
let params = PpoHyperparameters::conservative();
let config: ml::ppo::ppo::PPOConfig = params.into();
assert_eq!(
config.num_actions, 45,
"PpoHyperparameters should default to 45 actions, got: {}",
config.num_actions
);
Ok(())
}
#[test]
fn test_hyperopt_adapter_default_45_actions() -> Result<()> {
use ml::hyperopt::adapters::ppo::PPOParams;
// ASSERT: PPOParams default uses 45 actions when converted to config
let params = PPOParams::default();
// Create config manually (since PPOParams -> PPOConfig conversion is in PPOTrainer)
// We verify the architecture matches 45-action space
use ml::ppo::ppo::PPOConfig;
use ml::ppo::gae::GAEConfig;
let config = PPOConfig {
state_dim: 54,
num_actions: 63, // This is what hyperopt adapter should use
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![512, 384, 256, 128, 64],
policy_learning_rate: params.policy_learning_rate,
value_learning_rate: params.value_learning_rate,
clip_epsilon: params.clip_epsilon as f32,
value_loss_coeff: params.value_loss_coeff as f32,
entropy_coeff: params.entropy_coeff as f32,
gae_config: GAEConfig::default(),
batch_size: 2048,
mini_batch_size: 512,
num_epochs: 20,
max_grad_norm: 0.5,
early_stopping_enabled: true,
early_stopping_patience: 5,
early_stopping_min_delta: 1e-4,
early_stopping_min_epochs: 5,
max_position_absolute: 2.0,
transaction_cost_bps: 0.10,
cash_reserve_pct: 20.0,
circuit_breaker_threshold: 5,
use_lstm: false,
lstm_hidden_dim: 128,
lstm_num_layers: 1,
lstm_sequence_length: 32,
accumulation_steps: 1,
clip_epsilon_high: None,
use_symlog: true,
use_adaptive_entropy: true,
use_percentile_scaling: true,
};
assert_eq!(
config.num_actions, 45,
"Hyperopt adapter config should use 45 actions"
);
Ok(())
}