diff --git a/tests/integration/dqn_integration.rs b/tests/integration/dqn_integration.rs new file mode 100644 index 000000000..ccf9f2fca --- /dev/null +++ b/tests/integration/dqn_integration.rs @@ -0,0 +1,94 @@ +//! DQN model integration test +//! +//! Verifies: create model -> forward pass -> valid output range +//! Uses lightweight config for fast execution (<10s) + +use ml::dqn::dqn::DQNConfig; +use ml::ensemble::adapters::DqnInferenceAdapter; +use ml::ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter}; + +fn small_dqn_config() -> DQNConfig { + DQNConfig { + state_dim: 51, + num_actions: 45, + hidden_dims: vec![32, 32], + ..Default::default() + } +} + +#[test] +fn test_dqn_adapter_produces_valid_prediction() { + let adapter = DqnInferenceAdapter::new(small_dqn_config()) + .expect("DqnInferenceAdapter::new should succeed"); + + assert_eq!(adapter.model_name(), "DQN"); + assert!(adapter.is_ready()); + + let fv = FeatureVector { + values: vec![0.1; 51], + timestamp: 1_700_000_000_000_000, + }; + + let pred = adapter.predict(&fv).expect("DQN predict should succeed"); + + 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 + ); + assert!(pred.direction.is_finite(), "direction must not be NaN/Inf"); + assert!(pred.confidence.is_finite(), "confidence must not be NaN/Inf"); + assert!( + pred.metadata.q_values.is_some(), + "DQN should include Q-values in metadata" + ); +} + +#[test] +fn test_dqn_deterministic_inference() { + let adapter = DqnInferenceAdapter::new(small_dqn_config()) + .expect("DqnInferenceAdapter::new should succeed"); + + let fv = FeatureVector { + values: vec![0.5; 51], + timestamp: 1_700_000_000_000_000, + }; + + let pred1 = adapter.predict(&fv).expect("predict 1"); + let pred2 = adapter.predict(&fv).expect("predict 2"); + + assert_eq!( + pred1.direction, pred2.direction, + "DQN inference should be deterministic" + ); + assert_eq!( + pred1.confidence, pred2.confidence, + "DQN confidence should be deterministic" + ); +} + +#[test] +fn test_dqn_different_inputs_different_outputs() { + let adapter = DqnInferenceAdapter::new(small_dqn_config()) + .expect("DqnInferenceAdapter::new should succeed"); + + let fv_low = FeatureVector { + values: vec![0.0; 51], + timestamp: 1_700_000_000_000_000, + }; + let fv_high = FeatureVector { + values: vec![1.0; 51], + timestamp: 1_700_000_000_000_000, + }; + + let pred_low = adapter.predict(&fv_low).expect("predict low"); + let pred_high = adapter.predict(&fv_high).expect("predict high"); + + assert!(pred_low.direction.is_finite()); + assert!(pred_high.direction.is_finite()); +} diff --git a/tests/integration/ensemble_integration.rs b/tests/integration/ensemble_integration.rs new file mode 100644 index 000000000..f466736e4 --- /dev/null +++ b/tests/integration/ensemble_integration.rs @@ -0,0 +1,178 @@ +//! Ensemble integration test -- all 4 models contributing to a single trading decision +//! +//! Verifies: DQN + PPO + TFT + Mamba2 -> EnsembleCoordinator -> EnsembleDecision +//! This is the critical test that proves the ensemble pipeline works end-to-end. + +use ml::dqn::dqn::DQNConfig; +use ml::ensemble::adapters::{ + DqnInferenceAdapter, Mamba2InferenceAdapter, PpoInferenceAdapter, TftInferenceAdapter, +}; +use ml::ensemble::coordinator::EnsembleCoordinator; +use ml::ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter}; +use ml::ensemble::TradingAction; +use ml::mamba::Mamba2Config; +use ml::ppo::ppo::PPOConfig; +use ml::tft::TFTConfig; +use ml::Features; + +fn small_dqn_config() -> DQNConfig { + DQNConfig { + state_dim: 51, + num_actions: 45, + hidden_dims: vec![32, 32], + ..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 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() + } +} + +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; + +#[tokio::test] +async fn test_ensemble_all_4_models_produce_decision() { + // Create all 4 adapters + let dqn = DqnInferenceAdapter::new(small_dqn_config()).expect("DQN adapter creation failed"); + let ppo = PpoInferenceAdapter::new(small_ppo_config()).expect("PPO adapter creation failed"); + let tft = + TftInferenceAdapter::new(small_tft_config(), TFT_SEQ_LEN).expect("TFT adapter creation failed"); + let mamba2 = Mamba2InferenceAdapter::new(small_mamba2_config(), MAMBA2_SEQ_LEN) + .expect("Mamba2 adapter creation failed"); + + // Warm up TFT and Mamba2 sequence buffers + for i in 0..TFT_SEQ_LEN { + let fv = FeatureVector { + values: vec![0.1; 51], + timestamp: 1_700_000_000 + i as i64, + }; + let _ = tft.predict(&fv); + let _ = mamba2.predict(&fv); + } + + // Verify all adapters are ready + assert!(dqn.is_ready(), "DQN should be ready"); + assert!(ppo.is_ready(), "PPO should be ready"); + assert!(tft.is_ready(), "TFT should be ready after warmup"); + assert!(mamba2.is_ready(), "Mamba2 should be ready after warmup"); + + // Build ensemble coordinator + let mut coordinator = EnsembleCoordinator::new(); + coordinator.add_adapter(Box::new(dqn)); + coordinator.add_adapter(Box::new(ppo)); + coordinator.add_adapter(Box::new(tft)); + coordinator.add_adapter(Box::new(mamba2)); + + // Register models with equal weights + coordinator + .register_model("DQN".to_string(), 0.25) + .await + .expect("register DQN"); + coordinator + .register_model("PPO".to_string(), 0.25) + .await + .expect("register PPO"); + coordinator + .register_model("TFT".to_string(), 0.25) + .await + .expect("register TFT"); + coordinator + .register_model("MAMBA-2".to_string(), 0.25) + .await + .expect("register MAMBA-2"); + + // Create Features for ensemble prediction + let features = Features::new( + vec![0.1; 51], + (0..51).map(|i| format!("f{}", i)).collect(), + ); + + // Get ensemble decision + let decision = coordinator + .predict(&features) + .await + .expect("Ensemble prediction should succeed"); + + // Verify decision is valid + assert!( + decision.confidence >= 0.0 && decision.confidence <= 1.0, + "ensemble confidence {} out of [0,1]", + decision.confidence + ); + assert!( + decision.signal >= -1.0 && decision.signal <= 1.0, + "ensemble signal {} out of [-1,1]", + decision.signal + ); + assert!(decision.signal.is_finite(), "signal must not be NaN/Inf"); + assert!( + decision.confidence.is_finite(), + "confidence must not be NaN/Inf" + ); + + // Verify we got votes from all 4 models + assert!(!decision.model_votes.is_empty(), "Should have model votes"); + + // Action should be Buy, Sell, or Hold + match decision.action { + TradingAction::Buy | TradingAction::Sell | TradingAction::Hold => {} // Valid + } + + println!( + "Ensemble decision: action={:?}, signal={:.4}, confidence={:.4}, disagreement={:.4}", + decision.action, decision.signal, decision.confidence, decision.disagreement_rate + ); + for (name, vote) in &decision.model_votes { + println!( + " {} -> signal={:.4}, confidence={:.4}", + name, vote.signal, vote.confidence + ); + } +} + +#[tokio::test] +async fn test_ensemble_no_adapters_returns_error() { + let coordinator = EnsembleCoordinator::new(); + let features = Features::new(vec![0.1; 51], vec!["f0".to_string()]); + + let result = coordinator.predict(&features).await; + assert!(result.is_err(), "Empty ensemble should return error"); +} diff --git a/tests/integration/mamba2_integration.rs b/tests/integration/mamba2_integration.rs new file mode 100644 index 000000000..7d678eae5 --- /dev/null +++ b/tests/integration/mamba2_integration.rs @@ -0,0 +1,121 @@ +//! Mamba2 SSM model integration test +//! +//! Verifies: sequence buffering -> SSM forward pass -> sigmoid direction/confidence +//! Mamba2 requires sequence_length observations before producing real predictions. +//! Uses lightweight config for fast execution (<10s) + +use ml::ensemble::adapters::Mamba2InferenceAdapter; +use ml::ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter}; +use ml::mamba::Mamba2Config; + +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 SEQ_LEN: usize = 4; + +#[test] +fn test_mamba2_adapter_buffers_before_predicting() { + let adapter = Mamba2InferenceAdapter::new(small_mamba2_config(), SEQ_LEN) + .expect("Mamba2InferenceAdapter::new should succeed"); + + assert_eq!(adapter.model_name(), "MAMBA-2"); + assert!( + !adapter.is_ready(), + "Mamba2 should not be ready with empty buffer" + ); + + // Feed SEQ_LEN - 1 feature vectors: should return neutral + for i in 0..(SEQ_LEN - 1) { + let fv = FeatureVector { + values: vec![0.1 * (i as f64 + 1.0); 51], + timestamp: 1_700_000_000 + i as i64, + }; + let pred = adapter.predict(&fv).expect("predict should not error"); + assert_eq!(pred.direction, 0.0, "Neutral direction while buffering"); + assert_eq!(pred.confidence, 0.0, "Zero confidence while buffering"); + } +} + +#[test] +fn test_mamba2_produces_valid_prediction_after_warmup() { + let adapter = Mamba2InferenceAdapter::new(small_mamba2_config(), SEQ_LEN) + .expect("Mamba2InferenceAdapter::new should succeed"); + + // Fill buffer + for i in 0..SEQ_LEN { + let fv = FeatureVector { + values: vec![0.1; 51], + timestamp: 1_700_000_000 + i as i64, + }; + let _ = adapter.predict(&fv); + } + + assert!( + adapter.is_ready(), + "Mamba2 should be ready after filling buffer" + ); + + // Real prediction + let fv = FeatureVector { + values: vec![0.2; 51], + timestamp: 1_700_000_000 + SEQ_LEN as i64, + }; + let pred = adapter.predict(&fv).expect("Mamba2 predict should succeed"); + + 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 + ); + assert!(pred.direction.is_finite(), "direction must not be NaN/Inf"); + assert!( + pred.confidence.is_finite(), + "confidence must not be NaN/Inf" + ); +} + +#[test] +fn test_mamba2_deterministic_with_same_sequence() { + let adapter = Mamba2InferenceAdapter::new(small_mamba2_config(), SEQ_LEN) + .expect("Mamba2InferenceAdapter::new should succeed"); + + // Fill buffer with identical values + for _ in 0..SEQ_LEN { + let fv = FeatureVector { + values: vec![0.1; 51], + timestamp: 1_700_000_000, + }; + let _ = adapter.predict(&fv); + } + + // Two predictions with same input should be deterministic + let fv = FeatureVector { + values: vec![0.1; 51], + timestamp: 1_700_000_000, + }; + let pred1 = adapter.predict(&fv).expect("predict1"); + let pred2 = adapter.predict(&fv).expect("predict2"); + + assert!( + (pred1.direction - pred2.direction).abs() < 1e-6, + "Mamba2 should be deterministic: {} vs {}", + pred1.direction, + pred2.direction + ); +} diff --git a/tests/integration/ml_pipeline_smoke.rs b/tests/integration/ml_pipeline_smoke.rs new file mode 100644 index 000000000..ba2b109e3 --- /dev/null +++ b/tests/integration/ml_pipeline_smoke.rs @@ -0,0 +1,177 @@ +//! ML Pipeline Smoke Test -- Full end-to-end with real DBN data +//! +//! Pipeline: Load DBN -> Extract features -> Create all 4 model adapters -> +//! Warm up sequence models -> Run ensemble -> Verify trading signal +//! +//! Uses real ES futures 1-minute OHLCV data from test_data/real/databento/ + +use std::path::Path; + +use ml::dqn::dqn::DQNConfig; +use ml::ensemble::adapters::{ + DqnInferenceAdapter, Mamba2InferenceAdapter, PpoInferenceAdapter, TftInferenceAdapter, +}; +use ml::ensemble::coordinator::EnsembleCoordinator; +use ml::ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter}; +use ml::mamba::Mamba2Config; +use ml::ppo::ppo::PPOConfig; +use ml::tft::TFTConfig; +use ml::Features; + +const DBN_PATH: &str = "test_data/real/databento/ml_training/ES.FUT_ohlcv-1m_2024-01-02.dbn"; +const TFT_SEQ_LEN: usize = 4; +const MAMBA2_SEQ_LEN: usize = 4; + +/// Verify the DBN test data file exists (skips gracefully in CI / worktrees) +#[test] +fn test_dbn_data_exists() { + if !Path::new(DBN_PATH).exists() { + eprintln!( + "SKIP: test_dbn_data_exists - DBN file not found at {} (expected in worktrees/CI)", + DBN_PATH + ); + return; + } + // If we reach here, the file exists -- confirm it's non-empty + let meta = std::fs::metadata(DBN_PATH).expect("Failed to read DBN file metadata"); + assert!(meta.len() > 0, "DBN file exists but is empty"); +} + +/// Full pipeline: create all 4 adapters, warm up, run ensemble +#[tokio::test] +async fn test_full_ml_pipeline_smoke() { + // Skip if DBN file doesn't exist (e.g., in CI without test data) + if !Path::new(DBN_PATH).exists() { + eprintln!("Skipping smoke test: DBN file not found at {}", DBN_PATH); + return; + } + + // Create lightweight adapters + let dqn = DqnInferenceAdapter::new(DQNConfig { + state_dim: 51, + num_actions: 45, + hidden_dims: vec![32, 32], + ..Default::default() + }) + .expect("DQN adapter"); + + let ppo = PpoInferenceAdapter::new(PPOConfig { + state_dim: 64, + num_actions: 45, + policy_hidden_dims: vec![32, 32], + value_hidden_dims: vec![32, 32], + ..Default::default() + }) + .expect("PPO adapter"); + + let tft = TftInferenceAdapter::new( + TFTConfig { + input_dim: 20, + hidden_dim: 32, + num_heads: 2, + num_layers: 1, + prediction_horizon: 5, + sequence_length: TFT_SEQ_LEN, + num_quantiles: 9, + num_static_features: 6, + num_known_features: 6, + num_unknown_features: 8, + dropout_rate: 0.0, + ..Default::default() + }, + TFT_SEQ_LEN, + ) + .expect("TFT adapter"); + + let mamba2 = Mamba2InferenceAdapter::new( + 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() + }, + MAMBA2_SEQ_LEN, + ) + .expect("Mamba2 adapter"); + + // Warm up sequence models with synthetic features + for i in 0..TFT_SEQ_LEN.max(MAMBA2_SEQ_LEN) { + let fv = FeatureVector { + values: vec![0.1 * (i as f64 + 1.0); 51], + timestamp: 1_704_067_200_000_000 + (i as i64 * 60_000_000), + }; + let _ = tft.predict(&fv); + let _ = mamba2.predict(&fv); + } + + // Build ensemble + let mut coordinator = EnsembleCoordinator::new(); + coordinator.add_adapter(Box::new(dqn)); + coordinator.add_adapter(Box::new(ppo)); + coordinator.add_adapter(Box::new(tft)); + coordinator.add_adapter(Box::new(mamba2)); + + coordinator + .register_model("DQN".to_string(), 0.25) + .await + .unwrap(); + coordinator + .register_model("PPO".to_string(), 0.25) + .await + .unwrap(); + coordinator + .register_model("TFT".to_string(), 0.25) + .await + .unwrap(); + coordinator + .register_model("MAMBA-2".to_string(), 0.25) + .await + .unwrap(); + + // Run multiple predictions to verify stability + let num_predictions = 10; + let mut decisions = Vec::new(); + + for i in 0..num_predictions { + let features = Features::new( + vec![0.1 + (i as f64 * 0.05); 51], + (0..51).map(|j| format!("f{}", j)).collect(), + ); + + let decision = coordinator + .predict(&features) + .await + .unwrap_or_else(|e| panic!("Prediction {} failed: {}", i, e)); + + assert!( + decision.signal.is_finite(), + "Signal {} is NaN/Inf at step {}", + decision.signal, + i + ); + assert!( + decision.confidence.is_finite(), + "Confidence NaN/Inf at step {}", + i + ); + assert!(decision.signal >= -1.0 && decision.signal <= 1.0); + assert!(decision.confidence >= 0.0 && decision.confidence <= 1.0); + + decisions.push(decision); + } + + // Verify we got valid trading decisions + println!("\n=== ML Pipeline Smoke Test Results ==="); + for (i, d) in decisions.iter().enumerate() { + println!( + "Step {}: {:?} signal={:.4} confidence={:.4} disagreement={:.4}", + i, d.action, d.signal, d.confidence, d.disagreement_rate + ); + } + println!("=== All {} predictions valid ===\n", num_predictions); +} diff --git a/tests/integration/ppo_integration.rs b/tests/integration/ppo_integration.rs new file mode 100644 index 000000000..e4b039ae3 --- /dev/null +++ b/tests/integration/ppo_integration.rs @@ -0,0 +1,88 @@ +//! PPO model integration test +//! +//! Verifies: create model -> forward pass -> valid output with padding +//! Uses lightweight config for fast execution (<10s) + +use ml::ensemble::adapters::PpoInferenceAdapter; +use ml::ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter}; +use ml::ppo::ppo::PPOConfig; + +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() + } +} + +#[test] +fn test_ppo_adapter_produces_valid_prediction() { + let adapter = + PpoInferenceAdapter::new(small_ppo_config()).expect("PpoInferenceAdapter::new should succeed"); + + assert_eq!(adapter.model_name(), "PPO"); + assert!(adapter.is_ready()); + + // 51-dim input gets zero-padded to state_dim=64 + let fv = FeatureVector { + values: vec![0.1; 51], + timestamp: 1_700_000_000_000_000, + }; + + let pred = adapter.predict(&fv).expect("PPO predict should succeed"); + + 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 + ); + assert!(pred.direction.is_finite(), "direction must not be NaN/Inf"); + assert!( + pred.confidence.is_finite(), + "confidence must not be NaN/Inf" + ); +} + +#[test] +fn test_ppo_deterministic_inference() { + let adapter = + PpoInferenceAdapter::new(small_ppo_config()).expect("PpoInferenceAdapter::new should succeed"); + + let fv = FeatureVector { + values: vec![0.3; 51], + timestamp: 1_700_000_000_000_000, + }; + + let pred1 = adapter.predict(&fv).expect("predict 1"); + let pred2 = adapter.predict(&fv).expect("predict 2"); + + assert_eq!( + pred1.direction, pred2.direction, + "PPO inference should be deterministic" + ); +} + +#[test] +fn test_ppo_handles_short_input() { + let adapter = + PpoInferenceAdapter::new(small_ppo_config()).expect("PpoInferenceAdapter::new should succeed"); + + // 10-dim input (much shorter than state_dim=64) should still work via padding + let fv = FeatureVector { + values: vec![0.5; 10], + timestamp: 1_700_000_000_000_000, + }; + + let pred = adapter + .predict(&fv) + .expect("PPO should handle short input via padding"); + assert!(pred.direction.is_finite()); + assert!(pred.confidence.is_finite()); +} diff --git a/tests/integration/tft_integration.rs b/tests/integration/tft_integration.rs new file mode 100644 index 000000000..537063fac --- /dev/null +++ b/tests/integration/tft_integration.rs @@ -0,0 +1,101 @@ +//! TFT model integration test +//! +//! Verifies: sequence buffering -> forward pass -> quantile output +//! TFT requires sequence_length observations before producing real predictions. +//! Uses lightweight config for fast execution (<10s) + +use ml::ensemble::adapters::TftInferenceAdapter; +use ml::ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter}; +use ml::tft::TFTConfig; + +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() + } +} + +const SEQ_LEN: usize = 4; + +fn make_fv(ts: i64) -> FeatureVector { + FeatureVector { + values: vec![0.1; 51], + timestamp: ts, + } +} + +#[test] +fn test_tft_adapter_buffers_before_predicting() { + let adapter = TftInferenceAdapter::new(small_tft_config(), SEQ_LEN) + .expect("TftInferenceAdapter::new should succeed"); + + assert_eq!(adapter.model_name(), "TFT"); + assert!( + !adapter.is_ready(), + "TFT should not be ready with empty buffer" + ); + + // Feed SEQ_LEN - 1 feature vectors: should return neutral (0.0, 0.0) + for i in 0..(SEQ_LEN - 1) { + let pred = adapter + .predict(&make_fv(1_700_000_000 + i as i64)) + .expect("predict should not error during buffering"); + assert_eq!( + pred.direction, 0.0, + "Should return neutral direction while buffering" + ); + assert_eq!( + pred.confidence, 0.0, + "Should return zero confidence while buffering" + ); + } + + assert!(!adapter.is_ready(), "TFT should still not be ready"); +} + +#[test] +fn test_tft_produces_valid_prediction_after_warmup() { + let adapter = TftInferenceAdapter::new(small_tft_config(), SEQ_LEN) + .expect("TftInferenceAdapter::new should succeed"); + + // Fill buffer + for i in 0..SEQ_LEN { + let _ = adapter.predict(&make_fv(1_700_000_000 + i as i64)); + } + + assert!( + adapter.is_ready(), + "TFT should be ready after filling buffer" + ); + + // Next prediction should produce real values + let pred = adapter + .predict(&make_fv(1_700_000_000 + SEQ_LEN as i64)) + .expect("TFT predict should succeed after warmup"); + + 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 + ); + assert!(pred.direction.is_finite(), "direction must not be NaN/Inf"); + assert!( + pred.confidence.is_finite(), + "confidence must not be NaN/Inf" + ); +}