diff --git a/crates/ml/src/trainers/dqn/smoke_tests/mod.rs b/crates/ml/src/trainers/dqn/smoke_tests/mod.rs index 4effd1599..7385264b3 100644 --- a/crates/ml/src/trainers/dqn/smoke_tests/mod.rs +++ b/crates/ml/src/trainers/dqn/smoke_tests/mod.rs @@ -16,3 +16,5 @@ mod walk_forward; mod hyperopt; #[cfg(test)] mod regression; +#[cfg(test)] +pub mod reward_v8; diff --git a/crates/ml/src/trainers/dqn/smoke_tests/reward_v8.rs b/crates/ml/src/trainers/dqn/smoke_tests/reward_v8.rs new file mode 100644 index 000000000..4c276617b --- /dev/null +++ b/crates/ml/src/trainers/dqn/smoke_tests/reward_v8.rs @@ -0,0 +1,121 @@ +//! Reward v8 unit tests — verify mathematical properties of each component. +//! CPU-side tests that validate the CUDA kernel logic produces correct results. + +#[cfg(test)] +mod tests { + use std::f64::consts::PI; + + fn asymmetric_soft_clamp(x: f32) -> f32 { + if x >= 0.0 { x.min(10.0) } else { -10.0 * (1.0 - (x / 10.0).exp()) } + } + + #[test] + fn test_asymmetric_soft_clamp_properties() { + assert!((asymmetric_soft_clamp(0.0)).abs() < 1e-6, "f(0) = 0"); + assert!((asymmetric_soft_clamp(5.0) - 5.0).abs() < 1e-6, "f(5) = 5 (linear)"); + assert!((asymmetric_soft_clamp(15.0) - 10.0).abs() < 1e-6, "f(15) = 10 (capped)"); + assert!((asymmetric_soft_clamp(-5.0) - (-3.935)).abs() < 0.01, "f(-5) ≈ -3.93"); + assert!((asymmetric_soft_clamp(-15.0) - (-7.769)).abs() < 0.01, "f(-15) ≈ -7.77"); + assert!(asymmetric_soft_clamp(3.0) > asymmetric_soft_clamp(2.0), "monotonic positive"); + assert!(asymmetric_soft_clamp(-2.0) > asymmetric_soft_clamp(-3.0), "monotonic negative"); + assert!(asymmetric_soft_clamp(-5.0).abs() < asymmetric_soft_clamp(5.0).abs(), "asymmetric"); + } + + #[test] + fn test_kelly_prior_neutral() { + let prior_wins = 2.0_f64; + let prior_losses = 2.0; + let prior_sum_wins = 0.01; + let prior_sum_losses = 0.01; + let eff_total = prior_wins + prior_losses; + let win_rate = prior_wins / eff_total; + let avg_win = prior_sum_wins / prior_wins; + let avg_loss = prior_sum_losses / prior_losses; + let payoff = avg_win / avg_loss; + let kelly_f = (payoff * win_rate - (1.0 - win_rate)) / payoff; + assert!(kelly_f.abs() < 0.01, "Kelly with prior only should be ~0, got {kelly_f}"); + } + + #[test] + fn test_cosine_epsilon_schedule() { + let eps_start = 0.3_f64; + let eps_end = 0.02; + let total = 20.0; + let e0 = eps_end + 0.5 * (eps_start - eps_end) * (1.0 + (PI * 0.0 / total).cos()); + assert!((e0 - eps_start).abs() < 0.001, "Epoch 0 should be eps_start, got {e0}"); + let ef = eps_end + 0.5 * (eps_start - eps_end) * (1.0 + PI.cos()); + assert!((ef - eps_end).abs() < 0.001, "Final epoch should be eps_end, got {ef}"); + let em = eps_end + 0.5 * (eps_start - eps_end) * (1.0 + (PI * 0.5).cos()); + assert!(em > eps_end && em < eps_start, "Mid epsilon {em} between bounds"); + } + + #[test] + fn test_td_lambda_degenerates() { + let rewards = [1.0_f64, 2.0, 3.0]; + let q_next = [10.0, 20.0, 30.0]; + let gamma = 0.95; + let g1 = rewards[0] + gamma * q_next[0]; + assert!((g1 - 10.5).abs() < 0.01, "1-step return should be 10.5, got {g1}"); + let g_mc = rewards[0] + gamma * rewards[1] + gamma * gamma * rewards[2] + + gamma * gamma * gamma * q_next[2]; + let expected = 1.0 + 0.95 * 2.0 + 0.9025 * 3.0 + 0.857375 * 30.0; + assert!((g_mc - expected).abs() < 0.01, "MC return should be {expected}, got {g_mc}"); + } + + #[test] + fn test_micro_reward_adaptive_scale() { + let base = 0.001_f64; + let vol_calm = 0.0025_f64; + let adaptive_calm = base / (vol_calm / 0.005_f64).sqrt().max(0.5); + assert!(adaptive_calm > base, "Calm scale ({adaptive_calm}) > base ({base})"); + let vol_volatile = 0.02_f64; + let adaptive_volatile = base / (vol_volatile / 0.005_f64).sqrt().max(0.5); + assert!(adaptive_volatile < base, "Volatile scale ({adaptive_volatile}) < base ({base})"); + } + + #[test] + fn test_exposure_aux_gradient_unique() { + let b0 = 9; + let target = 3; + let prob = 1.0 / b0 as f64; + let grads: Vec = (0..b0).map(|a| prob - if a == target { 1.0 } else { 0.0 }).collect(); + assert!(grads[target] < 0.0, "Target gets negative gradient"); + for a in 0..b0 { + if a != target { assert!(grads[a] > 0.0, "Non-target {a} gets positive gradient"); } + } + assert!((grads[target] - grads[0]).abs() > 0.1, "Target differs from non-target"); + } + + #[test] + fn test_hindsight_relabel_improves_reward() { + let entry = 100.0_f64; + let futures = [101.0, 103.0, 102.0]; + let position = 1.0; + let actual_pnl = position * (futures[0] - entry); + let best_pnl = futures.iter().map(|&p| position * (p - entry)).fold(0.0_f64, f64::max); + assert!((best_pnl - 3.0).abs() < 0.01, "Best PnL should be 3.0"); + assert!(best_pnl >= actual_pnl, "Hindsight >= actual"); + } + + #[test] + fn test_curriculum_ordering() { + let trending = 0.02_f64; + let choppy = 0.001; + let score_trending = 1.0 / (trending + 0.001); + let score_choppy = 1.0 / (choppy + 0.001); + assert!(score_trending < score_choppy, "Trending (easy) < choppy (hard)"); + } + + #[test] + fn test_popart_normalization() { + let rewards = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0]; + let mean = rewards.iter().sum::() / rewards.len() as f64; + let var = rewards.iter().map(|r| (r - mean).powi(2)).sum::() / rewards.len() as f64; + let std = var.sqrt(); + let normalized: Vec = rewards.iter().map(|r| (r - mean) / std).collect(); + let norm_mean = normalized.iter().sum::() / normalized.len() as f64; + let norm_var = normalized.iter().map(|r| (r - norm_mean).powi(2)).sum::() / normalized.len() as f64; + assert!(norm_mean.abs() < 1e-10, "Mean should be 0, got {norm_mean}"); + assert!((norm_var - 1.0).abs() < 0.01, "Variance should be 1, got {norm_var}"); + } +}