test(v8): 9 explicit unit tests for all v8 reward components

This commit is contained in:
jgrusewski
2026-04-07 23:37:27 +02:00
parent ede797daa0
commit 39d93d74e1
2 changed files with 123 additions and 0 deletions

View File

@@ -16,3 +16,5 @@ mod walk_forward;
mod hyperopt;
#[cfg(test)]
mod regression;
#[cfg(test)]
pub mod reward_v8;

View File

@@ -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<f64> = (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::<f64>() / rewards.len() as f64;
let var = rewards.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / rewards.len() as f64;
let std = var.sqrt();
let normalized: Vec<f64> = rewards.iter().map(|r| (r - mean) / std).collect();
let norm_mean = normalized.iter().sum::<f64>() / normalized.len() as f64;
let norm_var = normalized.iter().map(|r| (r - norm_mean).powi(2)).sum::<f64>() / 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}");
}
}