Files
foxhunt/ml/tests/ppo_checkpoint_roundtrip_test.rs
jgrusewski 5935907cd7 feat(ppo): gradient accumulation, clip-higher, and WorkingPPO→PPO rename
- Add accumulation_steps config to PPOConfig with gradient accumulation
  in update_mlp() using existing accumulate_grads/scale_grads utilities
- Add clip_epsilon_high: Option<f32> for asymmetric PPO clipping to
  prevent entropy collapse during long training
- Rename WorkingPPO → PPO for consistency with DQN naming convention
- Add pub type WorkingPPO = PPO for backward compatibility
- Fix PPOConfig struct literals in trading_service and hyperopt adapter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:00:54 +01:00

114 lines
3.5 KiB
Rust

//! PPO Checkpoint Round-Trip Validation Test
//!
//! Proves that saving a PPO model checkpoint and loading it back
//! produces identical action probabilities for the same input state.
//!
//! Run manually:
//! ```sh
//! SQLX_OFFLINE=true cargo test -p ml --test ppo_checkpoint_roundtrip_test -- --ignored --nocapture
//! ```
#![allow(unused_crate_dependencies)]
use anyhow::Result;
use candle_core::Device;
use ml::ppo::ppo::{PPOConfig, PPO};
#[tokio::test]
#[ignore]
async fn test_ppo_checkpoint_roundtrip() -> Result<()> {
let checkpoint_dir = tempfile::tempdir()?;
let state_dim = 54;
let num_actions = 45;
// Create PPO model with known config
let config = PPOConfig {
state_dim,
num_actions,
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![128, 64],
use_lstm: false,
..PPOConfig::default()
};
let ppo = PPO::new(config.clone())?;
// Create test state
let test_state: Vec<f32> = (0..state_dim).map(|i| (i as f32 * 0.1).sin()).collect();
// Get predictions before save
let predictions_before = ppo.predict(&test_state)?;
// Save checkpoint (API requires three &str paths: actor, critic, metadata)
let actor_path = checkpoint_dir.path().join("actor.safetensors");
let critic_path = checkpoint_dir.path().join("critic.safetensors");
let metadata_path = checkpoint_dir.path().join("metadata.json");
let actor_str = actor_path.to_str().ok_or_else(|| anyhow::anyhow!("Invalid actor path"))?;
let critic_str = critic_path.to_str().ok_or_else(|| anyhow::anyhow!("Invalid critic path"))?;
let metadata_str =
metadata_path.to_str().ok_or_else(|| anyhow::anyhow!("Invalid metadata path"))?;
ppo.save_checkpoint(actor_str, critic_str, metadata_str)?;
// Verify files exist and are non-empty
assert!(actor_path.exists(), "Actor checkpoint not saved");
assert!(critic_path.exists(), "Critic checkpoint not saved");
assert!(metadata_path.exists(), "Metadata file not saved");
assert!(std::fs::metadata(&actor_path)?.len() > 0);
assert!(std::fs::metadata(&critic_path)?.len() > 0);
// Load into a fresh model (API takes &str, &str, PPOConfig, Device)
let loaded_ppo = PPO::load_checkpoint(actor_str, critic_str, config, Device::Cpu)?;
// Get predictions after load
let predictions_after = loaded_ppo.predict(&test_state)?;
// Compare predictions - should be identical (not just close, IDENTICAL)
assert_eq!(
predictions_before.len(),
predictions_after.len(),
"Prediction vector length mismatch"
);
for (i, (before, after)) in predictions_before
.iter()
.zip(predictions_after.iter())
.enumerate()
{
let diff = (before - after).abs();
assert!(
diff < 1e-6,
"Prediction mismatch at index {}: before={}, after={}, diff={}",
i,
before,
after,
diff
);
}
println!("Checkpoint round-trip validation passed!");
println!(
" Actor checkpoint: {} bytes",
std::fs::metadata(&actor_path)?.len()
);
println!(
" Critic checkpoint: {} bytes",
std::fs::metadata(&critic_path)?.len()
);
println!(
" Predictions compared: {} values",
predictions_before.len()
);
println!(
" Max difference: {:.2e}",
predictions_before
.iter()
.zip(predictions_after.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0f32, f32::max)
);
Ok(())
}