Files
foxhunt/crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs
jgrusewski 110d3b4125 chore(ml): delete dead imports, parens, and the unused MappedI32::read
Cleanup of compiler warnings flagged by both local cargo check and the
cluster ensure-binary log. Per `feedback_no_hiding`, every site is
either deleted or wired up — no #[allow] suppressions.

Lib (5 sites):
- gpu_backtest_evaluator.rs:34 — drop unused DevicePtrMut.
- gpu_dqn_trainer.rs:49 — drop unused DevicePtrMut (8 device_ptr_mut
  calls don't need the trait import in current cudarc). Line 19852:
  drop unnecessary parens around `b * sh2`.
- training_loop.rs:20 — drop unused DevicePtrMut; unbrace single-
  symbol use at 5766.
- state_reset_registry.rs:4 — delete the 10-symbol use-block of slot
  constants. Names appear in description strings (documentation only),
  symbols are never referenced.

Examples (3 sites):
- alpha_dqn_h600_smoke.rs:181, 186 — drop COL_RAW_CLOSE, FEAT_DIM,
  FillCoeffs, FillModel imports.
- alpha_baseline.rs:79 — delete unused MappedI32::read. Batched path
  uses read_all for N-element action readback; the single-element
  method was leftover from the pre-batched legacy path.

Lib + examples now have zero removable warnings. The remaining
unsafe_block lints (each cudarc kernel launch needs unsafe) are
structural and not actionable under the project's -W unsafe-code
policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 10:20:03 +02:00

4137 lines
209 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.
#![allow(unsafe_code)]
//! Vectorized GPU backtest evaluator — single-stream sequential execution.
//!
//! Runs walk-forward evaluation entirely on GPU:
//! 1. Upload test window data once (prices + features)
//! 2. Step loop: GPU gather kernel → forward pass → env kernel (sequential on one stream)
//! 3. Metrics reduction kernel → single readback
//!
//! Four forward-pass paths:
//! - **`evaluate()`**: Generic closure-based path (Candle tensors). Works for any model.
//! - **`evaluate_dqn()`**: Pure-CUDA forward kernel path. Eliminates Candle dispatch
//! overhead by running `q_forward_dueling_warp_shmem` directly via NVRTC. Enables
//! future CUDA Graph capture.
//! - **`evaluate_ppo()`**: Pure-CUDA PPO actor forward + softmax + exposure collapse.
//! - **`evaluate_supervised()`**: Candle forward + CUDA signal→action conversion.
//!
//! The only GPU→CPU transfer is the final metrics readback (n_windows × 10 floats).
//! All per-step data (states, actions, rewards, action history) stays GPU-resident.
//! Done-flags are checked per-thread inside the env kernel (no mid-loop downloads).
//!
//! ## Single-stream design
//!
//! All kernels run sequentially on a single CUDA stream:
//! gather_states → forward pass → env_step → repeat
//!
//! The previous dual-stream design with CudaEvent synchronization added overhead
//! that negated any parallelism benefit on H100 (forward pass already occupies
//! 100+ SMs, leaving no room for env_step overlap). Removing the second stream
//! and event sync yields a 15-20% wall-time reduction.
use std::mem::ManuallyDrop;
use std::sync::Arc;
use cudarc::driver::{CudaEvent, CudaFunction, CudaGraph, CudaSlice, CudaStream, DevicePtr, LaunchConfig, PushKernelArg};
use tracing::info;
use ml_core::nvtx::NvtxRange;
use crate::MLError;
use super::gpu_weights::{BranchingWeightSet, DuelingWeightSet, PpoActorWeightSet};
use super::lob_bar::LobBar;
use super::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
// ── CUDA Graph wrapper ───────────────────────────────────────────────────────
//
// `CudaGraph` is `!Send + !Sync` because it contains raw CUDA pointers.
// Our `GpuBacktestEvaluator` is used single-threaded (created, used, and
// dropped on the same thread that owns the CUDA context), but all other
// fields are `Send + Sync` and existing callers rely on that.
//
// We wrap `CudaGraph` in a newtype with manual `Send + Sync` to preserve
// the struct's auto-derived `Send + Sync` status.
//
// # Safety
// The graph is only launched on the same stream/context that created it.
// The evaluator is not shared across threads in practice.
struct SendSyncGraph(CudaGraph);
// Safety: CudaGraph is bound to a specific CUDA context. The evaluator
// that owns it is always used from the thread that created the context.
// No concurrent access occurs.
unsafe impl Send for SendSyncGraph {}
// Safety: same reasoning — single-owner, no concurrent launch calls.
unsafe impl Sync for SendSyncGraph {}
// ── Precompiled kernel cubins ──────────────────────────────────────────────────
static ENV_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/backtest_env_kernel.cubin"));
static METRICS_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/backtest_metrics_kernel.cubin"));
static PPO_FORWARD_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/backtest_forward_ppo_kernel.cubin"));
static SUPERVISED_SIGNAL_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/backtest_forward_supervised_kernel.cubin"));
static BACKTEST_DQN_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/experience_kernels.cubin"));
/// Val plan_isv parity kernels (task #94, 2026-04-24).
static BACKTEST_PLAN_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/backtest_plan_kernel.cubin"));
/// Number of timesteps to batch per cuBLAS forward call in the chunked step loop.
///
/// With n_windows=5 and CHUNK_SIZE=64, each cuBLAS SGEMM operates on [320, state_dim]
/// instead of [5, state_dim] -- 64x better SM utilisation. Reduces total kernel
/// launches from ~576K to ~74K (7.8x fewer) for a 32K-step backtest.
///
/// Portfolio features (3 out of 48 dims) are stale within each chunk. For a
/// 512-step chunk at 1-minute bars, that is ~8.5 hours of stale portfolio data.
/// The Q-network primarily responds to the 42 market features; portfolio
/// features (position_size, unrealized_pnl, drawdown) have <2% feature
/// importance in causal sensitivity analysis. 8× fewer cuBLAS forward passes
/// (301 vs 2406 for 154K bars) outweighs the marginal staleness.
const DQN_BACKTEST_CHUNK_SIZE: usize = 512;
/// Per-window Kelly-stats buffer width (f32 slots). Stored in a buffer
/// separate from `portfolio_buf` so the `backtest_state_gather` kernel's
/// stride-8 indexing into portfolio state stays correct while the env
/// kernel still has the stats it needs for the shared `apply_kelly_cap`
/// and `record_kelly_trade_outcome` helpers in `trade_physics.cuh`.
/// Slots: win_count, loss_count, sum_wins, sum_losses. Must equal
/// `KELLY_STATS_SIZE` in `backtest_env_kernel.cu`.
const BACKTEST_KELLY_STATS_SIZE: usize = 4;
/// SP21 T2.2 Phase 1 (2026-05-10) — per-trade tape capacity per window.
/// 200_000 trades is a conservative upper bound: typical eval windows are
/// 200k bars (xmd6b: window_bars=214654) with at most ~1 close per bar
/// (observed trade_count=139251 at trades_per_bar=0.65). Setting capacity
/// equal to the bar count guarantees no trade is dropped under any
/// trading frequency. Memory cost per window: 4 × 4 bytes × 200k = 3.2MB
/// (5 × 4 with the per-window count u32). For typical n_windows=5
/// walk-forward setups: 16MB total — acceptable for eval (fold-boundary
/// alloc, not per-step). The kernel guards against overflow via
/// `if (slot < max_trades_per_window)` so larger-than-expected windows
/// gracefully truncate the tape instead of crashing.
pub const MAX_TRADES_PER_WINDOW: usize = 200_000;
/// SP21 T2.2 Phase 1 (2026-05-10) — host-side per-trade record decoded
/// from the GPU per-trade tape buffers. The kernel writes the SoA tape;
/// `read_per_trade_tape()` decodes packed `dir_mag` and zips the slices
/// into `Vec<EvalTrade>` for the enrichment phase. Field order matches
/// what the enrichment functions in `trainer/enrichment.rs` consume.
#[derive(Debug, Clone, Copy)]
pub struct EvalTrade {
/// SP21 T2.2 Phase 6.5 (2026-05-11) — eval-window membership.
/// Used by `compute_hindsight_labels` to recover which window's
/// state buffer to look up when synthesizing a counterfactual
/// replay tuple from `bar_index`. Set host-side by
/// `read_per_trade_tape` from the read loop's `w` variable —
/// the GPU per-trade tape buffers don't carry this (each window
/// writes to its own SoA slot, and the flatten loses the index).
pub window_index: u32,
/// Global bar index (0-based step within the window) when the trade
/// CLOSED. Used by E6 (winner indices) for priority-replay tagging.
pub bar_index: u32,
/// Realized per-trade return = `prev_position × (close entry_price)
/// / pre_trade_equity`. Dimensionless (fraction of equity). Used by
/// E1 (Q-correction), E5 (agreement-Sharpe), E6/E7/E8.
pub pnl: f32,
/// SP21 T2.2 Phase 1.5 (2026-05-11) — predicted Q-value at trade
/// open. Captured by the env kernel from `q_values_per_window[w *
/// num_actions + action_taken]` at the open / reversal that started
/// this trade. Consumed by E1 (`compute_q_correction`: predicted_q
/// pnl bias). 0.0 for trades closed under callers that don't
/// wire `q_values_per_window` (single-step `evaluate()` for
/// non-DQN paths).
pub predicted_q: f32,
/// Trade duration in bars (capture of `hold_time` BEFORE close). Used
/// by E3 (gamma from holding-bars) and E4 (urgency-error rate).
pub holding_bars: u32,
/// Direction at close: 0=Short, 1=Hold, 2=Long, 3=Flat. Decoded from
/// the kernel-packed `dir_mag` (hi-16 bits).
pub direction: u8,
/// Magnitude at close: 0=Quarter, 1=Half, 2=Full. Decoded from
/// `dir_mag` (lo-16 bits).
pub magnitude: u8,
}
// ── DQN backtest forward config ───────────────────────────────────────────────
/// Configuration for cuBLAS-based DQN forward pass in backtest evaluation.
///
/// Bundles network architecture dimensions, C51 distributional parameters,
/// and branching configuration needed by `evaluate_dqn()`.
#[derive(Debug, Clone)]
pub struct DqnBacktestConfig {
/// Shared trunk hidden layer 1 dimension.
pub shared_h1: usize,
/// Shared trunk hidden layer 2 dimension.
pub shared_h2: usize,
/// Value head hidden dimension.
pub value_h: usize,
/// Advantage head hidden dimension.
pub adv_h: usize,
/// C51 distributional atom count (typically 51).
pub num_atoms: usize,
/// Direction branch action count (branch 0, default 4 — Short/Hold/Long/Flat).
pub branch_0_size: usize,
/// Magnitude branch action count (branch 1, default 3 — 25%/50%/100%).
pub branch_1_size: usize,
/// Order-type branch action count (branch 2, default 3).
pub branch_2_size: usize,
/// Urgency branch action count (branch 3, default 3 — Patient/Normal/Aggressive).
pub branch_3_size: usize,
/// C51 minimum support value.
pub v_min: f32,
/// C51 maximum support value.
pub v_max: f32,
}
impl DqnBacktestConfig {
/// Create from the legacy `network_dims` tuple with standard C51 defaults.
///
/// Uses branch sizes [4, 3, 3, 3], num_atoms=51, v_range from reward_scale=10/gamma=0.95.
pub fn from_network_dims(network_dims: (usize, usize, usize, usize)) -> Self {
// v_min/v_max computed from reward_scale=10, gamma=0.95:
// Q* ~ 10 / (1-0.95) * 1.2 = 240, clamped to [20, 300] → ±240
let v_range = (10.0_f32 / (1.0 - 0.95) * 1.2).clamp(20.0, 300.0);
Self {
shared_h1: network_dims.0,
shared_h2: network_dims.1,
value_h: network_dims.2,
adv_h: network_dims.3,
num_atoms: 51,
branch_0_size: 4,
branch_1_size: 3,
branch_2_size: 3,
branch_3_size: 3,
v_min: -v_range,
v_max: v_range,
}
}
}
// ── Public types ──────────────────────────────────────────────────────────────
/// Per-window evaluation result returned after a full backtest run.
#[derive(Debug, Clone)]
pub struct WindowMetrics {
pub sharpe: f32,
pub total_pnl: f32,
pub max_drawdown: f32,
pub sortino: f32,
pub win_rate: f32,
pub total_trades: f32,
// Phase 3 extended metrics
pub var_95: f32,
pub cvar_95: f32,
pub calmar: f32,
pub omega_ratio: f32,
// Action distribution (GPU-counted, indices 10-12)
pub buy_count: f32,
pub sell_count: f32,
pub hold_count: f32,
// Unique action IDs observed (popcount of 9-bit mask, index 13)
pub unique_actions: f32,
// SP15 Wave 3b (2026-05-06) — cost-net sharpe (per spec §6.2) and
// four constant-policy counterfactual baselines (§6.4). All 5 fields
// are annualised host-side in `consume_metrics_after_event` by
// multiplying the kernel's `raw_sharpe` by `self.annualization_factor`
// (matches `sharpe`'s annualisation pattern from Phase 1.1.b).
pub sharpe_cost_net: f32,
pub baseline_buyhold_sharpe: f32,
pub baseline_hold_only_sharpe: f32,
pub baseline_momentum_sharpe: f32,
pub baseline_reversion_sharpe: f32,
// 2026-05-09 — per-regime WR instrumentation (audit follow-up).
// Trades are bucketed at trade-OPEN by the bar's ADX-norm value
// (feature[40], post-`RollingPercentileRank`) per
// `gpu_walk_forward.rs::classify_regime_from_features`:
// - Trending (T): ADX > 0.4
// - Ranging (R): ADX < 0.2
// - Volatile (V): otherwise
// `win_rate_*` is `wins / trades` per bucket (0.0 when zero trades,
// matching the aggregate `win_rate` empty-trade convention).
pub win_rate_trending: f32,
pub trade_count_trending: f32,
pub win_rate_ranging: f32,
pub trade_count_ranging: f32,
pub win_rate_volatile: f32,
pub trade_count_volatile: f32,
}
/// Configuration for the GPU backtest evaluator.
#[derive(Debug, Clone)]
pub struct GpuBacktestConfig {
pub max_position: f32,
pub tx_cost_bps: f32,
pub spread_cost: f32,
pub initial_capital: f32,
/// Contract multiplier for futures (e.g., 50 for ES, 20 for NQ).
/// Used with `max_leverage` to compute leverage-safe position limits.
pub contract_multiplier: f32,
/// Maximum leverage ratio relative to capital (e.g., 2.0 = 2× leverage).
/// When > 0, caps effective position at `capital * max_leverage / (price * multiplier)`.
pub max_leverage: f32,
/// Number of OFI features embedded after market features in the feature vector.
/// When > 0, the gather kernel reorders output to `[market, portfolio, OFI, pad]`
/// (model training order) instead of `[market, OFI, portfolio, pad]` (storage order).
/// Typical value: 8 (VPIN, Kyle's Lambda, OFI L1/L5, depth imbalance, bid/ask slope,
/// trade imbalance). Default: 0 (no OFI).
pub ofi_dim: usize,
/// Number of bars in one trading day (390 for 1-minute, 1 for daily).
/// Used to compute the correct Sharpe annualization: `sqrt(bars_per_day * 252)`.
/// Default: 390.0 (1-minute bar frequency).
pub bars_per_day: f32,
/// Per-step subsample stride applied to the upstream return series
/// (validation backtest subsamples every Nth bar at
/// `crates/ml/src/trainers/dqn/trainer/metrics.rs:517-518`). The
/// constructor divides `bars_per_day` by this stride before computing
/// `annualization_factor` so reported Sharpe/Sortino/Calmar reflect the
/// effective sampling cadence of the per-step return series, not the
/// raw bar cadence. Default: 1 (no subsampling). MUST be >= 1.
/// Per audit `docs/lookahead-bias-audit-2026-04-28.md` §5: prior code
/// used the unscaled `bars_per_day` for an internally subsampled series
/// and reported sharpe inflated by `sqrt(stride)` (= 2 for stride=4).
pub val_subsample_stride: u32,
/// Holding cost rate per bar per unit position. Continuous inventory penalty.
/// Default: 0.00000052 (~0.52 bps / 100 bars)
pub holding_cost_rate: f32,
/// Bars threshold for churn penalty. Trades within this window get graduated penalty.
pub churn_threshold: f32,
/// Scale factor for churn penalty.
pub churn_penalty_scale: f32,
/// Opportunity cost scale for flat position penalty.
/// Penalty = |q_spread| * opp_cost_scale when position ≈ 0.
/// Set to 0.0 during backtest evaluation (no Q-gap available post-hoc).
pub opportunity_cost_scale: f32,
/// Initial margin as fraction of notional value.
/// CME initial margin ~6% of notional for equity index futures.
/// Default: 0.06.
pub margin_pct: f32,
/// Trading days per year for annualization. CME equity/futures: 252. Crypto: 365.
pub trading_days_per_year: f32,
}
impl Default for GpuBacktestConfig {
fn default() -> Self {
Self {
max_position: 1.0,
tx_cost_bps: 0.1,
spread_cost: 0.0001,
initial_capital: 100_000.0,
contract_multiplier: 50.0,
max_leverage: 2.0,
ofi_dim: 0,
bars_per_day: 390.0, // 1-minute bar frequency (6.5h × 60min)
holding_cost_rate: 0.00000052,
churn_threshold: 10.0,
churn_penalty_scale: 0.001,
opportunity_cost_scale: 0.0, // disabled during backtest: no Q-gap available
trading_days_per_year: 252.0,
margin_pct: 0.06,
val_subsample_stride: 1,
}
}
}
impl GpuBacktestConfig {
/// Compute leverage-safe max position given a reference price.
///
/// Returns `min(max_position, capital * max_leverage / (price * multiplier))`.
/// If `max_leverage <= 0` or `price <= 0`, returns `max_position` uncapped.
pub fn effective_max_position(&self, reference_price: f32) -> f32 {
if self.max_leverage <= 0.0 || reference_price <= 0.0 || self.contract_multiplier <= 0.0 {
return self.max_position;
}
let notional_per_contract = reference_price * self.contract_multiplier;
let leverage_cap = self.initial_capital * self.max_leverage / notional_per_contract;
self.max_position.min(leverage_cap)
}
}
// ── Main struct ───────────────────────────────────────────────────────────────
/// GPU backtest evaluator — runs walk-forward evaluation without CPU roundtrips.
///
/// Upload window data once via `new()`, then call `evaluate()` with a model
/// forward function. The entire step loop runs on GPU; only the final metrics
/// are downloaded (n_windows × 14 floats).
///
/// All kernels execute sequentially on a single CUDA stream:
/// ```text
/// gather_states → forward pass → env_step → repeat
/// ```
#[allow(missing_debug_implementations)]
pub struct GpuBacktestEvaluator {
stream: Arc<CudaStream>,
env_kernel: CudaFunction,
env_batch_kernel: CudaFunction,
metrics_kernel: CudaFunction,
gather_kernel: CudaFunction,
/// Chunked gather: writes `[chunk_len, N, padded_sd]` rows into
/// `chunked_states_buf` in a single launch. Replaces the per-step
/// `gather_kernel` + DtoD-into-chunk loop in `submit_dqn_step_loop_cublas`.
/// Cuts per-chunk launches from `2 * chunk_len` (gather + DtoD) to **1**.
/// Loaded from the same `experience_kernels.cubin` as `gather_kernel`;
/// the per-step `gather_kernel` is retained for `evaluate()` /
/// `evaluate_ppo()` / `evaluate_supervised()` where the closure-based
/// caller still needs a single-step output.
gather_chunk_kernel: CudaFunction,
/// SP15 Wave 5 follow-up (2026-05-07): pre-loaded handle for the
/// `cost_net_sharpe_kernel` (cost-net Sharpe per-window). Originally
/// `launch_sp15_cost_net_sharpe` resolved the symbol via `load_cubin`
/// + `load_function` ON EVERY CALL inside the per-window eval hot loop
/// (`gpu_backtest_evaluator.rs:2877`), which is fragile across CUDA
/// context lifetimes — works in single-pass train-best context (smoke
/// `train-9bcwm` verified), fails in hyperopt-trial child stream
/// context (workflow `train-xggfc` trial 1 failed at `load
/// sp15_baseline_kernels cubin: ILLEGAL_ADDRESS`; after the host-side
/// load corrupted trial 1's context, trials 2-20 cascade-failed at
/// `Fork CUDA stream for trial`). Pre-loading once in `new()` mirrors
/// the `5d63762ab` precedent fix for `bn_tanh_concat_dd_kernel`.
sp15_cost_net_sharpe_kernel: CudaFunction,
/// SP15 Wave 5 follow-up (2026-05-07): pre-loaded handle for
/// `baseline_buyhold_kernel` (counterfactual buyhold baseline). See
/// `sp15_cost_net_sharpe_kernel` docstring for root-cause / precedent.
sp15_baseline_buyhold_kernel: CudaFunction,
/// SP15 Wave 5 follow-up (2026-05-07): pre-loaded handle for
/// `baseline_hold_only_kernel` (counterfactual hold-only baseline).
/// See `sp15_cost_net_sharpe_kernel` docstring for root-cause /
/// precedent.
sp15_baseline_hold_only_kernel: CudaFunction,
/// SP15 Wave 5 follow-up (2026-05-07): pre-loaded handle for
/// `baseline_naive_momentum_kernel` (counterfactual naive-momentum
/// baseline). See `sp15_cost_net_sharpe_kernel` docstring for
/// root-cause / precedent.
sp15_baseline_naive_momentum_kernel: CudaFunction,
/// SP15 Wave 5 follow-up (2026-05-07): pre-loaded handle for
/// `baseline_naive_reversion_kernel` (counterfactual naive-reversion
/// baseline). See `sp15_cost_net_sharpe_kernel` docstring for
/// root-cause / precedent.
sp15_baseline_naive_reversion_kernel: CudaFunction,
// Uploaded data (read-only; persists across the step loop).
// MappedF32/I32Buffer (cuMemHostAlloc DEVICEMAP) — host writes via
// `host_ptr.write_from_slice(...)`, kernels read via `dev_ptr` after a
// stream-sync barrier. Zero-copy CPU↔GPU path per
// `feedback_no_htod_htoh_only_mapped_pinned.md`. The DtoD-via-pinned
// helpers are forbidden by the same-day pre-commit guard
// (`scripts/pre-commit-hook.sh::check_no_dtod_via_pinned`, commit
// `5275932f4`) — direct mapped-pinned storage replaces them.
prices_buf: MappedF32Buffer, // [n_windows * max_len * 4] raw OHLC
features_buf: MappedF32Buffer, // [n_windows * max_len * feat_dim] z-normed features
window_lens_buf: MappedI32Buffer, // [n_windows]
// Mutable state (written by kernels each step). MappedF32Buffer's
// `dev_ptr` aliases the same pinned pages as `host_ptr`, so kernels
// can read AND write through `dev_ptr` (see mapped_pinned.rs module
// docstring + `plan_diag_buf` precedent in this file). The host can
// refresh `portfolio_buf` between epochs via `write_from_slice` —
// see `reset_evaluation_state` below.
portfolio_buf: MappedF32Buffer, // [n_windows * 8]
/// Kelly win/loss stats — separate from portfolio_buf so the gather
/// kernel's stride-8 indexing stays correct. [n_windows * 4].
kelly_stats_buf: CudaSlice<f32>,
/// Plan/ISV features per window [n_windows * 7 f32]: plan_progress, pnl_vs_target, pnl_vs_stop, conviction, conviction_drift, regime_shift, remaining_fraction (D.6)
plan_isv_buf: CudaSlice<f32>,
/// Val plan_isv parity (task #94, 2026-04-24). Live plan_params from
/// the plan MLP, filled each step by `QValueProvider::compute_plan_params`
/// and consumed by `backtest_plan_state_isv`. Layout [N, 6]:
/// {target_bars, profit_target, stop_loss, scale_aggression,
/// conviction, asymmetry}. Zero-initialised so the first step
/// before any plan MLP call sees safe defaults.
plan_params_buf: CudaSlice<f32>,
/// Val plan_isv parity: persistent plan state across bars within a
/// trade. Layout [N, 7]: {target_bars, profit_target, stop_loss,
/// scale_aggression, conviction_at_entry, asymmetry,
/// entry_regime_stability}. Activated on Flat→Positioned transitions
/// (via the live plan_params), zeroed on return to Flat. Plays the
/// role training's ps[23..29] plays in the experience buffer.
plan_state_buf: CudaSlice<f32>,
/// Val plan_isv parity diagnostic scratch [8 f32]: {active_fraction,
/// mean_|isv[0..2]|, mean_isv[3], mean_|isv[4..5]|, raw_active_count}.
/// Written by `backtest_plan_diag_reduce` at epoch boundary, then read by
/// the host via mapped-pinned `host_ptr` for HEALTH_DIAG emission.
/// Mapped pinned (`cuMemHostAlloc DEVICEMAP`) so the kernel writes through
/// the device alias and the host reads via volatile load — zero DtoH copy
/// per `feedback_no_htod_htoh_only_mapped_pinned.md`. Caller synchronises
/// the eval stream before reading.
plan_diag_buf: MappedF32Buffer,
step_rewards_buf: CudaSlice<f32>, // [n_windows]
step_returns_buf: CudaSlice<f32>, // [n_windows * max_len]
done_buf: CudaSlice<i32>, // [n_windows]
actions_buf: CudaSlice<i32>, // [n_windows]
actions_history_buf: CudaSlice<i32>, // [n_windows * max_len]
/// SP21 T2.2 Phase 1 (2026-05-10) — per-trade tape device buffers.
/// Written by `backtest_env_step` / `backtest_env_step_batch` at
/// trade-close events (single thread per window writes its own slot
/// — race-free counter increment without atomicAdd per
/// `feedback_no_atomicadd`). Read host-side via
/// `read_per_trade_tape()` after each eval window completes; the
/// resulting `Vec<EvalTrade>` is the authoritative per-trade signal
/// for the enrichment phase (E1-E8) — replaces the prior
/// fake-trade synthesizer (`extract_eval_trades_from_metrics`)
/// per `feedback_no_stubs`. Reset to zero at `reset_per_trade_tape()`
/// at the start of each eval window.
///
/// Layout: `[n_windows × MAX_TRADES_PER_WINDOW]` for the SoA buffers,
/// `[n_windows]` for the count. SoA chosen over AoS for GPU coalescing
/// (each kernel thread writes one slot per buffer at the same index).
/// `dir_mag` packs direction (hi-16) and magnitude (lo-16) so the
/// per-trade emission stays at 4 buffers + count.
per_trade_pnl_buf: CudaSlice<f32>, // [n_windows * MAX_TRADES_PER_WINDOW]
per_trade_holding_bars_buf: CudaSlice<u32>, // [n_windows * MAX_TRADES_PER_WINDOW]
per_trade_bar_index_buf: CudaSlice<u32>, // [n_windows * MAX_TRADES_PER_WINDOW]
per_trade_dir_mag_buf: CudaSlice<u32>, // [n_windows * MAX_TRADES_PER_WINDOW]
per_trade_count_buf: CudaSlice<u32>, // [n_windows]
/// SP21 T2.2 Phase 1.5 (2026-05-11) — predicted Q-value at trade open.
/// 6th SoA buffer in the per-trade tape, parallel layout to
/// `per_trade_pnl_buf`. Written by both backtest kernels at trade
/// close (the `pre_entry_q` snapshot read from `entry_q_state_buf`).
/// Consumed host-side by `read_per_trade_tape()` and feeds E1
/// (`compute_q_correction`: predicted_q pnl bias).
per_trade_predicted_q_buf: CudaSlice<f32>, // [n_windows * MAX_TRADES_PER_WINDOW]
/// SP21 T2.2 Phase 1.5 (2026-05-11) — per-window persistent storage
/// of the predicted Q-value at the OPEN of the currently-active
/// trade. Written by both backtest kernels on fresh open or
/// reversal (kernel reads `q_values_per_window[w * num_actions +
/// action_taken]`); read at the next close to populate the
/// per-trade tape's `predicted_q` field. Single float per window
/// — `entry_q` is per-trade, not per-bar, so one live slot is
/// enough at any time (a closed trade's entry_q is already on
/// the tape; the slot is overwritten on the next open).
/// Zeroed in `reset_evaluation_state()` so cross-fold trades
/// don't bleed stale entry_q values.
entry_q_state_buf: CudaSlice<f32>, // [n_windows]
/// Intent magnitude history — pure-policy magnitude argmax from each
/// eval step, recorded BEFORE the Hold/Flat dir_idx forcing in the
/// action-select kernel and BEFORE Kelly/margin caps modify exposure
/// inside env_step. Parallel diagnostic layout to `actions_history_buf`:
/// `[n_windows * max_len]`, indexed `w * max_len + step`. Populated
/// only when `evaluate_dqn_graphed` runs — stays zero otherwise.
/// `None` until `ensure_action_select_ready` allocates it.
intent_mag_buf: Option<CudaSlice<i32>>,
/// Persistent per-step history of the policy's RAW factored action picks
/// (PRE-env_step). `[n_windows, max_len]` window-major, parallel layout to
/// `actions_history_buf`. Filled by scattering `chunked_actions_buf` into
/// the corresponding step slots after each chunk's `experience_action_select`
/// (reuses the existing `scatter_intent_chunk` kernel — same scatter
/// semantics, different source/destination buffers). Diagnostic-only:
/// pairs with `actions_history_buf` (POST-env_step `actual_dir`) so the
/// reader can compare the kernel's intent vs the env's realisation
/// across the full val window — localises whether eval-mode collapse is
/// upstream (kernel produces collapsed picks) or downstream (env_step
/// drains active picks to Flat). Reset to zero by `reset_evaluation_state`.
picked_action_history_buf: Option<CudaSlice<i32>>,
// Gather kernel output buffer (overwritten every step)
states_buf: CudaSlice<f32>, // [n_windows * state_dim] (8-aligned, f32 for cublasSgemm)
// Output buffer (written by metrics kernel) — mapped pinned host memory
// visible to the GPU through `dev_ptr` (`cuMemHostAlloc DEVICEMAP`). The
// kernel writes through the device alias; the host reads via volatile
// load on `host_ptr` after stream synchronisation. No DtoH copy — the
// only allowed CPU↔CPU path per
// `feedback_no_htod_htoh_only_mapped_pinned.md`. Layout `[n_windows * 13]`
// matches the kernel's flat per-window stride after SP15 Phase 1.1.b
// (sharpe split out into the dedicated `sharpe_per_bar_kernel`; see
// `sharpe_buf` below).
metrics_buf: MappedF32Buffer,
/// SP15 Phase 1.1.b (2026-05-06): per-window output of the dedicated
/// `sharpe_per_bar_kernel` (`launch_sp15_sharpe_per_bar`). Layout
/// `[n_windows * 3]`: `(mean, std, raw_sharpe)` per window. Host-side
/// annualisation in `consume_metrics_after_event` multiplies
/// `raw_sharpe` by `annualization_factor` to produce
/// `WindowMetrics.sharpe`, preserving the value contract from the
/// pre-split fused kernel (which applied the same factor in-kernel).
/// Mapped-pinned per `feedback_no_htod_htoh_only_mapped_pinned.md`.
sharpe_buf: MappedF32Buffer,
// ── SP15 Wave 3b (2026-05-06) — val-cost-streams buffers ─────────────────
//
// Three input LobBar SoA streams populated at construction time from
// `window_lob_bars`, three derivation outputs filled by
// `launch_sp15_position_history_derivation` after the eval rollout, and
// five per-window `[mean, std, raw_sharpe]` outputs from the cost-net
// sharpe + 4 constant-policy baselines. All mapped-pinned per
// `feedback_no_htod_htoh_only_mapped_pinned.md`. Layout matches the
// 1.1.b sharpe_per_bar pattern (3 f32 slots per window for the metric
// outputs; flat `[n_windows * max_len]` for the stream inputs +
// derivation outputs).
/// LobBar.price stream `[n_windows * max_len]` — feeds the 4 baselines.
/// Populated at construct time via `write_from_slice` from the
/// `window_lob_bars` parameter. Read-only thereafter.
close_prices_buf: MappedF32Buffer,
/// LobBar.spread / 2 stream `[n_windows * max_len]` — feeds cost-net
/// sharpe + 4 baselines (per spec §6.2 the cost-net kernel multiplies
/// `half_spread × |position| × side_ind` so the +entry+exit pair
/// accumulates one full spread per round-trip). Populated at construct
/// time. Read-only thereafter.
half_spread_buf: MappedF32Buffer,
/// LobBar.ofi stream `[n_windows * max_len]` — feeds cost-net sharpe.
/// PPO hyperopt path lacks per-bar OFI features so populates this with
/// 0.0 for all bars (cost-net OFI-impact term degrades to 0 on PPO;
/// commission + half-spread × position still apply); DQN adapter and
/// val_evaluator construct with the real per-bar OFI value.
ofi_scalar_buf: MappedF32Buffer,
/// SP15 Wave 3b — derivation output `[n_windows * max_len]` populated
/// post-rollout by `launch_sp15_position_history_derivation`. Per-bar
/// derived position ∈ {-1.0, 0.0, +1.0} reconstructed from the recorded
/// factored-action history. Consumed by cost-net sharpe.
position_history_buf: MappedF32Buffer,
/// SP15 Wave 3b — derivation output `[n_windows * max_len]`. Per-bar
/// 1.0 when position changed from prev bar (entry OR exit), else 0.0.
/// Consumed by cost-net sharpe (`half_spread × |pos| × side_ind` and
/// `ofi_lambda × |pos| × |ofi| × side_ind` per spec §6.2).
side_ind_buf: MappedF32Buffer,
/// SP15 Wave 3b — derivation output `[n_windows * max_len]`. Per-bar
/// 1.0 when transitioning to flat from a non-flat position
/// (round-trip close), else 0.0. Consumed by cost-net sharpe
/// (`commission_per_rt × rt_ind` per spec §6.2).
rt_ind_buf: MappedF32Buffer,
/// SP15 Wave 3b — per-window `[mean_pnl_net, std, raw_sharpe_cost_net]`
/// emitted by `launch_sp15_cost_net_sharpe`. Annualised host-side in
/// `consume_metrics_after_event` to produce `WindowMetrics.sharpe_cost_net`.
/// Layout `[n_windows * 3]`. Mapped-pinned.
cost_net_sharpe_buf: MappedF32Buffer,
/// SP15 Wave 3b — per-window `[mean, std, raw_sharpe]` from the buyhold
/// counterfactual baseline (constant +1 position). Annualised host-side.
/// Layout `[n_windows * 3]`.
baseline_buyhold_sharpe_buf: MappedF32Buffer,
/// SP15 Wave 3b — per-window `[mean, std, raw_sharpe]` from the
/// hold-only baseline (action=Hold every bar). Annualised host-side.
/// Layout `[n_windows * 3]`.
baseline_hold_only_sharpe_buf: MappedF32Buffer,
/// SP15 Wave 3b — per-window `[mean, std, raw_sharpe]` from the
/// naive-momentum baseline (`sign(prices[i] - prices[i-1])`).
/// Annualised host-side. Layout `[n_windows * 3]`.
baseline_momentum_sharpe_buf: MappedF32Buffer,
/// SP15 Wave 3b — per-window `[mean, std, raw_sharpe]` from the
/// naive-reversion baseline (mirror of momentum). Annualised host-side.
/// Layout `[n_windows * 3]`.
baseline_reversion_sharpe_buf: MappedF32Buffer,
/// SP15 Wave 3b — host-precomputed flat per-roundtrip commission
/// (dollars per round-trip). Per D3 resolution: derived from the
/// hyperopt config as `tx_cost_bps × 0.0001 × initial_capital`; the
/// kernel charges this once per round-trip close (rt_ind=1). Stored
/// on the evaluator (not in `GpuBacktestConfig`) so the per-window
/// launch loop reads a stable f32 without re-computing each call.
commission_per_rt: f32,
// ── Async eval pipeline (perf-fix 2026-04-28) ─────────────────────────────
//
// Splits eval-stream readback from host-side wait. `launch_metrics_and_record_event`
// submits the metrics kernel + DtoD-async copies of the per-step action history
// buffers into the mapped-pinned mirrors below, then records `eval_done_event`.
// `consume_metrics_after_event` calls `event.synchronize()` (the ONLY host wait
// per epoch boundary) and reads through `host_ptr`. The split lets the host
// CPU thread launch the next epoch's training kernels while eval drains —
// restoring the pipelining the dedicated `validation_stream` is supposed to
// enable. Original async-diag commits (d9cb14f1b + 673b04a8d) wired the GPU-
// side cross-stream event correctly but left the host blocked inside
// `synchronize()` after the metrics launch, so pipelining never engaged.
/// Recorded on `self.stream` after the metrics kernel + action-history DtoD
/// copies are queued. Lazy-allocated on first launch (the evaluator is
/// constructed before the CUDA context is available for `new_event` here in
/// some early-init paths, so we defer creation). Reused across epochs —
/// `record` re-records into the same event handle.
eval_done_event: Option<CudaEvent>,
/// Mapped-pinned mirror of `actions_history_buf` ([n_windows * max_len] i32).
/// Filled via `memcpy_dtod_async` on `self.stream` from `actions_history_buf`
/// inside `launch_metrics_and_record_event`. Read by the per-direction and
/// per-magnitude distribution helpers after `consume_metrics_after_event`
/// has synchronised the event. Replaces the synchronous `memcpy_dtoh` into
/// a freshly-allocated `Vec<i32>` per call — that path violates
/// `feedback_no_htod_htoh_only_mapped_pinned.md` and forced a host wait
/// inside the eval flow.
actions_history_pinned: MappedI32Buffer,
/// Mapped-pinned mirror of `intent_mag_buf` ([n_windows * max_len] i32).
/// Lazy-allocated alongside `intent_mag_buf` in `ensure_action_select_ready`.
/// Same DtoD-async-on-eval-stream pattern as `actions_history_pinned`.
intent_mag_pinned: Option<MappedI32Buffer>,
/// Mapped-pinned mirror of `picked_action_history_buf` ([n_windows * max_len] i32).
/// Lazy-allocated alongside `picked_action_history_buf` in
/// `ensure_action_select_ready`. Same pattern.
picked_action_history_pinned: Option<MappedI32Buffer>,
// Dimensions and config
n_windows: usize,
max_len: usize,
feature_dim: usize,
/// Portfolio feature dimension used during construction (always 3).
portfolio_dim: usize,
/// Aligned state dimension (feature_dim + portfolio_dim, 8-aligned for tensor cores).
state_dim: usize,
config: GpuBacktestConfig,
/// Precomputed annualization factor: sqrt(bars_per_day * 252).
annualization_factor: f32,
/// ISV signals device pointer for adaptive hold. Initialised to point at
/// `default_isv_buf` (zero-filled). Production training overrides via
/// `set_isv_signals_ptr` to share the master training-ISV bus; closure-
/// path callers (eval-baseline) inherit the safe zero-default. Pre-Phase
/// 8.7 this was `0` (null) — every kernel that read `isv[slot]` OOBed in
/// the closure path (CUDA_ERROR_ILLEGAL_ADDRESS at
/// `cost_net_sharpe_kernel+0x90` reading slot 407 → byte 1628 = 0x65c).
isv_signals_ptr: u64,
/// Default zero-filled ISV buffer owned by this evaluator. Backs
/// `isv_signals_ptr` when no external ISV is wired (closure-path eval).
/// Sized at `ISV_TOTAL_DIM=536` f32 to cover all current ISV slot
/// indices. Zero-init means `ofi_lambda=0 → c_ofi=0` in cost-net sharpe
/// (degraded but valid — matches the "no MBP-10 / no OFI in eval"
/// semantics anyway).
default_isv_buf: CudaSlice<f32>,
/// Actions buffer for forward kernel output (reused across steps).
forward_actions_buf: CudaSlice<i32>,
// ── cuBLAS DQN forward pass state (lazy-initialised) ────────────────
/// `experience_action_select` kernel for greedy argmax.
action_select_kernel: Option<CudaFunction>,
/// Branch sizes for factored action decoding (from DqnBacktestConfig).
b0_size: i32,
b1_size: i32,
b2_size: i32,
b3_size: i32,
/// Q-value gap threshold for trade conviction filter (0.0 = disabled).
q_gap_threshold: f32,
/// Chunked Q-gap output buffer (chunked path: [n_windows * CHUNK_SIZE]).
chunked_q_gaps_buf: Option<CudaSlice<f32>>,
/// Per-sample direction-branch conviction ∈ [0, 1], chunked layout
/// `[chunk_len * n_windows]` matching `chunked_actions_buf`. Written by
/// `experience_action_select` each chunk; consumed by `backtest_env_step_batch`
/// as the adaptive Kelly warmup-floor signal (via `unified_env_step_core`).
chunked_conviction_buf: Option<CudaSlice<f32>>,
/// Per-sample magnitude-branch conviction ∈ [0, 1], chunked layout matching
/// `chunked_conviction_buf`. Parallel signal threaded through the same
/// kernels for the Kelly cap's `max(dir_conv, mag_conv)` composer — lets
/// the cap relax for confident magnitude picks even when direction
/// conviction is low (typical at smoke horizon when q_dir values cluster
/// while the magnitude head sharply prefers Full).
chunked_magnitude_conviction_buf: Option<CudaSlice<f32>>,
/// Cached CUDA graph for `evaluate_dqn_graphed()` step loop.
///
/// Captured on first call, replayed on subsequent calls with the same weights
/// and evaluator geometry. The graph encodes the full unrolled step loop
/// (gather + forward + DtoD + env_step for each step 0..max_len).
/// Because all buffer pointers are owned by `self` and remain stable,
/// the graph is valid as long as the evaluator exists and the same
/// weight buffers are used.
dqn_graph: Option<SendSyncGraph>,
// ── Chunked forward pass state (lazy-initialised) ─────────────────────
/// Q-values buffer for chunked C51 expected-Q (n_windows * CHUNK_SIZE rows).
chunked_q_values: Option<CudaSlice<f32>>,
/// Branch-major raw C51 logits buffer for the chunked forward pass:
/// `[n_windows * CHUNK_SIZE, total_actions * num_atoms]`. Populated each
/// chunk by `QValueProvider::compute_q_and_b_logits_to`, which DtoD-copies
/// the trainer's `on_b_logits_buf`. The direction-branch slice
/// (`[N, B0, num_atoms]` at the start of each row) is consumed by the
/// Thompson direction sampler in `experience_action_select`
/// (Plan C T2 amendment, T4 evaluator wire-up 2026-04-29).
chunked_b_logits_buf: Option<CudaSlice<f32>>,
/// SP17 Commit C: chunked V-head logits buffer for action_select. Layout:
/// `[n_windows * CHUNK_SIZE, num_atoms]` sample-major. Populated each chunk
/// by `QValueProvider::compute_q_and_b_logits_to`, which DtoD-copies the
/// trainer's `on_v_logits_buf`. Consumed by Thompson direction selection
/// in `experience_action_select` so action-selection sees the SAME
/// dueling-decomposed distribution `softmax(V + A_centered)` that the
/// backward path uses. No NULL fallback — kernel hard-requires V.
chunked_v_logits_buf: Option<CudaSlice<f32>>,
/// Batched states buffer for chunked gather: [n_windows * CHUNK_SIZE, state_dim].
chunked_states_buf: Option<CudaSlice<f32>>,
/// SP21 T2.2 Phase 6.5 (2026-05-11) — full-window state retention
/// for hindsight synthetic injection. Layout
/// `[max_len × n_windows × state_dim_padded]` flattened with the
/// SAME `[step, window, dim]` ordering as `chunked_states_buf`
/// uses within each chunk — each chunk's `chunked_states_buf` is
/// DtoD-copied into this buffer at offset `chunk_start × n_windows
/// × state_dim_padded` immediately after every `launch_gather_chunk`.
///
/// **Mapped-pinned** (`cuMemHostAlloc DEVICEMAP`) per
/// `feedback_no_htod_htoh_only_mapped_pinned`: the DtoD copy
/// writes through the device-aliased pointer; host-side reads
/// via `host_ptr` are zero-copy (no `memcpy_dtoh`). Consumed by
/// `training_loop::inject_hindsight_experiences` which synthesizes
/// replay tuples from `EnrichmentResult.hindsight` and pushes them
/// into PER via `GpuReplayBuffer::insert_batch` — the host read
/// at `(window_idx, bar_idx)` happens AFTER an eval stream sync,
/// so mapped-pinned coherence guarantees the kernel writes are
/// visible.
///
/// Memory cost at production cfg (max_len=200_000, n_windows=5,
/// state_dim_padded≈128): 5 × 200_000 × 128 × 4 ≈ 512 MB of
/// pinned (non-swappable) host RAM. Substantial but feasible on
/// the L40S host (192 GB+). Single-allocation cost per evaluator
/// (not per-step). Lives for the trainer's lifetime; not part of
/// the CUDA Graph capture (the DtoD copies run on the eval stream
/// outside the train graph).
retained_states_buf: Option<MappedF32Buffer>,
/// Batched forward actions buffer for chunked action select: [n_windows * CHUNK_SIZE].
chunked_actions_buf: Option<CudaSlice<i32>>,
/// Chunked intent-magnitude buffer mirroring `chunked_actions_buf`:
/// `[chunk_len, n_windows]` step-major layout (thread `i = s*n + w`).
/// Filled by `experience_action_select` on each chunk, then scattered
/// into the window-major `intent_mag_buf` by `scatter_intent_kernel`.
chunked_intent_mag_buf: Option<CudaSlice<i32>>,
/// `scatter_intent_chunk` kernel handle — copies `chunked_intent_mag_buf`
/// into `intent_mag_buf` at the current chunk's step range.
scatter_intent_kernel: Option<CudaFunction>,
/// Val plan_isv parity kernels (task #94, 2026-04-24).
/// Lazy-loaded with the other action-select machinery.
plan_state_isv_kernel: Option<CudaFunction>,
plan_diag_reduce_kernel: Option<CudaFunction>,
/// D.8 TLOB val-path: OFI[32]→TLOB[16] attention applied after each gather step.
///
/// Weights are synchronised from the training TLOB before `evaluate_dqn_graphed`
/// via `set_tlob_from_training`. `None` when TLOB is disabled or weight sync
/// has not yet been called.
tlob: Option<super::gpu_tlob::GpuTlob>,
}
impl Drop for GpuBacktestEvaluator {
fn drop(&mut self) {
// Synchronize forked stream before any CudaSlice fields drop.
// Without this, pending GPU work (graph replay, cuBLAS, kernel launches)
// may reference buffers that cudarc's CudaSlice::drop frees immediately.
#[allow(unsafe_code)]
unsafe {
cudarc::driver::sys::cuStreamSynchronize(self.stream.cu_stream());
}
// Destroy CUDA graph before buffer drops — graph references
// internal device pointers that become invalid after CudaSlice drops.
self.dqn_graph = None;
}
}
impl GpuBacktestEvaluator {
// ── Constructor ───────────────────────────────────────────────────────────
/// Create evaluator and upload window data to GPU.
///
/// `window_prices`: one `Vec<[f32; 4]>` (OHLC rows) per window.
/// `window_features`: one `Vec<Vec<f32>>` (feature rows) per window.
/// `window_lob_bars`: one `Vec<LobBar>` per window — feeds the SP15
/// Wave 3b cost-net sharpe + 4 counterfactual baselines via three
/// mapped-pinned SoA streams (close prices, half-spread, OFI scalar).
/// Per spec §4.4: bars must be aligned 1:1 with `window_prices` and
/// `window_features`. Shorter `window_lob_bars[w]` is zero-padded to
/// `max_len`. PPO hyperopt path passes `ofi=0.0` for all bars (per
/// D4 resolution: PPO lacks per-bar OFI features; cost-net OFI
/// impact term degrades to 0 — commission + half-spread × position
/// still apply). DQN adapter + val_evaluator pass real OFI.
/// Shorter windows are zero-padded to `max_len`.
pub fn new(
window_prices: &[Vec<[f32; 4]>],
window_features: &[Vec<Vec<f32>>],
window_lob_bars: &[Vec<LobBar>],
feature_dim: usize,
config: GpuBacktestConfig,
parent_stream: &Arc<CudaStream>,
) -> Result<Self, MLError> {
let n_windows = window_prices.len();
if n_windows == 0 {
return Err(MLError::ConfigError("No windows provided".to_owned()));
}
if window_lob_bars.len() != n_windows {
return Err(MLError::ConfigError(format!(
"window_lob_bars len {} != n_windows {n_windows}",
window_lob_bars.len()
)));
}
let max_len = window_prices.iter().map(|w| w.len()).max().unwrap_or(0);
if max_len == 0 {
return Err(MLError::ConfigError("All windows are empty".to_owned()));
}
// ── Apply leverage cap to max_position ────────────────────────────
let mut config = config;
if config.max_leverage > 0.0 {
// Use median close price across ALL windows for robust leverage cap.
// Single-window median can be skewed if first window is atypical.
let mut all_closes: Vec<f32> = window_prices
.iter()
.flat_map(|w| {
let mid = w.len() / 2;
w.get(mid).map(|ohlc| ohlc[3])
})
.filter(|p| *p > 0.0)
.collect();
all_closes.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let ref_price = if all_closes.is_empty() {
0.0
} else {
all_closes[all_closes.len() / 2]
};
let effective = config.effective_max_position(ref_price);
if effective < config.max_position {
tracing::info!(
"Leverage cap: max_position {:.2} → {:.4} (price={:.0}, multiplier={}, leverage={}×, capital={:.0})",
config.max_position, effective, ref_price, config.contract_multiplier,
config.max_leverage, config.initial_capital
);
config.max_position = effective;
}
}
// ── Flatten prices (zero-pad shorter windows) ─────────────────────
let window_lens: Vec<i32> = window_prices.iter().map(|w| w.len() as i32).collect();
let mut flat_prices = vec![0.0_f32; n_windows * max_len * 4];
for (w, prices) in window_prices.iter().enumerate() {
for (t, ohlc) in prices.iter().enumerate() {
let base = (w * max_len + t) * 4;
flat_prices[base..base + 4].copy_from_slice(ohlc);
}
}
// ── Flatten features (zero-pad shorter windows / truncate wider) ──
let mut flat_features = vec![0.0_f32; n_windows * max_len * feature_dim];
for (w, feats) in window_features.iter().enumerate() {
for (t, fv) in feats.iter().enumerate() {
let base = (w * max_len + t) * feature_dim;
let copy_len = fv.len().min(feature_dim);
flat_features[base..base + copy_len].copy_from_slice(&fv[..copy_len]);
}
}
// ── CUDA stream ──────────────────────────────────────────────────
// Fork a dedicated stream for CUDA Graph capture.
// The default stream does NOT support begin_capture (returns
// CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED). Forking creates a non-default
// stream that supports capture and all async operations.
let stream = parent_stream.fork().map_err(|e| {
MLError::ModelError(format!("main stream fork: {e}"))
})?;
let context = stream.context();
// ── Compile / cache kernels ───────────────────────────────────────
let env_module = context
.load_cubin(ENV_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("env module load: {e}")))?;
let env_kernel = env_module
.load_function("backtest_env_step")
.map_err(|e| MLError::ModelError(format!("backtest_env_step load: {e}")))?;
let env_batch_kernel = env_module
.load_function("backtest_env_step_batch")
.map_err(|e| MLError::ModelError(format!("backtest_env_step_batch load: {e}")))?;
let metrics_module = context
.load_cubin(METRICS_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("metrics module load: {e}")))?;
let metrics_kernel = metrics_module
.load_function("compute_backtest_metrics")
.map_err(|e| MLError::ModelError(format!("compute_backtest_metrics load: {e}")))?;
let gather_module = context
.load_cubin(BACKTEST_DQN_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("gather module load: {e}")))?;
let gather_kernel = gather_module
.load_function("backtest_state_gather")
.map_err(|e| MLError::ModelError(format!("backtest_state_gather load: {e}")))?;
let gather_chunk_kernel = gather_module
.load_function("backtest_state_gather_chunk")
.map_err(|e| MLError::ModelError(format!("backtest_state_gather_chunk load: {e}")))?;
// SP15 Wave 5 follow-up (2026-05-07): pre-load the SP15 baseline +
// cost-net cubins ONCE here, in the evaluator's CUDA context, so
// the per-window eval hot loop (`for w in 0..self.n_windows` at
// `~line 2860`) calls `launch_sp15_*` against pre-resolved
// `&CudaFunction` handles instead of resolving the symbols on
// every iteration. The on-demand pattern caused
// `CUDA_ERROR_ILLEGAL_ADDRESS` in hyperopt-trial child stream
// contexts (workflow `train-xggfc`, 2026-05-07): trial 1 failed
// mid-eval with `load sp15_baseline_kernels cubin: ILLEGAL_ADDRESS`,
// and the corrupted context cascaded into trials 2-20 failing at
// `Fork CUDA stream for trial`. Mirrors `5d63762ab` precedent for
// `bn_tanh_concat_dd_kernel`.
let sp15_cost_net_module = context
.load_cubin(super::gpu_dqn_trainer::SP15_COST_NET_SHARPE_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!(
"sp15_cost_net_sharpe module load: {e}"
)))?;
let sp15_cost_net_sharpe_kernel = sp15_cost_net_module
.load_function("cost_net_sharpe_kernel")
.map_err(|e| MLError::ModelError(format!(
"cost_net_sharpe_kernel load: {e}"
)))?;
let sp15_baseline_module = context
.load_cubin(super::gpu_dqn_trainer::SP15_BASELINE_KERNELS_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!(
"sp15_baseline_kernels module load: {e}"
)))?;
let sp15_baseline_buyhold_kernel = sp15_baseline_module
.load_function("baseline_buyhold_kernel")
.map_err(|e| MLError::ModelError(format!(
"baseline_buyhold_kernel load: {e}"
)))?;
let sp15_baseline_hold_only_kernel = sp15_baseline_module
.load_function("baseline_hold_only_kernel")
.map_err(|e| MLError::ModelError(format!(
"baseline_hold_only_kernel load: {e}"
)))?;
let sp15_baseline_naive_momentum_kernel = sp15_baseline_module
.load_function("baseline_naive_momentum_kernel")
.map_err(|e| MLError::ModelError(format!(
"baseline_naive_momentum_kernel load: {e}"
)))?;
let sp15_baseline_naive_reversion_kernel = sp15_baseline_module
.load_function("baseline_naive_reversion_kernel")
.map_err(|e| MLError::ModelError(format!(
"baseline_naive_reversion_kernel load: {e}"
)))?;
// ── Upload read-only data via mapped-pinned (no HtoD, no DtoD) ────
// Direct MappedF32/I32Buffer storage: host writes payload via
// `host_ptr.write_from_slice`, kernels later read via `dev_ptr`.
// No memcpy_dtod_async, no stream.synchronize() at construction —
// mapped-pinned coherence guarantees the kernel sees the host
// writes after the next stream-sync barrier.
let prices_buf = unsafe { MappedF32Buffer::new(flat_prices.len()) }
.map_err(|e| MLError::ModelError(format!("prices mapped-pinned alloc: {e}")))?;
prices_buf.write_from_slice(&flat_prices);
let features_buf = unsafe { MappedF32Buffer::new(flat_features.len()) }
.map_err(|e| MLError::ModelError(format!("features mapped-pinned alloc: {e}")))?;
features_buf.write_from_slice(&flat_features);
let window_lens_buf = unsafe { MappedI32Buffer::new(window_lens.len()) }
.map_err(|e| MLError::ModelError(format!("window_lens mapped-pinned alloc: {e}")))?;
window_lens_buf.write_from_slice(&window_lens);
// ── Allocate mutable state buffers ────────────────────────────────
let portfolio_init = Self::init_portfolio_state(n_windows, config.initial_capital);
let portfolio_buf = unsafe { MappedF32Buffer::new(portfolio_init.len()) }
.map_err(|e| MLError::ModelError(format!("portfolio mapped-pinned alloc: {e}")))?;
portfolio_buf.write_from_slice(&portfolio_init);
// Kelly stats buffer — zero-initialized (no trades yet). The Kelly
// priors + warmup floor in trade_physics.cuh handle the cold start
// gracefully: during the first ~10 completed trades the cap is
// dominated by the 50% warmup floor, then transitions to pure
// data-driven Kelly.
let kelly_stats_buf = stream
.alloc_zeros::<f32>(n_windows * BACKTEST_KELLY_STATS_SIZE)
.map_err(|e| MLError::ModelError(format!("kelly_stats alloc: {e}")))?;
// SP21 T2.2 Phase 1 (2026-05-10) — per-trade tape device buffers.
// Sized at MAX_TRADES_PER_WINDOW per window (3.2MB per window for
// the 4 SoA buffers + count). Zero-init: per_trade_count[w]=0 so
// the first close in each window writes at slot 0. `reset_per_trade_tape`
// re-zeros at the start of each eval window so trade indices don't
// accumulate across folds.
let per_trade_pnl_buf = stream
.alloc_zeros::<f32>(n_windows * MAX_TRADES_PER_WINDOW)
.map_err(|e| MLError::ModelError(format!("per_trade_pnl alloc: {e}")))?;
let per_trade_holding_bars_buf = stream
.alloc_zeros::<u32>(n_windows * MAX_TRADES_PER_WINDOW)
.map_err(|e| MLError::ModelError(format!("per_trade_holding_bars alloc: {e}")))?;
let per_trade_bar_index_buf = stream
.alloc_zeros::<u32>(n_windows * MAX_TRADES_PER_WINDOW)
.map_err(|e| MLError::ModelError(format!("per_trade_bar_index alloc: {e}")))?;
let per_trade_dir_mag_buf = stream
.alloc_zeros::<u32>(n_windows * MAX_TRADES_PER_WINDOW)
.map_err(|e| MLError::ModelError(format!("per_trade_dir_mag alloc: {e}")))?;
// SP21 T2.2 Phase 1.5 (2026-05-11) — predicted-Q tape (6th SoA
// buffer) + per-window persistent entry_q state. Memory cost
// negligible vs the existing 4 buffers; same lifecycle.
let per_trade_predicted_q_buf = stream
.alloc_zeros::<f32>(n_windows * MAX_TRADES_PER_WINDOW)
.map_err(|e| MLError::ModelError(format!("per_trade_predicted_q alloc: {e}")))?;
let entry_q_state_buf = stream
.alloc_zeros::<f32>(n_windows)
.map_err(|e| MLError::ModelError(format!("entry_q_state alloc: {e}")))?;
let per_trade_count_buf = stream
.alloc_zeros::<u32>(n_windows)
.map_err(|e| MLError::ModelError(format!("per_trade_count alloc: {e}")))?;
// plan_isv_buf: [N, SL_PORTFOLIO_PLAN_DIM=7] — live plan_isv per window.
// Populated each step by backtest_plan_state_isv (val plan_isv parity,
// task #94) and consumed by the next step's backtest_state_gather.
// Zero-initialized so the first step (no active plan) emits zeros for
// all seven plan_isv slots (remaining_fraction included).
let plan_isv_buf = stream
.alloc_zeros::<f32>(n_windows * 7)
.map_err(|e| MLError::ModelError(format!("plan_isv alloc: {e}")))?;
// Val plan_isv parity (task #94, 2026-04-24). Three persistent
// buffers carrying the plan state machinery that replicates
// training's ps[23..29] + plan_params logic in the val backtest:
// plan_params_buf[N, 6] — live plan MLP output each step
// plan_state_buf[N, 7] — stored plan at entry, stable across
// bars within a trade
// plan_diag_buf[8] — epoch-end diagnostic reduction
let plan_params_buf = stream
.alloc_zeros::<f32>(n_windows * 6)
.map_err(|e| MLError::ModelError(format!("plan_params alloc: {e}")))?;
let plan_state_buf = stream
.alloc_zeros::<f32>(n_windows * 7)
.map_err(|e| MLError::ModelError(format!("plan_state alloc: {e}")))?;
// Mapped-pinned: kernel writes via `dev_ptr`, host reads via `host_ptr`
// after stream sync — zero DtoH copy. Caller must hold the CUDA context.
let plan_diag_buf = unsafe {
MappedF32Buffer::new(8)
.map_err(|e| MLError::ModelError(format!("plan_diag mapped-pinned alloc: {e}")))?
};
let step_rewards_buf = stream
.alloc_zeros::<f32>(n_windows)
.map_err(|e| MLError::ModelError(format!("step_rewards alloc: {e}")))?;
let step_returns_buf = stream
.alloc_zeros::<f32>(n_windows * max_len)
.map_err(|e| MLError::ModelError(format!("step_returns alloc: {e}")))?;
let done_buf = stream
.alloc_zeros::<i32>(n_windows)
.map_err(|e| MLError::ModelError(format!("done alloc: {e}")))?;
let actions_buf = stream
.alloc_zeros::<i32>(n_windows)
.map_err(|e| MLError::ModelError(format!("actions alloc: {e}")))?;
let actions_history_buf = stream
.alloc_zeros::<i32>(n_windows * max_len)
.map_err(|e| MLError::ModelError(format!("actions_history alloc: {e}")))?;
// Mapped-pinned: metrics_kernel writes through `dev_ptr` and the host
// reads via `host_ptr` after stream sync. Replaces the prior
// `clone_dtoh` readback (which violates the no-DtoH rule) with a
// zero-copy path per `feedback_no_htod_htoh_only_mapped_pinned.md`.
// SP15 Phase 1.1.b: stride dropped 14 → 13 (sharpe split out into
// the dedicated `sharpe_per_bar_kernel` consumed via `sharpe_buf`).
// 2026-05-09 — stride grew 13 → 19 with per-regime WR slots
// [13..19) (audit follow-up). Layout mirrored in
// `consume_metrics_after_event` and the kernel's `metrics_out`
// contract docstring.
let metrics_buf = unsafe {
MappedF32Buffer::new(n_windows * 19)
.map_err(|e| MLError::ModelError(format!("metrics mapped-pinned alloc: {e}")))?
};
// SP15 Phase 1.1.b: per-window mean/std/sharpe output for the
// dedicated `sharpe_per_bar_kernel`. Three f32 slots per window
// (mean, std, raw_sharpe) populated by N kernel launches (one per
// window) in `launch_metrics_and_record_event`. Host applies
// annualisation in `consume_metrics_after_event`.
let sharpe_buf = unsafe {
MappedF32Buffer::new(n_windows * 3)
.map_err(|e| MLError::ModelError(format!("sharpe mapped-pinned alloc: {e}")))?
};
// Mapped-pinned mirror for actions_history_buf — populated via
// `memcpy_dtod_async` on the eval stream before the eval_done_event is
// recorded. Replaces the per-call synchronous `memcpy_dtoh` into a
// freshly-allocated host `Vec<i32>`. See the field docstring above for
// the perf-fix rationale.
let actions_history_pinned = unsafe {
MappedI32Buffer::new(n_windows * max_len)
.map_err(|e| MLError::ModelError(format!(
"actions_history mapped-pinned alloc: {e}"
)))?
};
// State layout defined by state_layout.cuh — single source of truth.
// Canonical 96-dim: 42 market + 20 OFI + 16 MTF + 8 portfolio + 6 plan/ISV + 4 padding.
let state_dim = ml_core::state_layout::STATE_DIM;
let state_dim_padded = ml_core::state_layout::STATE_DIM_PADDED;
let states_buf = stream
.alloc_zeros::<f32>(n_windows * state_dim_padded)
.map_err(|e| MLError::ModelError(format!("states_buf alloc: {e}")))?;
// Pre-allocate forward kernel output buffer (used by evaluate_dqn path)
let forward_actions_buf = stream
.alloc_zeros::<i32>(n_windows)
.map_err(|e| MLError::ModelError(format!("forward_actions alloc: {e}")))?;
// Phase 8.7 (2026-05-12) — default zero-filled ISV bus for the
// closure-path callers (eval-baseline). Pre-fix, `isv_signals_ptr=0`
// (null) and every kernel reading any `isv[slot]` OOBed at
// `slot×4` bytes. compute-sanitizer caught it first at
// `cost_net_sharpe_kernel+0x90` reading slot 407 → 0x65c. Sized at
// ISV_TOTAL_DIM (536 f32s) to cover all current ISV slot indices.
// Production training overrides via `set_isv_signals_ptr` to share
// the master training ISV bus.
let default_isv_buf = stream
.alloc_zeros::<f32>(super::gpu_dqn_trainer::ISV_TOTAL_DIM)
.map_err(|e| MLError::ModelError(format!("default_isv_buf alloc: {e}")))?;
let default_isv_ptr = {
let (ptr, _g) = default_isv_buf.device_ptr(&stream);
ptr
};
// ── SP15 Wave 3b — val-cost-streams buffer alloc + LobBar SoA upload ──
//
// Three input streams sourced from `window_lob_bars` (zero-padded to
// `max_len`), three derivation outputs filled post-rollout by the
// position-history derivation kernel, and five per-window
// `[mean, std, raw_sharpe]` outputs (cost-net + 4 baselines).
// All mapped-pinned per `feedback_no_htod_htoh_only_mapped_pinned.md`.
let mut flat_close_prices = vec![0.0_f32; n_windows * max_len];
let mut flat_half_spread = vec![0.0_f32; n_windows * max_len];
let mut flat_ofi_scalar = vec![0.0_f32; n_windows * max_len];
for (w, bars) in window_lob_bars.iter().enumerate() {
for (t, b) in bars.iter().enumerate().take(max_len) {
let idx = w * max_len + t;
flat_close_prices[idx] = b.price;
flat_half_spread[idx] = b.spread * 0.5;
flat_ofi_scalar[idx] = b.ofi;
}
}
let close_prices_buf = unsafe { MappedF32Buffer::new(flat_close_prices.len()) }
.map_err(|e| MLError::ModelError(format!(
"close_prices mapped-pinned alloc: {e}"
)))?;
close_prices_buf.write_from_slice(&flat_close_prices);
let half_spread_buf = unsafe { MappedF32Buffer::new(flat_half_spread.len()) }
.map_err(|e| MLError::ModelError(format!(
"half_spread mapped-pinned alloc: {e}"
)))?;
half_spread_buf.write_from_slice(&flat_half_spread);
let ofi_scalar_buf = unsafe { MappedF32Buffer::new(flat_ofi_scalar.len()) }
.map_err(|e| MLError::ModelError(format!(
"ofi_scalar mapped-pinned alloc: {e}"
)))?;
ofi_scalar_buf.write_from_slice(&flat_ofi_scalar);
// Derivation outputs — zero-init; populated each
// `launch_metrics_and_record_event` by
// `launch_sp15_position_history_derivation`.
let position_history_buf = unsafe { MappedF32Buffer::new(n_windows * max_len) }
.map_err(|e| MLError::ModelError(format!(
"position_history mapped-pinned alloc: {e}"
)))?;
let side_ind_buf = unsafe { MappedF32Buffer::new(n_windows * max_len) }
.map_err(|e| MLError::ModelError(format!(
"side_ind mapped-pinned alloc: {e}"
)))?;
let rt_ind_buf = unsafe { MappedF32Buffer::new(n_windows * max_len) }
.map_err(|e| MLError::ModelError(format!(
"rt_ind mapped-pinned alloc: {e}"
)))?;
// Per-window `[mean, std, raw_sharpe]` outputs from the cost-net
// kernel + 4 counterfactual baselines. Same layout as `sharpe_buf`.
let cost_net_sharpe_buf = unsafe { MappedF32Buffer::new(n_windows * 3) }
.map_err(|e| MLError::ModelError(format!(
"cost_net_sharpe mapped-pinned alloc: {e}"
)))?;
let baseline_buyhold_sharpe_buf = unsafe { MappedF32Buffer::new(n_windows * 3) }
.map_err(|e| MLError::ModelError(format!(
"baseline_buyhold_sharpe mapped-pinned alloc: {e}"
)))?;
let baseline_hold_only_sharpe_buf = unsafe { MappedF32Buffer::new(n_windows * 3) }
.map_err(|e| MLError::ModelError(format!(
"baseline_hold_only_sharpe mapped-pinned alloc: {e}"
)))?;
let baseline_momentum_sharpe_buf = unsafe { MappedF32Buffer::new(n_windows * 3) }
.map_err(|e| MLError::ModelError(format!(
"baseline_momentum_sharpe mapped-pinned alloc: {e}"
)))?;
let baseline_reversion_sharpe_buf = unsafe { MappedF32Buffer::new(n_windows * 3) }
.map_err(|e| MLError::ModelError(format!(
"baseline_reversion_sharpe mapped-pinned alloc: {e}"
)))?;
// Host-precomputed flat per-roundtrip commission (per D3 resolution).
// Formula: `tx_cost_bps × 0.0001 × initial_capital`. The cost-net
// kernel charges this once per round-trip close (rt_ind=1); the
// half-spread + OFI-impact components scale with per-bar |position|
// and are charged via the separate spread/OFI streams. Stored on
// the evaluator so the per-window launch loop reads a stable f32
// without re-deriving each call. Mirrors the per-side commission
// semantics described in `cost_net_sharpe_kernel.cu` spec §6.2.
let commission_per_rt =
config.tx_cost_bps * 0.0001_f32 * config.initial_capital;
let upload_mb =
(flat_prices.len() * std::mem::size_of::<f32>()
+ flat_features.len() * std::mem::size_of::<f32>()) as f64
/ 1_048_576.0;
info!(
"GpuBacktestEvaluator: {} windows × {} max_len × {} features ({:.1} MB uploaded)",
n_windows, max_len, feature_dim, upload_mb,
);
Ok(Self {
stream,
env_kernel,
env_batch_kernel,
metrics_kernel,
gather_kernel,
gather_chunk_kernel,
// SP15 Wave 5 follow-up (2026-05-07): pre-loaded handles for
// SP15 baseline + cost-net launchers, see field-level
// docstrings in the struct definition.
sp15_cost_net_sharpe_kernel,
sp15_baseline_buyhold_kernel,
sp15_baseline_hold_only_kernel,
sp15_baseline_naive_momentum_kernel,
sp15_baseline_naive_reversion_kernel,
prices_buf,
features_buf,
window_lens_buf,
portfolio_buf,
kelly_stats_buf,
plan_isv_buf,
plan_params_buf,
plan_state_buf,
plan_diag_buf,
step_rewards_buf,
step_returns_buf,
done_buf,
actions_buf,
actions_history_buf,
// SP21 T2.2 Phase 1 (2026-05-10) — per-trade tape buffers
// (see field-decl docs for layout / lifecycle).
per_trade_pnl_buf,
per_trade_holding_bars_buf,
per_trade_bar_index_buf,
per_trade_dir_mag_buf,
per_trade_count_buf,
per_trade_predicted_q_buf,
entry_q_state_buf,
intent_mag_buf: None,
picked_action_history_buf: None,
states_buf,
metrics_buf,
sharpe_buf,
// SP15 Wave 3b — input streams + derivation outputs + 5 metric outputs.
close_prices_buf,
half_spread_buf,
ofi_scalar_buf,
position_history_buf,
side_ind_buf,
rt_ind_buf,
cost_net_sharpe_buf,
baseline_buyhold_sharpe_buf,
baseline_hold_only_sharpe_buf,
baseline_momentum_sharpe_buf,
baseline_reversion_sharpe_buf,
commission_per_rt,
// Lazy-init on first launch so the CUDA context is guaranteed
// active. Reused across epochs (re-record via `event.record(stream)`).
eval_done_event: None,
actions_history_pinned,
// Lazy-allocated alongside the underlying device buffers in
// `ensure_action_select_ready`. Stay `None` for non-DQN paths
// (evaluate / evaluate_ppo / evaluate_supervised) which never
// populate the underlying buffers.
intent_mag_pinned: None,
picked_action_history_pinned: None,
n_windows,
max_len,
feature_dim,
portfolio_dim: ml_core::state_layout::PORTFOLIO_BASE_DIM + ml_core::state_layout::MTF_DIM,
state_dim,
// Effective bars-per-day = configured bars-per-day / val_subsample_stride.
// The metrics kernel computes mean/std over the (already-subsampled)
// return series; the annualization scalar must therefore reflect the
// *effective* sampling cadence, not the underlying bar cadence.
// Per audit `docs/lookahead-bias-audit-2026-04-28.md` §5, using the
// unscaled bars_per_day inflates reported Sharpe by sqrt(stride).
annualization_factor: {
let stride = config.val_subsample_stride.max(1) as f32;
let effective_bars_per_day = config.bars_per_day / stride;
(effective_bars_per_day * config.trading_days_per_year).sqrt()
},
// Phase 8.7 (2026-05-12) — point at the zero-filled default ISV
// buffer. Production training overrides via `set_isv_signals_ptr`.
isv_signals_ptr: default_isv_ptr,
default_isv_buf,
config,
forward_actions_buf,
action_select_kernel: None,
b0_size: 0,
b1_size: 0,
b2_size: 0,
b3_size: 0,
q_gap_threshold: 0.0,
chunked_q_gaps_buf: None,
chunked_conviction_buf: None,
chunked_magnitude_conviction_buf: None,
dqn_graph: None,
chunked_q_values: None,
chunked_b_logits_buf: None,
chunked_v_logits_buf: None,
chunked_states_buf: None,
// SP21 T2.2 Phase 6.5: lazy-init alongside chunked_states_buf
// in `ensure_action_select_ready` once we know `state_dim`.
retained_states_buf: None,
chunked_actions_buf: None,
chunked_intent_mag_buf: None,
scatter_intent_kernel: None,
plan_state_isv_kernel: None,
plan_diag_reduce_kernel: None,
tlob: None,
})
}
/// Set ISV signals pointer for adaptive hold in validation backtest.
/// Pass the frozen ISV signals from the last training step.
pub fn set_isv_signals_ptr(&mut self, ptr: u64) {
self.isv_signals_ptr = ptr;
}
/// Install a val-path TLOB instance and immediately sync its weights from the training TLOB.
///
/// `eval_tlob` must already be constructed (with a `PerStreamCublasHandles` bound to
/// `self.stream`). `train_tlob` supplies the source weight parameters — copied via
/// DtoD async on `eval_tlob`'s stream.
///
/// Calling this before `evaluate_dqn_graphed` ensures every val forward pass runs the
/// same TLOB weights as the most recent training step, preserving train/val state-dist
/// parity for the TLOB slot (SL_TLOB_START) in the assembled state vector.
pub(crate) fn set_tlob_from_training(
&mut self,
mut eval_tlob: super::gpu_tlob::GpuTlob,
train_tlob: &super::gpu_tlob::GpuTlob,
) -> Result<(), MLError> {
eval_tlob.copy_params_from(train_tlob)?;
self.tlob = Some(eval_tlob);
Ok(())
}
// ── Private helpers ───────────────────────────────────────────────────────
/// Build the initial portfolio state vector.
///
/// Layout per window (8 floats):
/// `[value, position, cash, unrealised_pnl, max_equity, 0, 0, 0]`
fn init_portfolio_state(n_windows: usize, initial_capital: f32) -> Vec<f32> {
let mut state = vec![0.0_f32; n_windows * 8];
for w in 0..n_windows {
let base = w * 8;
state[base] = initial_capital; // value
// state[base + 1] = 0.0 // position (zero-initialised)
state[base + 2] = initial_capital; // cash
// state[base + 3] = 0.0 // unrealised_pnl
state[base + 4] = initial_capital; // max_equity (high-water mark)
}
state
}
/// Get the CUDA stream used by this evaluator.
///
/// Callers can use this to extract weight buffers on the same stream,
/// avoiding unnecessary synchronization between weight upload and
/// kernel launches.
pub fn stream(&self) -> &Arc<CudaStream> {
&self.stream
}
/// Number of evaluation windows (validation fold count).
///
/// Used by callers that need to size per-window buffers or TLOB instances
/// to match the evaluator's batch geometry.
pub(crate) fn n_windows(&self) -> usize {
self.n_windows
}
/// Set the four factored-action branch sizes from a `DqnBacktestConfig`.
///
/// The closure-based `evaluate()` path delegates the model forward pass
/// to a user-supplied closure but still uses the internal `env_step`
/// kernel to advance the simulator. `env_step` decodes flat action
/// indices into `(dir, mag, order, urgency)` via `decode_*_4b()` helpers
/// in `trade_physics.cuh`, which compute `action / (b1 * b2 * b3)`. If
/// any `b*_size` is zero the helpers divide by zero, the kernel reads
/// out-of-bounds, and CUDA raises `CUDA_ERROR_ILLEGAL_ADDRESS` at the
/// next synchronise — exactly the v7 smoke failure mode (2026-05-12).
///
/// The production `evaluate_dqn_graphed` path sets these via
/// `ensure_action_select_ready` (which also allocates intent buffers
/// the closure path doesn't need). This setter is the closure-path
/// equivalent — set b-sizes only, no buffer allocs.
pub fn set_branch_sizes(&mut self, dqn_cfg: &DqnBacktestConfig) {
self.b0_size = dqn_cfg.branch_0_size as i32;
self.b1_size = dqn_cfg.branch_1_size as i32;
self.b2_size = dqn_cfg.branch_2_size as i32;
self.b3_size = dqn_cfg.branch_3_size as i32;
}
/// Maximum batch size that the val TLOB instance must support.
///
/// `submit_dqn_step_loop_cublas` runs TLOB once per chunk on
/// `[chunk_len * n_windows, state_dim_padded]` rows of `chunked_states_buf`
/// instead of once per step on `[n_windows, ..]` rows of `states_buf`.
/// The val TLOB constructor (`metrics.rs::reset_evaluator_for_validation`)
/// must allocate buffers sized for this maximum so the per-chunk forward
/// can proceed without reallocation. Partial last chunks (chunk_len <
/// DQN_BACKTEST_CHUNK_SIZE) reuse the same buffers — `forward()` accepts
/// any `batch_size <= construction_batch_size`.
pub(crate) fn val_tlob_batch_size(&self) -> usize {
DQN_BACKTEST_CHUNK_SIZE * self.n_windows
}
/// Sharpe annualization factor used by `compute_backtest_metrics` kernel.
///
/// Returns the EXACT factor applied to `(mean/std)` inside the kernel
/// (`sqrt(bars_per_day × trading_days_per_year)` — see line 667 init).
/// Callers that need to un-annualize the kernel's reported Sharpe MUST
/// use this getter rather than recomputing from their own copy of
/// `bars_per_day`, otherwise the two numbers can drift if the evaluator's
/// config was overridden between construction and read (e.g. via
/// hyperopt). Source-of-truth alignment per `feedback_no_partial_refactor.md`.
pub fn annualization_factor(&self) -> f32 {
self.annualization_factor
}
/// C.3 Plan 3 Task 7: device pointer + sample count for the val-state
/// batch the most recent `evaluate_dqn_graphed` call left in
/// `chunked_states_buf`. Layout: `[chunk_len × n_windows, state_dim_padded]`
/// where `chunk_len = DQN_BACKTEST_CHUNK_SIZE` (rows are time-major
/// per-step blocks of `n_windows` rows; rows beyond the last actual chunk
/// length carry stale data from earlier chunks but the moments are not
/// sensitive to that within the OFI block).
///
/// Returns `(0, 0)` when the chunked buffer has not yet been allocated
/// (no val pass has run yet) — the caller's `state_kl` launcher no-ops in
/// that case.
pub(crate) fn val_state_sample(&self) -> (u64, usize) {
match self.chunked_states_buf.as_ref() {
Some(buf) => {
let (ptr, _guard) = buf.device_ptr(&self.stream);
(ptr, self.n_windows * DQN_BACKTEST_CHUNK_SIZE)
}
None => (0, 0),
}
}
// ── Public API ────────────────────────────────────────────────────────────
/// Build the state tensor for a given step: `[n_windows, feat_dim + portfolio_dim]`.
///
/// Launches the `gather_states` CUDA kernel which reads directly from the
/// pre-uploaded features buffer and the live portfolio state buffer on GPU,
/// then performs a zero-copy DtoD transfer from the kernel output `states_buf`
/// into a freshly-allocated Candle tensor. No data ever touches the CPU.
///
/// The kernel writes into `states_buf` (pre-allocated, `n_windows × state_dim`).
/// A `cuMemcpyDtoDAsync` then copies those bytes directly into the Candle tensor's
/// CUDA storage — eliminating the GPU→CPU→GPU roundtrip entirely.
///
/// # Panics
/// `portfolio_dim` must equal `self.portfolio_dim` (24: 8 portfolio + 16 multi-timeframe).
/// This matches the training experience_state_gather layout.
pub fn gather_states(
&self,
step: usize,
portfolio_dim: usize,
_device: &(),
) -> Result<CudaSlice<f32>, MLError> {
if portfolio_dim != self.portfolio_dim {
return Err(MLError::ConfigError(format!(
"gather_states: portfolio_dim={portfolio_dim} != expected {}",
self.portfolio_dim
)));
}
let state_dim = self.state_dim;
// Launch the gather kernel — one thread per window
// Shared memory: 256 threads × 8 portfolio f32 × 4 bytes = 8 KB
let grid = ((self.n_windows + 255) / 256) as u32;
let launch_cfg = LaunchConfig {
grid_dim: (grid.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 256 * 8 * std::mem::size_of::<f32>() as u32,
};
let n_windows_i32 = self.n_windows as i32;
let max_len_i32 = self.max_len as i32;
let feat_dim_i32 = self.feature_dim as i32;
let step_i32 = step as i32;
let padded_sd_i32 = ml_core::state_layout::STATE_DIM_PADDED as i32;
// Safety: argument order matches `backtest_state_gather` signature
// exactly: features, portfolio, plan_isv, states_out, n_windows,
// max_len, feat_dim, current_step, padded_sd, aux_dir_prob_per_env
// (SP22 H6 Phase 2), aux_outcome_probs_per_env (Phase C-2).
//
// Phase 1 A3 (2026-05-12) + Phase C-2 (2026-05-14): eval has no
// aux producer infrastructure yet (neither K=2 head nor K=3 head
// runs in eval), so both pointers stay NULL → kernel uses 0.0f
// sentinel for slot 121 via the K=2 fallback path (since
// aux_outcome_probs_per_env is NULL, branch falls through to
// K=2 assemble_state which writes 0.0 to slot 121 when
// aux_dir_prob_per_env is also NULL). A2 follow-up would wire
// both buffers via the eval-side aux trunk forward.
let aux_dir_prob_null: u64 = 0;
let aux_outcome_probs_null: u64 = 0;
unsafe {
self.stream
.launch_builder(&self.gather_kernel)
.arg(&self.features_buf.dev_ptr)
.arg(&self.portfolio_buf.dev_ptr)
.arg(&self.plan_isv_buf)
.arg(&self.states_buf)
.arg(&n_windows_i32)
.arg(&max_len_i32)
.arg(&feat_dim_i32)
.arg(&step_i32)
.arg(&padded_sd_i32)
.arg(&aux_dir_prob_null) // SP22 H6 A3: NULL (no K=2 in eval)
.arg(&aux_outcome_probs_null) // SP22 H6 vNext Phase C-2: NULL (no K=3 in eval)
.launch(launch_cfg)
.map_err(|e| MLError::ModelError(format!("backtest_state_gather launch step {step}: {e}")))?;
}
// Return a copy of the states buffer as CudaSlice<f32> (padded stride).
let state_dim_padded = (state_dim + 127) & !127;
let n_elems = self.n_windows * state_dim_padded;
let dst = self.stream.alloc_zeros::<f32>(n_elems)
.map_err(|e| MLError::ModelError(format!("alloc states step {step}: {e}")))?;
let src_view = self.states_buf.slice(..n_elems);
{
let (src_ptr, _src_sync) = src_view.device_ptr(&self.stream);
let (dst_ptr, _dst_sync) = dst.device_ptr(&self.stream);
let num_bytes = n_elems * std::mem::size_of::<f32>();
unsafe {
cudarc::driver::result::memcpy_dtod_async(
dst_ptr, src_ptr, num_bytes, self.stream.cu_stream(),
).map_err(|e| MLError::ModelError(format!("states DtoD copy step {step}: {e}")))?;
}
}
Ok(dst)
}
/// Run the full backtest evaluation loop (closure-based, single-stream).
///
/// For each step up to `max_len`:
/// 1. Launch `gather_states` GPU kernel → state tensor `[n_windows, state_dim]`
/// 2. Call `forward_fn` to get Q-values `[n_windows, n_actions]`
/// 3. Greedy argmax → action indices
/// 4. Launch `backtest_env_step` kernel
///
/// After the loop, launches `compute_backtest_metrics`.
/// Returns one `WindowMetrics` per window.
pub fn evaluate<F>(
&mut self,
forward_fn: &F,
portfolio_dim: usize,
) -> Result<Vec<WindowMetrics>, MLError>
where
F: Fn(&CudaSlice<f32>, usize, usize) -> Result<CudaSlice<i32>, MLError>,
{
let _nvtx = NvtxRange::new("backtest_evaluate");
// Phase 8.5 (2026-05-12) — defensive guard: `env_step` decodes flat
// action indices into (dir, mag, order, urgency) via
// `decode_*_4b()` helpers that divide by `b1 * b2 * b3`. If any
// b-size is zero, the kernel divides by zero and raises
// CUDA_ERROR_ILLEGAL_ADDRESS asynchronously at the next event-sync.
// The production `evaluate_dqn_graphed` path sets these via
// `ensure_action_select_ready`; closure-path callers must invoke
// `set_branch_sizes` before `evaluate()`. Surface the requirement
// up-front rather than crashing the CUDA context downstream.
if self.b0_size == 0 || self.b1_size == 0 || self.b2_size == 0 || self.b3_size == 0 {
return Err(MLError::ConfigError(format!(
"evaluate: branch sizes unset (b0={}, b1={}, b2={}, b3={}); \
call `set_branch_sizes(&DqnBacktestConfig)` before `evaluate()` \
to wire factored-action decoding for env_step.",
self.b0_size, self.b1_size, self.b2_size, self.b3_size
)));
}
let state_dim = self.state_dim;
for step in 0..self.max_len {
// 1. Gather state CudaSlice via GPU kernel [n_windows * state_dim]
let states = self.gather_states(step, portfolio_dim, &())?;
// 2. Model forward pass + greedy argmax (on-device, no roundtrip)
// Closure returns action indices as CudaSlice<i32> [n_windows]
let action_indices = forward_fn(&states, self.n_windows, state_dim)?;
// DtoD copy: action indices → actions_buf (same stream, implicit ordering)
{
let src_view = action_indices.slice(..self.n_windows);
let (src_ptr, _src_sync) = src_view.device_ptr(&self.stream);
let (dst_ptr, _dst_sync) = self.actions_buf.device_ptr(&self.stream);
let num_bytes = self.n_windows * std::mem::size_of::<i32>();
unsafe {
cudarc::driver::result::memcpy_dtod_async(
dst_ptr,
src_ptr,
num_bytes,
self.stream.cu_stream(),
)
.map_err(|e| MLError::ModelError(format!("actions DtoD step {step}: {e}")))?;
}
}
// 3. Launch env step kernel on the same stream — sequential ordering
// guarantees actions_buf is ready. The kernel also scatter-writes
// actions into actions_history_buf (GPU-resident, no CPU roundtrip).
self.launch_env_step(step)?;
// No mid-loop GPU→CPU transfers. The env kernel checks done_flags per-thread
// and early-returns (no-op) for finished windows, so running all max_len steps
// is safe and avoids the latency of periodic done-flag downloads.
}
// Synchronous launch+consume — `evaluate()` is the closure-based path
// used by callers that don't pipeline (no caller currently does — the
// pipelined DQN path goes through `evaluate_dqn_graphed_async`).
self.launch_metrics_and_record_event()?;
self.consume_metrics_after_event()
}
/// Pure-GPU DQN evaluation via cuBLAS SGEMM — no candle, no closures.
///
/// Uses cuBLAS matrix multiplications for the Q-network forward pass,
/// replacing the old warp-cooperative shared-memory-tiling kernel that
/// crashed with `CUDA_ERROR_INVALID_VALUE` for `hidden_dim >= 768`
/// (shared memory exceeds 49 KB limit).
///
/// # Arguments
/// * `online_weights` - Dueling Q-network weight buffers (12 CudaSlice pointers).
/// For branching DQN, `w_a1`/`b_a1`/`w_a2`/`b_a2` map to branch 0 (exposure).
/// * `branching_weights` - Optional branch 1 (order) and branch 2 (urgency) weights.
/// When `None`, branch 0 weights are replicated for all 3 branches.
/// * `dqn_cfg` - Network dimensions and C51 distributional parameters.
// evaluate_dqn() removed — all evaluation routes through evaluate_dqn_graphed()
// with QValueProvider for deterministic results.
// ── CUDA Graphaccelerated DQN evaluation ──────────────────────────────────
/// Pure-GPU DQN evaluation with CUDA Graph capture and replay.
///
/// Identical to [`evaluate_dqn`] but wraps the step loop in a CUDA Graph.
/// On the first call, the entire step loop (gather + cuBLAS forward +
/// expected_q + action_select + env_step for each step 0..max_len) is
/// captured into a graph and then launched. On subsequent calls with the
/// same evaluator, the cached graph is replayed.
///
/// The metrics kernel runs OUTSIDE the graph (one-shot, not worth graphing).
///
/// # Fallback
///
/// If graph capture or instantiation fails (e.g., unsupported driver version,
/// or a captured operation that is not graph-safe), falls back to the
/// non-graphed `evaluate_dqn` path with a warning.
///
/// # Graph invalidation
///
/// The cached graph records the device pointer values of all buffers and
/// weight pointers at capture time. It is valid as long as:
/// - The same `GpuBacktestEvaluator` instance is used (buffer pointers stable)
/// - The same `online_weights` object is passed (weight pointers stable)
///
/// If you change weights (different `DuelingWeightSet`), call
/// [`invalidate_dqn_graph`] first or just use `evaluate_dqn` instead.
pub fn evaluate_dqn_graphed(
&mut self,
online_weights: &DuelingWeightSet,
branching_weights: Option<&BranchingWeightSet>,
dqn_cfg: &DqnBacktestConfig,
q_provider: Option<&mut dyn super::q_value_provider::QValueProvider>,
) -> Result<Vec<WindowMetrics>, MLError> {
// Synchronous wrapper: launch async + immediate consume. Used by
// hyperopt (single-shot) and the smoke-test path that expects metrics
// back from this call without a separate consume step. The pipelined
// training path uses `evaluate_dqn_graphed_async` + a deferred
// `consume_metrics_after_event` from the next epoch's start (see
// `compute_validation_loss_launch` / `_consume` in trainer/metrics.rs).
self.evaluate_dqn_graphed_async(
online_weights, branching_weights, dqn_cfg, q_provider,
)?;
self.consume_metrics_after_event()
}
/// Async (launch-only) variant of [`evaluate_dqn_graphed`]. Submits the
/// full eval rollout + metrics + diagnostic kernels to `self.stream` and
/// records `eval_done_event`, then returns **without** any host-side wait.
///
/// The companion [`consume_metrics_after_event`] performs the single host
/// `cuEventSynchronize` and parses the mapped-pinned `metrics_buf`. Splitting
/// the two unblocks the host CPU thread to submit the next training epoch's
/// kernels while eval drains on the dedicated `validation_stream` — the
/// pipelining the original async-diag commit (d9cb14f1b) wired the GPU side
/// for but the host-side `synchronize()` inside the now-removed
/// `launch_metrics_and_download` defeated.
///
/// Per-epoch wall-clock saving on L40S: ~25-30s (the original audit's
/// 30-40s estimate, give or take, depending on training-side launch
/// density).
///
/// # Arguments
/// Same as [`evaluate_dqn_graphed`]. The `_online_weights` / `_branching_weights`
/// parameters are placeholders for caller-side ABI symmetry — actual
/// weight wiring goes through the `q_provider`'s `FusedTrainingCtx` which
/// owns the live training weights.
pub fn evaluate_dqn_graphed_async(
&mut self,
_online_weights: &DuelingWeightSet,
_branching_weights: Option<&BranchingWeightSet>,
dqn_cfg: &DqnBacktestConfig,
q_provider: Option<&mut dyn super::q_value_provider::QValueProvider>,
) -> Result<(), MLError> {
let _nvtx = NvtxRange::new("backtest_evaluate_dqn_graphed_async");
// Reset mutable state: portfolio, done flags, step returns, actions history.
// Without this, the second call sees done_flags=1 from the previous evaluation
// and skips the entire simulation — producing constant metrics.
self.reset_evaluation_state()?;
// Drain stale CUDA errors without CPU sync.
// The caller ensures training completion via cuStreamWaitEvent (async validation).
let _ = self.stream.context().check_err();
// Lazy-initialise action selection kernels + chunked buffers
self.ensure_action_select_ready(dqn_cfg)?;
// Direct kernel launches (no CUDA Graph for the backtest step loop).
//
// The step loop captures 500K+ kernel launches (10K steps × 10 windows
// × ~5 ops/step). cuGraphLaunch SIGSEGVs on graphs this large due to
// CUDA driver-internal memory limits. Direct launches are fine because:
// - Eval runs ONCE per hyperopt trial (not per training step)
// - Training is 99%+ of hyperopt wall time; eval is negligible
// - cuBLAS forward passes dominate eval throughput, not launch overhead
//
// The training pipeline (graph_forward + graph_adam in GpuDqnTrainer)
// still uses CUDA Graphs — those capture ~20 operations per replay,
// well within driver limits.
self.submit_dqn_step_loop_cublas(dqn_cfg, q_provider)?;
// Val plan_isv parity diagnostic (task #94): reduce plan_state +
// plan_isv across windows → 8 floats, log for epoch-boundary
// visibility into plan activity. Cold path, runs once per eval.
// The host-side `read_all` inside this method reads the PREVIOUS
// epoch's diagnostic via the documented one-epoch lag — the value
// currently in mapped-pinned memory was already synchronised by the
// previous epoch's `consume_metrics_after_event`, so no additional
// host wait is required here.
self.launch_plan_diag_and_log()?;
self.launch_metrics_and_record_event()
}
/// Val plan_isv parity diagnostic emission (task #94).
///
/// Launches `backtest_plan_diag_reduce` on the final plan_state + plan_isv
/// buffers and logs a single `val_plan_diag` line with labelled stats.
/// Called once per `evaluate_dqn_graphed_async` invocation at the epoch
/// boundary. The host-side `read_all` here reads the **previous epoch's**
/// diagnostic via the documented one-epoch lag (the value currently in
/// mapped-pinned memory was already synchronised by the previous epoch's
/// `consume_metrics_after_event`); the just-launched kernel populates the
/// buffer for the **next** epoch's read.
fn launch_plan_diag_and_log(&mut self) -> Result<(), MLError> {
let kernel = self.plan_diag_reduce_kernel.as_ref().ok_or_else(|| {
MLError::ModelError("plan_diag_reduce_kernel not loaded".to_owned())
})?;
let n_i32 = self.n_windows as i32;
// Mapped-pinned device pointer for the diag scratch — kernel writes
// through `dev_ptr`, host reads via `host_ptr`. No DtoH per
// `feedback_no_htod_htoh_only_mapped_pinned.md`. The metrics readback
// (now `consume_metrics_after_event`) is the single host-side wait
// per epoch boundary.
let plan_diag_dev_ptr = self.plan_diag_buf.dev_ptr;
unsafe {
self.stream
.launch_builder(kernel)
.arg(&self.plan_state_buf)
.arg(&self.plan_isv_buf)
.arg(&plan_diag_dev_ptr)
.arg(&n_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!(
"backtest_plan_diag_reduce launch: {e}"
)))?;
}
// Read mapped-pinned host buffer. The values currently in memory were
// produced by the PREVIOUS epoch's plan_diag launch and made globally
// visible by the previous epoch's `consume_metrics_after_event` (which
// calls `eval_done_event.synchronize()`); the kernel we just launched
// above will populate the buffer for the NEXT epoch's read. The first
// epoch returns zeros (safe default from `MappedF32Buffer::new`
// zero-init); subsequent epochs see the previous epoch's diagnostic —
// same one-epoch lag pattern as `pending_val_loss`. Acceptable for a
// diagnostic-only readout.
let diag = self.plan_diag_buf.read_all();
info!(
target: "val_plan_diag",
"val_plan_isv_diag: active_frac={:.3} n_active={:.0} \
mean|isv0|(progress)={:.4} mean|isv1|(pnl_vs_tgt)={:.4} mean|isv2|(pnl_vs_stop)={:.4} \
mean_isv3(conv_entry)={:.4} mean|isv4|(conv_drift)={:.4} mean|isv5|(regime_shift)={:.4}",
diag[0], diag[7], diag[1], diag[2], diag[3], diag[4], diag[5], diag[6],
);
Ok(())
}
/// Discard the cached CUDA graph.
///
/// Call this before `evaluate_dqn_graphed()` if you have changed the
/// `DuelingWeightSet` (different weight buffer pointers). The next call
/// to `evaluate_dqn_graphed` will re-capture a fresh graph.
pub fn invalidate_dqn_graph(&mut self) {
self.dqn_graph = None;
}
/// Task 0.7 — read back the actions_history buffer and compute the
/// per-magnitude action distribution observed during the most recent
/// evaluation. Action encoding is `dir*27 + mag*9 + ord*3 + urg`, so
/// magnitude = `(action / 9) % 3` (0=Quarter, 1=Half, 2=Full).
///
/// Returns `[quarter_frac, half_frac, full_frac]` summing to 1.0 over
/// non-negative action entries (negative entries indicate uninitialized
/// or skipped steps and are excluded).
///
/// **Sync contract**: caller MUST have invoked
/// [`consume_metrics_after_event`] (or the synchronous `evaluate_*`
/// wrappers, which call it internally) since the last
/// [`launch_metrics_and_record_event`]. The reader does no host wait —
/// it just `read_volatile`s the mapped-pinned mirror that
/// `launch_metrics_and_record_event` queued a DtoD-async copy into.
pub fn read_eval_action_distribution_per_magnitude(&self) -> Result<[f32; 3], MLError> {
let host = self.actions_history_pinned.read_all();
let mut counts = [0_u64; 3];
let mut total = 0_u64;
for &a in &host {
if a < 0 {
continue;
}
let mag = ((a / 9) % 3) as usize;
if mag < 3 {
counts[mag] += 1;
total += 1;
}
}
if total == 0 {
return Ok([0.0; 3]);
}
let t = total as f32;
Ok([
counts[0] as f32 / t,
counts[1] as f32 / t,
counts[2] as f32 / t,
])
}
/// SP9 (Fix 37, 2026-05-03): GPU-resident accessor for the
/// `intent_mag_buf` raw device pointer + element count. Replaces the
/// host-side reduction `read_eval_intent_magnitude_distribution()` that
/// was here pre-SP9 — that path performed a DtoH `read_all()` over the
/// full `intent_mag_pinned` mirror plus a host-side loop, violating
/// `feedback_no_cpu_compute_strict.md` and
/// `feedback_no_htod_htoh_only_mapped_pinned.md`.
///
/// Returns `(0, 0)` when `intent_mag_buf` has not been allocated yet
/// (lazy-init in `ensure_action_select_ready` — pre-DQN-eval). The
/// downstream SP9 GPU producer (`launch_eval_intent_dist_compute` on
/// `GpuDqnTrainer`) handles `n_total == 0` by writing zero fractions
/// to scratch — Pearl A's sentinel-bootstrap fires on the first
/// non-empty call.
///
/// **Sync contract**: caller must have synchronised on
/// `eval_done_event` (typically via `consume_metrics_after_event`)
/// before passing the device pointer through to a GPU launch — same
/// constraint as the deleted host helper.
pub fn intent_mag_dev_ptr_and_n(&self) -> (u64, i32) {
match self.intent_mag_buf.as_ref() {
Some(b) => {
let n_total = self.n_windows * self.max_len;
let (ptr, _guard) = b.device_ptr(&self.stream);
(ptr, n_total as i32)
}
None => (0_u64, 0_i32),
}
}
/// Task 2.X "make Full useful" diagnostic — read back the per-direction
/// action distribution from the most recent evaluation. Action encoding
/// is `dir*27 + mag*9 + ord*3 + urg`, so direction = `action / 27`
/// (0=Short, 1=Hold, 2=Long, 3=Flat).
///
/// Returns `[short, hold, long, flat]` summing to 1.0 over non-negative
/// action entries. When eval is strict-argmax and direction Q-values are
/// near-uniform, this distribution helps diagnose whether the eval
/// collapse is direction-side (e.g., `hold + flat > 0.95`) or whether
/// direction is healthy but magnitude itself is collapsing. Paired with
/// `read_eval_action_distribution_per_magnitude` to localise the
/// magnitude-only Task 2.X fix's leverage surface.
///
/// **Sync contract**: same as
/// [`read_eval_action_distribution_per_magnitude`].
pub fn read_eval_action_distribution_per_direction(&self) -> Result<[f32; 4], MLError> {
let host = self.actions_history_pinned.read_all();
let mut counts = [0_u64; 4];
let mut total = 0_u64;
for &a in &host {
if a < 0 {
continue;
}
let dir = (a / 27) as usize;
if dir < 4 {
counts[dir] += 1;
total += 1;
}
}
if total == 0 {
return Ok([0.0; 4]);
}
let t = total as f32;
Ok([
counts[0] as f32 / t,
counts[1] as f32 / t,
counts[2] as f32 / t,
counts[3] as f32 / t,
])
}
/// Read the per-direction histogram of the policy's RAW Boltzmann picks
/// across the FULL val window. Reads `picked_action_history_pinned` —
/// the mapped-pinned mirror of `picked_action_history_buf`, scattered
/// from `chunked_actions_buf` after each chunk's `experience_action_select`,
/// so the buffer accumulates the kernel's raw action_idx writes for every
/// bar before env_step touches anything.
///
/// `actions_history_buf` records the POST-physics `actual_dir`, so a
/// divergence between this reader and `read_eval_action_distribution_per_direction`
/// localises whether eval collapse is upstream (kernel produces collapsed
/// picks → both flat) or downstream (kernel diverse, env_step / Kelly cap
/// drains active picks to Flat → only post-physics flat).
///
/// Returns `[short, hold, long, flat]` summing to 1.0 over the full window.
/// `Ok([0.0; 4])` if `picked_action_history_pinned` has not been allocated yet
/// (first call before `ensure_action_select_ready` has run).
///
/// **Sync contract**: same as
/// [`read_eval_action_distribution_per_magnitude`].
pub fn read_chunked_actions_direction_distribution(&self) -> Result<[f32; 4], MLError> {
let buf = match self.picked_action_history_pinned.as_ref() {
Some(b) => b,
None => return Ok([0.0; 4]),
};
let host = buf.read_all();
let mut counts = [0_u64; 4];
let mut total = 0_u64;
for &a in &host {
// action_idx is always non-negative; the only invalid entry is
// a kernel-side bug. Slots beyond the window length still get
// written by experience_action_select (it runs for the full
// chunk_len × n_windows batch regardless of done_flags), so
// every slot in [0, max_len) carries a real Boltzmann pick.
// action_idx = 0 (Short Quarter Market Normal) is a legitimate
// pick; the Short bucket includes it.
if a < 0 {
continue;
}
let dir = (a / 27) as usize;
if dir < 4 {
counts[dir] += 1;
total += 1;
}
}
if total == 0 {
return Ok([0.0; 4]);
}
let t = total as f32;
Ok([
counts[0] as f32 / t,
counts[1] as f32 / t,
counts[2] as f32 / t,
counts[3] as f32 / t,
])
}
/// Reset mutable evaluation state: portfolio, done flags, step returns, actions history.
/// Must be called before each `evaluate_dqn_graphed` when reusing the evaluator
/// across epochs — otherwise done_flags=1 from the previous evaluation causes
/// the env_step to skip immediately, producing constant metrics.
fn reset_evaluation_state(&mut self) -> Result<(), MLError> {
// Re-init portfolio to initial capital via in-place host_ptr write.
// `portfolio_buf` is a `MappedF32Buffer` (cuMemHostAlloc DEVICEMAP):
// host writes through `host_ptr`, the env_step kernel reads/writes
// through `dev_ptr` aliasing the same pinned pages — no DtoD copy,
// no extra allocation. The kernel sees these writes after the next
// stream-sync barrier, which the caller (`evaluate_dqn_graphed`)
// performs before each chunk's first env_step launch.
let portfolio_init = Self::init_portfolio_state(self.n_windows, self.config.initial_capital);
self.portfolio_buf.write_from_slice(&portfolio_init);
// Zero done flags, step returns, step rewards
self.stream.memset_zeros(&mut self.done_buf)
.map_err(|e| MLError::ModelError(format!("reset done_buf: {e}")))?;
self.stream.memset_zeros(&mut self.step_returns_buf)
.map_err(|e| MLError::ModelError(format!("reset step_returns: {e}")))?;
self.stream.memset_zeros(&mut self.step_rewards_buf)
.map_err(|e| MLError::ModelError(format!("reset step_rewards: {e}")))?;
// Initialise actions_history_buf to -1 sentinel (NOT zero) so the
// reader (`read_eval_action_distribution_per_direction`) can distinguish
// unwritten slots from real action_idx=0 (Short Quarter Market Normal).
//
// Why this matters: after `done_flags[w]=1` (capital floor breach),
// backtest_env_step early-returns without writing actions_history for
// the remaining slots in [done_step, max_len). Zero-initialised
// unwritten slots decoded as Short via `dir = 0/27 = 0` and inflated
// val_dir_dist's Short bucket — a measurement artifact masking the
// real direction distribution. The reader already filters `if a < 0
// { continue; }`, so writing -1 (i32) = 0xFFFFFFFF (u32) lets it skip
// unwritten slots correctly.
let ah_ptr = self.actions_history_buf.raw_ptr();
let ah_len = self.actions_history_buf.len();
unsafe {
let rc = cudarc::driver::sys::cuMemsetD32Async(
ah_ptr,
0xFFFFFFFFu32,
ah_len,
self.stream.cu_stream(),
);
if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
return Err(MLError::ModelError(format!(
"reset actions_history (cuMemsetD32Async -1): {rc:?}"
)));
}
}
// Zero the parallel intent-magnitude history (diagnostic) so a short
// eval rollout can't read stale values from a prior longer rollout.
if let Some(ref mut intent) = self.intent_mag_buf {
self.stream.memset_zeros(intent)
.map_err(|e| MLError::ModelError(format!("reset intent_mag_buf: {e}")))?;
}
// Zero the picked-action history for the same reason.
if let Some(ref mut picked) = self.picked_action_history_buf {
self.stream.memset_zeros(picked)
.map_err(|e| MLError::ModelError(format!("reset picked_action_history_buf: {e}")))?;
}
// Reset Kelly stats — each evaluation starts cold. Kelly priors +
// warmup floor in trade_physics.cuh handle the cold-start period.
self.stream.memset_zeros(&mut self.kelly_stats_buf)
.map_err(|e| MLError::ModelError(format!("reset kelly_stats: {e}")))?;
// SP21 T2.2 Phase 1 (2026-05-10) — reset per-trade tape counters
// so the first close in each window writes at slot 0. The SoA
// value buffers (pnl, holding_bars, bar_index, dir_mag,
// predicted_q) don't need zeroing — they're only read up to
// `count[w]` host-side, and stale values past `count[w]` are
// never visited by the kernel writer (which guards on
// `slot < max_trades_per_window`) or the host reader (which
// slices `[0..count[w]]`).
self.stream.memset_zeros(&mut self.per_trade_count_buf)
.map_err(|e| MLError::ModelError(format!("reset per_trade_count: {e}")))?;
// SP21 T2.2 Phase 1.5 (2026-05-11) — reset entry_q persistent
// state so trades from a prior fold don't bleed stale entry_q
// values into the new fold's tape. The first open of the new
// fold writes a real entry_q before any close reads it.
self.stream.memset_zeros(&mut self.entry_q_state_buf)
.map_err(|e| MLError::ModelError(format!("reset entry_q_state: {e}")))?;
Ok(())
}
/// SP21 T2.2 Phase 1 (2026-05-10) — read the per-trade tape from the
/// device into a host-side `Vec<EvalTrade>`. Called after the eval
/// window completes (post `launch_metrics_and_record_event` +
/// `consume_metrics_after_event`); flattens the per-window SoA
/// buffers into one chronological-by-window concatenated `Vec`.
///
/// The kernel emits trades in close-event order WITHIN each window
/// (single-threaded per-window writes preserve order). The flatten
/// concatenates by window — call sites that need per-window tapes
/// can reconstruct via `count[]` if needed.
///
/// Synchronizes the eval stream before reading — assumes caller has
/// already issued the eval kernels and just needs to wait for them
/// to complete. Returns total trade count + the flat `Vec`.
/// SP21 T2.2 Phase 6.5 (2026-05-11) — read the val state vector at
/// `(window_idx, bar_idx)` from the retained-states buffer.
/// Returns the unpadded state row (length = `state_layout::STATE_DIM`)
/// as a host-side `Vec<f32>`. Padded columns
/// `[STATE_DIM..STATE_DIM_PADDED)` are dropped — they're zero
/// (cuBLAS K-tile alignment, see `backtest_state_gather`).
///
/// **Zero-copy via mapped-pinned host_ptr** per
/// `feedback_no_htod_htoh_only_mapped_pinned`. The retained
/// buffer's `host_ptr` aliases the same pages the kernel writes
/// to via `dev_ptr`; reads go through `read_volatile` to defeat
/// compiler reorder. Caller MUST sync the eval stream BEFORE
/// invoking — mapped-pinned coherence requires the kernel writes
/// to have completed first (`cuStreamSynchronize` or an event
/// wait). The matching wait in production is the
/// `consume_metrics_after_event` call that gates the
/// post-enrichment training step.
///
/// Used by `training_loop::inject_hindsight_experiences` to build
/// the `state` field of each synthetic replay tuple.
/// Bounds-checks `bar_idx < max_len` and `window_idx < n_windows`.
/// Cold-start (buffer not allocated): returns `Ok(None)`.
pub fn read_retained_state(
&self,
window_idx: usize,
bar_idx: usize,
) -> Result<Option<Vec<f32>>, MLError> {
let retained = match self.retained_states_buf.as_ref() {
Some(r) => r,
None => return Ok(None),
};
if window_idx >= self.n_windows {
return Err(MLError::ModelError(format!(
"read_retained_state: window_idx={window_idx} out of range [0, {})",
self.n_windows
)));
}
if bar_idx >= self.max_len {
return Err(MLError::ModelError(format!(
"read_retained_state: bar_idx={bar_idx} out of range [0, {})",
self.max_len
)));
}
let state_dim = self.state_dim;
let state_dim_padded = (state_dim + 127) & !127;
let row_offset = bar_idx * self.n_windows * state_dim_padded
+ window_idx * state_dim_padded;
// Zero-copy host read via mapped-pinned host_ptr. Volatile
// reads defeat compiler reorder; the eval-stream sync the
// caller performs upstream is the actual coherence barrier.
let mut host = Vec::with_capacity(state_dim);
// Safety: `host_ptr` is valid for `retained.len` f32s by
// construction (`MappedF32Buffer::new`); offset+state_dim is
// bounds-checked above (row_offset+state_dim_padded ≤
// retained.len since the buffer is sized for max_len
// rows of state_dim_padded each).
unsafe {
for i in 0..state_dim {
host.push(std::ptr::read_volatile(
retained.host_ptr.add(row_offset + i),
));
}
}
Ok(Some(host))
}
pub fn read_per_trade_tape(&self) -> Result<Vec<EvalTrade>, MLError> {
// 1. Read per-window counts (cheap — n_windows u32 values).
let mut counts = vec![0u32; self.n_windows];
self.stream
.memcpy_dtoh(&self.per_trade_count_buf, &mut counts)
.map_err(|e| MLError::ModelError(format!("read per_trade_count: {e}")))?;
// 2. Compute total + early-return if no trades. Saves a 16MB+
// DtoH copy when the policy was inactive (rare but possible at
// cold-start before warmup).
let total: usize = counts.iter().map(|&c| c as usize).sum();
if total == 0 {
return Ok(Vec::new());
}
// 3. Read the four SoA buffers in full (n_windows * MAX_TRADES_PER_WINDOW
// each). Slicing window-side would need an extra index-list copy
// per window; reading whole buffers is simpler and stays cheap
// (16MB at PCIe ≈ 1ms — well below per-epoch overhead). The host
// loop below filters down to `count[w]` per window.
let buf_len = self.n_windows * MAX_TRADES_PER_WINDOW;
let mut pnl_host = vec![0.0_f32; buf_len];
let mut holding_host = vec![0u32; buf_len];
let mut bar_idx_host = vec![0u32; buf_len];
let mut dir_mag_host = vec![0u32; buf_len];
let mut predicted_q_host = vec![0.0_f32; buf_len];
self.stream.memcpy_dtoh(&self.per_trade_pnl_buf, &mut pnl_host)
.map_err(|e| MLError::ModelError(format!("read per_trade_pnl: {e}")))?;
self.stream.memcpy_dtoh(&self.per_trade_holding_bars_buf, &mut holding_host)
.map_err(|e| MLError::ModelError(format!("read per_trade_holding_bars: {e}")))?;
self.stream.memcpy_dtoh(&self.per_trade_bar_index_buf, &mut bar_idx_host)
.map_err(|e| MLError::ModelError(format!("read per_trade_bar_index: {e}")))?;
self.stream.memcpy_dtoh(&self.per_trade_dir_mag_buf, &mut dir_mag_host)
.map_err(|e| MLError::ModelError(format!("read per_trade_dir_mag: {e}")))?;
self.stream.memcpy_dtoh(&self.per_trade_predicted_q_buf, &mut predicted_q_host)
.map_err(|e| MLError::ModelError(format!("read per_trade_predicted_q: {e}")))?;
// 4. Flatten window-major: for each window, take the first
// count[w] trades and append.
let mut tape = Vec::with_capacity(total);
for w in 0..self.n_windows {
let count = counts[w] as usize;
// Defensive cap: if a future kernel change skips the
// overflow guard, count COULD exceed MAX_TRADES_PER_WINDOW.
// The min() avoids out-of-bounds slicing.
let count = count.min(MAX_TRADES_PER_WINDOW);
let base = w * MAX_TRADES_PER_WINDOW;
for i in 0..count {
let idx = base + i;
let dm = dir_mag_host[idx];
let direction = ((dm >> 16) & 0xFFFF) as u8;
let magnitude = (dm & 0xFFFF) as u8;
tape.push(EvalTrade {
window_index: w as u32,
bar_index: bar_idx_host[idx],
pnl: pnl_host[idx],
predicted_q: predicted_q_host[idx],
holding_bars: holding_host[idx],
direction,
magnitude,
});
}
}
Ok(tape)
}
/// Submit the cuBLAS-based DQN step loop to the stream (chunked).
///
/// Processes `DQN_BACKTEST_CHUNK_SIZE` steps per cuBLAS forward call to
/// amortise the ~3 us kernel-launch overhead. Within each chunk:
///
/// 1. Gather states for all steps in the chunk (per-step kernel + DtoD copy
/// into the chunked states buffer)
/// 2. Single cuBLAS forward on [n_windows * chunk_len, state_dim]
/// 3. Single `compute_expected_q` on the chunked logits
/// 4. Single `experience_action_select` on the chunked Q-values
/// 5. Per-step: DtoD copy chunk actions -> actions_buf + env_step (sequential)
///
/// Portfolio features (3/48 dims) are stale within a chunk because env_step
/// updates portfolio_buf but gather already ran for all steps in the chunk.
/// This is acceptable: market features (42 dims) dominate the Q-network and
/// a 64-bar (~1 hour) staleness window has negligible impact on action quality.
fn submit_dqn_step_loop_cublas(
&mut self,
dqn_cfg: &DqnBacktestConfig,
mut q_provider: Option<&mut dyn super::q_value_provider::QValueProvider>,
) -> Result<(), MLError> {
let as_kernel = self.action_select_kernel.as_ref().ok_or_else(|| {
MLError::ModelError("action_select_kernel unexpectedly None".to_owned())
})?;
let ch_q_values = self.chunked_q_values.as_ref().ok_or_else(|| {
MLError::ModelError("chunked_q_values unexpectedly None".to_owned())
})?;
// Note: `chunked_states_buf` is NOT borrowed up-front. Each chunk
// iteration re-derives a fresh borrow after the optional TLOB call
// (which needs `&mut self.chunked_states_buf`). The previous shared
// borrow held across the loop blocked the TLOB mutable borrow
// required by the chunk-batched TLOB refactor.
let ch_actions = self.chunked_actions_buf.as_ref().ok_or_else(|| {
MLError::ModelError("chunked_actions_buf unexpectedly None".to_owned())
})?;
let ch_q_gaps = self.chunked_q_gaps_buf.as_ref().ok_or_else(|| {
MLError::ModelError("chunked_q_gaps_buf unexpectedly None".to_owned())
})?;
let ch_intent = self.chunked_intent_mag_buf.as_ref().ok_or_else(|| {
MLError::ModelError("chunked_intent_mag_buf unexpectedly None".to_owned())
})?;
let intent_history = self.intent_mag_buf.as_ref().ok_or_else(|| {
MLError::ModelError("intent_mag_buf unexpectedly None".to_owned())
})?;
let picked_action_history = self.picked_action_history_buf.as_ref().ok_or_else(|| {
MLError::ModelError("picked_action_history_buf unexpectedly None".to_owned())
})?;
let scatter_intent = self.scatter_intent_kernel.as_ref().ok_or_else(|| {
MLError::ModelError("scatter_intent_kernel unexpectedly None".to_owned())
})?;
let ch_conviction = self.chunked_conviction_buf.as_ref().ok_or_else(|| {
MLError::ModelError("chunked_conviction_buf unexpectedly None".to_owned())
})?;
let ch_magnitude_conviction = self.chunked_magnitude_conviction_buf.as_ref().ok_or_else(|| {
MLError::ModelError("chunked_magnitude_conviction_buf unexpectedly None".to_owned())
})?;
let ch_b_logits = self.chunked_b_logits_buf.as_ref().ok_or_else(|| {
MLError::ModelError("chunked_b_logits_buf unexpectedly None".to_owned())
})?;
// SP17 Commit C: chunked V-head logits row for Thompson direction sampling.
let ch_v_logits = self.chunked_v_logits_buf.as_ref().ok_or_else(|| {
MLError::ModelError("chunked_v_logits_buf unexpectedly None".to_owned())
})?;
let n = self.n_windows;
let state_dim = self.state_dim;
let b0 = dqn_cfg.branch_0_size as i32;
let b1 = dqn_cfg.branch_1_size as i32;
let b2 = dqn_cfg.branch_2_size as i32;
let b3 = dqn_cfg.branch_3_size as i32;
// SP21 T2.2 Phase 1.5 (2026-05-11) — total_actions for
// `q_values_per_window` indexing in the batch env kernel.
// Mirrors `dqn_cfg`'s product (line ~2714 in
// `ensure_action_select_ready` uses the same product to size
// `chunked_q_values` at `cn * total_actions`).
let num_actions_i32 = b0 * b1 * b2 * b3;
let state_dim_padded = (state_dim + 127) & !127;
let state_row_bytes = n * state_dim_padded * std::mem::size_of::<f32>();
let total_chunks = (self.max_len + DQN_BACKTEST_CHUNK_SIZE - 1) / DQN_BACKTEST_CHUNK_SIZE;
for chunk_start in (0..self.max_len).step_by(DQN_BACKTEST_CHUNK_SIZE) {
let chunk_end = (chunk_start + DQN_BACKTEST_CHUNK_SIZE).min(self.max_len);
let chunk_len = chunk_end - chunk_start;
let batch = n * chunk_len; // total rows for this chunk's forward pass
let chunk_idx = chunk_start / DQN_BACKTEST_CHUNK_SIZE;
// ── Phase 1: Gather states for the entire chunk in a single launch ────
//
// `backtest_state_gather_chunk` writes [chunk_len, n_windows, padded_sd]
// directly into chunked_states_buf with one kernel launch + zero DtoD
// copies (replaces chunk_len gather launches + chunk_len DtoD copies).
//
// Within a chunk portfolio_buf and plan_isv_buf are CONSTANT — env_step
// updates them only at chunk boundary (Phase 5 + Phase 6). So the per-
// step gather sees identical inputs and the batched gather is bit-
// identical to the prior per-step + DtoD pattern (modulo float
// reorder, of which there is none — every thread writes its own row
// from independent feature reads).
//
// After gather, TLOB runs ONCE on `chunked_states_buf` with batch
// = chunk_len * n_windows (replacing chunk_len separate forward
// calls each at batch = n_windows). The val TLOB instance is sized
// to `val_tlob_batch_size()` = DQN_BACKTEST_CHUNK_SIZE * n_windows
// by `metrics.rs::reset_evaluator_for_validation`; partial last-
// chunks (chunk_len < DQN_BACKTEST_CHUNK_SIZE) reuse the same buffers.
//
// Borrow scope: ch_states_base is computed in a tight scope so the
// immutable borrow drops before we take &mut self.chunked_states_buf
// for TLOB below. The raw u64 device pointer survives the borrow
// (it's just a number copied into a stack local).
let ch_states_base = {
let ch_states = self.chunked_states_buf.as_ref().ok_or_else(|| {
MLError::ModelError("chunked_states_buf unexpectedly None".to_owned())
})?;
let (ptr, _guard) = ch_states.device_ptr(&self.stream);
ptr
};
self.launch_gather_chunk(ch_states_base, chunk_start, chunk_len)?;
// SP21 T2.2 Phase 6.5 (2026-05-11) — retain the just-gathered
// chunk's states for hindsight injection. The chunked layout
// is `[chunk_len, n_windows, padded_sd]` (matches retained
// buffer's layout); DtoD-copy as a single contiguous block
// starting at `chunk_start × n_windows × padded_sd`. No
// transpose needed — both buffers share `[step, window,
// dim]` ordering.
//
// The retained buffer is mapped-pinned (`MappedF32Buffer`)
// per `feedback_no_htod_htoh_only_mapped_pinned`; the DtoD
// writes through the device-aliased `dev_ptr` (same memory
// the host sees via `host_ptr` after stream sync).
//
// Skip the copy if the retained buffer isn't allocated
// (test scaffolds bypassing the standard init path —
// hindsight injection just degrades to a no-op in that case).
if let Some(ref retained) = self.retained_states_buf {
let bytes = chunk_len * n * state_dim_padded
* std::mem::size_of::<f32>();
let dst_offset_bytes = (chunk_start * n * state_dim_padded
* std::mem::size_of::<f32>()) as u64;
let dst_ptr = retained.dev_ptr + dst_offset_bytes;
unsafe {
cudarc::driver::result::memcpy_dtod_async(
dst_ptr, ch_states_base, bytes, self.stream.cu_stream(),
).map_err(|e| MLError::ModelError(format!(
"retain states chunk_start={chunk_start}: {e}"
)))?;
}
}
// D.8 TLOB val-path: single forward over the entire chunk at
// batch = chunk_len * n_windows. TLOB reads OFI[42..74) and writes
// TLOB[74..90) for each row independently — safe to batch.
//
// The kernel writes only the TLOB slot [SL_TLOB_START..+SL_TLOB_DIM);
// other slots remain as the chunked gather wrote them.
if self.tlob.is_some() {
let chunked_states = self.chunked_states_buf.as_mut().ok_or_else(|| {
MLError::ModelError("chunked_states_buf unexpectedly None".to_owned())
})?;
let tlob = self.tlob.as_mut().expect("checked is_some above");
tlob.forward(chunked_states, batch)
.map_err(|e| MLError::ModelError(format!(
"val TLOB forward chunk_start={chunk_start} batch={batch}: {e}"
)))?;
}
// Sync after Phase 1 gather — catch any gather errors
if chunk_idx == 0 {
self.stream.synchronize().map_err(|e| MLError::ModelError(format!(
"backtest chunk {chunk_idx}: sync after gather phase: {e}"
)))?;
}
// ── Phase 2+3: Q-values + raw branch C51 logits via QValueProvider ──
// ch_states_base is the device pointer to chunked_states_buf base —
// computed in Phase 1 and stable across the chunk (the Option does
// not reallocate). Reuse it instead of taking another borrow.
//
// Plan C T4 (2026-04-29): the val pipeline now also requires the
// trainer's `on_b_logits_buf` (branch-major raw C51 logits) for
// Thompson direction sampling in Phase 4 below. The
// compute_q_and_b_logits_to method DtoD-copies q_out_buf AND
// on_b_logits_buf per sub-batch iteration, keeping the caller's
// chunked_b_logits_buf consistent with chunked_q_values across
// the entire chunk batch (handles the case where batch >
// trainer.batch_size and the inner loop runs multiple sub-iters).
let states_ptr = ch_states_base;
let q_out_ptr = raw_f32_ptr(ch_q_values, &self.stream);
let b_logits_out_ptr = raw_f32_ptr(ch_b_logits, &self.stream);
// SP17 Commit C: V-head logits output pointer (chunked).
let v_logits_out_ptr = raw_f32_ptr(ch_v_logits, &self.stream);
match q_provider {
Some(ref mut qp) => {
qp.compute_q_and_b_logits_to(
states_ptr, batch, q_out_ptr, b_logits_out_ptr, v_logits_out_ptr,
)
.map_err(|e| MLError::ModelError(format!(
"QValueProvider chunk_start={chunk_start}: {e}"
)))?;
}
None => {
return Err(MLError::ModelError(
"evaluate_dqn_graphed requires a QValueProvider — pass Some(trainer) or use FusedTrainingCtx".to_owned()
));
}
}
let batch_i32 = batch as i32;
// ── Phase 4: Greedy action selection on chunked Q-values ─────────
let blocks_256 = ((batch + 255) / 256) as u32;
let launch_cfg = LaunchConfig {
grid_dim: (blocks_256.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
};
let null_portfolio: u64 = 0;
let eval_min_hold: i32 = 0; // hold enforcement removed — cost-driven
let eval_max_pos: f32 = 0.0;
// Plan C T4 (2026-04-29): direction-branch Thompson buffers.
// Mirror of the gpu_experience_collector wire-up (T3, 380d0ac0d).
// Eval mode (eps_start==eps_end==0) takes the argmax-E[Q] branch
// inside the kernel (no Philox draws), but the buffers must still
// be supplied with real production data — `feedback_no_stubs`.
// ch_b_logits is BRANCH-MAJOR `[batch, total_actions * num_atoms]`;
// the kernel reads only the first `batch * b0 * num_atoms` region
// (Branch 0 = direction). per_sample_support is the trainer's
// mapped-pinned `[max_batch, 4, 3]` stride-12 buffer (sized by
// trainer.batch_size ≥ chunked batch, safe to share). Atom
// positions live on the trainer (model-global `[B0, num_atoms]`).
let qp_for_buffers = q_provider.as_ref().ok_or_else(|| MLError::ModelError(
"QValueProvider required for action_select Thompson buffers".to_owned()
))?;
let per_sample_support_ptr = qp_for_buffers.per_sample_support_ptr();
let atom_positions_ptr = qp_for_buffers.atom_positions_ptr();
let n_atoms_i32 = qp_for_buffers.num_atoms() as i32;
unsafe {
self.stream
.launch_builder(as_kernel)
.arg(ch_q_values)
.arg(ch_actions)
.arg(ch_q_gaps)
.arg(&0.0_f32) // eps_start (greedy in backtest)
.arg(&0.0_f32) // eps_end
.arg(&0_i32) // current_epoch
.arg(&1_i32) // total_epochs
.arg(&batch_i32)
.arg(&b0)
.arg(&b1)
.arg(&b2)
.arg(&self.b3_size)
.arg(&self.q_gap_threshold)
.arg(&null_portfolio)
.arg(&eval_min_hold)
.arg(&eval_max_pos)
.arg(&1.0_f32) // eps_exp_mult (no-op at eps=0)
.arg(&1.0_f32) // eps_ord_mult (no-op at eps=0)
.arg(&1.0_f32) // eps_urg_mult (no-op at eps=0)
.arg(&(chunk_start as i32)) // timestep for stateless Philox RNG
.arg(&0u64) // per_sample_epsilon: NULL → use cosine schedule
.arg(&self.isv_signals_ptr) // ISV signals for adaptive hold
.arg(&0i32) // F6: contrarian_active — always off during backtest
.arg(ch_intent) // out_intent_mag: pure-policy magnitude intent diagnostic
.arg(ch_conviction) // out_conviction: direction-branch Kelly warmup signal
.arg(ch_magnitude_conviction) // out_magnitude_conviction: magnitude-branch Kelly warmup signal
// Plan C T2 amendment buffers (direction-branch Thompson):
.arg(ch_b_logits) // b_logits_dir: [batch, b0*n_atoms] used (rest is mag/ord/urg, untouched)
// SP17 Commit C: V-head logits row for softmax(V + A_centered) sampling.
.arg(ch_v_logits) // v_logits_dir: [batch, n_atoms]
.arg(&per_sample_support_ptr) // per_sample_support: [N, 4, 3] stride-12 mapped pinned (trainer-owned)
.arg(&atom_positions_ptr) // atom_positions: [b0, n_atoms] adaptive C51 positions (trainer-owned)
.arg(&n_atoms_i32) // n_atoms: C51 atom count (i32 to match `int` ABI)
// SP15 Phase 3.5.4.c (2026-05-07): plasticity_m_warm —
// pass 0.0f during eval/backtest. Plasticity injection
// is a TRAINING-only mechanism (it resets advantage-head
// weights to break out of stuck-in-flat regimes during
// experience collection); during deterministic eval the
// weights must remain frozen at their checkpoint values.
// m_warm = 0.0f makes the action_select fire-bar
// predicate dead, and PLASTICITY_WARM_BARS_REMAINING
// stays at its 0 sentinel through eval (no producer
// running) so the OR-gate degenerates to cooldown-only.
.arg(&0.0f32)
.launch(launch_cfg)
.map_err(|e| MLError::ModelError(format!(
"chunked action_select chunk_start={chunk_start}: {e}"
)))?;
}
// ── Phase 4b: Scatter chunked intent into window-major history
//
// experience_action_select writes ch_intent[s * n + w] for batch
// index i = s * n + w. scatter_intent_chunk copies into the
// window-major intent_mag_buf at [w * max_len + (start + s)].
// Diagnostic-only — writes are additive with the Phase 5 env
// kernel (which never touches intent_mag_buf).
let start_step_i32 = chunk_start as i32;
let chunk_len_i32 = chunk_len as i32;
let scatter_threads = n * chunk_len;
let scatter_blocks = ((scatter_threads as u32) + 255) / 256;
unsafe {
self.stream
.launch_builder(scatter_intent)
.arg(ch_intent)
.arg(intent_history)
.arg(&(n as i32))
.arg(&(self.max_len as i32))
.arg(&start_step_i32)
.arg(&chunk_len_i32)
.launch(LaunchConfig {
grid_dim: (scatter_blocks.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!(
"scatter_intent_chunk chunk_start={chunk_start}: {e}"
)))?;
}
// ── Phase 4c: Parallel scatter of raw factored action picks ──
//
// Same scatter kernel, different src/dst — copies the kernel's
// RAW action_idx writes (chunked_actions_buf, before env_step
// touches anything) into picked_action_history_buf at the
// current chunk's step range. Pairs with actions_history_buf
// (POST-env_step actual_dir) so a downstream reader can compare
// the kernel's intent with the env's realisation across the
// full val window. Diagnostic-only.
unsafe {
self.stream
.launch_builder(scatter_intent)
.arg(ch_actions)
.arg(picked_action_history)
.arg(&(n as i32))
.arg(&(self.max_len as i32))
.arg(&start_step_i32)
.arg(&chunk_len_i32)
.launch(LaunchConfig {
grid_dim: (scatter_blocks.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!(
"scatter picked_actions chunk_start={chunk_start}: {e}"
)))?;
}
// ── Phase 5: Batched env_step (single launch for entire chunk) ──
//
// All chunk_len steps processed in one kernel launch. The batched
// kernel loops over steps internally, reading actions directly from
// the chunked_actions buffer. Eliminates chunk_len individual launches
// (was 512 launches → 1 launch per chunk).
let env_blocks = ((n as u32) + 255) / 256;
let feature_dim_i32 = self.feature_dim as i32;
unsafe {
self.stream
.launch_builder(&self.env_batch_kernel)
.arg(&self.prices_buf.dev_ptr)
.arg(&self.window_lens_buf.dev_ptr)
.arg(&self.features_buf.dev_ptr) // regime-adaptive trail: ADX/CUSUM source
.arg(&feature_dim_i32)
.arg(ch_actions)
.arg(&self.portfolio_buf.dev_ptr)
.arg(&self.step_rewards_buf)
.arg(&self.step_returns_buf)
.arg(&self.done_buf)
.arg(&self.actions_history_buf)
.arg(&(n as i32))
.arg(&(self.max_len as i32))
.arg(&self.config.max_position)
.arg(&self.config.tx_cost_bps)
.arg(&self.config.spread_cost)
.arg(&start_step_i32)
.arg(&chunk_len_i32)
.arg(&b0)
.arg(&b1)
.arg(&b2)
.arg(&b3)
.arg(&self.config.holding_cost_rate)
.arg(&self.config.churn_threshold)
.arg(&self.config.churn_penalty_scale)
.arg(&self.config.contract_multiplier)
.arg(&self.config.margin_pct)
.arg(&self.config.opportunity_cost_scale)
// Kelly cap shared with training: stats buffer + ISV[12]=health
.arg(&self.kelly_stats_buf)
.arg(&self.isv_signals_ptr)
// Phase 3 unified-env scalars (NULL = no-op in val mode).
.arg(&0u64) // exploration_scale_ptr (NULL)
.arg(&0u64) // shaping_scale_ptr (NULL)
.arg(ch_conviction) // conviction_ptr: direction-branch Kelly warmup signal
.arg(ch_magnitude_conviction) // magnitude_conviction_ptr: magnitude-branch Kelly warmup signal
// SP21 T2.2 Phase 1 (2026-05-10): per-trade tape outputs.
// Real device buffers — same tape semantics as the
// single-step variant; the batched step loop writes
// per-step closes into the same per-window SoA slots
// (counter persists across the batched chunk via the
// shared `per_trade_count_buf`).
.arg(&self.per_trade_pnl_buf)
.arg(&self.per_trade_holding_bars_buf)
.arg(&self.per_trade_bar_index_buf)
.arg(&self.per_trade_dir_mag_buf)
.arg(&self.per_trade_count_buf)
.arg(&(MAX_TRADES_PER_WINDOW as i32))
// SP21 T2.2 Phase 1.5 (2026-05-11) — entry_q tracking.
// Production val path: real entry_q_state +
// chunked Q-values (`ch_q_values` written by
// `compute_q_and_b_logits_to` above; layout
// `[chunk_len, n_windows, total_actions]` matching
// the batch kernel's index formula `s * n * num_act
// + w * num_act + a`). Per-trade `predicted_q`
// populated for every close emitted in this chunk.
.arg(&self.entry_q_state_buf)
.arg(ch_q_values)
.arg(&num_actions_i32)
.arg(&self.per_trade_predicted_q_buf)
.launch(LaunchConfig {
grid_dim: (env_blocks.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!(
"backtest_env_step_batch chunk {chunk_idx}: {e}"
)))?;
}
// ── Phase 6: Val plan_isv parity (task #94) ──────────────────────
//
// After Phase 5 has advanced portfolio_buf to the end-of-chunk
// state (last step = chunk_start + chunk_len - 1), compute:
// (a) plan_params[N, 6] for the LAST step's N windows via a
// targeted forward on ch_states[(chunk_len-1)*N..chunk_len*N]
// — this rewrites save_h_s2 = [N, SH2] for just those N
// rows (compute_q_values_to's earlier batched loop left
// save_h_s2 holding an arbitrary last-sub-batch slice, not
// aligned to the final-step N windows).
// (b) plan_state_isv: activation / deactivation / plan_isv
// compute so that next chunk's first launch_gather reads
// real plan_isv[7] at state positions [98..105).
//
// Using ch_q_values as scratch for the Q-output is safe — those
// Q-values were consumed by Phase 4 action-select and the Phase 5
// env step has already committed.
let plan_kernel = self.plan_state_isv_kernel.as_ref().ok_or_else(|| {
MLError::ModelError("plan_state_isv_kernel not loaded".to_owned())
})?;
let last_step = chunk_start + chunk_len - 1;
let last_step_row = chunk_len - 1;
let last_step_states_ptr = ch_states_base
+ (last_step_row * state_row_bytes) as u64;
let scratch_q_ptr = raw_f32_ptr(ch_q_values, &self.stream);
match q_provider {
Some(ref mut qp) => {
// (a.1) Forward on last-step N rows. Writes save_h_s2=[N,SH2].
qp.compute_q_values_to(last_step_states_ptr, n, scratch_q_ptr)
.map_err(|e| MLError::ModelError(format!(
"plan_params last-step forward chunk {chunk_idx}: {e}"
)))?;
// (a.2) Plan MLP on save_h_s2. Writes plan_params_buf[N,6].
let plan_params_ptr = {
let (p, g) = self.plan_params_buf.device_ptr(&self.stream);
let _ = ManuallyDrop::new(g);
p
};
qp.compute_plan_params(plan_params_ptr, n)
.map_err(|e| MLError::ModelError(format!(
"compute_plan_params chunk {chunk_idx}: {e}"
)))?;
}
None => {
return Err(MLError::ModelError(
"evaluate_dqn_graphed requires a QValueProvider for plan_params".to_owned()
));
}
}
// (b) Launch plan_state_isv kernel — updates plan_state in-place
// and writes plan_isv_buf[N, 7] consumed by the next chunk's
// first launch_gather() call.
//
// Fix 30 Stale-B (2026-05-02): the kernel previously took
// `features` + `feat_dim` to extract raw_close as
// `features[bar*feat_dim + 0]`, but post-Bug-1 that slot is
// z-normed log-return (NOT raw_close). Now passes the
// upload-once `prices` buffer (layout [n*max_len*4] OHLC,
// close at col 3) — the same source backtest_env_kernel.cu
// already uses for portfolio mark-to-market. `feat_dim` arg
// dropped because the kernel no longer needs feature stride.
let plan_blocks = ((n as u32) + 255) / 256;
let n_i32 = n as i32;
let last_step_i32 = last_step as i32;
let max_len_i32 = self.max_len as i32;
unsafe {
self.stream
.launch_builder(plan_kernel)
.arg(&self.plan_params_buf)
.arg(&self.plan_state_buf)
.arg(&self.portfolio_buf.dev_ptr)
.arg(&self.isv_signals_ptr)
.arg(&self.prices_buf.dev_ptr)
.arg(&last_step_i32)
.arg(&max_len_i32)
.arg(&self.plan_isv_buf)
.arg(&n_i32)
.launch(LaunchConfig {
grid_dim: (plan_blocks.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!(
"backtest_plan_state_isv chunk {chunk_idx}: {e}"
)))?;
}
// Periodic sync: catch deferred CUDA errors without stalling
// every chunk. Errors in chunk N surface within 10 chunks.
if chunk_idx % 10 == 0 || chunk_idx + 1 == total_chunks {
self.stream.synchronize().map_err(|e| MLError::ModelError(format!(
"backtest chunk {chunk_idx}/{total_chunks} (steps {chunk_start}..{chunk_end}) sync: {e}"
)))?;
}
}
Ok(())
}
// ── PPO evaluation (pure CUDA) ───────────────────────────────────────────
/// Pure-GPU PPO evaluation — actor forward, softmax, exposure collapse, argmax.
///
/// Takes PPO actor weights directly, bypassing candle entirely for the
/// forward pass. The only GPU→CPU transfer is the final metrics readback.
///
/// Call `reset_mutable_state()` before re-running on the same evaluator
/// instance if portfolio state should be reinitialised.
pub fn evaluate_ppo(
&mut self,
actor_weights: &PpoActorWeightSet,
) -> Result<Vec<WindowMetrics>, MLError> {
let context = self.stream.context();
// Load precompiled PPO forward cubin
let ppo_module = context
.load_cubin(PPO_FORWARD_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("PPO forward module load: {e}")))?;
let ppo_kernel = ppo_module
.load_function("backtest_forward_ppo_kernel")
.map_err(|e| MLError::ModelError(format!("backtest_forward_ppo_kernel load: {e}")))?;
for step in 0..self.max_len {
// 1. Gather states via GPU kernel — writes into self.states_buf
self.launch_gather(step)?;
// 2. Launch PPO forward kernel: states → actions (pure CUDA, no candle)
// grid=(ceil(N/32),1,1), block=(32,1,1) — 32 independent windows/block
let n_i32 = self.n_windows as i32;
let ppo_cfg = LaunchConfig {
grid_dim: (((self.n_windows as u32) + 31) / 32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
// Safety: argument order matches backtest_forward_ppo_kernel signature:
// states, pw1, pb1, pw2, pb2, pw3, pb3, out_actions, N, state_dim, num_actions
let state_dim_ppo_i32 = self.state_dim as i32;
let num_actions_ppo_i32 = 45_i32; // PPO_NUM_ACTIONS
unsafe {
self.stream
.launch_builder(&ppo_kernel)
.arg(&self.states_buf)
.arg(&actor_weights.pw1)
.arg(&actor_weights.pb1)
.arg(&actor_weights.pw2)
.arg(&actor_weights.pb2)
.arg(&actor_weights.pw3)
.arg(&actor_weights.pb3)
.arg(&self.actions_buf)
.arg(&n_i32)
.arg(&state_dim_ppo_i32)
.arg(&num_actions_ppo_i32)
.launch(ppo_cfg)
.map_err(|e| {
MLError::ModelError(format!(
"backtest_forward_ppo_kernel launch step {step}: {e}"
))
})?;
}
// 3. Launch env step kernel
self.launch_env_step(step)?;
}
// Synchronous launch+consume — `evaluate_ppo` is a single-shot path.
self.launch_metrics_and_record_event()?;
self.consume_metrics_after_event()
}
// ── Supervised evaluation (hybrid: candle forward + CUDA signal→action) ──
/// GPU-accelerated supervised model evaluation.
///
/// The model forward pass uses candle (unavoidable for complex architectures
/// like TFT, Mamba, etc.), but signal→action conversion is a pure CUDA kernel
/// replacing the candle `signal_to_action_scores` + `argmax` pipeline.
///
/// # Arguments
/// * `forward_fn` — model forward: `&CudaSlice<f32>[N * state_dim]` → `CudaSlice<f32>[N]` predictions
/// * `high_threshold_bps` — strong signal threshold (actions 0 and 4)
/// * `low_threshold_bps` — mild signal threshold (actions 1 and 3)
/// * `portfolio_dim` — must be 24 (8 portfolio + 16 multi-timeframe)
pub fn evaluate_supervised<F>(
&mut self,
forward_fn: &F,
high_threshold_bps: f32,
low_threshold_bps: f32,
portfolio_dim: usize,
) -> Result<Vec<WindowMetrics>, MLError>
where
F: Fn(&CudaSlice<f32>, usize, usize) -> Result<CudaSlice<f32>, MLError>,
{
if portfolio_dim != self.portfolio_dim {
return Err(MLError::ConfigError(format!(
"evaluate_supervised: portfolio_dim={portfolio_dim} != expected {}",
self.portfolio_dim
)));
}
let context = self.stream.context();
// Load precompiled supervised signal cubin
let sig_module = context
.load_cubin(SUPERVISED_SIGNAL_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("supervised signal module load: {e}")))?;
let sig_kernel = sig_module
.load_function("signal_to_action_kernel")
.map_err(|e| MLError::ModelError(format!("signal_to_action_kernel load: {e}")))?;
let state_dim = self.state_dim;
for step in 0..self.max_len {
// 1. Gather state CudaSlice via GPU kernel
let states = self.gather_states(step, portfolio_dim, &())?;
// 2. Model forward pass (pure cudarc, on-device)
let pred_slice = forward_fn(&states, self.n_windows, state_dim)?;
// 3. Launch signal_to_action_kernel: predictions → actions (pure CUDA)
let n_i32 = self.n_windows as i32;
let grid = ((self.n_windows + 255) / 256) as u32;
let sig_cfg = LaunchConfig {
grid_dim: (grid.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
};
// Safety: argument order matches signal_to_action_kernel:
// predictions, out_actions, high_threshold_bps, low_threshold_bps, N
unsafe {
self.stream
.launch_builder(&sig_kernel)
.arg(&pred_slice)
.arg(&self.actions_buf)
.arg(&high_threshold_bps)
.arg(&low_threshold_bps)
.arg(&n_i32)
.launch(sig_cfg)
.map_err(|e| {
MLError::ModelError(format!(
"signal_to_action_kernel launch step {step}: {e}"
))
})?;
}
// 4. Launch env step kernel
self.launch_env_step(step)?;
}
// Synchronous launch+consume — `evaluate_supervised` is a single-shot path.
self.launch_metrics_and_record_event()?;
self.consume_metrics_after_event()
}
// ── DQN forward helpers ──────────────────────────────────────────────
/// Lazily initialise action-selection kernels, RNG states, and chunked
/// Q-value / state / action buffers.
///
/// Called once on first `evaluate_dqn_graphed()`. The forward pass itself
/// is handled by the `QValueProvider` (trainer's CUDA-Graphed cuBLAS);
/// this method only sets up the action-selection and env-step infrastructure.
fn ensure_action_select_ready(&mut self, dqn_cfg: &DqnBacktestConfig) -> Result<(), MLError> {
if self.action_select_kernel.is_some() {
return Ok(());
}
let n = self.n_windows;
let total_actions = dqn_cfg.branch_0_size + dqn_cfg.branch_1_size + dqn_cfg.branch_2_size + dqn_cfg.branch_3_size;
// Compile experience kernels for action_select
let context = self.stream.context();
let dqn_module = context
.load_cubin(BACKTEST_DQN_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("backtest DQN module load: {e}")))?;
let as_kernel = dqn_module
.load_function("experience_action_select")
.map_err(|e| MLError::ModelError(format!("experience_action_select load: {e}")))?;
let scatter_intent = dqn_module
.load_function("scatter_intent_chunk")
.map_err(|e| MLError::ModelError(format!("scatter_intent_chunk load: {e}")))?;
// Val plan_isv parity (task #94, 2026-04-24): load the two kernels
// from the new backtest_plan_kernel cubin. See docstring in
// backtest_plan_kernel.cu for the activation / isv-compute /
// diagnostic-reduce pipeline.
let plan_module = context
.load_cubin(BACKTEST_PLAN_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("backtest_plan module load: {e}")))?;
let plan_state_isv_kernel_fn = plan_module
.load_function("backtest_plan_state_isv")
.map_err(|e| MLError::ModelError(format!("backtest_plan_state_isv load: {e}")))?;
let plan_diag_reduce_kernel_fn = plan_module
.load_function("backtest_plan_diag_reduce")
.map_err(|e| MLError::ModelError(format!("backtest_plan_diag_reduce load: {e}")))?;
self.plan_state_isv_kernel = Some(plan_state_isv_kernel_fn);
self.plan_diag_reduce_kernel = Some(plan_diag_reduce_kernel_fn);
// ── Chunked buffers (batch_size = n_windows * CHUNK_SIZE) ────────────
let chunk = DQN_BACKTEST_CHUNK_SIZE;
let cn = n * chunk; // chunked batch dimension
let ch_q_values = self.stream.alloc_zeros::<f32>(cn * total_actions)
.map_err(|e| MLError::ModelError(format!("alloc chunked q_values: {e}")))?;
// Plan C T4 (2026-04-29): chunked branch-major raw C51 logits buffer.
// Sized [cn, total_actions * num_atoms] — DtoD-populated each chunk
// by `compute_q_and_b_logits_to`. Direction-branch slice consumed by
// `experience_action_select` Thompson direction sampling. Padded by
// 32*3 floats to mirror the trainer's allocation pattern (cuBLAS
// tail safety; see gpu_dqn_trainer.rs:9759).
let total_branch_atoms_eval = total_actions * dqn_cfg.num_atoms;
let ch_b_logits = self.stream.alloc_zeros::<f32>(cn * total_branch_atoms_eval + 32 * 3)
.map_err(|e| MLError::ModelError(format!("alloc chunked b_logits: {e}")))?;
// SP17 Commit C (2026-05-08): chunked V-head logits buffer, sized
// `[cn, num_atoms]` sample-major. DtoD-populated each chunk by
// `compute_q_and_b_logits_to` and consumed by the Thompson direction
// selector in `experience_action_select` (closes the
// `softmax(V + A_centered)` contract gap). Same `+ 32*3` cuBLAS tail
// safety pad as ch_b_logits.
let ch_v_logits = self.stream.alloc_zeros::<f32>(cn * dqn_cfg.num_atoms + 32 * 3)
.map_err(|e| MLError::ModelError(format!("alloc chunked v_logits: {e}")))?;
let state_dim_padded = (self.state_dim + 127) & !127;
let ch_states_buf = self.stream.alloc_zeros::<f32>(cn * state_dim_padded)
.map_err(|e| MLError::ModelError(format!("alloc chunked states_buf: {e}")))?;
// SP21 T2.2 Phase 6.5 (2026-05-11): full-window retained-states
// buffer (mapped-pinned per `feedback_no_htod_htoh_only_mapped_
// pinned`). Size = max_len × n_windows × state_dim_padded.
// Layout matches the chunked layout `[step, window, dim]` so
// each chunk's `ch_states_buf` DtoD-copies into this buffer's
// `dev_ptr` at offset `chunk_start × n_windows × state_dim_
// padded` with no transpose. Host-side hindsight injection
// reads via `host_ptr` (zero-copy) after an eval stream sync.
// Lazy-init alongside other action-select scratch; freed when
// the evaluator is dropped (via `MappedF32Buffer`'s `Drop`).
let retained_states_buf = unsafe {
MappedF32Buffer::new(self.max_len * n * state_dim_padded)
.map_err(|e| MLError::ModelError(format!(
"alloc retained_states_buf (mapped-pinned): {e}"
)))?
};
let ch_actions_buf = self.stream.alloc_zeros::<i32>(cn)
.map_err(|e| MLError::ModelError(format!("alloc chunked actions_buf: {e}")))?;
let ch_intent_mag_buf = self.stream.alloc_zeros::<i32>(cn)
.map_err(|e| MLError::ModelError(format!("alloc chunked intent_mag_buf: {e}")))?;
// Intent-magnitude history: parallel layout to actions_history_buf.
let intent_mag_buf = self.stream.alloc_zeros::<i32>(n * self.max_len)
.map_err(|e| MLError::ModelError(format!("alloc intent_mag_buf: {e}")))?;
// Picked-action history: parallel layout to actions_history_buf,
// captures the kernel's RAW factored action_idx PRE-env_step. Filled
// by scattering chunked_actions_buf into per-step slots (reuses the
// scatter_intent_chunk kernel — same scatter semantics, different
// src/dst). Diagnostic-only.
let picked_action_history_buf = self.stream.alloc_zeros::<i32>(n * self.max_len)
.map_err(|e| MLError::ModelError(format!("alloc picked_action_history_buf: {e}")))?;
// Mapped-pinned mirrors for the two diagnostic history buffers above.
// Filled via `memcpy_dtod_async` on the eval stream before
// `eval_done_event` is recorded; read by the per-direction / intent /
// picked distribution helpers after the event syncs. See
// `actions_history_pinned` in the struct field docstring.
let intent_mag_pinned = unsafe {
MappedI32Buffer::new(n * self.max_len)
.map_err(|e| MLError::ModelError(format!(
"intent_mag mapped-pinned alloc: {e}"
)))?
};
let picked_action_history_pinned = unsafe {
MappedI32Buffer::new(n * self.max_len)
.map_err(|e| MLError::ModelError(format!(
"picked_action_history mapped-pinned alloc: {e}"
)))?
};
let ch_q_gaps = self.stream.alloc_zeros::<f32>(cn)
.map_err(|e| MLError::ModelError(format!("alloc chunked q_gaps: {e}")))?;
/* Per-sample conviction for adaptive Kelly warmup floor. Layout matches
* chunked_actions/chunked_q_gaps — [chunk_len * n_windows]. */
let ch_conviction = self.stream.alloc_zeros::<f32>(cn)
.map_err(|e| MLError::ModelError(format!("alloc chunked conviction: {e}")))?;
/* Parallel magnitude-branch conviction — same shape, threaded into
* action_select + env_step_batch alongside the direction-branch
* conviction so the Kelly cap's max(dir, mag) composer can relax
* for confident magnitude picks. */
let ch_magnitude_conviction = self.stream.alloc_zeros::<f32>(cn)
.map_err(|e| MLError::ModelError(format!("alloc chunked magnitude conviction: {e}")))?;
info!(
"GpuBacktestEvaluator: action-select initialised — {} windows, \
branches=[{},{},{},{}], chunk_size={chunk}, chunked_batch={cn}",
n, dqn_cfg.branch_0_size, dqn_cfg.branch_1_size, dqn_cfg.branch_2_size, dqn_cfg.branch_3_size,
);
self.b0_size = dqn_cfg.branch_0_size as i32;
self.b1_size = dqn_cfg.branch_1_size as i32;
self.b2_size = dqn_cfg.branch_2_size as i32;
self.b3_size = dqn_cfg.branch_3_size as i32;
self.action_select_kernel = Some(as_kernel);
self.chunked_q_values = Some(ch_q_values);
self.chunked_b_logits_buf = Some(ch_b_logits);
self.chunked_v_logits_buf = Some(ch_v_logits);
self.chunked_states_buf = Some(ch_states_buf);
self.retained_states_buf = Some(retained_states_buf);
self.chunked_actions_buf = Some(ch_actions_buf);
self.chunked_intent_mag_buf = Some(ch_intent_mag_buf);
self.chunked_q_gaps_buf = Some(ch_q_gaps);
self.intent_mag_buf = Some(intent_mag_buf);
self.picked_action_history_buf = Some(picked_action_history_buf);
self.intent_mag_pinned = Some(intent_mag_pinned);
self.picked_action_history_pinned = Some(picked_action_history_pinned);
self.scatter_intent_kernel = Some(scatter_intent);
self.chunked_conviction_buf = Some(ch_conviction);
self.chunked_magnitude_conviction_buf = Some(ch_magnitude_conviction);
Ok(())
}
/// Launch the `gather_states` GPU kernel for a given step.
///
/// Writes `[n_windows, state_dim]` floats into `self.states_buf`.
/// Does NOT produce a Candle Tensor — the pure-CUDA `evaluate_dqn` and
/// `evaluate_ppo` paths read directly from `states_buf`.
fn launch_gather(&self, step: usize) -> Result<(), MLError> {
// Shared memory: 256 threads × 8 portfolio f32 × 4 bytes = 8 KB
let grid = ((self.n_windows + 255) / 256) as u32;
let launch_cfg = LaunchConfig {
grid_dim: (grid.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 256 * 8 * std::mem::size_of::<f32>() as u32,
};
let n_windows_i32 = self.n_windows as i32;
let max_len_i32 = self.max_len as i32;
let feat_dim_i32 = self.feature_dim as i32;
let step_i32 = step as i32;
let padded_sd_i32 = ml_core::state_layout::STATE_DIM_PADDED as i32;
// Safety: argument order matches `backtest_state_gather` signature exactly:
// features, portfolio, plan_isv, states_out, n_windows, max_len, feat_dim,
// current_step, padded_sd, aux_dir_prob_per_env (SP22 H6).
// SP22 H6 Phase 1 A3: NULL → kernel uses 0.5 sentinel.
let aux_dir_prob_null: u64 = 0;
unsafe {
self.stream
.launch_builder(&self.gather_kernel)
.arg(&self.features_buf.dev_ptr)
.arg(&self.portfolio_buf.dev_ptr)
.arg(&self.plan_isv_buf)
.arg(&self.states_buf)
.arg(&n_windows_i32)
.arg(&max_len_i32)
.arg(&feat_dim_i32)
.arg(&step_i32)
.arg(&padded_sd_i32)
.arg(&aux_dir_prob_null) // SP22 H6 A3: NULL → 0.5 sentinel
.launch(launch_cfg)
.map_err(|e| {
MLError::ModelError(format!("backtest_state_gather launch step {step}: {e}"))
})?;
}
Ok(())
}
/// Launch `backtest_state_gather_chunk` — batches an entire chunk of
/// `chunk_len` steps × `n_windows` rows into a single kernel launch,
/// writing directly into `chunked_states_out` in `[chunk_len, N, padded_sd]`
/// layout. Replaces the prior per-step `launch_gather` + DtoD copy loop.
///
/// Within a chunk `portfolio_buf` and `plan_isv_buf` are constant — the
/// env-batch kernel updates them only at chunk boundary. So the gather
/// for all `chunk_len` steps reads identical per-window portfolio /
/// plan_isv inputs and writes a unique state row per (window, step_offset)
/// pair. See the docstring on the kernel itself for layout details.
///
/// `chunked_states_dev_ptr` must be the device pointer to the start of
/// the chunk's portion of `chunked_states_buf` (i.e. base; the kernel
/// writes rows `0..chunk_len * n_windows`). Caller is responsible for
/// `chunk_len <= DQN_BACKTEST_CHUNK_SIZE` (the buffer was sized for
/// `cn = DQN_BACKTEST_CHUNK_SIZE * n_windows` rows).
fn launch_gather_chunk(
&self,
chunked_states_dev_ptr: u64,
start_step: usize,
chunk_len: usize,
) -> Result<(), MLError> {
let total_threads = (self.n_windows * chunk_len) as u32;
// Block size 256 — same as single-step gather, well within L40S
// 1024-thread per-block limit. Grid sized to cover all
// (window, step_offset) pairs.
let blocks = (total_threads + 255) / 256;
let launch_cfg = LaunchConfig {
grid_dim: (blocks.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
};
let n_windows_i32 = self.n_windows as i32;
let max_len_i32 = self.max_len as i32;
let feat_dim_i32 = self.feature_dim as i32;
let start_step_i32 = start_step as i32;
let chunk_len_i32 = chunk_len as i32;
let padded_sd_i32 = ml_core::state_layout::STATE_DIM_PADDED as i32;
// Safety: argument order matches `backtest_state_gather_chunk` signature exactly:
// features, portfolio, plan_isv, chunked_states_out (raw u64 ptr),
// n_windows, max_len, feat_dim, start_step, chunk_len, padded_sd,
// aux_dir_prob_per_env (SP22 H6).
// SP22 H6 Phase 1 A3: NULL → kernel uses 0.5 sentinel.
let aux_dir_prob_null: u64 = 0;
unsafe {
self.stream
.launch_builder(&self.gather_chunk_kernel)
.arg(&self.features_buf.dev_ptr)
.arg(&self.portfolio_buf.dev_ptr)
.arg(&self.plan_isv_buf)
.arg(&chunked_states_dev_ptr)
.arg(&n_windows_i32)
.arg(&max_len_i32)
.arg(&feat_dim_i32)
.arg(&start_step_i32)
.arg(&chunk_len_i32)
.arg(&padded_sd_i32)
.arg(&aux_dir_prob_null) // SP22 H6 A3: NULL → 0.5 sentinel
.launch(launch_cfg)
.map_err(|e| MLError::ModelError(format!(
"backtest_state_gather_chunk launch start_step={start_step} chunk_len={chunk_len}: {e}"
)))?;
}
Ok(())
}
/// Launch `backtest_env_step` kernel for a given step on `self.stream`.
fn launch_env_step(&self, step: usize) -> Result<(), MLError> {
// Shared memory: 256 threads × PORTFOLIO_STATE_SIZE(8) f32 × 4 bytes = 8 KB
let grid = ((self.n_windows + 255) / 256) as u32;
let env_cfg = LaunchConfig {
grid_dim: (grid.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 256 * 8 * std::mem::size_of::<f32>() as u32,
};
let n_win_i32 = self.n_windows as i32;
let max_len_i32 = self.max_len as i32;
let step_i32 = step as i32;
// Branch sizes for factored action decoding (set from DqnBacktestConfig in ensure_action_select_ready).
let b0_i32 = self.b0_size;
let b1_i32 = self.b1_size;
let b2_i32 = self.b2_size;
let feature_dim_i32 = self.feature_dim as i32;
unsafe {
self.stream
.launch_builder(&self.env_kernel)
.arg(&self.prices_buf.dev_ptr)
.arg(&self.window_lens_buf.dev_ptr)
.arg(&self.features_buf.dev_ptr) // regime-adaptive trail: ADX/CUSUM source
.arg(&feature_dim_i32)
.arg(&self.actions_buf)
.arg(&self.portfolio_buf.dev_ptr)
.arg(&self.step_rewards_buf)
.arg(&self.step_returns_buf)
.arg(&self.done_buf)
.arg(&self.actions_history_buf)
.arg(&n_win_i32)
.arg(&max_len_i32)
.arg(&self.config.max_position)
.arg(&self.config.tx_cost_bps)
.arg(&self.config.spread_cost)
.arg(&step_i32)
.arg(&b0_i32)
.arg(&b1_i32)
.arg(&b2_i32)
.arg(&self.b3_size)
.arg(&self.config.holding_cost_rate)
.arg(&self.config.churn_threshold)
.arg(&self.config.churn_penalty_scale)
.arg(&self.config.contract_multiplier)
.arg(&self.config.margin_pct)
.arg(&self.config.opportunity_cost_scale)
// Kelly cap shared with training: stats buffer + ISV[12]=health
.arg(&self.kelly_stats_buf)
.arg(&self.isv_signals_ptr)
// Phase 3 unified-env scalars (NULL = no-op in val mode).
// ABI alignment with future unified_env_kernel; saboteur
// and shaping are not present in val so the values would
// be 0.0 anyway.
.arg(&0u64) // exploration_scale_ptr (NULL)
.arg(&0u64) // shaping_scale_ptr (NULL)
.arg(&0u64) // conviction_ptr (NULL) — fallback 1.0 in-kernel
.arg(&0u64) // magnitude_conviction_ptr (NULL) — fallback 0.0 in-kernel; max(dir, mag) → dir alone
// SP21 T2.2 Phase 1 (2026-05-10): per-trade tape outputs.
// Real device buffers wired — kernel emits per-trade
// records at every trade close into the SoA buffers, with
// race-free per-window counter increment. Buffers are
// reset to zero at the start of each eval window via
// `reset_per_trade_tape()`. Read host-side via
// `read_per_trade_tape()` after the eval window completes.
.arg(&self.per_trade_pnl_buf)
.arg(&self.per_trade_holding_bars_buf)
.arg(&self.per_trade_bar_index_buf)
.arg(&self.per_trade_dir_mag_buf)
.arg(&self.per_trade_count_buf)
.arg(&(MAX_TRADES_PER_WINDOW as i32))
// SP21 T2.2 Phase 1.5 (2026-05-11) — entry_q tracking.
// The single-step `evaluate()` path (used by hyperopt /
// smoke tests / non-DQN callers) receives only action
// indices from forward_fn — Q-values aren't exposed at
// the closure boundary. Pass real entry_q_state buffer
// (kernel still snapshots the state at potential close)
// and NULL q_values_per_window (kernel skips the
// open-time write). Per-trade `predicted_q` for trades
// closed by this path stays at buffer-init 0.0.
// Production val pipeline goes through
// `evaluate_dqn_graphed_async` → `submit_dqn_step_loop_cublas`,
// which DOES wire real q_values; see the batch launcher
// arg block in that method.
.arg(&self.entry_q_state_buf)
.arg(&0u64) // q_values_per_window = NULL
.arg(&0i32) // num_actions = 0 (NULL guard short-circuits)
.arg(&self.per_trade_predicted_q_buf)
.launch(env_cfg)
.map_err(|e| {
MLError::ModelError(format!("backtest_env_step launch step {step}: {e}"))
})?;
}
Ok(())
}
/// Launch the metrics reduction kernel and queue async DtoD copies of the
/// per-step action history buffers into their mapped-pinned mirrors. Records
/// `eval_done_event` on `self.stream` after the queued work, then returns
/// **without** any host-side wait.
///
/// This is the launch half of the perf-fix split (2026-04-28). The companion
/// `consume_metrics_after_event` calls `event.synchronize()` and reads through
/// the mapped-pinned `host_ptr`. Splitting the two lets the host CPU thread
/// submit the next epoch's training kernels on `cuda_stream` while eval drains
/// asynchronously on `validation_stream`.
///
/// Shared by all evaluation paths via the thin `evaluate_*` wrappers.
pub fn launch_metrics_and_record_event(&mut self) -> Result<(), MLError> {
// Shared memory: 11 reduction arrays × 256 threads × 4 bytes = 11 KB
// + 4096 floats for sort scratch / boundary data = 16 KB
// Total = 27 KB (well within the 48 KB L1/shmem limit).
// SP15 Phase 1.1.b shrunk this from 6 → 5 reduction arrays after the
// s_sq_sum reduction (sharpe-only) was removed; sharpe is now computed
// by the dedicated `sharpe_per_bar_kernel` launched per-window below.
// 2026-05-09 — per-regime WR adds 6 reduction arrays
// (s_trades_T/R/V + s_wins_T/R/V), bumping reduction-array count
// 5 → 11. The boundary buffer slot count also grows 7 → 9 to
// carry the open-bar regime + last-open-bar regime per chunk;
// those still live within the 4096-float `s_sorted` reservation.
let shmem_bytes = (256_u32 * 11 + 4096) * std::mem::size_of::<f32>() as u32;
let metrics_cfg = LaunchConfig {
grid_dim: (self.n_windows as u32, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: shmem_bytes,
};
let n_win_i32 = self.n_windows as i32;
let max_len_i32 = self.max_len as i32;
let annualization: f32 = self.annualization_factor;
// Safety: argument order matches `compute_backtest_metrics` signature exactly.
let num_actions_i32 = self.b0_size;
let order_actions_i32 = self.b1_size;
let urgency_actions_i32 = self.b2_size;
// 2026-05-09 — per-regime WR reads `features[bar_idx*feature_dim+40]`
// (ADX-norm) per bar. The features buffer was uploaded to GPU via
// mapped-pinned at construct time; pass its device pointer + the
// feature_dim scalar so the kernel can index per-window per-bar.
let feature_dim_i32 = self.feature_dim as i32;
// Pass mapped-pinned device pointer as kernel arg (f32* slot). The
// kernel writes through `dev_ptr`; the host reads `host_ptr` after
// sync. Same pattern as the IQN total_loss readback in `gpu_iqn_head.rs`.
let metrics_dev_ptr = self.metrics_buf.dev_ptr;
unsafe {
self.stream
.launch_builder(&self.metrics_kernel)
.arg(&self.step_returns_buf)
.arg(&self.portfolio_buf.dev_ptr)
.arg(&self.window_lens_buf.dev_ptr)
.arg(&self.actions_history_buf)
.arg(&self.features_buf.dev_ptr)
.arg(&feature_dim_i32)
.arg(&metrics_dev_ptr)
.arg(&n_win_i32)
.arg(&max_len_i32)
.arg(&annualization)
.arg(&num_actions_i32)
.arg(&order_actions_i32)
.arg(&urgency_actions_i32)
.launch(metrics_cfg)
.map_err(|e| MLError::ModelError(format!("compute_backtest_metrics launch: {e}")))?;
}
// SP15 Phase 1.1.b — dedicated per-bar Sharpe kernel.
// The fused metrics kernel above no longer computes Sharpe; the spec
// calls for the unified `sharpe_per_bar_kernel` to be the sole Sharpe
// producer for both train and val paths (see launcher docstring at
// gpu_dqn_trainer.rs::launch_sp15_sharpe_per_bar). The kernel is
// single-block by design (BLOCK=256, `if (blockIdx.x != 0) return;`)
// so per-window invocation requires N launches against
// `step_returns_buf + w*max_len*sizeof::<f32>()`. n_windows is small
// (typically 5) — the launches sit on the same stream as the metrics
// kernel and inherit its event-record dependency. No host wait
// between them; no extra synchronisation. Annualisation is applied
// host-side in `consume_metrics_after_event`.
{
let (step_returns_base, ptr_guard) =
self.step_returns_buf.device_ptr(&self.stream);
// Pin the device-ptr lifetime for the duration of the launches
// below — same precedent as `raw_f32_ptr` further down. The
// launches are queued synchronously to the stream, and the
// slice itself is owned by `self`, so the ptr remains valid
// past the borrow.
let _no_drop = ManuallyDrop::new(ptr_guard);
let elem_bytes = std::mem::size_of::<f32>() as u64;
let stride_bytes = (self.max_len as u64) * elem_bytes;
let sharpe_dev_base = self.sharpe_buf.dev_ptr;
// Read window lengths from the mapped-pinned host alias — the
// buffer was populated at construct time via `write_from_slice`
// and is otherwise read-only, so no sync barrier is required.
// `read_all()` performs `read_volatile` per entry to defeat any
// stale CPU cache line from before the construction store.
let window_lens_host: Vec<i32> = self.window_lens_buf.read_all();
for w in 0..self.n_windows {
let pnl_dev = step_returns_base + (w as u64) * stride_bytes;
let out_dev = sharpe_dev_base + (w as u64) * 3u64 * elem_bytes;
let n_w = window_lens_host[w];
super::gpu_dqn_trainer::launch_sp15_sharpe_per_bar(
&self.stream,
pnl_dev,
n_w,
out_dev,
)?;
}
}
// ── SP15 Wave 3b — val-cost-streams launch sequence ─────────────────
//
// 1) `launch_sp15_position_history_derivation` — single launch over
// all windows (grid = [n_windows, 1, 1]); each block walks one
// window's `actions_history_buf` sequentially, writing per-bar
// `position_history` / `side_ind` / `rt_ind` f32 streams.
//
// 2) Per-window `launch_sp15_cost_net_sharpe` — pnl from the
// per-window slice of `step_returns_buf`; half_spread / ofi /
// position / side_ind / rt_ind from the SoA streams produced
// above, sliced per window. Output `[mean, std, raw_sharpe]`
// written to `cost_net_sharpe_buf[w * 3..(w+1) * 3]`.
//
// 3) Per-window × 4 baseline launches (`buyhold` / `hold_only` /
// `naive_momentum` / `naive_reversion`). Each takes per-window
// slices of `close_prices_buf`, `half_spread_buf`,
// `ofi_scalar_buf` + the host-precomputed `commission_per_rt`,
// writing `[mean, std, raw_sharpe]` into its own per-window
// output buffer slot.
//
// All launches sit on `self.stream` and inherit the existing
// `eval_done_event` / DtoD-async dependency — no extra
// synchronisation needed. Annualisation is applied host-side in
// `consume_metrics_after_event`.
{
let n_win_i32_local = self.n_windows as i32;
let max_len_i32_local = self.max_len as i32;
// 1) Position-history derivation — one grid launch over all windows.
let (actions_hist_ptr, actions_hist_guard) =
self.actions_history_buf.device_ptr(&self.stream);
let _no_drop_actions = ManuallyDrop::new(actions_hist_guard);
super::gpu_dqn_trainer::launch_sp15_position_history_derivation(
&self.stream,
actions_hist_ptr,
n_win_i32_local,
max_len_i32_local,
self.b1_size,
self.b2_size,
self.b3_size,
self.position_history_buf.dev_ptr,
self.side_ind_buf.dev_ptr,
self.rt_ind_buf.dev_ptr,
)?;
// 2) + 3) Per-window cost-net + 4 baseline launches.
let (step_returns_base2, step_returns_guard2) =
self.step_returns_buf.device_ptr(&self.stream);
let _no_drop_returns = ManuallyDrop::new(step_returns_guard2);
let elem_bytes = std::mem::size_of::<f32>() as u64;
let stream_stride_bytes = (self.max_len as u64) * elem_bytes;
let isv_ptr_local = self.isv_signals_ptr;
let commission = self.commission_per_rt;
let cost_net_base = self.cost_net_sharpe_buf.dev_ptr;
let baseline_buyhold_base = self.baseline_buyhold_sharpe_buf.dev_ptr;
let baseline_hold_only_base = self.baseline_hold_only_sharpe_buf.dev_ptr;
let baseline_momentum_base = self.baseline_momentum_sharpe_buf.dev_ptr;
let baseline_reversion_base = self.baseline_reversion_sharpe_buf.dev_ptr;
let close_prices_base = self.close_prices_buf.dev_ptr;
let half_spread_base = self.half_spread_buf.dev_ptr;
let ofi_scalar_base = self.ofi_scalar_buf.dev_ptr;
let position_history_base = self.position_history_buf.dev_ptr;
let side_ind_base = self.side_ind_buf.dev_ptr;
let rt_ind_base = self.rt_ind_buf.dev_ptr;
let window_lens_host_v: Vec<i32> = self.window_lens_buf.read_all();
for w in 0..self.n_windows {
let n_w = window_lens_host_v[w];
if n_w <= 0 {
continue;
}
let off_stream = (w as u64) * stream_stride_bytes;
let off_three = (w as u64) * 3u64 * elem_bytes;
let pnl_dev = step_returns_base2 + off_stream;
let half_spread_dev = half_spread_base + off_stream;
let ofi_dev = ofi_scalar_base + off_stream;
let close_prices_dev = close_prices_base + off_stream;
let position_dev = position_history_base + off_stream;
let side_ind_dev = side_ind_base + off_stream;
let rt_ind_dev = rt_ind_base + off_stream;
// Cost-net sharpe (1.2.b).
super::gpu_dqn_trainer::launch_sp15_cost_net_sharpe(
&self.stream,
pnl_dev,
half_spread_dev,
ofi_dev,
side_ind_dev,
rt_ind_dev,
position_dev,
n_w,
commission,
isv_ptr_local,
cost_net_base + off_three,
&self.sp15_cost_net_sharpe_kernel,
)?;
// Buyhold baseline (1.4.b).
super::gpu_dqn_trainer::launch_sp15_baseline_buyhold(
&self.stream,
close_prices_dev,
half_spread_dev,
ofi_dev,
n_w,
commission,
baseline_buyhold_base + off_three,
&self.sp15_baseline_buyhold_kernel,
)?;
// Hold-only baseline.
super::gpu_dqn_trainer::launch_sp15_baseline_hold_only(
&self.stream,
close_prices_dev,
half_spread_dev,
ofi_dev,
n_w,
commission,
baseline_hold_only_base + off_three,
&self.sp15_baseline_hold_only_kernel,
)?;
// Naive-momentum baseline.
super::gpu_dqn_trainer::launch_sp15_baseline_naive_momentum(
&self.stream,
close_prices_dev,
half_spread_dev,
ofi_dev,
n_w,
commission,
baseline_momentum_base + off_three,
&self.sp15_baseline_naive_momentum_kernel,
)?;
// Naive-reversion baseline.
super::gpu_dqn_trainer::launch_sp15_baseline_naive_reversion(
&self.stream,
close_prices_dev,
half_spread_dev,
ofi_dev,
n_w,
commission,
baseline_reversion_base + off_three,
&self.sp15_baseline_naive_reversion_kernel,
)?;
}
}
// Queue DtoD-async copies from the device action-history buffers into
// their mapped-pinned mirrors. The kernel above (and the env-step kernel
// chain that produced these buffers) ordered before this point on the
// same stream, so the copies see the post-rollout values. The host reads
// through `host_ptr` after `eval_done_event.synchronize()` — a single
// barrier covers the metrics readback AND all four distribution readers.
//
// Mirrors are unconditional for `actions_history_buf` (always allocated)
// and conditional for `intent_mag_buf` / `picked_action_history_buf`
// (allocated only on DQN paths via `ensure_action_select_ready`). Skip
// the conditional copies when the source buffers are not yet wired —
// those readers return `Ok([0.0; *])` from the unwritten zero-init.
unsafe {
let n_bytes_full = self.n_windows * self.max_len * std::mem::size_of::<i32>();
// actions_history_buf → actions_history_pinned
{
let (src_ptr, _src_guard) =
self.actions_history_buf.device_ptr(&self.stream);
let dst_ptr = self.actions_history_pinned.dev_ptr;
cudarc::driver::result::memcpy_dtod_async(
dst_ptr, src_ptr, n_bytes_full, self.stream.cu_stream(),
).map_err(|e| MLError::ModelError(format!(
"actions_history dtod-to-pinned: {e}"
)))?;
}
// intent_mag_buf → intent_mag_pinned (DQN path only)
if let (Some(src_buf), Some(dst_buf)) =
(self.intent_mag_buf.as_ref(), self.intent_mag_pinned.as_ref())
{
let (src_ptr, _src_guard) = src_buf.device_ptr(&self.stream);
cudarc::driver::result::memcpy_dtod_async(
dst_buf.dev_ptr, src_ptr, n_bytes_full, self.stream.cu_stream(),
).map_err(|e| MLError::ModelError(format!(
"intent_mag dtod-to-pinned: {e}"
)))?;
}
// picked_action_history_buf → picked_action_history_pinned (DQN path).
// The picked buffer has the same shape `[n_windows * max_len]` (see
// its allocation in `ensure_action_select_ready`).
if let (Some(src_buf), Some(dst_buf)) = (
self.picked_action_history_buf.as_ref(),
self.picked_action_history_pinned.as_ref(),
) {
let (src_ptr, _src_guard) = src_buf.device_ptr(&self.stream);
cudarc::driver::result::memcpy_dtod_async(
dst_buf.dev_ptr, src_ptr, n_bytes_full, self.stream.cu_stream(),
).map_err(|e| MLError::ModelError(format!(
"picked_action_history dtod-to-pinned: {e}"
)))?;
}
}
// Lazy-create the event on first launch (so the CUDA context is
// guaranteed active). Re-record into the same event handle on every
// subsequent launch — `record` overwrites the prior recording per the
// CUDA driver semantics, no allocator churn.
if self.eval_done_event.is_none() {
let ev = self.stream.context().new_event(None).map_err(|e| {
MLError::ModelError(format!("eval_done_event create: {e}"))
})?;
self.eval_done_event = Some(ev);
}
let ev = self.eval_done_event.as_ref().expect("just created above");
ev.record(&self.stream).map_err(|e| {
MLError::ModelError(format!("eval_done_event record: {e}"))
})?;
Ok(())
}
/// Wait on `eval_done_event` (host-side `cuEventSynchronize`) and parse the
/// per-window metrics from the mapped-pinned `metrics_buf`. The event's GPU
/// dependency chain ensures the metrics kernel + all action-history mirror
/// copies queued by `launch_metrics_and_record_event` are globally visible
/// before the host load.
///
/// This is the consume half of the perf-fix split (2026-04-28). Pipelined
/// callers (`compute_validation_loss_consume`) invoke this at the start of
/// the next epoch — the host CPU thread already submitted the previous
/// epoch's training to `cuda_stream`, so by the time control reaches here
/// the eval stream has typically drained and the event is already complete.
/// Synchronous callers (the `evaluate_*` wrappers used by hyperopt / smoke
/// tests) invoke launch + consume back-to-back — no wall-clock change for
/// those paths since they had nothing to overlap with anyway.
pub fn consume_metrics_after_event(&self) -> Result<Vec<WindowMetrics>, MLError> {
let ev = self.eval_done_event.as_ref().ok_or_else(|| {
MLError::ModelError(
"consume_metrics_after_event called before launch_metrics_and_record_event"
.to_owned(),
)
})?;
ev.synchronize().map_err(|e| {
MLError::ModelError(format!("eval_done_event synchronize: {e}"))
})?;
let metrics_host: Vec<f32> = self.metrics_buf.read_all();
// SP15 Phase 1.1.b — per-window mean/std/raw_sharpe from the
// dedicated `sharpe_per_bar_kernel`. Three f32 slots per window:
// [0] mean, [1] std, [2] raw_sharpe (= mean / max(std, 1e-12)).
// Annualisation is applied below host-side, preserving the value
// contract from the pre-split fused kernel.
let sharpe_host: Vec<f32> = self.sharpe_buf.read_all();
// SP15 Wave 3b — per-window outputs from the cost-net sharpe kernel
// and the four counterfactual baselines. Same `[mean, std, raw_sharpe]`
// layout as `sharpe_buf`. Annualised host-side below using the same
// factor as `sharpe` — every Sharpe-style output goes through one
// multiplication by `annualization_factor` per
// `feedback_no_partial_refactor.md` (single annualisation locus).
let cost_net_host: Vec<f32> = self.cost_net_sharpe_buf.read_all();
let baseline_buyhold_host: Vec<f32> = self.baseline_buyhold_sharpe_buf.read_all();
let baseline_hold_only_host: Vec<f32> = self.baseline_hold_only_sharpe_buf.read_all();
let baseline_momentum_host: Vec<f32> = self.baseline_momentum_sharpe_buf.read_all();
let baseline_reversion_host: Vec<f32> = self.baseline_reversion_sharpe_buf.read_all();
let annualization = self.annualization_factor;
// Parse flat metrics into per-window structs.
// Layout matches compute_backtest_metrics kernel output AFTER the
// SP15 Phase 1.1.b sharpe split + the 2026-05-09 per-regime WR
// expansion (19 floats per window — 13 base metrics + 6
// per-regime WR/trade-count slots):
// [0] total_pnl
// [1] max_drawdown
// [2] sortino
// [3] win_rate
// [4] total_trades
// [5] var_95
// [6] cvar_95
// [7] calmar
// [8] omega_ratio
// [9] buy_count
// [10] sell_count
// [11] hold_count
// [12] unique_actions
// [13] win_rate_trending
// [14] trade_count_trending
// [15] win_rate_ranging
// [16] trade_count_ranging
// [17] win_rate_volatile
// [18] trade_count_volatile
// Sharpe is sourced from `sharpe_host` (sharpe_per_bar_kernel) and
// annualised host-side: `sharpe = raw_sharpe * annualization_factor`,
// matching the in-kernel multiplication done in the pre-split fused
// kernel (was `(mean / std_val) * annualization_factor`).
let results: Vec<WindowMetrics> = (0..self.n_windows)
.map(|w| {
let base = w * 19;
let sharpe_base = w * 3;
let raw_sharpe = sharpe_host[sharpe_base + 2];
let sharpe_annualised = raw_sharpe * annualization;
// SP15 Wave 3b — same `[mean, std, raw_sharpe]` layout
// and same annualisation pattern as `sharpe_buf`. Index
// 2 = raw_sharpe; multiply by `annualization_factor`
// for consistency with `WindowMetrics.sharpe`.
let cost_net_raw = cost_net_host[sharpe_base + 2];
let baseline_buyhold_raw = baseline_buyhold_host[sharpe_base + 2];
let baseline_hold_only_raw = baseline_hold_only_host[sharpe_base + 2];
let baseline_momentum_raw = baseline_momentum_host[sharpe_base + 2];
let baseline_reversion_raw = baseline_reversion_host[sharpe_base + 2];
WindowMetrics {
sharpe: sharpe_annualised,
total_pnl: metrics_host[base],
max_drawdown: metrics_host[base + 1].min(1.0), // Cap at 100% (liquidation)
sortino: metrics_host[base + 2],
win_rate: metrics_host[base + 3],
total_trades: metrics_host[base + 4],
var_95: metrics_host[base + 5],
cvar_95: metrics_host[base + 6],
calmar: metrics_host[base + 7],
omega_ratio: metrics_host[base + 8],
buy_count: metrics_host[base + 9],
sell_count: metrics_host[base + 10],
hold_count: metrics_host[base + 11],
unique_actions: metrics_host[base + 12],
sharpe_cost_net: cost_net_raw * annualization,
baseline_buyhold_sharpe: baseline_buyhold_raw * annualization,
baseline_hold_only_sharpe: baseline_hold_only_raw * annualization,
baseline_momentum_sharpe: baseline_momentum_raw * annualization,
baseline_reversion_sharpe: baseline_reversion_raw * annualization,
win_rate_trending: metrics_host[base + 13],
trade_count_trending: metrics_host[base + 14],
win_rate_ranging: metrics_host[base + 15],
trade_count_ranging: metrics_host[base + 16],
win_rate_volatile: metrics_host[base + 17],
trade_count_volatile: metrics_host[base + 18],
}
})
.collect();
Ok(results)
}
}
// ── Free helpers ──────────────────────────────────────────────────────────────
/// Extract raw F32 device pointer from a `CudaSlice<f32>`.
fn raw_f32_ptr(slice: &CudaSlice<f32>, stream: &Arc<CudaStream>) -> u64 {
let (ptr, guard) = slice.device_ptr(stream);
let _no_drop = ManuallyDrop::new(guard);
ptr
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_init_portfolio_state() {
let state = GpuBacktestEvaluator::init_portfolio_state(3, 100_000.0);
assert_eq!(state.len(), 24); // 3 windows × 8 floats
// Window 0
assert_eq!(state[0], 100_000.0, "window 0 value");
assert_eq!(state[1], 0.0, "window 0 position");
assert_eq!(state[2], 100_000.0, "window 0 cash");
assert_eq!(state[3], 0.0, "window 0 unrealised_pnl");
assert_eq!(state[4], 100_000.0, "window 0 max_equity");
// Window 1 starts at index 8
assert_eq!(state[8], 100_000.0, "window 1 value");
assert_eq!(state[10], 100_000.0, "window 1 cash");
// Window 2 starts at index 16
assert_eq!(state[16], 100_000.0, "window 2 value");
}
#[test]
fn test_window_metrics_fields() {
let m = WindowMetrics {
sharpe: 1.5,
total_pnl: 0.05,
max_drawdown: 0.02,
sortino: 2.0,
win_rate: 0.55,
total_trades: 42.0,
var_95: 0.03,
cvar_95: 0.04,
calmar: 0.75,
omega_ratio: 1.2,
buy_count: 150.0,
sell_count: 120.0,
hold_count: 230.0,
unique_actions: 4.0,
sharpe_cost_net: 1.2,
baseline_buyhold_sharpe: 0.4,
baseline_hold_only_sharpe: 0.0,
baseline_momentum_sharpe: 0.3,
baseline_reversion_sharpe: -0.1,
win_rate_trending: 0.6,
trade_count_trending: 14.0,
win_rate_ranging: 0.45,
trade_count_ranging: 18.0,
win_rate_volatile: 0.55,
trade_count_volatile: 10.0,
};
assert!(m.sharpe > 0.0);
assert!(m.win_rate > 0.5);
assert!(m.total_trades > 0.0);
assert!(m.calmar > 0.0);
assert!(m.omega_ratio > 1.0);
// Per-regime aggregates should sum to total trades.
let regime_total = m.trade_count_trending + m.trade_count_ranging + m.trade_count_volatile;
assert!((regime_total - m.total_trades).abs() < 1e-3,
"regime totals must sum to total_trades: {} vs {}", regime_total, m.total_trades);
}
#[test]
fn test_gpu_backtest_config_default() {
let c = GpuBacktestConfig::default();
assert_eq!(c.max_position, 1.0);
assert_eq!(c.tx_cost_bps, 0.1);
assert!((c.spread_cost - 0.0001).abs() < 1e-8);
assert_eq!(c.initial_capital, 100_000.0);
// 1-minute bar frequency: 390 bars/day (6.5h × 60min)
assert_eq!(c.bars_per_day, 390.0);
}
#[test]
fn test_annualization_factor_1min_bars() {
let c = GpuBacktestConfig::default();
// For 1-minute bars: sqrt(390 * 252) = sqrt(98280) ≈ 313.5
let expected = (390.0_f32 * 252.0).sqrt();
let actual = (c.bars_per_day * 252.0).sqrt();
assert!((actual - expected).abs() < 0.01, "annualization: {actual} vs {expected}");
assert!(actual > 300.0 && actual < 320.0, "annualization should be ~313: {actual}");
}
/// Annualization compensates for val subsample stride — `effective_bars_per_day
/// = bars_per_day / stride`, so `annualization_factor =
/// sqrt(effective_bars_per_day * trading_days_per_year)`.
/// Per audit `docs/lookahead-bias-audit-2026-04-28.md` §5: prior code used
/// `bars_per_day` unscaled, inflating reported Sharpe by `sqrt(stride)`
/// (factor 2 for stride=4). Matches the constructor expression at
/// `gpu_backtest_evaluator.rs::new()`.
#[test]
fn test_annualization_factor_compensates_for_subsample_stride() {
// Replicates the constructor expression — single source of truth for
// the stride-aware annualization formula.
fn factor(c: &GpuBacktestConfig) -> f32 {
let stride = c.val_subsample_stride.max(1) as f32;
let effective_bars_per_day = c.bars_per_day / stride;
(effective_bars_per_day * c.trading_days_per_year).sqrt()
}
// stride=1 (default) — identical to legacy behavior.
let c1 = GpuBacktestConfig {
val_subsample_stride: 1,
..Default::default()
};
let f1 = factor(&c1);
let expected1 = (390.0_f32 * 252.0).sqrt();
assert!((f1 - expected1).abs() < 0.01, "stride=1: {f1} vs {expected1}");
// stride=4 — Sharpe annualization MUST shrink by factor 2.0.
let c4 = GpuBacktestConfig {
val_subsample_stride: 4,
..Default::default()
};
let f4 = factor(&c4);
let expected4 = ((390.0_f32 / 4.0) * 252.0).sqrt();
assert!((f4 - expected4).abs() < 0.01, "stride=4: {f4} vs {expected4}");
assert!(
(f1 / f4 - 2.0).abs() < 1e-3,
"stride=4 must halve annualization: f1={f1}, f4={f4}, ratio={}",
f1 / f4
);
// stride=0 is sanitized to 1 (defensive — no divide-by-zero).
let c0 = GpuBacktestConfig {
val_subsample_stride: 0,
..Default::default()
};
let f0 = factor(&c0);
assert!((f0 - expected1).abs() < 0.01, "stride=0 should sanitize to 1: {f0} vs {expected1}");
}
#[test]
fn test_new_rejects_empty_windows() {
let context = cudarc::driver::CudaContext::new(0);
if context.is_err() { return; } // skip on non-CUDA
let stream = context.ok().map(|c| c.default_stream());
if let Some(ref s) = stream {
let result = GpuBacktestEvaluator::new(
&[],
&[],
&[],
42,
GpuBacktestConfig::default(),
s,
);
assert!(result.is_err());
let msg = format!("{:?}", result.err());
assert!(msg.contains("No windows"), "expected 'No windows', got: {msg}");
}
}
/// Verify PTX sources compile without errors (requires CUDA — nvcc path).
#[test]
fn test_env_cubin_loads() {
let context = cudarc::driver::CudaContext::new(0).expect("CUDA context required");
let module = context.load_cubin(ENV_CUBIN.to_vec()).expect("load env cubin");
module.load_function("backtest_env_step").expect("load backtest_env_step");
}
#[test]
fn test_metrics_cubin_loads() {
let context = cudarc::driver::CudaContext::new(0).expect("CUDA context required");
let module = context.load_cubin(METRICS_CUBIN.to_vec()).expect("load metrics cubin");
module.load_function("compute_backtest_metrics").expect("load compute_backtest_metrics");
}
#[test]
fn test_gather_cubin_loads() {
let context = cudarc::driver::CudaContext::new(0).expect("CUDA context required");
let module = context.load_cubin(BACKTEST_DQN_CUBIN.to_vec()).expect("load backtest DQN cubin");
module.load_function("backtest_state_gather").expect("load backtest_state_gather");
}
/// Verify that gather_states() returns a ConfigError when portfolio_dim != 24.
#[test]
fn test_gather_states_portfolio_dim_mismatch() {
// We don't have a CUDA device in unit tests, so test the validation path
// indirectly by checking the expected portfolio_dim: 8 portfolio base + 16 MTF = 24.
let expected = ml_core::state_layout::PORTFOLIO_BASE_DIM + ml_core::state_layout::MTF_DIM;
assert_eq!(expected, 24, "portfolio_dim should be 8 portfolio + 16 MTF = 24");
}
/// Verify that the cuBLAS DQN helper kernels (compute_expected_q,
/// experience_action_select) compile from experience_kernels.cu.
#[test]
fn test_backtest_dqn_cubin_loads() {
let context = cudarc::driver::CudaContext::new(0).expect("CUDA context required");
let module = context.load_cubin(BACKTEST_DQN_CUBIN.to_vec()).expect("load backtest DQN cubin");
module.load_function("compute_expected_q").expect("load compute_expected_q");
}
/// Verify PPO forward cubin loads.
#[test]
fn test_ppo_forward_cubin_loads() {
let context = cudarc::driver::CudaContext::new(0).expect("CUDA context required");
let module = context.load_cubin(PPO_FORWARD_CUBIN.to_vec()).expect("load PPO forward cubin");
module.load_function("backtest_forward_ppo_kernel").expect("load backtest_forward_ppo_kernel");
}
/// Verify supervised signal cubin loads.
#[test]
fn test_supervised_signal_cubin_loads() {
let context = cudarc::driver::CudaContext::new(0).expect("CUDA context required");
let module = context.load_cubin(SUPERVISED_SIGNAL_CUBIN.to_vec()).expect("load supervised signal cubin");
module.load_function("signal_to_action_kernel").expect("load signal_to_action_kernel");
}
#[test]
fn test_gpu_backtest_evaluator_state_dim_calculation() {
// Canonical layout from ml_core::state_layout — 128-dim feature
// vector (42 market + 32 OFI + 14 TLOB + 16 MTF + 8 portfolio +
// 6 plan/ISV + 10 padding = 128). Already padded to 128 for cuBLAS
// K-tile alignment. The historical 96-dim layout (pre-2026-04
// refactor) was deprecated when the OFI / TLOB / MTF blocks were
// expanded; the canonical constant in `ml_core::state_layout`
// is the source of truth — code wins over docs (cf.
// `feedback_trust_code_not_docs`). Migrated under SP15 Wave 4.1b
// alongside the s1_input_dim 102→103 propagation that followed
// the same `feedback_trust_code_not_docs` correction in the
// wave's audit doc.
assert_eq!(ml_core::state_layout::STATE_DIM, 128);
assert_eq!(ml_core::state_layout::STATE_DIM_PADDED, 128);
}
/// Verify that CUDA Graph infrastructure compiles and struct fields are present.
///
/// This test validates:
/// - `SendSyncGraph` wrapper type is constructible (type-level check)
/// - `dqn_graph` field exists on `GpuBacktestEvaluator`
/// - `invalidate_dqn_graph` method is callable
/// - The struct remains `Send + Sync` despite wrapping `CudaGraph`
#[test]
fn test_cuda_graph_capture_smoke() {
// Verify GpuBacktestConfig still works (not affected by graph additions)
let c = GpuBacktestConfig::default();
assert_eq!(c.initial_capital, 100_000.0);
// Verify that the CudaGraph import resolves (type-level check).
// CudaGraph is !Send + !Sync, but SendSyncGraph wraps it safely.
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<Option<SendSyncGraph>>();
assert_sync::<Option<SendSyncGraph>>();
// The evaluator cannot be constructed without a CUDA device, but we
// can verify the method signatures compile by checking that
// GpuBacktestEvaluator has the expected methods (via trait object coercion).
// The evaluate_dqn_graphed and invalidate_dqn_graph methods are tested
// at runtime on GPU hardware via integration tests.
}
#[test]
fn test_dqn_backtest_config_from_network_dims() {
let cfg = DqnBacktestConfig::from_network_dims((256, 256, 128, 128));
assert_eq!(cfg.shared_h1, 256);
assert_eq!(cfg.shared_h2, 256);
assert_eq!(cfg.value_h, 128);
assert_eq!(cfg.adv_h, 128);
assert_eq!(cfg.num_atoms, 51);
assert_eq!(cfg.branch_0_size, 4);
assert_eq!(cfg.branch_1_size, 3);
assert_eq!(cfg.branch_2_size, 3);
assert_eq!(cfg.branch_3_size, 3);
// v_range = (10 / (1-0.95) * 1.2).clamp(20, 300) = 240
let expected_v_range = (10.0_f32 / (1.0 - 0.95) * 1.2).clamp(20.0, 300.0);
assert!((cfg.v_min - (-expected_v_range)).abs() < f32::EPSILON);
assert!((cfg.v_max - expected_v_range).abs() < f32::EPSILON);
}
/// Verify the chunked step loop parameters produce the expected kernel
/// launch reduction relative to the per-step loop.
#[test]
fn test_chunked_step_loop_launch_reduction() {
let chunk = DQN_BACKTEST_CHUNK_SIZE; // 512
let max_len = 32_000_usize;
let n_windows = 5_usize;
// Number of chunks (last chunk may be smaller)
let n_chunks = (max_len + chunk - 1) / chunk;
assert_eq!(n_chunks, 63); // 32000 / 512 = 62.5 → ceil = 63
// Per-step loop: 16 kernel launches per step (10 SGEMM + 6 bias)
// + 2 kernels (expected_q + action_select) + 1 DtoD + 1 env_step
// = 20 operations per step
let per_step_ops = max_len * 20;
assert_eq!(per_step_ops, 640_000);
// Chunked loop:
// Per chunk: 16 (cuBLAS forward) + 1 (expected_q) + 1 (action_select) = 18
// Per step: 1 (gather) + 1 (DtoD gather copy) + 1 (DtoD action copy) + 1 (env_step) = 4
let chunked_per_chunk_ops = n_chunks * 18; // 63 * 18 = 1134
let chunked_per_step_ops = max_len * 4; // 32000 * 4 = 128000
let chunked_total = chunked_per_chunk_ops + chunked_per_step_ops;
assert_eq!(chunked_total, 1_134 + 128_000);
// Verify the reduction factor (larger chunks → higher reduction)
let reduction = per_step_ops as f64 / chunked_total as f64;
assert!(reduction > 4.0, "expected >4x reduction, got {reduction:.1}x");
// Chunked batch size should be n_windows * chunk_size
let chunked_batch = n_windows * chunk;
assert_eq!(chunked_batch, 2_560); // 5 * 512
}
}