test(sp21): T2.2 Phase 1.5 — kernel-direct GPU oracle for per-trade predicted_q

Promotes the Phase 2.5 follow-up from the plan to a load-bearing test.
Three #[ignore = "requires GPU"] tests exercise backtest_env_step_batch
directly with controlled inputs:

  1. predicted_q_populated_on_close_with_real_q_values — open Long Full
     at step 0 with q_values_per_window[w=0, a=Long_Full]=2.5, hold for
     two bars, close Flat at step 3. Asserts the per-trade tape's
     predicted_q[0] ≈ 2.5 (entry_q captured at open, persisted in
     entry_q_state across holds, snapshotted before close emit).

  2. predicted_q_stays_zero_when_q_values_is_null — same scenario with
     q_values_per_window=NULL (single-step evaluate() semantics). The
     kernel's open-time write is gated on the NULL guard, so
     entry_q_state[w] stays at buffer-init 0.0 and the tape's
     predicted_q is 0.0. Proves the NULL-tolerance contract is
     kernel-enforced, not just launcher convention.

  3. no_trades_no_predicted_q_emission — all-Flat action sequence
     produces zero trades. Path-coverage check that entry_q's open
     guard fires only on actual opens / reverses.

Test design:
- Kernel-direct (loads ENV_CUBIN via include_bytes!), no eval-pipeline
  scaffolding (no QValueProvider, no cuBLAS forward, no chunked state
  gather). Avoids the heavy mock infrastructure the plan flagged.
- Flat-market 4-bar single-window window: every OHLC=100, zero costs,
  initial_capital=100k. Isolates the entry_q signal from P&L noise
  so the assert-predicted_q is exact under IEEE-754 (kernel writes
  the Q-value verbatim with no math).
- NULL fallback for isv_signals/conviction/exploration_scale matches
  the existing single-step launcher pattern; FEATURE_DIM=4 (<41) ⇒
  compute_regime_trail_scales takes the fixed-width fallback (no
  feature deref needed).

Verification:
- SQLX_OFFLINE=true cargo test -p ml --test sp21_per_trade_predicted_q_test
  --features cuda -- --ignored --nocapture
- 3/3 pass on RTX 3050 Ti (sm_86).
- All 4 prior SP20 GPU oracle suites still pass (25 tests).
- Total: 28 GPU oracle tests, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-10 22:12:07 +02:00
parent 26deaa5004
commit ea12c172e6

View File

@@ -0,0 +1,521 @@
//! SP21 T2.2 Phase 1.5 (2026-05-11) — `predicted_q` per-trade tape GPU oracle test.
//!
//! Validates the `entry_q` tracking added to `backtest_env_step_batch`:
//!
//! 1. **predicted_q populated on close** — open a Long position at step 0
//! with a controlled `q_values_per_window[Long_Full] = 2.5`, hold for two
//! bars, close with Flat at step 3. Read the per-trade tape and assert
//! the single emitted trade carries `predicted_q ≈ 2.5` (the entry_q
//! captured at open and snapshotted before the close-detection block).
//! 2. **NULL q_values_per_window keeps predicted_q at 0** — same scenario
//! with `q_values_per_window = NULL` (single-step `evaluate()` semantics).
//! Kernel's open-time write is gated on the NULL guard, so
//! `entry_q_state[w]` stays 0.0 and the tape's `predicted_q` is 0.0.
//! 3. **entry_q persists across hold bars** — the persistent `entry_q_state`
//! buffer is per-window, written ONCE at open, read back at close. Hold
//! bars don't touch it. The test indirectly proves persistence by the
//! single-trade success of test (1).
//!
//! These cover the entry_q capture path end-to-end at the kernel level. The
//! eval pipeline (cuBLAS forward → action select → env step) integration test
//! lives in `gpu_backtest_validation.rs`; this file focuses on the per-trade
//! tape's new `predicted_q` field as a kernel-direct oracle.
//!
//! Run on a GPU host:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test sp21_per_trade_predicted_q_test --features cuda \
//! -- --ignored --nocapture
#![allow(clippy::tests_outside_test_module)]
#![allow(clippy::too_many_lines)]
#[cfg(feature = "cuda")]
#[allow(unsafe_code)] // CUDA kernel launch.
mod gpu {
use std::sync::Arc;
use cudarc::driver::{
CudaContext, CudaFunction, CudaStream, DevicePtr, LaunchConfig, PushKernelArg,
};
/// Same cubin loaded by `gpu_backtest_evaluator.rs`. Test-side
/// `include_bytes!` reads from the per-crate `OUT_DIR` set by
/// `crates/ml/build.rs` — the same OUT_DIR used by the lib build.
const ENV_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/backtest_env_kernel.cubin"
));
/// Branch sizes — match `DqnBacktestConfig::from_network_dims` defaults.
const B0: i32 = 4; // Short / Hold / Long / Flat
const B1: i32 = 3; // Quarter / Half / Full
const B2: i32 = 3; // Order type
const B3: i32 = 3; // Patient / Normal / Aggressive
/// Total factored action count. Matches the kernel's
/// `num_actions` arg.
const NUM_ACTIONS: i32 = B0 * B1 * B2 * B3; // 108
/// Direction constants (mirrors `state_layout.cuh:123-126`).
const DIR_LONG: i32 = 2;
const DIR_HOLD: i32 = 1;
const DIR_FLAT: i32 = 3;
/// Magnitude constants (mirrors `state_layout.cuh:133-135`).
const MAG_FULL: i32 = 2;
const MAG_QUARTER: i32 = 0;
/// Encode a factored action: `dir * b1*b2*b3 + mag * b2*b3 + order * b3 + urgency`.
/// Mirrors `trade_physics.cuh::decode_direction_4b` etc. exactly so the
/// kernel decoder maps our test actions back to the canonical DIR/MAG.
const fn encode_action(dir: i32, mag: i32, order: i32, urgency: i32) -> i32 {
dir * (B1 * B2 * B3) + mag * (B2 * B3) + order * B3 + urgency
}
/// `MAX_TRADES_PER_WINDOW` matches the kernel guard at the per-trade
/// emission site. We size at 16 here — far smaller than production's
/// 200_000 — because the test only emits a handful of trades and the
/// per-window buffer alloc is `n_windows × max_trades`.
const MAX_TRADES_PER_WINDOW: i32 = 16;
/// Tolerance for f32 comparisons against expected entry_q values.
/// The kernel writes the Q-value verbatim from `q_values_per_window`
/// (no math), so the round-trip is exact under IEEE-754 — but a small
/// epsilon defends against any future float-rewrite of the open path.
const Q_TOL: f32 = 1e-5;
/// 4-bar single-window scenario: open Long Full at step 0, hold for two
/// bars, Flat-close at step 3. Returns (n_windows=1, max_len=4, chunk_len=4).
const N_WINDOWS: usize = 1;
const MAX_LEN: usize = 4;
const CHUNK_LEN: usize = 4;
const FEATURE_DIM: i32 = 4; // < 41 → kernel uses fixed-width trail (no features deref)
fn try_cuda_stream() -> Option<Arc<CudaStream>> {
match CudaContext::new(0) {
Ok(ctx) => Some(ctx.default_stream()),
Err(_) => {
eprintln!("CUDA not available, skipping per-trade-tape predicted_q test");
None
}
}
}
fn load_env_batch_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(ENV_CUBIN.to_vec())
.expect("load backtest_env_kernel.cubin");
module
.load_function("backtest_env_step_batch")
.expect("load backtest_env_step_batch function")
}
/// One window of OHLC. All bars at price 100.0 (flat market) → trade
/// closes at break-even, isolating the entry_q signal from any P&L
/// noise. The close emission predicate `pre_entry_price > 0.0f &&
/// value > 1.0f` only requires entry_price was non-zero (set on open
/// at step 0) and capital is positive — both satisfied with flat
/// prices and `initial_capital = 100_000`.
fn flat_market_prices() -> Vec<f32> {
let mut prices = Vec::with_capacity(N_WINDOWS * MAX_LEN * 4);
for _ in 0..(N_WINDOWS * MAX_LEN) {
// OHLC = [100, 100, 100, 100]
prices.push(100.0_f32);
prices.push(100.0_f32);
prices.push(100.0_f32);
prices.push(100.0_f32);
}
prices
}
/// Initial portfolio state matching `GpuBacktestEvaluator::init_portfolio_state`.
fn init_portfolio_state(initial_capital: f32) -> Vec<f32> {
let mut state = vec![0.0_f32; N_WINDOWS * 8];
state[0] = initial_capital; // value
state[2] = initial_capital; // cash
state[4] = initial_capital; // max_equity
state
}
/// Action sequence: open Long Full at step 0, hold, hold, close Flat.
/// Layout `[chunk_len, n_windows]` flattened — the kernel reads via
/// `chunked_actions[s * n_windows + w]`.
fn open_hold_close_actions() -> Vec<i32> {
// step 0: Long Full Market Normal (open)
let a_open = encode_action(DIR_LONG, MAG_FULL, 0, 1);
// step 1, 2: Hold Quarter Market Normal (carry the position; Hold = no-op,
// see `project_hold_action_design.md`)
let a_hold = encode_action(DIR_HOLD, MAG_QUARTER, 0, 1);
// step 3: Flat Quarter Market Normal (close all positions to zero)
let a_close = encode_action(DIR_FLAT, MAG_QUARTER, 0, 1);
vec![a_open, a_hold, a_hold, a_close]
}
/// Q-values buffer layout `[chunk_len, n_windows, num_actions]` flattened.
/// The kernel indexes via `q[s * n_windows * num_actions + w * num_actions
/// + action_taken]` — at step 0 we set the slot for the open action only;
/// other slots stay at 0.0 (the kernel never reads them since it always
/// indexes by the chosen action).
fn q_values_with_open_at(open_action: i32, open_q: f32) -> Vec<f32> {
let n = N_WINDOWS;
let mut q = vec![0.0_f32; CHUNK_LEN * n * NUM_ACTIONS as usize];
let s = 0_usize;
let w = 0_usize;
let a = open_action as usize;
q[s * n * NUM_ACTIONS as usize + w * NUM_ACTIONS as usize + a] = open_q;
q
}
/// Configuration constants for the kernel — match `GpuBacktestConfig::default`
/// where it matters and zero out the optional shaping params.
struct KernelConfig {
max_position: f32,
tx_cost_bps: f32,
spread_cost: f32,
contract_multiplier: f32,
margin_pct: f32,
initial_capital: f32,
}
impl KernelConfig {
fn for_test() -> Self {
Self {
max_position: 1.0,
// Zero costs so any P&L deviation comes from the kernel logic,
// not transaction friction. The test asserts on `predicted_q`
// (independent of P&L) but the close emission predicate's
// `value > 1.0f` guard is more comfortably satisfied with
// no cost drain.
tx_cost_bps: 0.0,
spread_cost: 0.0,
contract_multiplier: 1.0,
margin_pct: 0.0,
initial_capital: 100_000.0,
}
}
}
/// Run the batch kernel one chunk over the 4-bar window with the given
/// q_values_per_window pointer (real or NULL=0u64) and `num_actions`.
/// Returns (per_trade_count[0], per_trade_predicted_q[0..count]).
#[allow(clippy::too_many_arguments)]
fn run_one_chunk(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
cfg: &KernelConfig,
actions: &[i32],
q_values_dev_ptr: u64, // 0 = NULL
num_actions: i32,
) -> (u32, Vec<f32>) {
// ── Allocate input buffers ──
let mut prices_dev = stream
.alloc_zeros::<f32>(N_WINDOWS * MAX_LEN * 4)
.expect("alloc prices");
let prices_host = flat_market_prices();
stream.memcpy_htod(&prices_host, &mut prices_dev).expect("HtoD prices");
let mut window_lens_dev = stream
.alloc_zeros::<i32>(N_WINDOWS)
.expect("alloc window_lens");
let window_lens_host = vec![MAX_LEN as i32; N_WINDOWS];
stream
.memcpy_htod(&window_lens_host, &mut window_lens_dev)
.expect("HtoD window_lens");
// Features buffer — sized `[n * max_len * feature_dim]`. With
// FEATURE_DIM=4 (< 41), the kernel's `compute_regime_trail_scales`
// takes the fallback (no feature deref), so zeros are safe.
let features_dev = stream
.alloc_zeros::<f32>(N_WINDOWS * MAX_LEN * FEATURE_DIM as usize)
.expect("alloc features");
let mut actions_dev = stream
.alloc_zeros::<i32>(CHUNK_LEN * N_WINDOWS)
.expect("alloc chunked_actions");
stream
.memcpy_htod(actions, &mut actions_dev)
.expect("HtoD chunked_actions");
let mut portfolio_dev = stream
.alloc_zeros::<f32>(N_WINDOWS * 8)
.expect("alloc portfolio");
let portfolio_host = init_portfolio_state(cfg.initial_capital);
stream
.memcpy_htod(&portfolio_host, &mut portfolio_dev)
.expect("HtoD portfolio");
let step_rewards_dev = stream
.alloc_zeros::<f32>(N_WINDOWS)
.expect("alloc step_rewards");
let step_returns_dev = stream
.alloc_zeros::<f32>(N_WINDOWS * MAX_LEN)
.expect("alloc step_returns");
let done_dev = stream
.alloc_zeros::<i32>(N_WINDOWS)
.expect("alloc done_flags");
let actions_history_dev = stream
.alloc_zeros::<i32>(N_WINDOWS * MAX_LEN)
.expect("alloc actions_history");
let kelly_stats_dev = stream
.alloc_zeros::<f32>(N_WINDOWS * 4)
.expect("alloc kelly_stats");
// Per-trade tape buffers (5 SoA + count + 1 new SoA for predicted_q).
let per_trade_pnl_dev = stream
.alloc_zeros::<f32>(N_WINDOWS * MAX_TRADES_PER_WINDOW as usize)
.expect("alloc per_trade_pnl");
let per_trade_holding_bars_dev = stream
.alloc_zeros::<u32>(N_WINDOWS * MAX_TRADES_PER_WINDOW as usize)
.expect("alloc per_trade_holding_bars");
let per_trade_bar_index_dev = stream
.alloc_zeros::<u32>(N_WINDOWS * MAX_TRADES_PER_WINDOW as usize)
.expect("alloc per_trade_bar_index");
let per_trade_dir_mag_dev = stream
.alloc_zeros::<u32>(N_WINDOWS * MAX_TRADES_PER_WINDOW as usize)
.expect("alloc per_trade_dir_mag");
let per_trade_count_dev = stream
.alloc_zeros::<u32>(N_WINDOWS)
.expect("alloc per_trade_count");
let per_trade_predicted_q_dev = stream
.alloc_zeros::<f32>(N_WINDOWS * MAX_TRADES_PER_WINDOW as usize)
.expect("alloc per_trade_predicted_q");
// Phase 1.5 entry_q persistent state — one float per window.
// Initialized to 0.0; the kernel writes it on open / reverse.
let entry_q_state_dev = stream
.alloc_zeros::<f32>(N_WINDOWS)
.expect("alloc entry_q_state");
// Scalar args.
let n_i32 = N_WINDOWS as i32;
let max_len_i32 = MAX_LEN as i32;
let start_step_i32 = 0_i32;
let chunk_len_i32 = CHUNK_LEN as i32;
let max_trades_i32 = MAX_TRADES_PER_WINDOW;
let feature_dim_i32 = FEATURE_DIM;
// ── Launch ──
let env_blocks = ((N_WINDOWS as u32) + 255) / 256;
let launch_cfg = LaunchConfig {
grid_dim: (env_blocks.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
};
unsafe {
stream
.launch_builder(kernel)
.arg(&prices_dev)
.arg(&window_lens_dev)
.arg(&features_dev)
.arg(&feature_dim_i32)
.arg(&actions_dev)
.arg(&portfolio_dev)
.arg(&step_rewards_dev)
.arg(&step_returns_dev)
.arg(&done_dev)
.arg(&actions_history_dev)
.arg(&n_i32)
.arg(&max_len_i32)
.arg(&cfg.max_position)
.arg(&cfg.tx_cost_bps)
.arg(&cfg.spread_cost)
.arg(&start_step_i32)
.arg(&chunk_len_i32)
.arg(&B0)
.arg(&B1)
.arg(&B2)
.arg(&B3)
.arg(&0.0_f32) // holding_cost_rate (unused per Phase 3)
.arg(&0.0_f32) // churn_threshold (unused)
.arg(&0.0_f32) // churn_penalty_scale (unused)
.arg(&cfg.contract_multiplier)
.arg(&cfg.margin_pct)
.arg(&0.0_f32) // opp_cost_scale (unused)
.arg(&kelly_stats_dev)
.arg(&0u64) // isv_signals NULL → health=0.5 fallback
.arg(&0u64) // exploration_scale_ptr NULL
.arg(&0u64) // shaping_scale_ptr NULL
.arg(&0u64) // conviction_ptr NULL → 1.0 fallback
.arg(&0u64) // magnitude_conviction_ptr NULL → 0.0 fallback
.arg(&per_trade_pnl_dev)
.arg(&per_trade_holding_bars_dev)
.arg(&per_trade_bar_index_dev)
.arg(&per_trade_dir_mag_dev)
.arg(&per_trade_count_dev)
.arg(&max_trades_i32)
// SP21 T2.2 Phase 1.5 args
.arg(&entry_q_state_dev)
.arg(&q_values_dev_ptr) // 0 = NULL for the NULL-tolerance test
.arg(&num_actions)
.arg(&per_trade_predicted_q_dev)
.launch(launch_cfg)
.expect("backtest_env_step_batch launch");
}
stream.synchronize().expect("post-launch sync");
// ── Read tape ──
let mut count_host = vec![0u32; N_WINDOWS];
stream
.memcpy_dtoh(&per_trade_count_dev, &mut count_host)
.expect("DtoH per_trade_count");
let mut predicted_q_host =
vec![0.0_f32; N_WINDOWS * MAX_TRADES_PER_WINDOW as usize];
stream
.memcpy_dtoh(&per_trade_predicted_q_dev, &mut predicted_q_host)
.expect("DtoH per_trade_predicted_q");
// Drop device buffers explicitly to free GPU memory before the
// next test runs (each #[test] gets its own isolated alloc set).
drop(prices_dev);
drop(window_lens_dev);
drop(features_dev);
drop(actions_dev);
drop(portfolio_dev);
drop(step_rewards_dev);
drop(step_returns_dev);
drop(done_dev);
drop(actions_history_dev);
drop(kelly_stats_dev);
drop(per_trade_pnl_dev);
drop(per_trade_holding_bars_dev);
drop(per_trade_bar_index_dev);
drop(per_trade_dir_mag_dev);
drop(per_trade_count_dev);
drop(per_trade_predicted_q_dev);
drop(entry_q_state_dev);
let count_w0 = count_host[0];
let predicted_q_w0: Vec<f32> = predicted_q_host[0..(count_w0 as usize)].to_vec();
(count_w0, predicted_q_w0)
}
/// Test 1: production path (real q_values) populates `predicted_q` from
/// the entry_q snapshot at trade open.
#[test]
#[ignore = "requires GPU"]
fn predicted_q_populated_on_close_with_real_q_values() {
let stream = match try_cuda_stream() {
Some(s) => s,
None => return,
};
let kernel = load_env_batch_kernel(&stream);
let cfg = KernelConfig::for_test();
let actions = open_hold_close_actions();
let open_action = actions[0];
// The Q-value we expect predicted_q to carry across the trade.
let expected_entry_q: f32 = 2.5;
let q_host = q_values_with_open_at(open_action, expected_entry_q);
let mut q_dev = stream
.alloc_zeros::<f32>(q_host.len())
.expect("alloc q_values");
stream.memcpy_htod(&q_host, &mut q_dev).expect("HtoD q_values");
let q_dev_ptr = {
let (ptr, _g) = q_dev.device_ptr(&stream);
ptr
};
let (count, predicted_q) = run_one_chunk(
&stream, &kernel, &cfg, &actions, q_dev_ptr, NUM_ACTIONS,
);
// Drop the q_values buffer ourselves — it's owned by this test only.
drop(q_dev);
assert_eq!(
count, 1,
"expected exactly 1 trade closed (open Long@step 0, Flat@step 3); got count={count}"
);
assert!(
!predicted_q.is_empty(),
"predicted_q tape should have ≥1 entry when count={count}"
);
let actual_q = predicted_q[0];
assert!(
(actual_q - expected_entry_q).abs() < Q_TOL,
"predicted_q[0] = {actual_q}, expected {expected_entry_q} (entry_q captured from \
q_values_per_window[w=0, a={open_action}] at step-0 open and snapshotted before the \
step-3 close emission)"
);
}
/// Test 2: NULL `q_values_per_window` (single-step `evaluate()` semantics)
/// keeps `predicted_q` at the buffer-init 0.0 value. The kernel's
/// open-time write is gated on the NULL guard at the bottom of the
/// per-step loop, so `entry_q_state[w]` stays 0.0 and the close emit
/// snapshots 0.0 into the tape.
#[test]
#[ignore = "requires GPU"]
fn predicted_q_stays_zero_when_q_values_is_null() {
let stream = match try_cuda_stream() {
Some(s) => s,
None => return,
};
let kernel = load_env_batch_kernel(&stream);
let cfg = KernelConfig::for_test();
let actions = open_hold_close_actions();
// q_values_per_window = NULL (0u64). num_actions = 0 so the kernel's
// open-time guard `num_actions > 0` short-circuits independently.
let (count, predicted_q) = run_one_chunk(
&stream, &kernel, &cfg, &actions, 0u64, 0,
);
assert_eq!(
count, 1,
"trade still closes regardless of q_values availability; got count={count}"
);
let actual_q = predicted_q[0];
assert!(
actual_q.abs() < Q_TOL,
"predicted_q[0] = {actual_q}, expected ~0.0 (NULL q_values_per_window means the \
kernel skips the entry_q write at open; the persistent entry_q_state[w] stays at \
buffer-init 0.0 and the close emission snapshots 0.0 into the tape)"
);
}
/// Test 3: explicit verification that the entry_q write is GATED on
/// `is_open || is_reverse_open` — a sequence with NO open (e.g.,
/// flat-flat-flat-flat) should produce zero trades and never touch
/// `entry_q_state`. Provides an additional path-coverage check beyond
/// tests (1) and (2).
#[test]
#[ignore = "requires GPU"]
fn no_trades_no_predicted_q_emission() {
let stream = match try_cuda_stream() {
Some(s) => s,
None => return,
};
let kernel = load_env_batch_kernel(&stream);
let cfg = KernelConfig::for_test();
// All Flat actions: no position ever opens, no close emission.
let a_flat = encode_action(DIR_FLAT, MAG_QUARTER, 0, 1);
let actions = vec![a_flat; CHUNK_LEN];
let q_host = q_values_with_open_at(0, 99.9); // won't be read
let mut q_dev = stream
.alloc_zeros::<f32>(q_host.len())
.expect("alloc q_values");
stream.memcpy_htod(&q_host, &mut q_dev).expect("HtoD q_values");
let q_dev_ptr = {
let (ptr, _g) = q_dev.device_ptr(&stream);
ptr
};
let (count, _predicted_q) = run_one_chunk(
&stream, &kernel, &cfg, &actions, q_dev_ptr, NUM_ACTIONS,
);
drop(q_dev);
assert_eq!(
count, 0,
"all-Flat action sequence should yield zero trades; got count={count}"
);
}
}