Files
foxhunt/crates/ml/tests/ppo_long_training_test.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- 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>
2026-03-15 12:00:13 +01:00

310 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 Long Training Test (30 epochs)
//!
//! Proves 30 epochs of PPO training completes without divergence:
//! all losses finite, checkpoint saved, policy loss bounded by PPO clipping,
//! and training pipeline runs end-to-end on production-sized state (54 features).
//!
//! Note: Unlike DQN, PPO value loss does NOT monotonically decrease. As the policy
//! changes, the value landscape shifts, causing value loss to fluctuate. This is
//! expected behavior documented in the PPO literature.
//!
//! Run manually:
//! ```sh
//! SQLX_OFFLINE=true cargo test -p ml --test ppo_long_training_test -- --ignored --nocapture
//! ```
#![allow(unused_crate_dependencies)]
use anyhow::Result;
use ml::trainers::ppo::{PpoHyperparameters, PpoTrainer, PpoTrainingMetrics};
use std::time::Instant;
use tracing::info;
/// Create synthetic market data with learnable signal.
/// Uses sine-wave patterns that PPO can learn to predict — same
/// approach as `ppo_training_pipeline_test.rs` but with 54 features
/// to match the production state_dim.
fn create_synthetic_market_data(num_bars: usize) -> Vec<Vec<f32>> {
use std::f32::consts::PI;
let state_dim = 54;
let mut data = Vec::with_capacity(num_bars);
for i in 0..num_bars {
let t = i as f32 / num_bars as f32;
let mut state = Vec::with_capacity(state_dim);
// Price features (sine wave — learnable pattern)
let price = 4000.0 + 100.0 * (t * 2.0 * PI).sin();
state.push(price); // close
state.push(price * 1.01); // high
state.push(price * 0.99); // low
state.push(price); // open
state.push(1000.0 + 200.0 * (t * 4.0 * PI).sin()); // volume
// Technical indicators
state.push(50.0 + 20.0 * (t * PI).sin()); // RSI
state.push((t * 2.0 * PI).sin()); // MACD
state.push((t * 3.0 * PI).cos()); // Signal line
state.push(20.0); // ATR
state.push(price * 0.98); // BB lower
state.push(price * 1.02); // BB upper
state.push(price); // EMA
// Pad remaining features to state_dim=54
while state.len() < state_dim {
state.push(0.0);
}
data.push(state);
}
data
}
#[tokio::test]
#[ignore]
async fn test_ppo_30_epoch_convergence() -> Result<()> {
let checkpoint_dir = tempfile::tempdir()?;
let start_time = Instant::now();
// --- Configure hyperparameters ---
// Use conservative defaults with a lower critic LR to reduce value loss volatility.
// Default critic_lr=0.001 causes value loss spikes on synthetic data.
let mut hyperparams = PpoHyperparameters::conservative();
hyperparams.epochs = 30;
hyperparams.batch_size = 64;
hyperparams.rollout_steps = 512;
hyperparams.minibatch_size = 32;
hyperparams.critic_learning_rate = Some(1e-4); // 10x lower than default for stability
hyperparams.early_stopping_enabled = false; // run all 30 epochs
hyperparams.min_epochs_before_stopping = 30;
// --- Generate synthetic data (2000 bars, state_dim=54) ---
let market_data = create_synthetic_market_data(2000);
// --- Create trainer (CPU, no GPU needed for smoke test) ---
let trainer = PpoTrainer::new(
hyperparams,
54, // state_dim matching production
checkpoint_dir.path(),
false, // CPU
None, // no vectorized envs
)?;
// --- Train, collecting metrics each epoch ---
let mut metrics_history: Vec<PpoTrainingMetrics> = Vec::new();
let _final_metrics = trainer
.train(market_data, |metrics: PpoTrainingMetrics| {
metrics_history.push(metrics);
})
.await?;
let training_duration = start_time.elapsed();
// --- Collect results ---
let value_losses: Vec<f32> = metrics_history.iter().map(|m| m.value_loss).collect();
let policy_losses: Vec<f32> = metrics_history.iter().map(|m| m.policy_loss).collect();
let explained_vars: Vec<f32> = metrics_history.iter().map(|m| m.explained_variance).collect();
let initial_value_loss = value_losses.first().copied().unwrap_or(f32::MAX);
let final_value_loss = value_losses.last().copied().unwrap_or(f32::MAX);
let initial_policy_loss = policy_losses.first().copied().unwrap_or(f32::MAX);
let final_policy_loss = policy_losses.last().copied().unwrap_or(f32::MAX);
let final_explained_var = explained_vars.last().copied().unwrap_or(f32::MIN);
// ═══════════════════════════════════════════════════════════════
// ASSERTIONS
// ═══════════════════════════════════════════════════════════════
// --- ASSERT 1: All 30 epochs completed ---
assert!(
metrics_history.len() >= 10,
"Expected at least 10 epochs of metrics, got {}",
metrics_history.len()
);
// --- ASSERT 2: Value loss stays bounded (no catastrophic divergence) ---
// PPO value loss legitimately fluctuates as the policy changes — unlike DQN,
// it does NOT monotonically decrease. We check it stays below 1000 (absolute
// bound) to catch NaN-adjacent divergence only.
let max_value_loss = value_losses
.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max);
assert!(
max_value_loss < 1000.0,
"Value loss diverged catastrophically: max={max_value_loss:.4} (initial={initial_value_loss:.4})"
);
// --- ASSERT 3: All value losses finite (no NaN/Inf) ---
for (i, loss) in value_losses.iter().enumerate() {
assert!(
loss.is_finite(),
"Value loss at epoch {} is not finite: {loss}",
i + 1
);
}
// --- ASSERT 4: All policy losses finite ---
for (i, loss) in policy_losses.iter().enumerate() {
assert!(
loss.is_finite(),
"Policy loss at epoch {} is not finite: {loss}",
i + 1
);
}
// --- ASSERT 5: Policy loss stays bounded (PPO clipping) ---
// PPO's clipped surrogate objective should keep policy loss bounded.
// Typical range: -0.5 to 2.0 for well-behaved training.
let max_policy_loss = policy_losses
.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max);
let min_policy_loss = policy_losses
.iter()
.copied()
.fold(f32::INFINITY, f32::min);
assert!(
max_policy_loss < 100.0,
"Policy loss exceeded bound: max={max_policy_loss:.4}"
);
assert!(
min_policy_loss > -100.0,
"Policy loss exceeded negative bound: min={min_policy_loss:.4}"
);
// --- ASSERT 6: Checkpoint files saved at epoch 10, 20, 30 ---
let actor_10 = checkpoint_dir
.path()
.join("ppo_actor_epoch_10.safetensors");
let critic_10 = checkpoint_dir
.path()
.join("ppo_critic_epoch_10.safetensors");
assert!(
actor_10.exists(),
"Actor checkpoint at epoch 10 not found: {}",
actor_10.display()
);
assert!(
critic_10.exists(),
"Critic checkpoint at epoch 10 not found: {}",
critic_10.display()
);
let actor_size = std::fs::metadata(&actor_10)?.len();
let critic_size = std::fs::metadata(&critic_10)?.len();
assert!(
actor_size > 0,
"Actor checkpoint file is empty ({actor_size} bytes)"
);
assert!(
critic_size > 0,
"Critic checkpoint file is empty ({critic_size} bytes)"
);
// --- ASSERT 7: Explained variance is not catastrophically negative ---
// With 30 epochs, explained variance should improve from initial values.
// -1.0 or worse means the value network is making things worse than predicting the mean.
assert!(
final_explained_var > -10.0,
"Explained variance is catastrophically negative: {final_explained_var:.4}"
);
// --- Report ---
let value_reduction_pct = if initial_value_loss.abs() > f32::EPSILON {
(1.0 - final_value_loss / initial_value_loss) * 100.0
} else {
0.0
};
info!("PPO 30-EPOCH LONG TRAINING REPORT");
info!(epochs_completed = metrics_history.len(), "Epochs completed");
info!(initial_value_loss = %format!("{initial_value_loss:.6}"), "Initial value loss");
info!(final_value_loss = %format!("{final_value_loss:.6}"), "Final value loss");
info!(value_reduction_pct = %format!("{value_reduction_pct:.1}"), "Value loss change (%)");
info!(initial_policy_loss = %format!("{initial_policy_loss:.6}"), "Initial policy loss");
info!(final_policy_loss = %format!("{final_policy_loss:.6}"), "Final policy loss");
info!(final_explained_var = %format!("{final_explained_var:.4}"), "Final explained variance");
info!(actor_size, "Actor checkpoint (ep10) bytes");
info!(critic_size, "Critic checkpoint (ep10) bytes");
info!(training_secs = %format!("{:.1}", training_duration.as_secs_f64()), "Training time");
info!("ALL 7 ASSERTIONS PASSED");
Ok(())
}