When TAIL_EVENT_RECENCY < N_window (default 100), boost τ_min by factor (default 1.5) — agent uses more pessimistic action selection during tail-recent regimes. Defense-in-depth alongside Kelly resurrection (F2): F2 catches the sizing-layer absorbing state; F5 makes action selection more risk-averse right after a shock. Tests: - G24: TAIL_EVENT_RECENCY < 100 → τ_action ≥ τ_min × 1.5 - G25: TAIL_EVENT_RECENCY ≥ 100 → τ_action behavior unchanged 22 risk_stack_invariants now pass on RTX 3050 Ti. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
852 lines
40 KiB
Rust
852 lines
40 KiB
Rust
//! Risk-stack invariants (spec `docs/superpowers/specs/2026-05-30-adaptive-risk-management-design.md`).
|
||
//!
|
||
//! 22 GPU-oracle gates.
|
||
//! Layer 1 (CMDP): G1-G4
|
||
//! Layer 2 (IQN τ): G5-G7
|
||
//! Layer 3 (Inventory): G8-G9
|
||
//! Layer 4 (Kelly): G10-G12
|
||
//! Regime observer + Kelly rez: G15-G21
|
||
//! Layer 2 tail-recency boost: G24-G25
|
||
//!
|
||
//! Per [[feedback_no_cpu_test_fallbacks]] every oracle is an analytical
|
||
//! identity of the kernel's documented math — no host reimplementation.
|
||
//!
|
||
//! Run:
|
||
//! `cargo test -p ml-alpha --test risk_stack_invariants --release -- --ignored --nocapture`
|
||
|
||
use anyhow::Result;
|
||
use cudarc::driver::{CudaSlice, CudaStream};
|
||
#[allow(unused_imports)]
|
||
use ml_alpha::rl::isv_slots::*;
|
||
use ml_alpha::trainer::integrated::{
|
||
write_slice_f32_d_pub, IntegratedTrainer, IntegratedTrainerConfig,
|
||
};
|
||
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
|
||
use ml_core::device::MlDevice;
|
||
use std::sync::Arc;
|
||
|
||
// ─── Bootstrap defaults (mirrored from `with_controllers_bootstrapped`) ───
|
||
const SESSION_DD_LIMIT_USD: f32 = -3500.0; // 10% of $35k starting capital
|
||
const COOLDOWN_DURATION: f32 = 500.0;
|
||
const IQN_TAU_MIN: f32 = 0.1;
|
||
const IQN_TAU_DD_SENSITIVITY: f32 = 5.0; // hits MIN at 10% DD
|
||
const KELLY_SAFETY_FRAC: f32 = 0.5; // half-Kelly (Thorp)
|
||
const KELLY_MIN_TRADES: f32 = 1000.0;
|
||
const STARTING_CAPITAL_USD: f32 = 35000.0; // ES single-contract base
|
||
|
||
fn build_trainer() -> Option<(MlDevice, IntegratedTrainer)> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
eprintln!("CUDA 0 not available — skipping ({e})");
|
||
return None;
|
||
}
|
||
};
|
||
let cfg = IntegratedTrainerConfig {
|
||
perception: PerceptionTrainerConfig {
|
||
seq_len: 4,
|
||
n_batch: 1,
|
||
..PerceptionTrainerConfig::default()
|
||
},
|
||
dqn_seed: 0xC1,
|
||
ppo_seed: 0xC2,
|
||
..IntegratedTrainerConfig::default()
|
||
};
|
||
let trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
|
||
Some((dev, trainer))
|
||
}
|
||
|
||
fn sync(trainer: &IntegratedTrainer) {
|
||
trainer.stream.synchronize().expect("sync trainer stream");
|
||
}
|
||
|
||
/// Mapped-pinned upload per [[feedback_no_htod_htoh_only_mapped_pinned]]:
|
||
/// allocate the device buffer, then stage through the canonical public
|
||
/// helper which routes through `MappedF32Buffer` + DtoD.
|
||
fn upload_f32(stream: &Arc<CudaStream>, host: &[f32]) -> Result<CudaSlice<f32>> {
|
||
let mut d = stream.alloc_zeros::<f32>(host.len())?;
|
||
write_slice_f32_d_pub(stream, host, &mut d)?;
|
||
Ok(d)
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
// LAYER 1 — CMDP hard constraints
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
|
||
// CMDP gates Layer-1 state is per-batch (one independent backtest session
|
||
// per b). The four tests below exercise the per-batch buffers via b_size=1
|
||
// (one account) to match the single-account semantics of the limits.
|
||
|
||
/// Write a single value into a per-batch f32 device buffer through the
|
||
/// canonical mapped-pinned path (no raw HtoD on un-pinned slices per
|
||
/// `feedback_no_htod_htoh_only_mapped_pinned`).
|
||
fn write_pb(
|
||
stream: &Arc<CudaStream>,
|
||
dst: &mut CudaSlice<f32>,
|
||
values: &[f32],
|
||
) -> Result<()> {
|
||
write_slice_f32_d_pub(stream, values, dst)?;
|
||
Ok(())
|
||
}
|
||
|
||
// G1 — DD breaker triggered + recovery cooldown started (Fix A).
|
||
// 1. session_pnl crosses dd_limit → dd_triggered flips to 1.0
|
||
// 2. cooldown_remaining starts at COOLDOWN_DURATION (recovery clock)
|
||
// 3. RL_SESSION_PNL_USD_INDEX is mean-of-active (this account is now
|
||
// inactive → mean = 0); worst is mirrored to RL_SESSION_PNL_WORST_INDEX.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g1_cmdp_dd_breaker_triggers_at_limit() -> Result<()> {
|
||
let Some((dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
sync(&trainer);
|
||
|
||
let dd_limit = trainer.read_isv_host(RL_SESSION_DD_LIMIT_USD_INDEX);
|
||
assert!((dd_limit - SESSION_DD_LIMIT_USD).abs() < 1e-3);
|
||
assert_eq!(trainer.read_isv_host(RL_SESSION_PNL_USD_INDEX), 0.0);
|
||
assert_eq!(trainer.read_isv_host(RL_SESSION_PNL_WORST_INDEX), 0.0);
|
||
assert_eq!(trainer.read_isv_host(RL_SESSION_DD_TRIGGERED_INDEX), 0.0);
|
||
|
||
let rewards_d = upload_f32(&stream, &[-4000.0])?;
|
||
let dones_d = upload_f32(&stream, &[1.0])?;
|
||
trainer.launch_rl_cmdp_constraints_check(&rewards_d, &dones_d, 1)?;
|
||
sync(&trainer);
|
||
|
||
// The single account just tripped → it's now INACTIVE.
|
||
let mean_active = trainer.read_isv_host(RL_SESSION_PNL_USD_INDEX);
|
||
assert_eq!(
|
||
mean_active, 0.0,
|
||
"mean-active-pnl must be 0 with all accounts triggered; got {mean_active}"
|
||
);
|
||
|
||
// Worst slot mirrors the most-negative per-batch pnl.
|
||
let worst_pnl = trainer.read_isv_host(RL_SESSION_PNL_WORST_INDEX);
|
||
assert!(
|
||
(worst_pnl - (-4000.0)).abs() < 1e-3,
|
||
"worst-account pnl must be -4000; got {worst_pnl}"
|
||
);
|
||
|
||
assert_eq!(
|
||
trainer.read_isv_host(RL_SESSION_DD_TRIGGERED_INDEX), 1.0,
|
||
"any-DD-triggered summary must flip to 1.0"
|
||
);
|
||
|
||
// Fix A: DD trip also starts a recovery cooldown.
|
||
let cool = trainer.read_isv_host(RL_COOLDOWN_REMAINING_STEPS_INDEX);
|
||
assert!(
|
||
(cool - COOLDOWN_DURATION).abs() < 1e-3,
|
||
"DD trip must start recovery cooldown at duration={COOLDOWN_DURATION}; got {cool}"
|
||
);
|
||
eprintln!("G1 OK — DD trip → triggered=1, mean_active=0, worst=-4000, cooldown={cool}");
|
||
Ok(())
|
||
}
|
||
|
||
// G1b (NEW) — Fix A recovery: cooldown countdown to 0 resets the account.
|
||
// After ~500 idle launches, session_pnl + dd_triggered should both clear,
|
||
// and the account rejoins the active fleet.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g1b_cmdp_recovery_on_cooldown_expiry() -> Result<()> {
|
||
let Some((dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
|
||
// Trip DD on the first launch.
|
||
let trip_rewards = upload_f32(&stream, &[-4000.0])?;
|
||
let trip_dones = upload_f32(&stream, &[1.0])?;
|
||
trainer.launch_rl_cmdp_constraints_check(&trip_rewards, &trip_dones, 1)?;
|
||
|
||
// Idle launches to decrement cooldown to zero.
|
||
let idle_rewards = upload_f32(&stream, &[0.0])?;
|
||
let idle_dones = upload_f32(&stream, &[0.0])?;
|
||
let duration = COOLDOWN_DURATION as usize;
|
||
for _ in 0..duration {
|
||
trainer.launch_rl_cmdp_constraints_check(&idle_rewards, &idle_dones, 1)?;
|
||
}
|
||
sync(&trainer);
|
||
|
||
// Post-recovery: dd cleared, pnl reset to 0, account is active again.
|
||
let mean_active = trainer.read_isv_host(RL_SESSION_PNL_USD_INDEX);
|
||
let worst = trainer.read_isv_host(RL_SESSION_PNL_WORST_INDEX);
|
||
let triggered = trainer.read_isv_host(RL_SESSION_DD_TRIGGERED_INDEX);
|
||
let cool = trainer.read_isv_host(RL_COOLDOWN_REMAINING_STEPS_INDEX);
|
||
assert_eq!(mean_active, 0.0, "post-recovery mean must be 0; got {mean_active}");
|
||
assert_eq!(worst, 0.0, "post-recovery worst must be 0 (pnl reset); got {worst}");
|
||
assert_eq!(triggered, 0.0, "post-recovery dd_triggered must clear; got {triggered}");
|
||
assert_eq!(cool, 0.0, "cooldown must be exhausted; got {cool}");
|
||
eprintln!("G1b OK — account recovered after {duration}-step cooldown");
|
||
Ok(())
|
||
}
|
||
|
||
// G2 — Cooldown starts when an account's consec_loss streak crosses limit.
|
||
// b=1, 10 successive done-loss launches drive the per-batch streak to 10
|
||
// → cooldown_remaining_per_batch[0] snaps to cooldown_duration; the
|
||
// kernel also resets the per-batch streak to 0 on the same launch.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g2_cmdp_cooldown_starts_after_consec_loss_limit() -> Result<()> {
|
||
let Some((dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
|
||
sync(&trainer);
|
||
assert_eq!(trainer.read_isv_host(RL_CONSEC_LOSS_COUNT_INDEX), 0.0);
|
||
assert_eq!(trainer.read_isv_host(RL_COOLDOWN_REMAINING_STEPS_INDEX), 0.0);
|
||
|
||
// 10 sequential done-losses on the same account. Per-loss -$10, total
|
||
// -$100 ≫ -$3500 limit, so we isolate the consec-streak arm.
|
||
let rewards_d = upload_f32(&stream, &[-10.0])?;
|
||
let dones_d = upload_f32(&stream, &[1.0])?;
|
||
for _ in 0..10 {
|
||
trainer.launch_rl_cmdp_constraints_check(&rewards_d, &dones_d, 1)?;
|
||
}
|
||
sync(&trainer);
|
||
|
||
let cooldown = trainer.read_isv_host(RL_COOLDOWN_REMAINING_STEPS_INDEX);
|
||
let consec = trainer.read_isv_host(RL_CONSEC_LOSS_COUNT_INDEX);
|
||
// The 10th launch trips the limit. Kernel sets per_batch_cooldown[0]=duration
|
||
// then decrement *also runs this step* — so summary reads (duration - 1).
|
||
// Spec is "limit hit → start cooldown" but the decrement happens above
|
||
// the streak-check in the kernel ordering, so a single launch nets to
|
||
// exactly `duration` (no decrement yet because cooldown was 0 entering
|
||
// the launch). Confirm against `COOLDOWN_DURATION` directly.
|
||
assert!(
|
||
(cooldown - COOLDOWN_DURATION).abs() < 1e-3,
|
||
"cooldown_remaining must snap to duration={COOLDOWN_DURATION} on the 10th loss; got {cooldown}"
|
||
);
|
||
assert_eq!(consec, 0.0, "consec_loss_count must reset on limit hit");
|
||
eprintln!(
|
||
"G2 OK — 10 consecutive losses on one account → cooldown = {cooldown}, streak reset to {consec}"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// G3 — Cooldown decrements one step per launch on the affected account.
|
||
// Seed cooldown_remaining_per_batch[0]=50, run 3 idle launches, expect 47.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g3_cmdp_cooldown_decrements_per_step() -> Result<()> {
|
||
let Some((dev, mut trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
|
||
// Seed the per-batch cooldown directly (the ISV summary slot is
|
||
// kernel-OUT only, not an input).
|
||
write_pb(&stream, &mut trainer.cooldown_remaining_per_batch_d, &[50.0])?;
|
||
|
||
// Three idle launches with a break-even close — exercises the
|
||
// decrement without touching the streak counter.
|
||
let rewards_d = upload_f32(&stream, &[0.0])?;
|
||
let dones_d = upload_f32(&stream, &[1.0])?;
|
||
for _ in 0..3 {
|
||
trainer.launch_rl_cmdp_constraints_check(&rewards_d, &dones_d, 1)?;
|
||
}
|
||
sync(&trainer);
|
||
|
||
let cooldown = trainer.read_isv_host(RL_COOLDOWN_REMAINING_STEPS_INDEX);
|
||
assert!(
|
||
(cooldown - 47.0).abs() < 1e-3,
|
||
"cooldown must decrement 50→47 over 3 launches; got {cooldown}"
|
||
);
|
||
eprintln!("G3 OK — cooldown decremented 50 → {cooldown} over 3 launches");
|
||
Ok(())
|
||
}
|
||
|
||
// G4 — A done-win resets that account's consec_loss streak to 0.
|
||
// Seed consec_loss_per_batch[0]=5, feed one win → counter resets before
|
||
// the limit check.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g4_cmdp_win_resets_consec_loss_counter() -> Result<()> {
|
||
let Some((dev, mut trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
|
||
write_pb(&stream, &mut trainer.consec_loss_per_batch_d, &[5.0])?;
|
||
let rewards_d = upload_f32(&stream, &[25.0])?;
|
||
let dones_d = upload_f32(&stream, &[1.0])?;
|
||
trainer.launch_rl_cmdp_constraints_check(&rewards_d, &dones_d, 1)?;
|
||
sync(&trainer);
|
||
|
||
let consec = trainer.read_isv_host(RL_CONSEC_LOSS_COUNT_INDEX);
|
||
assert_eq!(consec, 0.0, "win must reset consec_loss_count to 0; got {consec}");
|
||
eprintln!("G4 OK — done-win reset consec_loss_count 5 → 0");
|
||
Ok(())
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
// LAYER 2 — IQN risk-averse action τ
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
|
||
// G5 — At positive session_pnl τ stays at 0.5 (median/risk-neutral).
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g5_iqn_tau_at_peak_returns_0_5() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
trainer.isv_mapped.write_record(RL_SESSION_PNL_USD_INDEX, 1000.0);
|
||
trainer.launch_rl_iqn_action_tau_controller()?;
|
||
sync(&trainer);
|
||
|
||
let tau = trainer.read_isv_host(RL_IQN_ACTION_TAU_INDEX);
|
||
assert!(
|
||
(tau - 0.5).abs() < 1e-5,
|
||
"τ must be 0.5 when session_pnl > 0 (no drawdown); got {tau}"
|
||
);
|
||
eprintln!("G5 OK — τ = {tau} at session_pnl = +1000");
|
||
Ok(())
|
||
}
|
||
|
||
// G6 — Small drawdown linearly reduces τ. With sensitivity=5.0,
|
||
// session_pnl = -350 → drawdown_frac = 0.01 → τ_target = 0.5 - 5×0.01 = 0.45.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g6_iqn_tau_adapts_linearly_to_drawdown() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
trainer.isv_mapped.write_record(RL_SESSION_PNL_USD_INDEX, -350.0);
|
||
trainer.launch_rl_iqn_action_tau_controller()?;
|
||
sync(&trainer);
|
||
|
||
let tau = trainer.read_isv_host(RL_IQN_ACTION_TAU_INDEX);
|
||
let drawdown_frac = 350.0 / STARTING_CAPITAL_USD;
|
||
let expected = 0.5 - IQN_TAU_DD_SENSITIVITY * drawdown_frac;
|
||
assert!(
|
||
(tau - expected).abs() < 1e-4,
|
||
"τ must be 0.5 - sensitivity × dd_frac = {expected}; got {tau}"
|
||
);
|
||
eprintln!("G6 OK — τ = {tau} at session_pnl = -350 (≈ 1% DD)");
|
||
Ok(())
|
||
}
|
||
|
||
// G7 — Deep drawdown clamps τ to τ_min. session_pnl = -7000 (20% of $35k)
|
||
// drives τ_target = 0.5 - 5×0.2 = -0.5, which must clamp to τ_min = 0.1.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g7_iqn_tau_clamps_at_min_on_deep_drawdown() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
trainer.isv_mapped.write_record(RL_SESSION_PNL_USD_INDEX, -7000.0);
|
||
trainer.launch_rl_iqn_action_tau_controller()?;
|
||
sync(&trainer);
|
||
|
||
let tau = trainer.read_isv_host(RL_IQN_ACTION_TAU_INDEX);
|
||
assert!(
|
||
(tau - IQN_TAU_MIN).abs() < 1e-5,
|
||
"τ must clamp at τ_min={IQN_TAU_MIN} for 20% DD; got {tau}"
|
||
);
|
||
eprintln!("G7 OK — τ clamped at {tau} = τ_min for 20% drawdown");
|
||
Ok(())
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
// LAYER 3 — Avellaneda-Stoikov inventory penalty β
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
|
||
// G8 — β bootstraps to target on first observation (prev==0 sentinel).
|
||
// reward_mag=20, inv_var=4 ⇒ inv_std=2 ⇒ target = 0.01×20 / (2×2) = 0.05.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g8_inventory_beta_bootstraps_to_target() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
// Sentinel: β starts at 0.0 per bootstrap.
|
||
assert_eq!(trainer.read_isv_host(RL_INVENTORY_PENALTY_BETA_INDEX), 0.0);
|
||
|
||
trainer.isv_mapped.write_record(RL_REWARD_MAGNITUDE_EMA_INDEX, 20.0);
|
||
trainer.isv_mapped.write_record(RL_INVENTORY_VARIANCE_EMA_INDEX, 4.0);
|
||
trainer.launch_rl_inventory_beta_controller()?;
|
||
sync(&trainer);
|
||
|
||
let beta = trainer.read_isv_host(RL_INVENTORY_PENALTY_BETA_INDEX);
|
||
// expected = 0.01 × reward_mag / (2 × inv_std) = 0.01 × 20 / (2 × 2) = 0.05
|
||
assert!(
|
||
(beta - 0.05).abs() < 1e-5,
|
||
"β must bootstrap-replace to target=0.05; got {beta}"
|
||
);
|
||
eprintln!("G8 OK — β bootstrapped to {beta} = 0.01·20/(2·√4)");
|
||
Ok(())
|
||
}
|
||
|
||
// G9 — After bootstrap, β Wiener-blends with the new target instead of
|
||
// snapping. α_floor=0.4 ⇒ next β = 0.6×prev + 0.4×new_target.
|
||
// Verifies the canonical [[pearl_first_observation_bootstrap]] discipline.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g9_inventory_beta_wiener_blends_after_bootstrap() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
// Step 1: bootstrap to 0.05.
|
||
trainer.isv_mapped.write_record(RL_REWARD_MAGNITUDE_EMA_INDEX, 20.0);
|
||
trainer.isv_mapped.write_record(RL_INVENTORY_VARIANCE_EMA_INDEX, 4.0);
|
||
trainer.launch_rl_inventory_beta_controller()?;
|
||
sync(&trainer);
|
||
let beta1 = trainer.read_isv_host(RL_INVENTORY_PENALTY_BETA_INDEX);
|
||
assert!((beta1 - 0.05).abs() < 1e-5);
|
||
|
||
// Step 2: target shifts to 0.10 (double reward_mag).
|
||
// Wiener: β_new = 0.6 × 0.05 + 0.4 × 0.10 = 0.07.
|
||
trainer.isv_mapped.write_record(RL_REWARD_MAGNITUDE_EMA_INDEX, 40.0);
|
||
trainer.launch_rl_inventory_beta_controller()?;
|
||
sync(&trainer);
|
||
let beta2 = trainer.read_isv_host(RL_INVENTORY_PENALTY_BETA_INDEX);
|
||
assert!(
|
||
(beta2 - 0.07).abs() < 1e-5,
|
||
"β must Wiener-blend (α=0.4) to 0.6·0.05 + 0.4·0.10 = 0.07; got {beta2}"
|
||
);
|
||
eprintln!("G9 OK — β Wiener-blended 0.05 → {beta2} (target 0.10)");
|
||
Ok(())
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
// LAYER 4 — Kelly fraction position sizing
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
|
||
// G10 — At p=0.55, R-multiple b=avg_win/avg_loss=1.8, half-Kelly returns
|
||
// 0.5 × (0.55×1.8 − 0.45)/1.8 = 0.5 × 0.30 = 0.15.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g10_kelly_at_known_edge_returns_half_kelly() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
// Past warmup so the gate releases.
|
||
trainer.isv_mapped.write_record(RL_CUMULATIVE_DONES_INDEX, 2000.0);
|
||
trainer.isv_mapped.write_record(RL_WIN_RATE_EMA_INDEX, 0.55);
|
||
trainer.isv_mapped.write_record(RL_AVG_WIN_USD_EMA_INDEX, 1.8);
|
||
trainer.isv_mapped.write_record(RL_AVG_LOSS_USD_EMA_INDEX, 1.0);
|
||
// Explicit dead-zone disable: this test verifies analytic-Kelly behavior,
|
||
// not resurrection. alloc_zeros leaves these at 0 already, but make
|
||
// it explicit per F2.3 spec.
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_FLAG_INDEX, 0.0);
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX, 0.0);
|
||
trainer.launch_rl_kelly_fraction_controller()?;
|
||
sync(&trainer);
|
||
|
||
let f = trainer.read_isv_host(RL_KELLY_FRACTION_INDEX);
|
||
// f_kelly = (0.55·1.8 − 0.45)/1.8 = 0.3 → f = safety·f_kelly = 0.5·0.3 = 0.15
|
||
let expected = KELLY_SAFETY_FRAC * 0.3;
|
||
assert!(
|
||
(f - expected).abs() < 1e-5,
|
||
"Kelly at p=0.55, R=1.8 must be {expected}; got {f}"
|
||
);
|
||
eprintln!("G10 OK — Kelly = {f} at p=0.55, R-multiple=1.8 (half-Kelly target {expected})");
|
||
Ok(())
|
||
}
|
||
|
||
// G11 — At negative edge (p=0.21, b=1.8) the analytic Kelly is negative;
|
||
// the kernel clamps f to 0 — never short-the-edge / lever-up-into-losses.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g11_kelly_at_negative_edge_clamps_to_zero() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
trainer.isv_mapped.write_record(RL_CUMULATIVE_DONES_INDEX, 2000.0);
|
||
trainer.isv_mapped.write_record(RL_WIN_RATE_EMA_INDEX, 0.21);
|
||
trainer.isv_mapped.write_record(RL_AVG_WIN_USD_EMA_INDEX, 1.8);
|
||
trainer.isv_mapped.write_record(RL_AVG_LOSS_USD_EMA_INDEX, 1.0);
|
||
// Explicit dead-zone disable: this test verifies analytic-Kelly behavior,
|
||
// not resurrection. alloc_zeros leaves these at 0 already, but make
|
||
// it explicit per F2.3 spec.
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_FLAG_INDEX, 0.0);
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX, 0.0);
|
||
trainer.launch_rl_kelly_fraction_controller()?;
|
||
sync(&trainer);
|
||
|
||
let f = trainer.read_isv_host(RL_KELLY_FRACTION_INDEX);
|
||
assert_eq!(f, 0.0, "Kelly at negative edge must clamp to 0; got {f}");
|
||
eprintln!("G11 OK — Kelly = 0 at p=0.21 (analytic f_kelly < 0, clamped)");
|
||
Ok(())
|
||
}
|
||
|
||
// G12 — Warmup gate: while cumulative_dones < min_trades_for_release, the
|
||
// kernel holds f at bootstrap=1.0 regardless of EMA inputs. This is the
|
||
// fix that resolved the fold-1 win-rate-collapse → negative-Kelly attractor
|
||
// (see [[pearl_position_sizing_missing_adaptation_layer]]).
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g12_kelly_held_at_bootstrap_during_warmup() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
// 500 trades observed, threshold 1000 → still warming up.
|
||
trainer.isv_mapped.write_record(RL_CUMULATIVE_DONES_INDEX, 500.0);
|
||
let min_trades = trainer.read_isv_host(RL_KELLY_MIN_TRADES_FOR_RELEASE_INDEX);
|
||
assert!((min_trades - KELLY_MIN_TRADES).abs() < 1e-3);
|
||
|
||
// Even with negative-edge EMA inputs, Kelly must hold at 1.0.
|
||
trainer.isv_mapped.write_record(RL_WIN_RATE_EMA_INDEX, 0.10);
|
||
trainer.isv_mapped.write_record(RL_AVG_WIN_USD_EMA_INDEX, 1.0);
|
||
trainer.isv_mapped.write_record(RL_AVG_LOSS_USD_EMA_INDEX, 5.0);
|
||
// Explicit dead-zone disable: this test verifies analytic-Kelly behavior,
|
||
// not resurrection. alloc_zeros leaves these at 0 already, but make
|
||
// it explicit per F2.3 spec.
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_FLAG_INDEX, 0.0);
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX, 0.0);
|
||
trainer.launch_rl_kelly_fraction_controller()?;
|
||
sync(&trainer);
|
||
|
||
let f = trainer.read_isv_host(RL_KELLY_FRACTION_INDEX);
|
||
assert_eq!(
|
||
f, 1.0,
|
||
"Kelly must hold at bootstrap=1.0 during warmup; got {f}"
|
||
);
|
||
eprintln!("G12 OK — Kelly held at {f} during warmup (500 < {KELLY_MIN_TRADES} trades)");
|
||
Ok(())
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
// REGIME OBSERVER + KELLY RESURRECTION — G15-G21
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
|
||
// Bootstrap constants from with_controllers_bootstrapped (regime_observer block).
|
||
const KELLY_EPS_RECOVERY_MIN: f32 = 0.05;
|
||
const KELLY_EPS_RECOVERY_MAX: f32 = 0.50;
|
||
const KELLY_EPS_RECOVERY_N: f32 = 100.0;
|
||
const DEAD_ZONE_MAX_DURATION: f32 = 1000.0;
|
||
|
||
/// Seed regime_observer config ISV slots (mirrors bootstrap entries).
|
||
fn seed_regime_config(trainer: &IntegratedTrainer) {
|
||
trainer.isv_mapped.write_record(RL_KELLY_EPS_RECOVERY_MIN_INDEX, KELLY_EPS_RECOVERY_MIN);
|
||
trainer.isv_mapped.write_record(RL_KELLY_EPS_RECOVERY_MAX_INDEX, KELLY_EPS_RECOVERY_MAX);
|
||
trainer.isv_mapped.write_record(RL_KELLY_EPS_RECOVERY_N_RECOVERY_INDEX, KELLY_EPS_RECOVERY_N);
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_MAX_DURATION_INDEX, DEAD_ZONE_MAX_DURATION);
|
||
}
|
||
|
||
// G15 — DEAD_ZONE_FLAG = 1 when composite fires: kelly==0, flat==b_size, cooldown==0.
|
||
// Setup: Kelly clamped to 0 via write_record, all lots=0 (alloc_zeros default),
|
||
// cooldown=0 (alloc_zeros default). After regime_observer, flag must be 1.
|
||
#[test]
|
||
#[ignore = "Requires CUDA (MlDevice::cuda(0))"]
|
||
fn g15_dead_zone_flag_fires_on_composite() -> Result<()> {
|
||
let Some((dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
let b_size = 4_usize;
|
||
|
||
seed_regime_config(&trainer);
|
||
|
||
// kelly=0: analytic result when edge is negative (already 0 at init, but explicit)
|
||
trainer.isv_mapped.write_record(RL_KELLY_FRACTION_INDEX, 0.0);
|
||
// cooldown=0: already zeroed by alloc_zeros
|
||
trainer.isv_mapped.write_record(RL_COOLDOWN_REMAINING_STEPS_INDEX, 0.0);
|
||
|
||
// lots=0 for all b_size accounts → flat_count_d[0] == b_size
|
||
let lots_d: cudarc::driver::CudaSlice<i32> = stream.alloc_zeros::<i32>(b_size)?;
|
||
trainer.launch_rl_regime_flat_count(&lots_d, b_size)?;
|
||
trainer.launch_rl_regime_observer(b_size)?;
|
||
sync(&trainer);
|
||
|
||
let flag = trainer.read_isv_host(RL_REGIME_DEAD_ZONE_FLAG_INDEX);
|
||
assert_eq!(flag, 1.0, "DEAD_ZONE_FLAG must fire when kelly=0, all-flat, cooldown=0; got {flag}");
|
||
eprintln!("G15 OK — DEAD_ZONE_FLAG = {flag} (composite satisfied)");
|
||
Ok(())
|
||
}
|
||
|
||
// G16 — DEAD_ZONE_FLAG = 0 when any condition violated.
|
||
// Condition violated: kelly > 0 (positive edge). All else matches G15.
|
||
#[test]
|
||
#[ignore = "Requires CUDA (MlDevice::cuda(0))"]
|
||
fn g16_dead_zone_flag_clear_when_condition_violated() -> Result<()> {
|
||
let Some((dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
let b_size = 4_usize;
|
||
|
||
seed_regime_config(&trainer);
|
||
|
||
// kelly > 0: condition NOT met
|
||
trainer.isv_mapped.write_record(RL_KELLY_FRACTION_INDEX, 0.15);
|
||
trainer.isv_mapped.write_record(RL_COOLDOWN_REMAINING_STEPS_INDEX, 0.0);
|
||
|
||
let lots_d: cudarc::driver::CudaSlice<i32> = stream.alloc_zeros::<i32>(b_size)?;
|
||
trainer.launch_rl_regime_flat_count(&lots_d, b_size)?;
|
||
trainer.launch_rl_regime_observer(b_size)?;
|
||
sync(&trainer);
|
||
|
||
let flag = trainer.read_isv_host(RL_REGIME_DEAD_ZONE_FLAG_INDEX);
|
||
assert_eq!(flag, 0.0, "DEAD_ZONE_FLAG must stay 0 when kelly > 0; got {flag}");
|
||
eprintln!("G16 OK — DEAD_ZONE_FLAG = {flag} (kelly=0.15 breaks composite)");
|
||
Ok(())
|
||
}
|
||
|
||
// G17 — Kelly resurrection sets kelly_f = ε_recovery_live when DEAD_ZONE_FLAG=1
|
||
// and TIMEOUT_FLAG=0. Expected ε_recovery_live = ε_min (recency=0 → recovery_factor=0).
|
||
#[test]
|
||
#[ignore = "Requires CUDA (MlDevice::cuda(0))"]
|
||
fn g17_kelly_resurrection_sets_eps_recovery_live() -> Result<()> {
|
||
let Some((dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
let b_size = 4_usize;
|
||
|
||
seed_regime_config(&trainer);
|
||
|
||
// Set up dead zone composite (same as G15)
|
||
trainer.isv_mapped.write_record(RL_KELLY_FRACTION_INDEX, 0.0);
|
||
trainer.isv_mapped.write_record(RL_COOLDOWN_REMAINING_STEPS_INDEX, 0.0);
|
||
let lots_d: cudarc::driver::CudaSlice<i32> = stream.alloc_zeros::<i32>(b_size)?;
|
||
trainer.launch_rl_regime_flat_count(&lots_d, b_size)?;
|
||
trainer.launch_rl_regime_observer(b_size)?;
|
||
sync(&trainer);
|
||
|
||
// Verify prerequisite: flag is set, timeout is not
|
||
let flag = trainer.read_isv_host(RL_REGIME_DEAD_ZONE_FLAG_INDEX);
|
||
let timeout = trainer.read_isv_host(RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX);
|
||
assert_eq!(flag, 1.0, "prerequisite: DEAD_ZONE_FLAG must be 1");
|
||
assert_eq!(timeout, 0.0, "prerequisite: TIMEOUT_FLAG must be 0");
|
||
|
||
// Now launch Kelly — resurrection must override kelly_f with ε_recovery_live
|
||
trainer.isv_mapped.write_record(RL_CUMULATIVE_DONES_INDEX, 2000.0);
|
||
trainer.isv_mapped.write_record(RL_WIN_RATE_EMA_INDEX, 0.21);
|
||
trainer.isv_mapped.write_record(RL_AVG_WIN_USD_EMA_INDEX, 1.8);
|
||
trainer.isv_mapped.write_record(RL_AVG_LOSS_USD_EMA_INDEX, 1.0);
|
||
trainer.launch_rl_kelly_fraction_controller()?;
|
||
sync(&trainer);
|
||
|
||
let eps_live = trainer.read_isv_host(RL_KELLY_EPS_RECOVERY_LIVE_INDEX);
|
||
let kelly_f = trainer.read_isv_host(RL_KELLY_FRACTION_INDEX);
|
||
assert!(
|
||
(kelly_f - eps_live).abs() < 1e-5,
|
||
"Kelly resurrection must set kelly_f = ε_recovery_live={eps_live}; got {kelly_f}"
|
||
);
|
||
eprintln!("G17 OK — Kelly resurrected to ε_recovery_live = {kelly_f}");
|
||
Ok(())
|
||
}
|
||
|
||
// G18 — Kelly retains analytic value when DEAD_ZONE_FLAG = 0.
|
||
// p=0.55, R=1.8 → analytic half-Kelly = 0.15; resurrection must NOT fire.
|
||
#[test]
|
||
#[ignore = "Requires CUDA (MlDevice::cuda(0))"]
|
||
fn g18_kelly_retains_analytic_when_no_dead_zone() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
seed_regime_config(&trainer);
|
||
|
||
// Dead zone disabled explicitly
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_FLAG_INDEX, 0.0);
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX, 0.0);
|
||
// ε_recovery_live set to something different so any override would be detectable
|
||
trainer.isv_mapped.write_record(RL_KELLY_EPS_RECOVERY_LIVE_INDEX, 0.99);
|
||
|
||
trainer.isv_mapped.write_record(RL_CUMULATIVE_DONES_INDEX, 2000.0);
|
||
trainer.isv_mapped.write_record(RL_WIN_RATE_EMA_INDEX, 0.55);
|
||
trainer.isv_mapped.write_record(RL_AVG_WIN_USD_EMA_INDEX, 1.8);
|
||
trainer.isv_mapped.write_record(RL_AVG_LOSS_USD_EMA_INDEX, 1.0);
|
||
trainer.launch_rl_kelly_fraction_controller()?;
|
||
sync(&trainer);
|
||
|
||
let kelly_f = trainer.read_isv_host(RL_KELLY_FRACTION_INDEX);
|
||
let expected = KELLY_SAFETY_FRAC * 0.3; // 0.5 × (0.55×1.8 − 0.45)/1.8
|
||
assert!(
|
||
(kelly_f - expected).abs() < 1e-5,
|
||
"Kelly must return analytic {expected} when dead_zone=0; got {kelly_f}"
|
||
);
|
||
eprintln!("G18 OK — Kelly analytic = {kelly_f} (no resurrection, dead_zone=0)");
|
||
Ok(())
|
||
}
|
||
|
||
// G19 — ε_recovery_live ramps linearly:
|
||
// T=0 → ε_min (0.05)
|
||
// T=100 → ε_max (0.50)
|
||
// T=50 → 0.05 + (0.50 − 0.05) × 0.5 = 0.275
|
||
// Verified by setting TAIL_EVENT_RECENCY directly and running regime_observer.
|
||
#[test]
|
||
#[ignore = "Requires CUDA (MlDevice::cuda(0))"]
|
||
fn g19_eps_recovery_live_ramps_linearly() -> Result<()> {
|
||
let Some((dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
let b_size = 1_usize;
|
||
|
||
seed_regime_config(&trainer);
|
||
|
||
// Allocate lots_d (unused for ε computation but required by launcher)
|
||
let lots_d: cudarc::driver::CudaSlice<i32> = stream.alloc_zeros::<i32>(b_size)?;
|
||
|
||
// Sub-check 1: recency = 0 → ε_min
|
||
trainer.isv_mapped.write_record(RL_REGIME_TAIL_EVENT_RECENCY_INDEX, 0.0);
|
||
trainer.launch_rl_regime_flat_count(&lots_d, b_size)?;
|
||
trainer.launch_rl_regime_observer(b_size)?;
|
||
sync(&trainer);
|
||
let eps0 = trainer.read_isv_host(RL_KELLY_EPS_RECOVERY_LIVE_INDEX);
|
||
// At recency=0: recovery_factor = min(1, 0/100) = 0 → eps = eps_min + 0
|
||
// But regime_observer increments recency by 1 each non-tail step.
|
||
// After one call with recency=0, recency becomes 1 (not tail), so
|
||
// recovery_factor = 1/100 = 0.01 → eps = 0.05 + 0.01×0.45 = 0.0545.
|
||
// Re-seed to 0 and check directly before any increment: we must read
|
||
// the live value after the kernel which just ran with recency=0 as input.
|
||
// The kernel sets recency += 1, THEN computes eps from new recency=1.
|
||
// So eps0 = ε_min + (ε_max - ε_min) * (1/100) = 0.05 + 0.45*0.01 = 0.0545.
|
||
let expected0 = KELLY_EPS_RECOVERY_MIN + (KELLY_EPS_RECOVERY_MAX - KELLY_EPS_RECOVERY_MIN)
|
||
* (1.0_f32 / KELLY_EPS_RECOVERY_N);
|
||
assert!(
|
||
(eps0 - expected0).abs() < 1e-5,
|
||
"ε at recency=0 input (→1 after increment) must be {expected0}; got {eps0}"
|
||
);
|
||
|
||
// Sub-check 2: recency = 99 → after increment = 100 → ε_max
|
||
trainer.isv_mapped.write_record(RL_REGIME_TAIL_EVENT_RECENCY_INDEX, 99.0);
|
||
trainer.launch_rl_regime_flat_count(&lots_d, b_size)?;
|
||
trainer.launch_rl_regime_observer(b_size)?;
|
||
sync(&trainer);
|
||
let eps100 = trainer.read_isv_host(RL_KELLY_EPS_RECOVERY_LIVE_INDEX);
|
||
// recency becomes 100, recovery_factor = min(1, 100/100) = 1.0 → ε_max
|
||
assert!(
|
||
(eps100 - KELLY_EPS_RECOVERY_MAX).abs() < 1e-5,
|
||
"ε at recency=99→100 must be ε_max={KELLY_EPS_RECOVERY_MAX}; got {eps100}"
|
||
);
|
||
|
||
// Sub-check 3: recency = 49 → after increment = 50 → midpoint
|
||
trainer.isv_mapped.write_record(RL_REGIME_TAIL_EVENT_RECENCY_INDEX, 49.0);
|
||
trainer.launch_rl_regime_flat_count(&lots_d, b_size)?;
|
||
trainer.launch_rl_regime_observer(b_size)?;
|
||
sync(&trainer);
|
||
let eps50 = trainer.read_isv_host(RL_KELLY_EPS_RECOVERY_LIVE_INDEX);
|
||
let expected50 = KELLY_EPS_RECOVERY_MIN
|
||
+ (KELLY_EPS_RECOVERY_MAX - KELLY_EPS_RECOVERY_MIN) * (50.0_f32 / KELLY_EPS_RECOVERY_N);
|
||
assert!(
|
||
(eps50 - expected50).abs() < 1e-5,
|
||
"ε at recency=49→50 must be {expected50}; got {eps50}"
|
||
);
|
||
eprintln!("G19 OK — ε_recovery_live ramps: T=1→{eps0:.5}, T=50→{eps50:.5}, T=100→{eps100:.5}");
|
||
Ok(())
|
||
}
|
||
|
||
// G20 — TIMEOUT_FLAG fires when DURATION > MAX_DURATION.
|
||
// Pre-seed DURATION = MAX_DURATION + 1 directly, then verify TIMEOUT after
|
||
// one regime_observer call with the dead-zone composite still active.
|
||
#[test]
|
||
#[ignore = "Requires CUDA (MlDevice::cuda(0))"]
|
||
fn g20_timeout_flag_fires_when_duration_exceeds_max() -> Result<()> {
|
||
let Some((dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
let stream = dev.cuda_stream()?.clone();
|
||
let b_size = 4_usize;
|
||
|
||
seed_regime_config(&trainer);
|
||
|
||
// Compose the dead-zone: kelly=0, all flat, cooldown=0
|
||
trainer.isv_mapped.write_record(RL_KELLY_FRACTION_INDEX, 0.0);
|
||
trainer.isv_mapped.write_record(RL_COOLDOWN_REMAINING_STEPS_INDEX, 0.0);
|
||
|
||
// Pre-seed DURATION just above MAX_DURATION (1000) so the NEXT step's
|
||
// duration = 1001 + 1 = 1002? No — if dead_zone fires on this step,
|
||
// duration = prev_duration + 1 (1001+1=1002). The check is duration > max_dur.
|
||
// Easier: seed DURATION = MAX_DURATION already (1000), dead_zone fires →
|
||
// duration becomes 1001, then 1001 > 1000 is true → TIMEOUT_FLAG = 1.
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_DURATION_INDEX, DEAD_ZONE_MAX_DURATION);
|
||
|
||
let lots_d: cudarc::driver::CudaSlice<i32> = stream.alloc_zeros::<i32>(b_size)?;
|
||
trainer.launch_rl_regime_flat_count(&lots_d, b_size)?;
|
||
trainer.launch_rl_regime_observer(b_size)?;
|
||
sync(&trainer);
|
||
|
||
let timeout = trainer.read_isv_host(RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX);
|
||
let duration = trainer.read_isv_host(RL_REGIME_DEAD_ZONE_DURATION_INDEX);
|
||
assert_eq!(
|
||
timeout, 1.0,
|
||
"TIMEOUT_FLAG must fire when duration ({duration}) > MAX_DURATION ({DEAD_ZONE_MAX_DURATION}); got {timeout}"
|
||
);
|
||
eprintln!("G20 OK — TIMEOUT_FLAG = {timeout} at duration = {duration} > {DEAD_ZONE_MAX_DURATION}");
|
||
Ok(())
|
||
}
|
||
|
||
// G21 — Kelly resurrection NOT triggered when TIMEOUT_FLAG = 1.
|
||
// When both DEAD_ZONE_FLAG=1 and TIMEOUT_FLAG=1, Kelly must remain at the
|
||
// analytic value (0 for negative-edge inputs) — no resurrection override.
|
||
#[test]
|
||
#[ignore = "Requires CUDA (MlDevice::cuda(0))"]
|
||
fn g21_kelly_resurrection_blocked_by_timeout() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
seed_regime_config(&trainer);
|
||
|
||
// Force both flags set via direct ISV writes
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_FLAG_INDEX, 1.0);
|
||
trainer.isv_mapped.write_record(RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX, 1.0);
|
||
// ε_recovery_live at a detectable value — resurrection would write this
|
||
trainer.isv_mapped.write_record(RL_KELLY_EPS_RECOVERY_LIVE_INDEX, 0.99);
|
||
|
||
// Negative-edge inputs → analytic Kelly = 0 (clamped)
|
||
trainer.isv_mapped.write_record(RL_CUMULATIVE_DONES_INDEX, 2000.0);
|
||
trainer.isv_mapped.write_record(RL_WIN_RATE_EMA_INDEX, 0.21);
|
||
trainer.isv_mapped.write_record(RL_AVG_WIN_USD_EMA_INDEX, 1.8);
|
||
trainer.isv_mapped.write_record(RL_AVG_LOSS_USD_EMA_INDEX, 1.0);
|
||
trainer.launch_rl_kelly_fraction_controller()?;
|
||
sync(&trainer);
|
||
|
||
let kelly_f = trainer.read_isv_host(RL_KELLY_FRACTION_INDEX);
|
||
assert_eq!(
|
||
kelly_f, 0.0,
|
||
"Kelly must NOT resurrect when TIMEOUT_FLAG=1 (stay at analytic 0); got {kelly_f}"
|
||
);
|
||
eprintln!("G21 OK — Kelly = {kelly_f} (resurrection blocked by timeout_flag=1)");
|
||
Ok(())
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
// LAYER 2 — IQN τ tail-recency boost (F5)
|
||
// ═════════════════════════════════════════════════════════════════════
|
||
|
||
// G24 — When TAIL_EVENT_RECENCY < N_window, τ_action ≥ τ_min × boost.
|
||
// Setup: recency=50 (< 100 default window), τ_min=0.10, boost=1.5.
|
||
// With no drawdown (session_pnl=0), base τ = 0.5 and τ_min_eff = 0.15.
|
||
// fmaxf(0.5, 0.15) = 0.5 is above 0.15 — but verify τ ≥ τ_min×boost.
|
||
// Use heavy drawdown (session_pnl=-70000) so base τ = τ_min = 0.10;
|
||
// then τ_min_eff = 0.15 → τ_action = fmaxf(0.10, 0.15) = 0.15.
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g24_iqn_tau_tail_recency_boost_when_within_window() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
const TAU_MIN: f32 = 0.10;
|
||
const BOOST_FACTOR: f32 = 1.5;
|
||
const TAIL_WINDOW: f32 = 100.0;
|
||
const RECENCY_IN: f32 = 50.0; // < window → boost applies
|
||
|
||
// Override bootstrap values to known test values.
|
||
trainer.isv_mapped.write_record(RL_IQN_ACTION_TAU_MIN_INDEX, TAU_MIN);
|
||
trainer.isv_mapped.write_record(RL_IQN_TAU_TAIL_BOOST_FACTOR_INDEX, BOOST_FACTOR);
|
||
trainer.isv_mapped.write_record(RL_IQN_TAU_TAIL_BOOST_N_WINDOW_INDEX, TAIL_WINDOW);
|
||
trainer.isv_mapped.write_record(RL_REGIME_TAIL_EVENT_RECENCY_INDEX, RECENCY_IN);
|
||
// Drive drawdown high enough that DD-path alone would set τ = τ_min.
|
||
// sensitivity × dd_frac ≥ 0.5 ⇒ dd_frac ≥ 0.1 ⇒ pnl ≤ -3500.
|
||
trainer.isv_mapped.write_record(RL_SESSION_PNL_USD_INDEX, -7000.0);
|
||
|
||
trainer.launch_rl_iqn_action_tau_controller()?;
|
||
sync(&trainer);
|
||
|
||
let tau = trainer.read_isv_host(RL_IQN_ACTION_TAU_INDEX);
|
||
let expected_floor = TAU_MIN * BOOST_FACTOR; // 0.15
|
||
assert!(
|
||
tau >= expected_floor - 1e-5,
|
||
"τ must be ≥ τ_min × boost = {expected_floor} when recency={RECENCY_IN} < window={TAIL_WINDOW}; got {tau}"
|
||
);
|
||
eprintln!("G24 OK — τ = {tau} ≥ τ_min × boost = {expected_floor} (recency={RECENCY_IN} < {TAIL_WINDOW})");
|
||
Ok(())
|
||
}
|
||
|
||
// G25 — When TAIL_EVENT_RECENCY ≥ N_window, boost does NOT apply.
|
||
// Same setup as G24 but recency=200 (≥ 100 window) → τ_min_eff = τ_min = 0.10.
|
||
// With deep drawdown τ_action = τ_min = 0.10 (not 0.15).
|
||
#[test]
|
||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||
fn g25_iqn_tau_no_boost_outside_window() -> Result<()> {
|
||
let Some((_dev, trainer)) = build_trainer() else { return Ok(()) };
|
||
|
||
const TAU_MIN: f32 = 0.10;
|
||
const BOOST_FACTOR: f32 = 1.5;
|
||
const TAIL_WINDOW: f32 = 100.0;
|
||
const RECENCY_OUT: f32 = 200.0; // ≥ window → no boost
|
||
|
||
trainer.isv_mapped.write_record(RL_IQN_ACTION_TAU_MIN_INDEX, TAU_MIN);
|
||
trainer.isv_mapped.write_record(RL_IQN_TAU_TAIL_BOOST_FACTOR_INDEX, BOOST_FACTOR);
|
||
trainer.isv_mapped.write_record(RL_IQN_TAU_TAIL_BOOST_N_WINDOW_INDEX, TAIL_WINDOW);
|
||
trainer.isv_mapped.write_record(RL_REGIME_TAIL_EVENT_RECENCY_INDEX, RECENCY_OUT);
|
||
// Deep drawdown: same as G24.
|
||
trainer.isv_mapped.write_record(RL_SESSION_PNL_USD_INDEX, -7000.0);
|
||
|
||
trainer.launch_rl_iqn_action_tau_controller()?;
|
||
sync(&trainer);
|
||
|
||
let tau = trainer.read_isv_host(RL_IQN_ACTION_TAU_INDEX);
|
||
let boosted_floor = TAU_MIN * BOOST_FACTOR; // 0.15 — must NOT reach this
|
||
// τ should be exactly τ_min (0.10), not the boosted τ_min (0.15).
|
||
assert!(
|
||
(tau - TAU_MIN).abs() < 1e-5,
|
||
"τ must equal τ_min={TAU_MIN} (no boost) when recency={RECENCY_OUT} ≥ window={TAIL_WINDOW}; got {tau} (boosted_floor would be {boosted_floor})"
|
||
);
|
||
eprintln!("G25 OK — τ = {tau} = τ_min (no boost, recency={RECENCY_OUT} ≥ {TAIL_WINDOW})");
|
||
Ok(())
|
||
}
|