Files
foxhunt/crates/ml/tests/ensemble_inference_integration_test.rs
jgrusewski 66bc8d12e5 refactor: remove configurable state_dim — use STATE_DIM constant everywhere
Remove pub state_dim field from DQNConfig and GpuReplayBufferConfig; remove the
state_dim field from GpuExperienceCollector. Replace all reads with
ml_core::state_layout::STATE_DIM (and STATE_DIM_PADDED for cuBLAS-padded
strides). Checkpoint loading now validates saved state_dim against the
constant and hard-errors on mismatch. GpuAttentionConfig.state_dim is a
distinct attention-feature dim and is left untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:29:05 +02:00

317 lines
10 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,
unused_crate_dependencies,
)]
//! Integration test: 4-model inference ensemble (DQN + PPO + Mamba2 + TFT)
//!
//! Constructs all four adapters with small configs, pre-warms the
//! sequence-based models (Mamba2, TFT) by feeding feature vectors into
//! their buffers, then runs the full ensemble and verifies bounded
//! predictions from all 4 models.
use ml::dqn::DQNConfig;
use ml::ensemble::adapters::{
DqnInferenceAdapter, Mamba2InferenceAdapter, PpoInferenceAdapter, TftInferenceAdapter,
};
use ml::ensemble::inference_ensemble::InferenceEnsemble;
use ml::ensemble::{FeatureVector, ModelInferenceAdapter};
use ml::mamba::Mamba2Config;
use ml::ppo::PPOConfig;
use ml::tft::TFTConfig;
/// Small DQN config -- 51 input features, 45 actions, two small hidden layers.
fn small_dqn_config() -> DQNConfig {
DQNConfig {
num_actions: 63,
hidden_dims: vec![64, 64],
..Default::default()
}
}
/// Small PPO config -- 64 input (zero-padded from 51), 45 actions, small MLPs.
fn small_ppo_config() -> PPOConfig {
PPOConfig {
state_dim: 64,
num_actions: 63,
policy_hidden_dims: vec![64, 64],
value_hidden_dims: vec![64, 64],
..Default::default()
}
}
/// Small Mamba2 config -- d_model=64 (>51 features), 1 layer, seq_len=4.
fn small_mamba2_config() -> Mamba2Config {
Mamba2Config {
d_model: 64,
d_state: 16,
d_head: 16,
num_heads: 2,
expand: 2,
num_layers: 1,
max_seq_len: 8,
dropout: 0.0,
..Default::default()
}
}
/// Small TFT config -- input_dim=20, 1 layer, seq_len=4.
/// input_dim = num_static(6) + num_known(6) + num_unknown(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()
}
}
use tracing::info;
const SEQ_LEN: usize = 4;
const NUM_FEATURE_VECTORS: usize = 5;
/// Build a 51-dimensional feature vector with a distinguishing pattern.
fn make_feature_vector(index: usize) -> FeatureVector {
let base = 0.01 * (index as f64 + 1.0);
FeatureVector {
values: vec![base; 51],
timestamp: 1_700_000_000_000_000 + index as i64,
}
}
#[test]
fn test_four_model_ensemble_integration() {
// ---- construct all 4 adapters ----
let dqn = DqnInferenceAdapter::new(small_dqn_config())
.expect("DqnInferenceAdapter::new should succeed with small config");
let ppo = PpoInferenceAdapter::new(small_ppo_config())
.expect("PpoInferenceAdapter::new should succeed with small config");
let mamba2 = Mamba2InferenceAdapter::new(small_mamba2_config(), SEQ_LEN)
.expect("Mamba2InferenceAdapter::new should succeed with small config");
let tft = TftInferenceAdapter::new(small_tft_config(), SEQ_LEN)
.expect("TftInferenceAdapter::new should succeed with small config");
// ---- DQN + PPO are ready immediately; Mamba2 + TFT need buffer fill ----
assert!(dqn.is_ready(), "DQN should be ready immediately");
assert!(ppo.is_ready(), "PPO should be ready immediately");
assert!(
!mamba2.is_ready(),
"Mamba2 should NOT be ready (empty buffer)"
);
assert!(!tft.is_ready(), "TFT should NOT be ready (empty buffer)");
// ---- pre-warm sequence-based adapters (Mamba2, TFT) ----
// The ensemble only calls predict on ready adapters, so we must fill
// the internal sequence buffers before assembling the ensemble.
for i in 0..SEQ_LEN {
let fv = make_feature_vector(i);
let mamba2_pred = mamba2
.predict(&fv)
.unwrap_or_else(|e| panic!("Mamba2 warm-up {} should not error: {}", i, e));
let tft_pred = tft
.predict(&fv)
.unwrap_or_else(|e| panic!("TFT warm-up {} should not error: {}", i, e));
// Before the buffer is full, direction and confidence should be neutral (0.0)
if i < SEQ_LEN - 1 {
assert_eq!(
mamba2_pred.direction, 0.0,
"Mamba2 should return neutral direction during warm-up (step {})",
i
);
assert_eq!(
tft_pred.direction, 0.0,
"TFT should return neutral direction during warm-up (step {})",
i
);
} else {
// Buffer is now full -- should produce real predictions
assert!(
mamba2_pred.direction >= -1.0 && mamba2_pred.direction <= 1.0,
"Mamba2 warm-up final: direction {} out of [-1, 1]",
mamba2_pred.direction
);
assert!(
tft_pred.direction >= -1.0 && tft_pred.direction <= 1.0,
"TFT warm-up final: direction {} out of [-1, 1]",
tft_pred.direction
);
}
info!(
i,
mamba2_direction = mamba2_pred.direction,
mamba2_confidence = mamba2_pred.confidence,
tft_direction = tft_pred.direction,
tft_confidence = tft_pred.confidence,
"Warm-up step"
);
}
// All 4 adapters should now be ready
assert!(mamba2.is_ready(), "Mamba2 should be ready after warm-up");
assert!(tft.is_ready(), "TFT should be ready after warm-up");
// ---- build the ensemble with all 4 pre-warmed adapters ----
let adapters: Vec<Box<dyn ModelInferenceAdapter>> =
vec![Box::new(dqn), Box::new(ppo), Box::new(mamba2), Box::new(tft)];
let ensemble = InferenceEnsemble::new(adapters);
// All 4 models should be ready
assert_eq!(
ensemble.ready_count(),
4,
"All 4 adapters should be ready after warm-up"
);
// ---- feed 5 feature vectors through the full ensemble ----
for i in 0..NUM_FEATURE_VECTORS {
let fv = make_feature_vector(SEQ_LEN + i);
let pred = ensemble
.predict(&fv)
.unwrap_or_else(|e| panic!("Ensemble prediction {} should succeed: {}", i, e));
// Direction must be in [-1.0, 1.0]
assert!(
pred.direction >= -1.0 && pred.direction <= 1.0,
"FV {}: direction {} out of [-1, 1]",
i,
pred.direction
);
// Confidence must be in [0.0, 1.0]
assert!(
pred.confidence >= 0.0 && pred.confidence <= 1.0,
"FV {}: confidence {} out of [0, 1]",
i,
pred.confidence
);
// All 4 models should contribute (ensemble name includes all)
assert!(
pred.model_name.contains("DQN"),
"FV {}: ensemble name should include DQN, got: {}",
i,
pred.model_name
);
assert!(
pred.model_name.contains("PPO"),
"FV {}: ensemble name should include PPO, got: {}",
i,
pred.model_name
);
assert!(
pred.model_name.contains("MAMBA-2"),
"FV {}: ensemble name should include MAMBA-2, got: {}",
i,
pred.model_name
);
assert!(
pred.model_name.contains("TFT"),
"FV {}: ensemble name should include TFT, got: {}",
i,
pred.model_name
);
info!(
i,
direction = pred.direction,
confidence = pred.confidence,
model_name = %pred.model_name,
"Ensemble prediction"
);
}
// ---- confirm ready_count is still 4 after all predictions ----
assert_eq!(
ensemble.ready_count(),
4,
"All 4 adapters should remain ready after {} ensemble predictions",
NUM_FEATURE_VECTORS
);
info!("All 4-model ensemble integration checks passed");
}