Files
foxhunt/testing/integration/integration/checkpoint_roundtrip.rs
jgrusewski cf91106e32 fix: migrate 44 test files from Candle to native CUDA — zero test compile errors
Complete Candle→cudarc migration for all test code. The workspace
now compiles clean with `cargo check --workspace --tests` (0 errors)
and `cargo clippy --workspace --lib -D warnings` (0 errors).

Migration patterns applied across all files:
- Tensor → GpuTensor (from_host, zeros, randn, full)
- Device → MlDevice (cuda, cuda_if_available, new_cuda)
- All GpuTensor ops now take &Arc<CudaStream>
- VarMap/VarBuilder → GpuVarStore or removed
- DType removed (everything f32)
- Candle autograd tests (Var, GradStore, backward) → #[ignore]
- Preprocessing tests → host-side Vec<f32> (CPU-side by design)
- PPO hidden state → host-side Vec<f32> slices
- UnifiedTrainable: forward_loss(&[f32], &[f32]) → f64

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:02:26 +01:00

717 lines
24 KiB
Rust

//! Checkpoint roundtrip integration tests
//!
//! Verifies that saving a model checkpoint and loading it into a fresh model
//! produces identical inference output. Tests cover:
//!
//! - DQN: raw Q-network weight save/load via safetensors
//! - DQN: adapter roundtrip via `DqnInferenceAdapter::from_checkpoint`
//! - DQN: `CheckpointManager` + `Checkpointable` trait flow
//! - PPO: checkpoint save/load roundtrip
//! - TFT: dual adapter deterministic inference with sequence buffer
//! - TFT: quantile metadata verification after buffer fill
//! - Mamba2: dual adapter deterministic inference with sequence buffer
//! - Mamba2: `CheckpointManager` + `Checkpointable` trait on `Mamba2SSM`
use std::sync::OnceLock;
use ml::checkpoint::{CheckpointConfig, CheckpointManager, CompressionType};
use ml::cuda_autograd::GpuTensor;
use ml::dqn::agent::DQNAgent;
use ml::dqn::dqn::{DQNConfig, DQN};
use ml::ensemble::adapters::{
DqnInferenceAdapter, Mamba2InferenceAdapter, PpoInferenceAdapter, TftInferenceAdapter,
};
use ml::ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter};
use ml::mamba::{Mamba2Config, Mamba2SSM};
use ml::ppo::ppo::{PPOConfig, PPO};
use ml::prelude::MlDevice;
use ml::tft::TFTConfig;
// ---------------------------------------------------------------------------
// Shared CUDA device
// ---------------------------------------------------------------------------
static SHARED_CUDA: OnceLock<MlDevice> = OnceLock::new();
fn cuda_device() -> MlDevice {
SHARED_CUDA
.get_or_init(|| MlDevice::cuda(0).expect("CUDA device required"))
.clone()
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn small_dqn_config() -> DQNConfig {
DQNConfig {
state_dim: 51,
num_actions: 45,
hidden_dims: vec![32, 32],
// Disable all advanced network variants so the forward pass goes through
// the base q_network (whose GpuVarStore we save/load).
use_dueling: false,
use_iqn: false,
use_cql: false,
..Default::default()
}
}
fn small_ppo_config() -> PPOConfig {
PPOConfig {
state_dim: 64,
num_actions: 45,
policy_hidden_dims: vec![32, 32],
value_hidden_dims: vec![32, 32],
..Default::default()
}
}
fn fixed_feature_vector_51() -> FeatureVector {
FeatureVector {
values: vec![0.3; 51],
timestamp: 1_700_000_000,
}
}
fn fixed_feature_vector_64() -> FeatureVector {
FeatureVector {
values: vec![0.3; 64],
timestamp: 1_700_000_000,
}
}
/// Extract Q-values from a DQN model given an f32 input vector.
///
/// Returns the raw Q-value vector (f64) for comparison.
fn dqn_q_values(model: &DQN, input: &[f32]) -> Result<Vec<f64>, ml::MLError> {
let device = cuda_device();
let stream = device.cuda_stream()?.clone();
let len = input.len();
let tensor = GpuTensor::from_host(input, vec![1, len], &stream)
.map_err(|e| ml::MLError::ModelError(format!("Failed to create tensor: {e}")))?;
let output = model.forward(&tensor)?;
let squeezed = output
.squeeze(0, &stream)
.map_err(|e| ml::MLError::ModelError(format!("Failed to squeeze: {e}")))?;
let f32_vec: Vec<f32> = squeezed
.to_vec1(&stream)
.map_err(|e| ml::MLError::ModelError(format!("Failed to extract vec: {e}")))?;
Ok(f32_vec.iter().map(|&v| v as f64).collect())
}
/// Save GpuVarStore weights to a safetensors file on disk.
fn save_varstore_safetensors(
vars: &ml::cuda_autograd::GpuVarStore,
path: &std::path::Path,
) {
let host_data = vars.export_to_host().expect("export_to_host should succeed");
// Build owned byte buffers for each tensor
let owned: Vec<(String, Vec<usize>, Vec<u8>)> = host_data
.iter()
.map(|(name, (shape, values))| {
let bytes: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
(name.clone(), shape.clone(), bytes)
})
.collect();
// Build TensorView references
let views: Vec<(&str, safetensors::tensor::TensorView<'_>)> = owned
.iter()
.map(|(name, shape, bytes)| {
(
name.as_str(),
safetensors::tensor::TensorView::new(
safetensors::Dtype::F32,
shape.clone(),
bytes,
)
.expect("TensorView"),
)
})
.collect();
let serialized =
safetensors::tensor::serialize(views, None).expect("serialize should succeed");
std::fs::write(path, serialized).expect("write should succeed");
}
// ---------------------------------------------------------------------------
// DQN: raw Q-network weight roundtrip
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_dqn_raw_weight_roundtrip_exact_output() {
let config = small_dqn_config();
// 1. Create DQN model with random weights
let model_a = DQN::new(config.clone()).expect("DQN::new should succeed");
// 2. Run inference to get Q-values before save
let input = vec![0.3f32; 51];
let q_before = dqn_q_values(&model_a, &input).expect("forward should succeed");
assert!(!q_before.is_empty(), "Q-values should not be empty");
// 3. Save weights to temp directory via export_to_host + safetensors
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let ckpt_path = tmp.path().join("dqn_roundtrip.safetensors");
save_varstore_safetensors(model_a.get_q_network_vars(), &ckpt_path);
// 4. Create second DQN model (same config, different random init)
let mut model_b = DQN::new(config).expect("DQN::new should succeed for model_b");
// 5. Load weights from checkpoint
let path_str = ckpt_path
.to_str()
.expect("temp path should be valid UTF-8");
model_b
.load_from_safetensors(path_str)
.expect("load_from_safetensors should succeed");
// 6. Run inference on same input
let q_after = dqn_q_values(&model_b, &input).expect("forward after load should succeed");
// 7. Assert exact element-wise equality
assert_eq!(
q_before.len(),
q_after.len(),
"Q-value vector length mismatch: {} vs {}",
q_before.len(),
q_after.len(),
);
for (i, (a, b)) in q_before.iter().zip(q_after.iter()).enumerate() {
assert!(
(a - b).abs() < 1e-6,
"Q-value mismatch at index {i}: {a} vs {b}",
);
}
}
// ---------------------------------------------------------------------------
// DQN: adapter roundtrip via from_checkpoint
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_dqn_adapter_roundtrip_via_from_checkpoint() {
let config = small_dqn_config();
// 1. Create a DQN model, save its weights via export_to_host + safetensors
let model = DQN::new(config.clone()).expect("DQN::new should succeed");
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let ckpt_path = tmp.path().join("dqn_adapter_test.safetensors");
save_varstore_safetensors(model.get_q_network_vars(), &ckpt_path);
// 2. Get reference Q-values from original model
let input = vec![0.3f32; 51];
let q_original = dqn_q_values(&model, &input).expect("original forward should succeed");
// 3. Load into a DqnInferenceAdapter via from_checkpoint
let path_str = ckpt_path
.to_str()
.expect("temp path should be valid UTF-8");
let adapter = DqnInferenceAdapter::from_checkpoint(config, path_str)
.expect("from_checkpoint should succeed");
// 4. Run inference through the adapter
let fv = fixed_feature_vector_51();
let pred = adapter.predict(&fv).expect("adapter predict should succeed");
// 5. Verify prediction shape and ranges
assert!(
pred.direction >= -1.0 && pred.direction <= 1.0,
"direction {} out of [-1,1]",
pred.direction,
);
assert!(
pred.confidence >= 0.0 && pred.confidence <= 1.0,
"confidence {} out of [0,1]",
pred.confidence,
);
// 6. Verify Q-values from adapter match original model
let adapter_q = pred
.metadata
.q_values
.as_ref()
.expect("DQN adapter should emit q_values");
assert_eq!(
q_original.len(),
adapter_q.len(),
"Q-value vector length mismatch",
);
for (i, (orig, loaded)) in q_original.iter().zip(adapter_q.iter()).enumerate() {
assert!(
(orig - loaded).abs() < 1e-5,
"Q-value mismatch at index {i}: original={orig} vs loaded={loaded}",
);
}
}
// ---------------------------------------------------------------------------
// DQN: CheckpointManager + Checkpointable trait roundtrip
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_dqn_checkpoint_manager_roundtrip() {
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let ckpt_config = CheckpointConfig {
base_dir: tmp.path().to_path_buf(),
compression: CompressionType::None,
auto_cleanup: false,
..Default::default()
};
let manager =
CheckpointManager::new(ckpt_config).expect("CheckpointManager::new should succeed");
// Create a DQNAgent (the type that implements Checkpointable)
let config = small_dqn_config();
let agent_a = DQNAgent::new(config.clone()).expect("DQNAgent::new should succeed");
// Save checkpoint
let ckpt_id = manager
.save_checkpoint(&agent_a, Some(vec!["roundtrip-test".to_string()]))
.await
.expect("save_checkpoint should succeed");
// Create second agent with same config
let mut agent_b = DQNAgent::new(config).expect("DQNAgent::new should succeed for agent_b");
// Load checkpoint into agent_b
let metadata = manager
.load_checkpoint(&mut agent_b, &ckpt_id)
.await
.expect("load_checkpoint should succeed");
assert_eq!(metadata.model_name, "dqn_agent");
assert!(metadata.tags.contains(&"roundtrip-test".to_string()));
}
// ---------------------------------------------------------------------------
// PPO: checkpoint roundtrip
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_ppo_checkpoint_roundtrip() {
let config = small_ppo_config();
// 1. Create PPO model with random weights
let ppo_a = PPO::new(config.clone()).expect("PPO::new should succeed");
// 2. Save checkpoint
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let ckpt_path = tmp.path().join("ppo_checkpoint");
ppo_a
.save_checkpoint(&ckpt_path)
.expect("PPO save_checkpoint should succeed");
// 3. Load into a fresh PPO model
let _ppo_b = PPO::load_checkpoint(&ckpt_path).expect("PPO::load_checkpoint should succeed");
// PPO::load_checkpoint currently re-initializes weights (config-only load).
// Just verify it doesn't crash and produces valid config.
}
// ---------------------------------------------------------------------------
// PPO: PpoInferenceAdapter deterministic on same weights
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_ppo_inference_adapter_deterministic() {
let config = small_ppo_config();
let adapter =
PpoInferenceAdapter::new(config).expect("PpoInferenceAdapter::new should succeed");
let fv = fixed_feature_vector_64();
let pred1 = adapter.predict(&fv).expect("first predict should succeed");
let pred2 = adapter.predict(&fv).expect("second predict should succeed");
assert_eq!(
pred1.direction, pred2.direction,
"PPO adapter should be deterministic: {} vs {}",
pred1.direction, pred2.direction,
);
assert_eq!(
pred1.confidence, pred2.confidence,
"PPO adapter confidence should be deterministic: {} vs {}",
pred1.confidence, pred2.confidence,
);
}
// ---------------------------------------------------------------------------
// TFT + Mamba2 Helpers
// ---------------------------------------------------------------------------
/// Small TFT config for fast tests.
/// input_dim = num_static + num_known + num_unknown = 6 + 6 + 8 = 20
fn small_tft_config() -> TFTConfig {
TFTConfig {
input_dim: 20,
hidden_dim: 32,
num_heads: 2,
num_layers: 1,
prediction_horizon: 5,
sequence_length: 4,
num_quantiles: 9,
num_static_features: 6,
num_known_features: 6,
num_unknown_features: 8,
dropout_rate: 0.0,
..Default::default()
}
}
/// Small Mamba2 config for fast tests.
fn small_mamba2_config() -> Mamba2Config {
Mamba2Config {
d_model: 32,
d_state: 8,
d_head: 8,
num_heads: 2,
expand: 2,
num_layers: 1,
max_seq_len: 8,
dropout: 0.0,
..Default::default()
}
}
const TFT_SEQ_LEN: usize = 4;
const MAMBA2_SEQ_LEN: usize = 4;
/// Build a feature vector with 51 values at a given timestamp.
/// The adapter extracts and zero-pads as needed for its model dimensions.
fn make_fv(ts: i64) -> FeatureVector {
FeatureVector {
values: vec![0.1; 51],
timestamp: ts,
}
}
/// Feed `count` feature vectors into an adapter to fill its sequence buffer.
/// Returns the predictions from each call (including the neutral ones).
fn fill_adapter_buffer(
adapter: &dyn ModelInferenceAdapter,
count: usize,
base_ts: i64,
) -> Vec<ml::ensemble::inference_adapter::EnsemblePrediction> {
let mut preds = Vec::with_capacity(count);
for i in 0..count {
let fv = make_fv(base_ts + i as i64);
let pred = adapter.predict(&fv).expect("predict during buffer fill should succeed");
preds.push(pred);
}
preds
}
// ---------------------------------------------------------------------------
// TFT: single adapter deterministic inference (same input => same output)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_tft_adapter_deterministic_same_buffer() {
let config = small_tft_config();
// 1. Create a single TFT adapter
let adapter = TftInferenceAdapter::new(config, TFT_SEQ_LEN)
.expect("TftInferenceAdapter creation should succeed");
// 2. Fill the buffer with identical feature vectors
let base_ts: i64 = 1_700_000_000;
for i in 0..TFT_SEQ_LEN {
let fv = make_fv(base_ts + i as i64);
let pred = adapter
.predict(&fv)
.expect("predict should succeed during fill");
// While buffering, should return neutral
if i < TFT_SEQ_LEN - 1 {
assert_eq!(
pred.direction, 0.0,
"Should return neutral direction while buffering (step {i})"
);
assert_eq!(
pred.confidence, 0.0,
"Should return zero confidence while buffering (step {i})"
);
}
}
// 3. Adapter should now be ready
assert!(
adapter.is_ready(),
"Adapter should be ready after filling buffer"
);
// 4. Feed identical FVs and compare predictions (buffer is full, so each
// call runs a real forward pass with the same buffer content)
let fv = make_fv(base_ts);
let pred1 = adapter
.predict(&fv)
.expect("first full-buffer predict should succeed");
let pred2 = adapter
.predict(&fv)
.expect("second full-buffer predict should succeed");
// 5. Assert identical outputs (same weights, same buffer content => same output)
assert!(
(pred1.direction - pred2.direction).abs() < 1e-6,
"TFT adapter should be deterministic: direction {} vs {}",
pred1.direction,
pred2.direction,
);
assert!(
(pred1.confidence - pred2.confidence).abs() < 1e-6,
"TFT adapter should be deterministic: confidence {} vs {}",
pred1.confidence,
pred2.confidence,
);
// 6. Direction and confidence bounds check
assert!(
pred1.direction >= -1.0 && pred1.direction <= 1.0,
"TFT direction {} out of [-1,1]",
pred1.direction,
);
assert!(
pred1.confidence >= 0.0 && pred1.confidence <= 1.0,
"TFT confidence {} out of [0,1]",
pred1.confidence,
);
// 7. Model name
assert_eq!(pred1.model_name, "TFT");
}
// ---------------------------------------------------------------------------
// TFT: quantile metadata verification
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_tft_quantile_metadata_present_after_buffer_fill() {
let config = small_tft_config();
let adapter = TftInferenceAdapter::new(config.clone(), TFT_SEQ_LEN)
.expect("TftInferenceAdapter creation should succeed");
// Fill the buffer
let base_ts: i64 = 1_700_000_000;
let preds = fill_adapter_buffer(&adapter, TFT_SEQ_LEN, base_ts);
// Predictions before buffer is full should NOT have quantile metadata
for (i, pred) in preds.iter().enumerate() {
if i < TFT_SEQ_LEN - 1 {
assert!(
pred.metadata.quantiles.is_none(),
"Neutral prediction at step {i} should not have quantile metadata"
);
}
}
// The final prediction (buffer just filled) should have quantile metadata
let last_pred = preds
.last()
.expect("should have at least one prediction");
assert!(
last_pred.metadata.quantiles.is_some(),
"TFT prediction with full buffer should include quantile metadata"
);
// Verify quantile count matches config.num_quantiles
let quantiles = last_pred
.metadata
.quantiles
.as_ref()
.expect("quantiles should be present");
assert_eq!(
quantiles.len(),
config.num_quantiles,
"Quantile count should match config.num_quantiles ({}), got {}",
config.num_quantiles,
quantiles.len(),
);
// Quantiles should be finite numbers
for (i, &q) in quantiles.iter().enumerate() {
assert!(
q.is_finite(),
"Quantile at index {i} should be finite, got {q}"
);
}
// Additional prediction should also have quantiles
let fv_extra = make_fv(base_ts + TFT_SEQ_LEN as i64);
let pred_extra = adapter
.predict(&fv_extra)
.expect("extra predict should succeed");
assert!(
pred_extra.metadata.quantiles.is_some(),
"Subsequent TFT prediction should also include quantile metadata"
);
let quantiles_extra = pred_extra
.metadata
.quantiles
.as_ref()
.expect("quantiles should be present in extra prediction");
assert_eq!(
quantiles_extra.len(),
config.num_quantiles,
"Subsequent quantile count should match config"
);
// Model name should be "TFT"
assert_eq!(last_pred.model_name, "TFT");
}
// ---------------------------------------------------------------------------
// Mamba2: single adapter deterministic inference (same input => same output)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_mamba2_adapter_deterministic_same_buffer() {
let config = small_mamba2_config();
// 1. Create a single Mamba2 adapter
let adapter = Mamba2InferenceAdapter::new(config, MAMBA2_SEQ_LEN)
.expect("Mamba2InferenceAdapter creation should succeed");
// 2. Fill the buffer with identical feature vectors
let base_ts: i64 = 1_700_000_000;
for i in 0..MAMBA2_SEQ_LEN {
let fv = make_fv(base_ts);
let pred = adapter
.predict(&fv)
.expect("predict should succeed during fill");
// While buffering, should return neutral
if i < MAMBA2_SEQ_LEN - 1 {
assert_eq!(
pred.direction, 0.0,
"Should return neutral direction while buffering (step {i})"
);
assert_eq!(
pred.confidence, 0.0,
"Should return zero confidence while buffering (step {i})"
);
}
}
// 3. Adapter should now be ready
assert!(
adapter.is_ready(),
"Adapter should be ready after filling buffer"
);
// 4. Feed identical FVs and compare predictions (buffer is full, each
// call shifts the ring buffer with the same value, so content is stable)
let fv = make_fv(base_ts);
let pred1 = adapter
.predict(&fv)
.expect("first full-buffer predict should succeed");
let pred2 = adapter
.predict(&fv)
.expect("second full-buffer predict should succeed");
// 5. Assert identical outputs (same weights, same buffer content => same output)
assert!(
(pred1.direction - pred2.direction).abs() < 1e-6,
"Mamba2 adapter should be deterministic: direction {} vs {}",
pred1.direction,
pred2.direction,
);
assert!(
(pred1.confidence - pred2.confidence).abs() < 1e-6,
"Mamba2 adapter should be deterministic: confidence {} vs {}",
pred1.confidence,
pred2.confidence,
);
// 6. Direction and confidence bounds check
assert!(
pred1.direction >= -1.0 && pred1.direction <= 1.0,
"Mamba2 direction {} out of [-1,1]",
pred1.direction,
);
assert!(
pred1.confidence >= 0.0 && pred1.confidence <= 1.0,
"Mamba2 confidence {} out of [0,1]",
pred1.confidence,
);
// 7. Model name should be "MAMBA-2"
assert_eq!(pred1.model_name, "MAMBA-2");
// 8. Mamba2 should NOT have quantile metadata (that is TFT-specific)
assert!(
pred1.metadata.quantiles.is_none(),
"Mamba2 predictions should not have quantile metadata"
);
}
// ---------------------------------------------------------------------------
// Mamba2: CheckpointManager + Checkpointable trait roundtrip on Mamba2SSM
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_mamba2_checkpoint_manager_roundtrip() {
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let ckpt_config = CheckpointConfig {
base_dir: tmp.path().to_path_buf(),
compression: CompressionType::None,
auto_cleanup: false,
..Default::default()
};
let manager =
CheckpointManager::new(ckpt_config).expect("CheckpointManager::new should succeed");
// Create a Mamba2SSM (implements Checkpointable)
let config = small_mamba2_config();
let device = cuda_device();
let stream = device.cuda_stream().expect("stream").clone();
let model_a = Mamba2SSM::new(config.clone(), &stream)
.expect("Mamba2SSM::new should succeed for model_a");
// Save checkpoint
let ckpt_id = manager
.save_checkpoint(&model_a, Some(vec!["mamba2-roundtrip".to_string()]))
.await
.expect("save_checkpoint should succeed for Mamba2SSM");
// Create second model with same config
let mut model_b =
Mamba2SSM::new(config, &stream).expect("Mamba2SSM::new should succeed for model_b");
// Load checkpoint into model_b
let metadata = manager
.load_checkpoint(&mut model_b, &ckpt_id)
.await
.expect("load_checkpoint should succeed for Mamba2SSM");
// Verify metadata
assert!(
metadata.tags.contains(&"mamba2-roundtrip".to_string()),
"Checkpoint tags should contain 'mamba2-roundtrip', got {:?}",
metadata.tags,
);
assert_eq!(
metadata.model_type,
ml::ModelType::MAMBA,
"Checkpoint model type should be MAMBA"
);
// Verify hyperparameters were preserved
let d_model_val = metadata
.hyperparameters
.get("d_model")
.and_then(|v| v.as_u64());
assert_eq!(
d_model_val,
Some(32),
"Checkpoint should preserve d_model=32, got {:?}",
d_model_val,
);
}