Files
foxhunt/crates/ml/tests/gpu_kernel_parity_test.rs
jgrusewski fe223b3843 fix(dqn): eliminate OOM in hyperopt by gating GPU features on small GPUs
Three root causes fixed:
1. GPU PER + experience collector (cudarc) created dual CUDA allocator
   fragmentation on GPUs ≤8 GB, making training impossible even at
   batch_size=1. Added `use_gpu_replay_buffer` config flag; both GPU PER
   and experience collector now disabled when VRAM ≤8192 MB.

2. Search space bounds were WIDENED instead of capped — max_batch_size
   returning 4096 replaced the original 512 upper bound, and
   max_hidden_dim_base_full returning 3072 replaced the original 1024.
   Fixed with min() to only narrow, never widen.

3. VRAM estimator assumed GPU features always active, overcharging when
   they're disabled on small GPUs. Now conditional: when
   replay_buffer_capacity=0 (proxy for GPU PER disabled), collector/cudarc
   costs are zero and fragmentation multiplier drops from 3× to 1.5×.

Additional small-GPU guard: GPUs ≤8 GB get clamped search space
(batch≤128, hidden≤512, atoms≤51, buffer≤50K) to fit 3 regime heads
+ C51 + noisy nets + dueling in limited VRAM.

Validated: 7/7 trials complete on RTX 3050 Ti 4 GB, zero OOM, best trial
Sharpe 9.8 with 50.6% win rate. Previous runs had 100% OOM failure rate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:35:38 +01:00

650 lines
26 KiB
Rust

//! GPU kernel Q-value parity tests.
//!
//! Validates that the CUDA experience collection kernel produces the same
//! Q-values as the Candle network forward pass for all network variants:
//! 1. Standard dueling Q-network
//! 2. Distributional dueling Q-network (C51)
//! 3. NoisyNet dueling (non-deterministic — verify noise injection works)
//!
//! These tests require a CUDA GPU and are `#[ignore]`d by default.
//! Run with: `cargo test -p ml --test gpu_kernel_parity_test -- --ignored`
#[cfg(feature = "cuda")]
mod gpu_parity {
use std::sync::Arc;
use candle_core::Device;
use ml::cuda_pipeline::gpu_experience_collector::{
ExperienceCollectorConfig, GpuExperienceCollector,
};
use ml::cuda_pipeline::gpu_weights::extract_dueling_weights;
use ml::dqn::dueling::{DuelingConfig, DuelingQNetwork};
use ml::dqn::distributional_dueling::{
DistributionalDuelingConfig, DistributionalDuelingQNetwork,
};
type CudaStream = candle_core::cuda_backend::cudarc::driver::CudaStream;
/// Skip test if no CUDA device available.
fn require_cuda() -> Device {
match Device::cuda_if_available(0) {
Ok(dev) if dev.is_cuda() => dev,
_ => {
eprintln!("CUDA not available, skipping GPU test");
std::process::exit(0);
}
}
}
/// Get the CudaStream from a Candle CUDA device.
fn cuda_stream(device: &Device) -> Arc<CudaStream> {
match device {
Device::Cuda(cuda_dev) => cuda_dev.cuda_stream(),
_ => panic!("Expected CUDA device"),
}
}
fn dueling_config() -> DuelingConfig {
DuelingConfig::new(54, 5, vec![256, 256], 128, 128)
}
fn dist_config() -> DistributionalDuelingConfig {
DistributionalDuelingConfig::new(54, 5, 51, vec![256, 256], 128, 128)
}
/// Default network dims matching dueling_config/dist_config: shared=[256,256], value=128, adv=128
const TEST_DIMS: (usize, usize, usize, usize) = (256, 256, 128, 128);
/// Default kernel dims: state_dim=54 (tests use 51 market + 3 portfolio), market_dim=51, atoms_max=51
const TEST_KERNEL_DIMS: (usize, usize, usize) = (54, 51, 51);
type CudaSlice<T> = candle_core::cuda_backend::cudarc::driver::CudaSlice<T>;
/// Build synthetic market features on GPU.
fn synthetic_market_data(
total_bars: usize,
device: &Device,
) -> (CudaSlice<f32>, CudaSlice<f32>) {
let stream = cuda_stream(device);
let market_len = total_bars * 51;
let mut market_data = vec![0.0_f32; market_len];
for i in 0..market_len {
market_data[i] = ((i as f32 * 0.7123 + 0.3).sin()) * 0.5;
}
let mut market_buf = stream.alloc_zeros::<f32>(market_len).unwrap();
stream.memcpy_htod(&market_data, &mut market_buf).unwrap();
let target_len = total_bars * 4;
let mut target_data = vec![0.0_f32; target_len];
for i in 0..total_bars {
target_data[i * 4] = 100.0 + (i as f32 * 0.01);
target_data[i * 4 + 1] = 100.5 + (i as f32 * 0.01);
target_data[i * 4 + 2] = 99.5 + (i as f32 * 0.01);
target_data[i * 4 + 3] = 1000.0;
}
let mut target_buf = stream.alloc_zeros::<f32>(target_len).unwrap();
stream.memcpy_htod(&target_data, &mut target_buf).unwrap();
(market_buf, target_buf)
}
// -----------------------------------------------------------------------
// Test 1: Standard dueling forward — verify kernel produces valid Q-values
// -----------------------------------------------------------------------
#[test]
#[ignore]
fn test_gpu_dueling_forward_produces_valid_q_values() {
let device = require_cuda();
let stream = cuda_stream(&device);
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let target_network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let mut collector = GpuExperienceCollector::new(
stream.clone(),
network.vars(),
target_network.vars(),
None,
100_000.0,
0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
).unwrap();
let total_bars = 2000;
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
let n_episodes = 4_usize;
let timesteps = 50;
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 100) as i32).collect();
let config = ExperienceCollectorConfig {
n_episodes: n_episodes as i32,
timesteps_per_episode: timesteps,
total_bars: total_bars as i32,
episode_length: timesteps,
epsilon: 0.1,
gamma: 0.99,
use_noisy_nets: false,
use_distributional: false,
..Default::default()
};
let batch = collector
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config)
.expect("GPU experience collection failed");
let expected_total = n_episodes * timesteps as usize;
assert_eq!(batch.actions.len(), expected_total, "actions length mismatch");
assert_eq!(batch.rewards.len(), expected_total, "rewards length mismatch");
assert_eq!(batch.target_q_values.len(), expected_total, "target_q length mismatch");
assert_eq!(batch.td_errors.len(), expected_total, "td_errors length mismatch");
for (i, &q) in batch.target_q_values.iter().enumerate() {
assert!(q.is_finite(), "target_q[{i}] = {q} is not finite");
assert!(q.abs() < 1000.0, "target_q[{i}] = {q} unexpectedly large");
}
for (i, &a) in batch.actions.iter().enumerate() {
assert!((0..5).contains(&a), "action[{i}] = {a} out of range [0, 4]");
}
for (i, &td) in batch.td_errors.iter().enumerate() {
assert!(td.is_finite(), "td_error[{i}] = {td} is not finite");
}
let q_min = batch.target_q_values.iter().cloned().fold(f32::INFINITY, f32::min);
let q_max = batch.target_q_values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
println!("Standard dueling: {expected_total} experiences, Q range [{q_min:.4}, {q_max:.4}]");
}
// -----------------------------------------------------------------------
// Test 2: Distributional dueling (C51) — the D6 correctness test
// -----------------------------------------------------------------------
#[test]
#[ignore]
fn test_gpu_distributional_forward_produces_valid_q_values() {
let device = require_cuda();
let stream = cuda_stream(&device);
let network = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
let target = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
let mut collector = GpuExperienceCollector::new(
stream.clone(),
network.vars(),
target.vars(),
None,
100_000.0,
0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
).unwrap();
let total_bars = 2000;
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
let n_episodes = 4_usize;
let timesteps = 50;
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 100) as i32).collect();
let config = ExperienceCollectorConfig {
n_episodes: n_episodes as i32,
timesteps_per_episode: timesteps,
total_bars: total_bars as i32,
episode_length: timesteps,
epsilon: 0.1,
gamma: 0.99,
use_noisy_nets: false,
use_distributional: true,
num_atoms: 51,
v_min: -25.0,
v_max: 25.0,
..Default::default()
};
let batch = collector
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config)
.expect("GPU experience collection (C51) failed");
let expected_total = n_episodes * timesteps as usize;
assert_eq!(batch.actions.len(), expected_total);
// C51 Q = sum(z_i * p_i), must be in [v_min, v_max]
for (i, &q) in batch.target_q_values.iter().enumerate() {
assert!(q.is_finite(), "C51 target_q[{i}] = {q} is not finite");
assert!(
q >= -25.0 && q <= 25.0,
"C51 target_q[{i}] = {q} outside atom support [-25, 25] — \
distributional forward pass likely wrong"
);
}
for (i, &a) in batch.actions.iter().enumerate() {
assert!((0..5).contains(&a), "C51 action[{i}] = {a} out of range [0, 4]");
}
let q_min = batch.target_q_values.iter().cloned().fold(f32::INFINITY, f32::min);
let q_max = batch.target_q_values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
println!("C51 distributional: {expected_total} experiences, Q range [{q_min:.4}, {q_max:.4}]");
}
// -----------------------------------------------------------------------
// Test 3: Candle vs GPU kernel Q-value parity (standard dueling)
// -----------------------------------------------------------------------
#[test]
#[ignore]
fn test_candle_vs_kernel_q_value_parity() {
let device = require_cuda();
let stream = cuda_stream(&device);
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let target_network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
// Candle forward pass for a known state
let state_data: Vec<f32> = (0..54).map(|i| (i as f32 * 0.1).sin() * 0.5).collect();
let state_tensor = candle_core::Tensor::new(&state_data[..], &device)
.unwrap()
.unsqueeze(0)
.unwrap();
let candle_q = network.forward(&state_tensor).unwrap();
let candle_q_vec: Vec<f32> = candle_q.squeeze(0).unwrap().to_vec1().unwrap();
let mut collector = GpuExperienceCollector::new(
stream.clone(),
network.vars(),
target_network.vars(),
None,
100_000.0,
0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
).unwrap();
// Market data with test state as every bar
let total_bars = 200;
let market_len = total_bars * 51;
let mut market_data = vec![0.0_f32; market_len];
for bar in 0..total_bars {
for f in 0..51 {
market_data[bar * 51 + f] = state_data[f.min(53)];
}
}
let mut market_buf = stream.alloc_zeros::<f32>(market_len).unwrap();
stream.memcpy_htod(&market_data, &mut market_buf).unwrap();
let target_len = total_bars * 4;
let mut target_data = vec![0.0_f32; target_len];
for i in 0..total_bars {
target_data[i * 4] = 100.0;
target_data[i * 4 + 1] = 100.5;
target_data[i * 4 + 2] = 99.5;
target_data[i * 4 + 3] = 1000.0;
}
let mut target_buf = stream.alloc_zeros::<f32>(target_len).unwrap();
stream.memcpy_htod(&target_data, &mut target_buf).unwrap();
let episode_starts = vec![0_i32];
let config = ExperienceCollectorConfig {
n_episodes: 1,
timesteps_per_episode: 1,
total_bars: total_bars as i32,
episode_length: 1,
epsilon: 0.0,
gamma: 0.99,
use_noisy_nets: false,
use_distributional: false,
count_bonus_coefficient: 0.0,
..Default::default()
};
let batch = collector
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config)
.unwrap();
let candle_argmax = candle_q_vec
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(idx, _)| idx)
.unwrap();
let kernel_action = batch.actions[0] as usize;
println!("Candle Q-values: {candle_q_vec:?} → argmax={candle_argmax}");
println!("Kernel chose action: {kernel_action}");
println!(
"Kernel target_q[0] = {:.4}, td_error[0] = {:.4}",
batch.target_q_values[0], batch.td_errors[0]
);
// The kernel assembles state as [51 market features, 3 portfolio features]
// while Candle uses the raw 54-dim test state. Portfolio at t=0 = [100000, 0, 0]
// differs from the test state's last 3 dims, so argmax may differ for close
// Q-values. Verify the kernel at least produces finite, bounded Q-values.
assert!(
batch.target_q_values[0].is_finite(),
"Kernel target_q[0] is not finite — forward pass broken"
);
assert!(
batch.target_q_values[0].abs() < 1000.0,
"Kernel target_q[0] = {} is unreasonably large",
batch.target_q_values[0]
);
// If Q-values are well-separated (>0.5 gap), actions SHOULD match
let q_gap = candle_q_vec[candle_argmax]
- candle_q_vec
.iter()
.enumerate()
.filter(|&(i, _)| i != candle_argmax)
.map(|(_, &v)| v)
.fold(f32::NEG_INFINITY, f32::max);
if q_gap > 0.5 {
assert_eq!(
kernel_action, candle_argmax,
"Kernel action {kernel_action} != Candle argmax {candle_argmax} \
despite Q gap {q_gap:.3} — forward pass likely wrong"
);
} else {
println!(
"Q-values too close (gap={q_gap:.4}) for strict parity — \
portfolio feature perturbation can flip argmax. Kernel action={kernel_action}, \
Candle argmax={candle_argmax}"
);
}
}
// -----------------------------------------------------------------------
// Test 4: NoisyNet exploration produces different actions than clean
// -----------------------------------------------------------------------
#[test]
#[ignore]
fn test_gpu_noisy_net_exploration_differs_from_clean() {
let device = require_cuda();
let stream = cuda_stream(&device);
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let target = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let total_bars = 2000;
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
let n_episodes = 32_usize;
let timesteps = 100;
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 50) as i32).collect();
// Run WITHOUT noise
let mut collector_clean = GpuExperienceCollector::new(
stream.clone(), network.vars(), target.vars(), None,
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
).unwrap();
let config_clean = ExperienceCollectorConfig {
n_episodes: n_episodes as i32,
timesteps_per_episode: timesteps,
total_bars: total_bars as i32,
episode_length: timesteps,
epsilon: 0.0,
gamma: 0.99,
use_noisy_nets: false,
use_distributional: false,
count_bonus_coefficient: 0.0,
..Default::default()
};
let batch_clean = collector_clean
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config_clean)
.unwrap();
// Run WITH noise
let mut collector_noisy = GpuExperienceCollector::new(
stream.clone(), network.vars(), target.vars(), None,
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
).unwrap();
let config_noisy = ExperienceCollectorConfig {
n_episodes: n_episodes as i32,
timesteps_per_episode: timesteps,
total_bars: total_bars as i32,
episode_length: timesteps,
epsilon: 0.0,
gamma: 0.99,
use_noisy_nets: true,
noisy_sigma_init: 0.5,
use_distributional: false,
count_bonus_coefficient: 0.0,
..Default::default()
};
let batch_noisy = collector_noisy
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config_noisy)
.unwrap();
let total = batch_clean.actions.len();
let diff_count = (0..total)
.filter(|&i| batch_clean.actions[i] != batch_noisy.actions[i])
.count();
let diff_pct = (diff_count as f64 / total as f64) * 100.0;
println!("NoisyNet: {diff_count}/{total} actions differ ({diff_pct:.1}%)");
assert!(
diff_count > 0,
"NoisyNet produced ZERO different actions vs clean — noise injection is broken"
);
assert!(
diff_pct > 2.0,
"NoisyNet only changed {diff_pct:.1}% of actions — noise may be too weak"
);
}
// -----------------------------------------------------------------------
// Test 5: Weight sync roundtrip
// -----------------------------------------------------------------------
#[test]
#[ignore]
fn test_gpu_weight_extraction_and_sync() {
let device = require_cuda();
let stream = cuda_stream(&device);
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let target = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let weights = extract_dueling_weights(network.vars(), &stream)
.expect("Weight extraction failed");
// Download and verify w_s1 is non-zero and finite
let mut shared_0_w = vec![0.0_f32; 256 * 54];
stream.memcpy_dtoh(&weights.w_s1, &mut shared_0_w).unwrap();
assert!(!shared_0_w.iter().all(|&x| x == 0.0), "w_s1 is all zeros");
assert!(shared_0_w.iter().all(|x| x.is_finite()), "w_s1 has NaN/Inf");
// Verify sync works
let mut collector = GpuExperienceCollector::new(
stream.clone(), network.vars(), target.vars(), None,
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
).unwrap();
collector.sync_online_weights(network.vars()).expect("Online sync failed");
collector.sync_target_weights(target.vars()).expect("Target sync failed");
println!("Weight extraction and sync roundtrip: OK");
}
// -----------------------------------------------------------------------
// Test 6: C51 distributional weight shapes and RMSNorm extraction
// -----------------------------------------------------------------------
#[test]
#[ignore]
fn test_gpu_distributional_weight_shapes() {
let device = require_cuda();
let stream = cuda_stream(&device);
let network = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
let weights = extract_dueling_weights(network.vars(), &stream)
.expect("Distributional weight extraction failed");
// value_out: [51, 128] = 6528 elements
let mut value_out_w = vec![0.0_f32; 51 * 128];
stream.memcpy_dtoh(&weights.w_v2, &mut value_out_w).unwrap();
assert!(value_out_w.iter().any(|&x| x != 0.0), "w_v2 all zeros");
// advantage_out: [255, 128] = 32640 elements
let mut adv_out_w = vec![0.0_f32; 255 * 128];
stream.memcpy_dtoh(&weights.w_a2, &mut adv_out_w).unwrap();
assert!(adv_out_w.iter().any(|&x| x != 0.0), "w_a2 all zeros");
// RMSNorm gamma should exist and be ~1.0
let rmsnorm = ml::cuda_pipeline::gpu_weights::extract_rmsnorm_weights(
network.vars(), &stream,
)
.expect("RMSNorm extraction failed");
assert!(rmsnorm.is_some(), "Distributional network should have RMSNorm weights");
let rmsnorm = rmsnorm.unwrap();
let mut gamma_s0 = vec![0.0_f32; 256];
stream.memcpy_dtoh(&rmsnorm.gamma_s0, &mut gamma_s0).unwrap();
let mean_gamma: f32 = gamma_s0.iter().sum::<f32>() / gamma_s0.len() as f32;
assert!(
(mean_gamma - 1.0).abs() < 0.01,
"RMSNorm gamma_s0 mean = {mean_gamma} (expected ~1.0)"
);
println!("Distributional weights: value_out [51,128], adv_out [255,128], RMSNorm gamma mean = {mean_gamma:.4}");
}
// -----------------------------------------------------------------------
// Test 7: Multiple kernel launches produce finite results
// -----------------------------------------------------------------------
#[test]
#[ignore]
fn test_gpu_kernel_repeated_launches_stable() {
let device = require_cuda();
let stream = cuda_stream(&device);
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let target = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let total_bars = 2000;
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
let n_episodes = 4_usize;
let timesteps = 20;
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 100) as i32).collect();
let cfg = ExperienceCollectorConfig {
n_episodes: n_episodes as i32,
timesteps_per_episode: timesteps,
total_bars: total_bars as i32,
episode_length: timesteps,
epsilon: 0.0,
gamma: 0.99,
use_noisy_nets: false,
use_distributional: false,
count_bonus_coefficient: 0.0,
..Default::default()
};
let mut collector = GpuExperienceCollector::new(
stream.clone(), network.vars(), target.vars(), None,
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
).unwrap();
for run in 0..5 {
let batch = collector
.collect_experiences(&market_buf, &target_buf, &episode_starts, &cfg)
.unwrap();
for (i, &q) in batch.target_q_values.iter().enumerate() {
assert!(q.is_finite(), "Run {run} target_q[{i}] = {q} is not finite");
}
}
println!("5 consecutive kernel launches: all produce finite Q-values");
}
// -----------------------------------------------------------------------
// Test 8: Standard vs C51 both produce valid output
// -----------------------------------------------------------------------
#[test]
#[ignore]
fn test_gpu_distributional_vs_standard_both_valid() {
let device = require_cuda();
let stream = cuda_stream(&device);
let std_net = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let std_target = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
let dist_net = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
let dist_target = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
let total_bars = 2000;
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
let n_episodes = 4_usize;
let timesteps = 50;
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 100) as i32).collect();
let mut std_collector = GpuExperienceCollector::new(
stream.clone(), std_net.vars(), std_target.vars(), None,
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
).unwrap();
let std_batch = std_collector
.collect_experiences(
&market_buf, &target_buf, &episode_starts,
&ExperienceCollectorConfig {
n_episodes: n_episodes as i32,
timesteps_per_episode: timesteps,
total_bars: total_bars as i32,
episode_length: timesteps,
use_distributional: false,
..Default::default()
},
)
.unwrap();
let mut dist_collector = GpuExperienceCollector::new(
stream.clone(), dist_net.vars(), dist_target.vars(), None,
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
).unwrap();
let dist_batch = dist_collector
.collect_experiences(
&market_buf, &target_buf, &episode_starts,
&ExperienceCollectorConfig {
n_episodes: n_episodes as i32,
timesteps_per_episode: timesteps,
total_bars: total_bars as i32,
episode_length: timesteps,
use_distributional: true,
num_atoms: 51,
v_min: -25.0,
v_max: 25.0,
..Default::default()
},
)
.unwrap();
assert_eq!(std_batch.actions.len(), dist_batch.actions.len());
// C51 Q-values bounded by atom support
let dist_min = dist_batch.target_q_values.iter().cloned().fold(f32::INFINITY, f32::min);
let dist_max = dist_batch.target_q_values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(dist_min >= -25.0 && dist_max <= 25.0,
"C51 Q-values [{dist_min}, {dist_max}] exceed atom support [-25, 25]");
// Standard Q-values finite
let std_min = std_batch.target_q_values.iter().cloned().fold(f32::INFINITY, f32::min);
let std_max = std_batch.target_q_values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(std_min.is_finite() && std_max.is_finite(), "Standard Q-values have NaN/Inf");
println!("Standard Q range: [{std_min:.4}, {std_max:.4}]");
println!("C51 Q range: [{dist_min:.4}, {dist_max:.4}]");
}
}