Files
foxhunt/crates/ml-alpha/tests/risk_stack_invariants.rs
jgrusewski 30982963ef feat(rl): IQN τ tail-recency consumer + G24/G25 invariants (F5)
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>
2026-05-31 12:59:48 +02:00

852 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(())
}