#![allow(unsafe_code)] // Required for CUDA kernel launches

//! GPU-accelerated DQN experience collection via timestep-level loop + cuBLAS.
//!
//! Replaces the monolithic `dqn_full_experience_kernel` (3,272 lines, 1.56%
//! occupancy from warp-cooperative matvec) with a host-driven timestep loop
//! that calls:
//!   1. `experience_state_gather`  — assemble batch state tensor (NVRTC kernel)
//!   2. `CublasForward::forward_online` — Q-network forward via cuBLAS SGEMM
//!   3. `compute_expected_q`       — C51 distributional → expected Q-values
//!   4. `experience_action_select` — epsilon-greedy on Q-values (NVRTC kernel)
//!   5. `experience_env_step`      — portfolio simulation + reward (NVRTC kernel)
//!
//! cuBLAS SGEMM processes the entire batch in a single GEMM call per layer,
//! raising GPU occupancy from ~1.56% to >60% on H100.

use std::ffi::c_void;
use std::sync::Arc;

use cudarc::driver::{
    CudaFunction, CudaSlice, CudaStream, DevicePtr, DevicePtrMut, LaunchConfig, PushKernelArg,
};
use tracing::{debug, info};

use crate::MLError;
use super::batched_forward::{CublasForward, CublasGemmSet};
use super::gpu_curiosity_trainer::GpuCuriosityTrainer;
use super::gpu_dqn_trainer::{
    GpuDqnTrainConfig, compute_param_sizes, compute_total_params,
};
use super::gpu_weights::CuriosityWeightSet;
use super::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
use super::shared_cublas_handle::PerStreamCublasHandles;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Absolute upper bound for validation — reject configs above this.
/// H100 (132 SMs) can run 32768 episodes; `optimal_n_episodes()` sizes dynamically.
const MAX_EPISODES_LIMIT: usize = 0x8000;
/// Absolute upper bound for validation — reject configs above this.
const MAX_TIMESTEPS_LIMIT: usize = 1000;
const PORTFOLIO_STATE_SIZE: usize = 8;
/// Portfolio stride for DQN experience kernels (43 floats per episode).
/// Matches PORTFOLIO_STRIDE in experience_kernels.cu and trade_stats_kernel.cu.
/// Do NOT change PORTFOLIO_STATE_SIZE above — it's for the PPO/legacy path.
const PORTFOLIO_STRIDE: usize = 43;  // was 41. Slots 30-37: prev-bar OFI; slot 38: PS_PEAK_PNL_BAR (Plan 3 C.4); slot 39: PS_INTRA_TRADE_MIN_PNL (Plan 3 D.4a); slot 40: PS_REGIME_SHIFT_BAR (Plan 3 D.4b); slots 41-42: PS_PRE_ENTRY_CONVICTION_{EMA,VAR_EMA} (Plan 3 D.4c)

/// Number of floats in the trade_stats_reduce output buffer.
/// Layout: [win_count, loss_count, sum_wins, sum_losses, sum_returns, sum_sq_returns]
const TRADE_STATS_FLOATS: usize = 6;

/// SP20 Phase 3 Task 3.2 (2026-05-10): circular per-env Hold
/// opportunity-cost buffer size. Per spec §4.2: "circular buffer of
/// size LOOKAHEAD_HORIZON_MAX = 30 bars (matches SP19's longest
/// horizon and bounds max trade duration)". Bars older than 30 are
/// overwritten (acceptable per spec — trades > 30 bars don't sample
/// beyond their last 30 bars). Used in `experience_env_step` Hold
/// opportunity-cost dual-emission (Component 2).
pub const HOLD_BASELINE_BUFFER_SIZE: usize = 30;

// ---------------------------------------------------------------------------
// TradeStats — GPU-sourced per-epoch trade statistics
// ---------------------------------------------------------------------------

/// Per-epoch trade statistics extracted from GPU portfolio_states[N, 14:19].
///
/// These fields are accumulated by `experience_env_step` kernel at each trade
/// completion event (flat→position or position→flat transition). The
/// `collect_trade_stats()` method runs a GPU reduction kernel to sum across
/// all N episodes, then downloads the 6-float result.
#[derive(Debug, Clone, Default)]
pub struct TradeStats {
    /// Total completed trades (entries that later exited back to flat)
    pub total_trades: usize,
    /// Trades with positive return
    pub winning_trades: usize,
    /// Trades with negative or zero return
    pub losing_trades: usize,
    /// Sum of returns from winning trades (positive)
    pub sum_wins: f64,
    /// Sum of |returns| from losing trades (positive)
    pub sum_losses: f64,
    /// Net sum of all trade returns
    pub sum_returns: f64,
    /// Sum of squared trade returns (for Sharpe/Sortino computation)
    pub sum_sq_returns: f64,
    /// Per-bar step returns from rewards_out (for MaxDD and Sortino)
    pub step_returns: Vec<f64>,
    /// Per-bar done flags (1.0 = episode boundary / capital floor reset).
    /// Same length as step_returns. Used to reset equity curve at episode
    /// boundaries when computing MaxDD in training financials.
    pub done_flags: Vec<f64>,
}

// ---------------------------------------------------------------------------
// Pinned host memory for DtoH transfers
// ---------------------------------------------------------------------------

/// Pinned (page-locked) host buffer for async DtoH memcpy.
///
/// CUDA DMA engine can transfer directly to pinned memory without staging
/// through a system bounce buffer, achieving ~2x PCIe bandwidth vs pageable.
/// All buffers are allocated once in the constructor and reused across launches.
struct PinnedHostBuf<T: Copy> {
    ptr: *mut T,
    len: usize,
}

// Safety: PinnedHostBuf is only accessed from the thread that created the
// GpuExperienceCollector, which holds a CUDA context. The ptr is valid
// for the lifetime of the allocation (freed in Drop).
unsafe impl<T: Copy> Send for PinnedHostBuf<T> {}
unsafe impl<T: Copy> Sync for PinnedHostBuf<T> {}

impl<T: Copy> PinnedHostBuf<T> {
    /// Allocate `len` elements of pinned host memory.
    ///
    /// # Safety
    /// Caller must ensure a CUDA context is active on the current thread.
    unsafe fn new(len: usize) -> Result<Self, MLError> {
        if len == 0 {
            return Ok(Self { ptr: std::ptr::null_mut(), len: 0 });
        }
        let num_bytes = len * std::mem::size_of::<T>();
        // CU_MEMHOSTALLOC_PORTABLE: accessible from any CUDA context
        let flags = cudarc::driver::sys::CU_MEMHOSTALLOC_PORTABLE;
        let host_ptr = cudarc::driver::result::malloc_host(num_bytes, flags)
            .map_err(|e| MLError::ModelError(format!(
                "pinned host alloc ({len} x {} bytes): {e}",
                std::mem::size_of::<T>(),
            )))?
            as *mut T;
        // Zero-init for safety
        std::ptr::write_bytes(host_ptr, 0, len);
        Ok(Self { ptr: host_ptr, len })
    }
}

impl<T: Copy> Drop for PinnedHostBuf<T> {
    fn drop(&mut self) {
        if !self.ptr.is_null() {
            // Safety: ptr was returned by malloc_host and is non-null.
            unsafe {
                #[allow(clippy::let_underscore_must_use)]
                let _ = cudarc::driver::result::free_host(self.ptr as *mut c_void);
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------

/// Configuration for the GPU experience collection kernel.
#[derive(Debug, Clone)]
pub struct ExperienceCollectorConfig {
    /// Maximum absolute position size
    pub max_position: f32,
    /// Episode length in bars (for wrap detection)
    pub episode_length: i32,
    /// Total bars in the market data buffer
    pub total_bars: i32,
    /// Epsilon for epsilon-greedy action selection
    pub epsilon: f32,
    /// Discount factor for TD target computation
    pub gamma: f32,
    /// Maximum curiosity reward (clamped)
    pub curiosity_max_reward: f32,
    /// Number of episodes to run
    pub n_episodes: i32,
    /// Timesteps per episode
    pub timesteps_per_episode: i32,
    /// Barrier profit multiplier (e.g. 1.02)
    pub barrier_profit_mult: f32,
    /// Barrier loss multiplier (e.g. 0.98)
    pub barrier_loss_mult: f32,
    /// Barrier max bars before forced exit (e.g. 500.0)
    pub barrier_max_bars: f32,
    /// Barrier reward scaling factor (e.g. 0.5)
    pub barrier_scale: f32,
    /// Diversity entropy reward scaling factor (e.g. 1.0)
    pub diversity_scale: f32,
    /// Curiosity intrinsic reward scaling factor (e.g. 1.0)
    pub curiosity_scale: f32,
    /// Risk penalty weight (e.g. 0.1)
    pub risk_weight: f32,
    /// Drawdown fraction before penalty starts (0.02 = 2%).
    /// Class A audit-fix Batch 4-A (2026-05-08): no longer used by the
    /// legacy `compute_drawdown_penalty` path (deleted), but reused as
    /// the `dd_budget` argument to `launch_sp15_dd_state` (line ~5750)
    /// for the SP15 DD_PCT computation in dd_state_kernel.
    pub dd_threshold: f32,
    /// Transaction cost multiplier (hyperopt-tunable, scales base 0.01% rate)
    pub tx_cost_multiplier: f32,
    /// UCB count-bonus coefficient for GPU action selection (0.0 = disabled)
    pub count_bonus_coefficient: f32,
    /// Minimum Q-value clamp (e.g. -200.0)
    pub q_clip_min: f32,
    /// Maximum Q-value clamp (e.g. 200.0)
    pub q_clip_max: f32,
    /// Huber loss kappa for TD error (robust priority). 0.0 = disabled (raw L1).
    pub huber_kappa: f32,
    /// D5: Initial noise std dev for NoisyNet (Rainbow DQN default: 0.5)
    pub noisy_sigma_init: f32,
    /// D6: Number of atoms in C51 distribution (default: 51)
    pub num_atoms: i32,
    /// D6: Minimum value support for C51 (default: -50.0)
    pub v_min: f32,
    /// D6: Maximum value support for C51 (default: 50.0)
    pub v_max: f32,
    /// EMA decay rate for per-thread reward normalization in the GPU kernel.
    pub reward_norm_alpha: f32,
    /// Fill simulation: median spread for order routing (0.0 = disabled)
    pub fill_median_spread: f32,
    /// Fill simulation: median volatility for urgency routing
    pub fill_median_vol: f32,
    /// Fill simulation: IoC base fill probability (default 0.85)
    pub fill_ioc_fill_prob: f32,
    /// Fill simulation: minimum limit fill probability (default 0.30)
    pub fill_limit_fill_min: f32,
    /// Fill simulation: maximum limit fill probability (default 0.80)
    pub fill_limit_fill_max: f32,
    /// Fill simulation: fraction of spread paid by market orders (default 0.50)
    pub fill_spread_cost_frac: f32,
    /// Fill simulation: fraction of spread captured by limit orders (default 0.50)
    pub fill_spread_capture_frac: f32,
    /// Whether fill simulation is enabled (requires median_spread > 0)
    pub fill_simulation_enabled: bool,
    /// Q-value gap threshold for trade conviction filter.
    /// When greedy Q(best_direction) - Q(flat) < threshold, default to flat.
    /// 0.0 = disabled (trade on every bar). Typical range [0.0, 0.5].
    pub q_gap_threshold: f32,
    /// Kelly optimal fraction for position sizing (0.0-1.0).
    /// N-step returns lookahead (1 = standard TD, 3-5 typical for Rainbow DQN)
    pub n_steps: i32,
    /// Holding cost rate per bar per unit position. Continuous inventory penalty.
    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,
    /// Bid-ask spread cost per unit position change. Matches backtest_env_kernel's spread_cost.
    pub spread_cost: f32,
    /// Contract multiplier (dollar value per point).
    /// ES = 50, NQ = 20, 6E = 125_000, ZN = 1000.
    /// Default: 50.0 (ES).
    pub contract_multiplier: 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,

    // ── Gems & Pearls: generalization parameters ────────────────────

    /// #11 Time-reversal modulus: episodes where index % mod == 0 are played backwards.
    /// 5 = 20% reversed, 3 = 33%, 0 = disabled.
    pub time_reversal_mod: i32,

    /// #10 Mirror universe: negate return features + invert exposure actions.
    /// Doubles effective data diversity by training on mirrored price sequences.
    pub mirror_active: bool,

    /// #22 Feature noise scale: add N(0, scale) to each feature. 0.0 = disabled.
    pub feature_noise_scale: f32,
    /// #13 Vol normalizer: divide return features [0..3] by this value. 0.0 = disabled.
    /// Computed on CPU as realized vol of close-to-close returns.
    pub vol_normalizer: f32,
    /// #23 Feature mask: per-feature binary mask. None = no masking.
    /// Generated per-epoch on CPU, uploaded to GPU.
    pub feature_mask: Option<Vec<f32>>,
    /// v8: Dense directional micro-reward scale (adaptive vol).
    pub micro_reward_scale: f32,
    /// Dense micro-reward: price confirmation weight (default 0.5).
    pub price_confirm_weight: f32,
    /// Dense micro-reward: book aggression weight (default 0.3).
    pub book_aggression_weight: f32,
    /// Dense micro-reward: retrospective hold quality weight (default 0.2).
    pub hold_quality_weight: f32,
    /// Dense micro-reward: tanh temperature (default 3.0).
    pub micro_reward_temp: f32,
    /// v8: Current training epoch (for GPU cosine epsilon schedule).
    pub current_epoch: i32,
    /// v8: Total training epochs (for GPU cosine epsilon schedule).
    pub total_epochs: i32,
    /// v8: Epsilon schedule start value.
    pub eps_start: f32,
    /// v8: Epsilon schedule end value.
    pub eps_end: f32,
    /// Per-branch epsilon multiplier: exposure (dir+mag). Default 1.0.
    pub eps_exp_mult: f32,
    /// Per-branch epsilon multiplier: order type. Default 1.5.
    pub eps_ord_mult: f32,
    /// Per-branch epsilon multiplier: urgency. Default 1.5.
    pub eps_urg_mult: f32,
    /// v8: TD(λ) trace parameter. 0.0 = 1-step TD (n-step only), >0.01 = TD(λ) overwrites n-step.
    pub td_lambda: f32,
    /// v8: Maximum trace length for truncated TD(λ).
    pub max_trace_length: i32,
    /// v8: Fraction of replay batch relabeled with hindsight optimal exit (0.0 = disabled).
    pub hindsight_fraction: f32,
    /// v8: Lookahead bars for hindsight optimal exit.
    pub hindsight_lookahead: i32,
    /// SP6 Pearl 3: per-branch advantage noise std for exploration.
    /// Branch order: [direction(0), magnitude(1), order(2), urgency(3)].
    /// 0.0 per-branch disables noise for that branch. Default: [0.1; 4].
    pub noise_sigma_per_branch: [f32; 4],

    /// SP12 v3 (2026-05-04): per-trade event-driven reward composition.
    ///
    /// Cold-start fallback for the patience target on voluntary trade exits
    /// (in bars). Class A P0-C (2026-05-08): the kernel uses
    /// `ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]` (EMA of winning-trade hold
    /// times) as the effective target; this field is only used when ISV[451]
    /// is still at sentinel (0.0 = no winning trades observed yet) or when
    /// `isv_signals_ptr` is NULL. Default = `MIN_HOLD_TARGET=30`
    /// (state_layout.cuh cold-start fallback).
    /// Trail-fire forced exits exempted — see kernel docstring.
    pub min_hold_target: f32,
    /// SP12 v3: max penalty magnitude when min-hold deficit is infinite
    /// (soft factor → 1). Phase 1 default = `MIN_HOLD_PENALTY_MAX=3.0`
    /// (60% of `REWARD_POS_CAP`).
    pub min_hold_penalty_max: f32,
    /// SP12 v3: temperature controller for the min-hold soft saturation.
    ///
    /// Class A audit-fix Batch 4-B (2026-05-08, Item 4): MIGRATED from
    /// the deleted epoch-driven schedule
    /// `T_end + (T_start - T_end) × exp(-epoch / decay)` (formerly
    /// `training_loop.rs::min_hold_temperature_for_epoch`) to the
    /// signal-driven ISV slot
    /// `MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX=460`. The training loop
    /// reads the freshly-produced value via
    /// `DQNTrainer::read_min_hold_temperature_from_isv` and seeds this
    /// field per-epoch. Producer kernel:
    /// `min_hold_temperature_update_kernel.cu` (per-epoch boundary).
    ///
    /// Default at construction = 50.0 (= MIN_HOLD_TEMPERATURE_FALLBACK
    /// = the deleted MIN_HOLD_TEMPERATURE_START anchor for bit-identical
    /// pre-Batch-4B epoch-0 behavior). The kernel-passed scalar is the
    /// cold-start fallback used by the consumer when the ISV slot is
    /// at sentinel.
    pub min_hold_temperature: f32,
}

impl Default for ExperienceCollectorConfig {
    fn default() -> Self {
        Self {
            max_position: 1.0,
            episode_length: 500,
            total_bars: 0,  // MUST be set from actual data length before collect
            epsilon: 0.1,
            gamma: 0.99,
            curiosity_max_reward: 0.1,
            n_episodes: 2048,
            timesteps_per_episode: 500,
            barrier_profit_mult: 1.02,
            barrier_loss_mult: 0.98,
            barrier_max_bars: 500.0,
            barrier_scale: 0.5,
            diversity_scale: 1.0,
            curiosity_scale: 1.0,
            risk_weight: 0.1,
            dd_threshold: 0.02,
            // Class A audit-fix Batch 4-A (2026-05-08): `w_dd` field
            // DELETED from the config struct atomically with the legacy
            // compute_drawdown_penalty deletion. SP15 path uses ISV
            // slot 420 (LAMBDA_DD) instead.
            tx_cost_multiplier: 1.0,
            count_bonus_coefficient: 0.0,
            q_clip_min: -200.0,  // Reward v6: tighter than old -500 but covers v_range + safety margin
            q_clip_max: 200.0,
            huber_kappa: 0.0,
            noisy_sigma_init: 0.5,
            num_atoms: 51,          // C51 standard atom count
            // v_min/v_max: tight support for 16× atom resolution (delta_z=0.6 vs 9.6)
            v_min: -15.0,
            v_max: 15.0,
            reward_norm_alpha: 0.0, // Disabled for reward v4: per-bar returns are already naturally scaled
            fill_median_spread: 0.0,
            fill_median_vol: 0.0,
            fill_ioc_fill_prob: 0.85,
            fill_limit_fill_min: 0.30,
            fill_limit_fill_max: 0.80,
            fill_spread_cost_frac: 0.50,
            fill_spread_capture_frac: 0.50,
            fill_simulation_enabled: false,
            q_gap_threshold: 0.0,
            n_steps: 1,
            holding_cost_rate: 0.00000052,  // ~0.52 bps / 100 bars
            churn_threshold: 10.0,
            churn_penalty_scale: 0.001,
            spread_cost: 0.0,  // default: no spread cost (overridden by hyperparams)
            contract_multiplier: 50.0,
            margin_pct: 0.06,
            time_reversal_mod: 0,
            mirror_active: false,
            feature_noise_scale: 0.0,
            vol_normalizer: 0.0,
            feature_mask: None,
            micro_reward_scale: 0.0,
            price_confirm_weight: 0.5,
            book_aggression_weight: 0.3,
            hold_quality_weight: 0.2,
            micro_reward_temp: 3.0,
            current_epoch: 0,
            total_epochs: 20,
            eps_start: 0.3,
            eps_end: 0.02,
            eps_exp_mult: 1.5,  // magnitude needs extra exploration (was 1.0)
            eps_ord_mult: 1.5,
            eps_urg_mult: 1.5,
            td_lambda: 0.0,
            max_trace_length: 7,
            hindsight_fraction: 0.0,
            hindsight_lookahead: 10,
            noise_sigma_per_branch: [0.1; 4],
            // SP12 v3 defaults — match Invariant-1 anchors in state_layout.cuh.
            // Class A audit-fix Batch 4-B (2026-05-08, Item 4): the training
            // loop overrides `min_hold_temperature` per epoch via
            // `DQNTrainer::read_min_hold_temperature_from_isv` (signal-driven
            // ISV slot 460 — replaces the deleted epoch-driven schedule);
            // the default 50.0 here is the cold-start fallback anchor matching
            // the deleted MIN_HOLD_TEMPERATURE_START. The other two are
            // static Phase 1 constants.
            min_hold_target: 30.0,
            min_hold_penalty_max: 3.0,
            min_hold_temperature: 50.0,
        }
    }
}

impl ExperienceCollectorConfig {
    /// Total number of experience tuples produced by one kernel launch.
    /// Total experiences including #7 counterfactual augmentation (2x).
    pub fn total_experiences(&self) -> usize {
        self.n_episodes as usize * self.timesteps_per_episode as usize * 2
    }
}

// ---------------------------------------------------------------------------
// Output batch
// ---------------------------------------------------------------------------

// ExperienceBatch (CPU Vec<f32>) DELETED — use GpuExperienceBatch only.
// Production training loop uses collect_experiences_gpu() which keeps data GPU-resident.

/// GPU-resident experience batch -- pure cudarc `CudaSlice`.
///
/// All buffers are raw `CudaSlice` on the collector's forked stream.
/// The caller syncs the stream before reading or converting on another stream.
#[allow(missing_debug_implementations)]
pub struct GpuExperienceBatch {
    /// States `[total * state_dim]` on GPU — f32 from experience collector,
    /// converted to f32 at replay buffer insertion boundary.
    pub states: CudaSlice<f32>,
    /// Next-states `[total * state_dim]` on GPU (f32, episode-aware shift)
    pub next_states: CudaSlice<f32>,
    /// Action indices `[total]` on GPU (i32, values 0..4)
    pub actions: CudaSlice<i32>,
    /// Rewards `[total]` on GPU (f32 -- no f32 NaN risk)
    pub rewards: CudaSlice<f32>,
    /// Done flags `[total]` on GPU (f32, 0.0 or 1.0 -- no f32 NaN risk)
    pub dones: CudaSlice<f32>,
    /// Episode index per transition `[total]` on GPU (i32).
    ///
    /// `episode_ids[i] = i / timesteps_per_episode`.
    /// Episode `ep` occupies flat indices `[ep * timesteps, (ep+1) * timesteps)`.
    /// Required by HER Future and Final strategies for GPU-native donor selection.
    pub episode_ids: CudaSlice<i32>,
    /// SP13 Layer B: 30-bar aux sign label per transition `[total]` on GPU (i32).
    /// `-1` = skip (no 30-bar lookahead window available, e.g. last L-30 bars of
    /// each episode), `0` = down (price_t+30 < price_t), `1` = up otherwise.
    /// B0 plumbing: zero-initialized — B1 wires the producer kernel that fills
    /// real labels and the consumer (CrossEntropy aux loss head). The replay
    /// buffer threads this column through `insert_batch` → `gather_i32_scalar`
    /// → `GpuBatchPtrs.aux_sign_labels_ptr`.
    pub aux_sign_labels: CudaSlice<i32>,
    /// SP22 H6 vNext Phase B4b-2 (2026-05-14): per-(env, t) trade-outcome
    /// label column. Sized `[n_episodes × timesteps]` i32. Values in
    /// `{-1, 0, 1, 2}` where -1 = mask (no trade close at this bar),
    /// 0 = Profit, 1 = Stop, 2 = Timeout. Sparse — ~95-99% slots are -1.
    ///
    /// Producer: `trade_outcome_label_kernel`'s `out_labels_per_sample`
    /// output (Phase B4b-1 kernel amendment), written per-rollout-step.
    /// Consumer: `GpuReplayBuffer::insert_batch` scatters this column
    /// into the replay ring; `sample_proportional` gathers per-batch
    /// labels into the trainer's `aux_to_label_buf` via the
    /// `trainer_aux_outcome_labels_ptr` direct path.
    pub aux_outcome_labels: CudaSlice<i32>,
    /// SP20 Phase 3 Task 3.4 (2026-05-10): per-bar aux_conf per
    /// transition `[total]` on GPU (f32) — the K=2 peak-softmax-above-
    /// uniform `aux_conf ∈ [0, 0.5]` value at the time the bar was
    /// rolled out. Written by `experience_env_step` per (i, t) using
    /// `sp20_compute_per_bar_aux_conf_k2(aux_logits_per_env + i * 2)`.
    /// Default 0.0 = K=2 uniform-prior (no information) for test
    /// scaffolds where `aux_logits_per_env == NULL`.
    ///
    /// The replay buffer threads this column through `insert_batch` →
    /// `gather_f32_scalar` → `GpuBatchPtrs.aux_conf_ptr` so the Phase
    /// 5 Aux→Q gate can read `aux_conf_at_state` (the confidence at
    /// the SAMPLED bar) inside the Bellman target computation:
    ///
    ///   gate = sigmoid((aux_conf - ISV[AUX_CONF_THRESHOLD]) / ISV[AUX_GATE_TEMP])
    ///   Q_target = gate × Q_full + (1 - gate) × Q_baseline
    ///
    /// Per spec §4.4 the gate ∈ [0, 1] gracefully degrades the Bellman
    /// target toward `mean_a Q(s, a)` at uncertain states without
    /// freezing learning.
    pub aux_conf: CudaSlice<f32>,
    /// State dimensionality (so the consumer can reshape)
    pub state_dim: usize,
    /// Number of episodes
    pub n_episodes: usize,
    /// Timesteps per episode
    pub timesteps: usize,
}

// ---------------------------------------------------------------------------
// GpuExperienceCollector
// ---------------------------------------------------------------------------

/// GPU experience collector using timestep-level loop + cuBLAS forward pass.
///
/// Replaces the monolithic warp-cooperative experience kernel with 3 focused
/// NVRTC kernels (state gather, action select, env step) interleaved with
/// cuBLAS SGEMM for the Q-network forward pass. This raises GPU occupancy
/// from ~1.56% to >60% on H100.
///
/// Manages all GPU buffers for Q-network weights (as flat F32 parameter
/// buffer), per-episode state, and output arrays.
#[allow(missing_debug_implementations)]
pub struct GpuExperienceCollector {
    stream: Arc<CudaStream>,

    /// Market feature dimension (e.g. 42).
    market_dim: usize,
    /// Network hidden dims — (shared_h1, shared_h2, value_h, adv_h).
    network_dims: (usize, usize, usize, usize),
    /// Number of C51 atoms (1 for non-distributional).
    num_atoms: usize,
    /// Per-sample, per-branch C51 support tiled [alloc_episodes, 4, 3]:
    /// [v_min, v_max, delta_z] per (episode, branch). Filled once per epoch via
    /// `update_per_sample_support()` — matches the Phase 2d stride-12 contract
    /// consumed by compute_expected_q / quantile_q_select / mag_concat_qdir.
    /// Read-only by the GPU (kernels read via `dev_ptr`); CPU writes via
    /// `host_ptr` from `update_per_sample_support`. Mapped pinned per
    /// `feedback_no_htod_htoh_only_mapped_pinned.md`.
    per_sample_support_buf: MappedF32Buffer,
    /// Per-sample epsilon from IQL expectile gap (0 = use cosine schedule).
    per_sample_epsilon_ptr: u64,
    /// Branch sizes [4, 3, 3, 3] for 4-branch hierarchical DQN.
    /// [direction, magnitude, order, urgency]
    branch_sizes: [usize; 4],

    /// Number of episodes buffers were allocated for (from config, not MAX constant).
    alloc_episodes: usize,
    /// Number of timesteps buffers were allocated for (from config, not MAX constant).
    alloc_timesteps: usize,

    // ── cuBLAS forward pass context ─────────────────────────────────
    cublas_forward: CublasForward,
    /// CUDA Graph exec for deterministic cuBLAS forward in experience collection.
    exp_fwd_graph: cudarc::driver::sys::CUgraphExec,

    // ── Zero-copy trainer params pointer ─────────────────────────────
    /// Raw device pointer into the trainer's `params_buf`.
    /// The collector reads weights directly — no DtoD copy needed.
    /// Set to 0 initially; updated via `set_trainer_params_ptr()` once
    /// the fused training context is created.
    trainer_params_ptr: u64,
    /// Total number of F32 parameters (for info logging only).
    total_params: usize,
    /// Per-tensor sizes for computing weight pointers.
    param_sizes: [usize; super::gpu_dqn_trainer::NUM_WEIGHT_TENSORS],

    // ── CVaR position scaling (IQN dual-head) ──────────────────────
    /// Device pointer to CVaR scales [N]. 0 = NULL = no scaling.
    /// Set by `set_cvar_scales()` before experience collection.
    cvar_scales_ptr: u64,

    // ── IQR exploration bonus (IQN anti-collapse) ─────────────────
    /// Tiled IQR bonus buffer [alloc_episodes * q_stride] f32.
    /// Pre-computed as alpha * iqr[a] tiled across N rows, uploaded to GPU
    /// once per epoch in `set_iqr_bonus()`. Added to q_values before action selection.
    /// None = no IQR bonus active.
    iqr_tiled_gpu: Option<CudaSlice<f32>>,
    /// SAXPY kernel for IQR bonus: y[i] += alpha * x[i].
    saxpy_f32_kernel: CudaFunction,

    // ── Ensemble variance exploration bonus ────────────────────────
    /// Tiled ensemble variance bonus buffer [alloc_episodes * q_stride] f32.
    /// Pre-computed as beta * sqrt(mean_var) tiled as a constant across all
    /// actions and N episodes, uploaded once per epoch in `set_ensemble_variance_bonus()`.
    /// Added to q_values before action selection (uniform across branches).
    /// High ensemble disagreement = high uncertainty = explore more.
    /// None = no ensemble bonus active (ensemble_count <= 1 or beta = 0.0).
    ensemble_var_tiled_gpu: Option<CudaSlice<f32>>,

    // ── Compiled experience kernels ─────────────────────────────────
    state_gather_kernel: CudaFunction,
    action_select_kernel: CudaFunction,
    expert_override_kernel: CudaFunction,
    env_step_kernel: CudaFunction,
    expected_q_kernel: CudaFunction,
    /// Quantile Q-select kernel: extracts Q_10/Q_50/Q_90 from C51 CDF, blends via iqn_readiness.
    quantile_q_select_kernel: CudaFunction,
    nstep_kernel: CudaFunction,
    fill_episode_ids_kernel: CudaFunction,
    trade_stats_kernel: CudaFunction,
    reward_rank_kernel: CudaFunction,
    /// Sort-based O(N log N) reward rank normalization kernels.
    reward_compute_abs_sharpe_kernel: CudaFunction,
    bitonic_sort_step_kernel: CudaFunction,
    reward_scatter_rank_kernel: CudaFunction,
    /// Scratch buffer for bitonic sort keys: [sort_buf_capacity] f32.
    reward_sort_keys_buf: CudaSlice<f32>,
    /// Scratch buffer for bitonic sort values (original indices): [sort_buf_capacity] i32.
    reward_sort_indices_buf: CudaSlice<i32>,
    /// Allocated capacity of sort buffers (next power-of-2 of max_n).
    sort_buf_capacity: usize,
    /// Kernel: copy raw rewards to sort keys (no |sharpe| transform) for quantile extraction.
    reward_copy_raw_kernel: CudaFunction,
    /// Kernel: gather evenly-spaced quantile positions from sorted array.
    gather_quantiles_kernel: CudaFunction,
    /// Scratch buffer for gather_quantiles output: [max_atoms] f32.
    quantiles_scratch_buf: CudaSlice<f32>,

    // ── Per-timestep batch buffers (reused each timestep) ───────────
    /// #30 Assembled state batch for SGEMM forward: [N, state_dim] f32.
    batch_states: CudaSlice<f32>,
    /// Selected actions per episode: [N].
    batch_actions: CudaSlice<i32>,
    /// Q-gap conviction per episode [N]: output by action_select, input to env_step.
    q_gaps_buf: CudaSlice<f32>,
    /// Per-sample direction-branch conviction ∈ [0, 1] per episode [N]: output by
    /// action_select, input to env_step. Derived from direction Q-spread normalised
    /// by q_dir_abs_ref (ISV[21]). Drives adaptive Kelly warmup floor in
    /// `unified_env_step_core` (replaces the old static 0.5 floor).
    conviction_buf: CudaSlice<f32>,
    /// Per-sample magnitude-branch conviction [N] for adaptive Kelly warmup floor.
    /// Parallel to `conviction_buf` (direction-branch). Written by
    /// `experience_action_select` (out_magnitude_conviction) on each step;
    /// consumed by `experience_env_step` → `unified_env_step_core` where
    /// the cap formula composes `max(dir_conv, mag_conv)` so a confident
    /// magnitude pick can unblock the cap when direction conviction is low.
    magnitude_conviction_buf: CudaSlice<f32>,
    /// Kelly optimal fraction (per-epoch scalar from CPU).
    /// Current timestep counter per episode: [N].
    current_timesteps: CudaSlice<i32>,
    /// Q-values output from expected Q or direct logits: [N, total_branch_actions].
    q_values: CudaSlice<f32>,
    /// Quantile-blended Q-values for action selection: [N, total_branch_actions].
    /// Written by quantile_q_select after compute_expected_q.
    /// Replaces q_values as input to experience_action_select.
    q_select_buf: CudaSlice<f32>,
    /// Distributional Var[Q] per action [alloc_episodes, total_branch_actions] f32.
    /// Computed alongside E[Q] in compute_expected_q. Used for variance position sizing.
    q_var_buf: CudaSlice<f32>,
    /// IQN readiness scalar [0, 1]: 0=exploring/optimistic, 1=exploiting/pessimistic.
    /// Set via set_iqn_readiness() before each epoch's experience collection.
    iqn_readiness: f32,
    /// Atom utilization EMA [0,1]: graceful quantile degradation when atoms collapse.
    /// Set via set_utilization_ema() alongside iqn_readiness each epoch.
    util_ema: f32,

    // ── cuBLAS activation scratch (reused each timestep) ────────────
    exp_h_s1: CudaSlice<f32>,      // [N, shared_h1]
    exp_h_s2: CudaSlice<f32>,      // [N, shared_h2]
    exp_h_v: CudaSlice<f32>,       // [N, value_h]
    exp_v_logits: CudaSlice<f32>,  // [N, num_atoms] f32 (output layer — no f32 truncation)
    exp_b_logits: CudaSlice<f32>,  // [N, total_branch_atoms] f32

    // ── Weight sets (only curiosity — dueling/branching/rmsnorm deleted, zero-copy from trainer) ──
    curiosity_weights: CuriosityWeightSet,

    /// GPU buffer for trade_stats_reduce output: [TRADE_STATS_FLOATS] = 6 floats.
    trade_stats_buf: CudaSlice<f32>,

    // Per-episode state buffers [alloc_episodes, ...]
    portfolio_states: CudaSlice<f32>,  // [alloc_episodes * PORTFOLIO_STRIDE] (43 floats per episode)
    episode_starts_buf: CudaSlice<i32>,// [alloc_episodes]

    /// SP20 Phase 2 Task 2.0 (2026-05-09): per-env label sign captured at trade
    /// open, consumed at trade close by `sp20_compute_event_reward`'s
    /// `dir_match` check (spec §4.1).
    ///
    /// Layout: `[alloc_episodes]` i32 — one slot per env, persists across
    /// the rollout and replay-window inside `experience_env_step`. Values:
    ///   `+1` — aux next-bar predicted "up" at trade open
    ///   `-1` — aux next-bar predicted "not-up" (down/flat) at trade open
    ///    `0` — sentinel: trade-open was during aux skip-window (look-ahead
    ///         past `total_bars`) or no position currently open. The 4-quadrant
    ///         reward maps `pnl_sign != label_at_open_sign` to "wrong reason"
    ///         and `pnl_sign == 0` (close_pnl == 0) to no-info; sentinel 0
    ///         input flows through naturally because no `pnl_sign != 0` case
    ///         can match `label_at_open_sign == 0` (mismatch ⇒ "wrong reason").
    ///         This lands the wrong-reason quadrant for sentinel inputs, which
    ///         is the safer default than over-rewarding a trade with no
    ///         reasoning signal.
    ///
    /// Producer: `experience_env_step` writes to this slot in the
    /// `entering_trade` branch by mapping
    /// `exp_aux_nb_label_buf[env]` (which is the per-step aux next-bar
    /// label written by `aux_sign_label_per_step_kernel` — values
    /// `{-1, 0, +1}`) to `{0, -1, +1}` respectively (the kernel's `0` =
    /// "not-up" maps to `-1` for the spec sign convention; the kernel's
    /// `-1` = "skip" maps to sentinel `0`).
    ///
    /// Consumer: `experience_env_step` reads the slot in the `is_close`
    /// branch and passes it to `sp20_compute_event_reward(close_pnl,
    /// label_at_open_sign, isv[LOSS_CAP_INDEX])`.
    ///
    /// FoldReset semantics (registered in `state_reset_registry.rs` as
    /// `label_at_open_per_env`): zero on every fold boundary so the new
    /// fold's first `entering_trade` branch fully populates the slot from
    /// the new fold's aux head outputs (no stale label leakage across
    /// folds — each fold has independent aux next-bar predictions).
    pub(crate) label_at_open_per_env: CudaSlice<i32>, // [alloc_episodes]

    /// SP20 Phase 2 Task 2.2 (2026-05-09): per-env trade-close alpha
    /// scratch — `alpha = R_event - hold_baseline` per spec §4.1
    /// (Component 1 reward kernel). Phase 2 emits `hold_baseline = 0.0f`
    /// as a Phase 3.2 forward-reference placeholder; Phase 3.2's Hold
    /// opportunity-cost dual emission lands the real
    /// `hold_baseline_buffer.sum_over_trade_range()` consumer.
    ///
    /// Layout: `[alloc_episodes]` f32 — one slot per env, written by
    /// `experience_env_step`'s `is_close` (`segment_complete`) branch
    /// after the `sp20_compute_event_reward` call. Read once per
    /// rollout step by `sp20_aggregate_inputs_kernel` to populate the
    /// `EmaInputs.alpha` field (mean over closed envs); the Phase 1.4
    /// hardcoded `out_inputs->alpha = 0.0f` placeholder is replaced
    /// atomically with this commit.
    ///
    /// FoldReset semantics (registered in `state_reset_registry.rs` as
    /// `alpha_per_env`): zero on every fold boundary so the new fold's
    /// first trade-close fully populates the slot from the new fold's
    /// reward calculation (no stale alpha leakage across folds — each
    /// fold has independent ALPHA_EMA bootstrap state per Phase 1.4's
    /// `obs_count[OBS_COUNT_ALPHA]` reset).
    ///
    /// On non-close steps the slot retains the previous trade-close
    /// alpha (or 0.0 sentinel pre-first-close); the aggregation kernel
    /// reads only when `trade_close_per_env[env] != 0`, so stale values
    /// don't leak into the EMA.
    pub(crate) alpha_per_env: CudaSlice<f32>, // [alloc_episodes]

    /// SP20 Phase 2 Task 2.2-fix (2026-05-10): per-env segment-level win
    /// flag scratch. Companion to `alpha_per_env` — same producer site
    /// (`segment_complete` branch in experience_kernels.cu), same fold-
    /// reset semantics, same NULL-tolerant consumer (sp20_aggregate_
    /// inputs_kernel reads when non-NULL, falls back to legacy per-bar
    /// `step_ret > 0` predicate when NULL for test scaffolds).
    pub(crate) is_win_per_env: CudaSlice<i32>, // [alloc_episodes]

    /// SP22 H6 vNext Phase A3 (2026-05-13): per-env trade-close
    /// `plan_isv[PLAN_ISV_PNL_VS_TARGET]` save-for-backward scratch for
    /// the trade-outcome aux head's label producer. Written at every
    /// `segment_complete` bar by `experience_env_step` (alongside
    /// `alpha_per_env[i]`). Value semantics: `unrealized_pnl / (plan_profit
    /// × equity)` clamped symmetrically to [-2, +2]. Consumed by the
    /// `trade_outcome_label_kernel` at the next training step's label
    /// production — `>= 1.0` predicate classifies as Profit. Sized
    /// `[alloc_episodes]`. FoldReset sentinel 0.0 across all slots —
    /// registered in `state_reset_registry.rs` as
    /// `pnl_vs_target_at_close_per_env` (mirror of alpha_per_env reset
    /// pattern). Phase A3 (this commit): buffer + producer write wired;
    /// consumer (label kernel launcher) lands in Phase A4/A5.
    pub(crate) pnl_vs_target_at_close_per_env: CudaSlice<f32>, // [alloc_episodes]

    /// SP22 H6 vNext Phase A3 (2026-05-13): per-env trade-close
    /// `plan_isv[PLAN_ISV_PNL_VS_STOP]` save-for-backward scratch.
    /// Companion to `pnl_vs_target_at_close_per_env` — same producer site
    /// (segment_complete branch), same fold-reset semantics, same
    /// NULL-tolerant kernel arg. Value semantics: `-unrealized_pnl /
    /// (plan_stop × equity)` clamped [-2, +2]. Consumed by the
    /// `trade_outcome_label_kernel` `>= 1.0` predicate as the Stop
    /// classification oracle. Sized `[alloc_episodes]`. FoldReset
    /// sentinel 0.0.
    pub(crate) pnl_vs_stop_at_close_per_env: CudaSlice<f32>, // [alloc_episodes]

    /// SP21 T3.3 (2026-05-10): per-env per-bar Hold opportunity-cost
    /// scratch. Written every step by `experience_env_step` as
    /// `per_bar_opp_cost = -aux_conf × cost_scale ∈ [-0.25, 0]`.
    /// Consumer: `sp20_aggregate_inputs_kernel` reads `[env]` slot only
    /// for envs in `dir == DIR_HOLD` and emits the mean as
    /// `out_inputs->per_bar_hold_reward`. NULL-tolerant for tests
    /// (consumer falls back to `0.0f` matching pre-T3.3 Phase 3.2-
    /// forward-reference contract). Defrosts HOLD_REWARD_EMA — was
    /// stuck at 0 because the aggregator hardcoded 0.0f for this slot.
    pub(crate) per_bar_opp_cost_per_env: CudaSlice<f32>, // [alloc_episodes]

    /// SP20 Phase 3 Task 3.2 (2026-05-10): per-env circular Hold
    /// opportunity-cost buffer (Component 2) per spec §4.2.
    ///
    /// Layout: `[alloc_episodes × HOLD_BASELINE_BUFFER_SIZE = 30]` f32
    /// row-major device-resident scratch.
    ///
    /// Producer: `experience_env_step` per-bar branches
    /// (positioned-non-event line ~3650 + flat-non-event line ~3737)
    /// — both write `hold_baseline_buffer[env, current_t %
    /// HOLD_BASELINE_BUFFER_SIZE] = -aux_conf × cost_scale` UNCONDITIONALLY
    /// (Path 2 counterfactual emission, regardless of action). The
    /// segment_complete branch does NOT write — the trade-close bar's
    /// reward path bypasses the per-bar branches.
    ///
    /// Consumer: `experience_env_step` `is_close && segment_hold_time
    /// > 0.0f` branch — sums backwards `min(HOLD_BASELINE_BUFFER_SIZE,
    /// segment_hold_time)` slots from `(current_t - 1) %
    /// HOLD_BASELINE_BUFFER_SIZE` to compute `hold_baseline`, replacing
    /// the Phase 2 Task 2.2 placeholder `hold_baseline = 0.0f`. The
    /// resulting `alpha = R_event - hold_baseline` flows through to
    /// `alpha_per_env[env]` for the SP20 aggregation kernel
    /// (per_bar EMA on Hold-state bars only — the
    /// `sp20_aggregate_inputs_kernel` Phase 3.2 forward-reference for
    /// `per_bar_hold_reward` lands separately on the
    /// `sp20-phase-2-fix` branch per concurrent-agent collision
    /// avoidance).
    ///
    /// Buffer-sizing rationale (spec §4.2): 30 bars matches SP19's
    /// longest horizon (`LOOKAHEAD_HORIZON_MAX`). Bars older than 30
    /// are overwritten — fine because trades > 30 bars don't sample
    /// beyond their last 30 bars per the spec's bounded-trade-duration
    /// argument.
    ///
    /// FoldReset semantics (registered in `state_reset_registry.rs` as
    /// `hold_baseline_buffer`): zero on every fold boundary so the new
    /// fold's first trade-close summation reads only this fold's
    /// per-bar writes — no stale bars leak across folds. Reset path:
    /// `cudarc::driver::CudaStream::memset_zeros` on the `CudaSlice<f32>`
    /// (device-resident, no host buffer to fill).
    ///
    /// Per `pearl_first_observation_bootstrap`: cold-start with
    /// `ISV[HOLD_COST_SCALE]` at sentinel 0 means
    /// `per_bar_opp_cost = 0` (Path 2 writes zeros to the buffer);
    /// the trade-close consumer sums zeros and lands `hold_baseline =
    /// 0.0f` (matches Phase 2 Task 2.2 placeholder behaviour bit-for-
    /// bit on the very first trade close after fold reset). The
    /// controller's first observation lifts cost_scale, then per_bar
    /// writes become non-zero, then the next trade-close summation
    /// engages — gracefully bootstrapped, no warmup gate needed.
    pub(crate) hold_baseline_buffer: CudaSlice<f32>, // [alloc_episodes × 30]

    // Output buffers [alloc_episodes * alloc_timesteps, ...]
    states_out: CudaSlice<f32>,               // #30 [alloc_episodes * alloc_timesteps * STATE_DIM] f32
    actions_out: CudaSlice<i32>,       // [alloc_episodes * alloc_timesteps]
    rewards_out: CudaSlice<f32>,              // [alloc_episodes * alloc_timesteps] f32 (no f32 NaN)
    done_out: CudaSlice<f32>,                 // [alloc_episodes * alloc_timesteps] f32 (no f32 NaN)
    /// Raw per-bar portfolio returns (unshaped) for accurate Sharpe/MaxDD/Sortino.
    /// True fractional return: (equity_t - equity_{t-1}) / equity_{t-1}.
    raw_returns_out: CudaSlice<f32>,   // [alloc_episodes * alloc_timesteps]

    /// H6 diagnostic: per-sample trail fire + pre-step magnitude bin + traded flag + hold-at-exit.
    /// Filled by `experience_env_step`; reduced host-side by `trail_fire_and_hold_per_mag`.
    /// Race-free by construction: each thread writes only its own [i*L+t] slot.
    trail_triggered_per_sample: CudaSlice<i32>,   // [alloc_episodes * alloc_timesteps]
    pre_mag_per_sample: CudaSlice<i32>,           // [alloc_episodes * alloc_timesteps] (-1/0/1/2)
    traded_per_sample: CudaSlice<i32>,            // [alloc_episodes * alloc_timesteps]
    hold_at_exit_per_sample: CudaSlice<f32>,      // [alloc_episodes * alloc_timesteps]

    /// Task 0.8 reward-term contribution: per-sample additive term magnitudes
    /// + sample-selector firing flags. Filled by `experience_env_step`;
    /// reduced host-side by `reward_contrib_fractions`. Race-free by
    /// construction: each thread writes only its own [i*L+t] (or [cf_off]) slot.
    ///
    /// `cf_flip_per_sample` is indexed at `cf_off = out_off + N*L` and is therefore
    /// sized `alloc_episodes * alloc_timesteps * cf_mult` (matches `rewards_out`).
    /// The rest live at `out_off` and are sized `alloc_episodes * alloc_timesteps`.
    cf_flip_per_sample: CudaSlice<i32>,            // [alloc_episodes * alloc_timesteps * cf_mult]
    micro_reward_per_sample: CudaSlice<f32>,       // [alloc_episodes * alloc_timesteps]
    // Task 2.4: loss_aversion_per_sample REMOVED — R6 clamp relocated to
    // c51_loss_kernel.cu::block_bellman_project_f (Q-target smoothing).
    total_reward_per_sample: CudaSlice<f32>,       // [alloc_episodes * alloc_timesteps]
    /// SP11 Fix 39 (2026-05-04, A1.2): per-bar saboteur engagement signal.
    /// [alloc_episodes * alloc_timesteps] — written by `experience_env_step`
    /// at the END (after `total_reward_per_sample` is finalised) as
    /// `traded × |reward| × max(|eff_spread − 1|, |eff_slip − 1|)`. Pure
    /// GPU buffer (kernel-to-kernel only); the SP11 saboteur engagement
    /// kernel reads via raw device pointer. See spec §3.3.1 + the param
    /// docstring on `experience_env_step::saboteur_delta_reward_per_sample`.
    saboteur_delta_reward_per_sample: CudaSlice<f32>, // [alloc_episodes * alloc_timesteps]
    /// SP11 Fix 39 B1b fix-up (2026-05-04): per-bar popart-component-
    /// specific reward magnitude. [alloc_episodes * alloc_timesteps] —
    /// written by `experience_env_step` at the `r_popart` voluntary-exit
    /// branch. Resolves the slot-63 PopArt-input vs popart-component-
    /// magnitude overload that B1b smoke surfaced. Spec §4 amendment
    /// "Why slot 360". Pure GPU buffer (kernel-to-kernel only); the SP11
    /// popart-component EMA kernel reads via raw device pointer.
    popart_component_per_sample: CudaSlice<f32>,      // [alloc_episodes * alloc_timesteps]
    /// C.2 Reward-component attribution (Plan 3 Task 1, spec §4.C.2).
    /// [alloc_episodes * alloc_timesteps * 6] row-major.
    /// Components: [0]=popart(total), [1]=cf, [2]=trail(0), [3]=micro, [4]=opp_cost, [5]=bonus.
    /// Producer: experience_env_step (rc[0,1,3,4,5] live; rc[2]=0 placeholder until C.4).
    /// Consumer: reward_component_ema GPU kernel writes ISV[63..69) after each epoch.
    reward_components_per_sample: CudaSlice<f32>,  // [alloc_episodes * alloc_timesteps * 6]

    /// B.2 Plan 3 Task 3: Flat→Positioned transition flag per (i,t).
    /// [alloc_episodes * alloc_timesteps] — 1 iff (prev_sign == 0 && curr_sign != 0).
    /// Producer: experience_env_step (unconditional per-slot write, race-free).
    /// Consumer: trade_attempt_rate_ema_update GPU kernel reduces + EMA into ISV[71].
    flat_to_pos_per_sample: CudaSlice<i32>,  // [alloc_episodes * alloc_timesteps]

    /// B.4 Plan 3 Task 4: per-(i,t) readiness value as observed by experience_env_step.
    /// [alloc_episodes * alloc_timesteps] — broadcast scalar from readiness_ptr[0].
    /// Producer: experience_env_step (unconditional per-slot write, race-free).
    /// Consumer: plan_threshold_update GPU kernel reduces + EMA into ISV[75]
    /// (READINESS_EMA_INDEX), which derives ISV[49] (PLAN_THRESHOLD_INDEX).
    readiness_per_sample: CudaSlice<f32>,  // [alloc_episodes * alloc_timesteps]

    /// Task 2.X prerequisite (policy-quality scoping): per-magnitude win rate +
    /// realized step-return variance instrumentation. Mirrors the Task 0.5 /
    /// 0.8 per-sample-array-plus-host-reduce pattern — race-free by
    /// construction (each thread writes only its own [i*L+t] slot), reduced
    /// host-side by `per_magnitude_winrate_and_variance`.
    ///
    ///   action_mag_per_sample       — actual_mag_core from unified_env_step_core.
    ///                                 0=Quarter, 1=Half, 2=Full; -1 = slot
    ///                                 unreached this step (early-return before
    ///                                 the trade lifecycle block).
    ///   step_ret_per_sample         — step_ret_core, raw signed per-step return.
    ///   trade_close_per_sample      — 1 iff exiting_trade || reversing_trade.
    ///   trade_profitable_per_sample — 1 iff trade_close AND step_ret > 0.
    action_mag_per_sample: CudaSlice<i32>,         // [alloc_episodes * alloc_timesteps]
    step_ret_per_sample: CudaSlice<f32>,           // [alloc_episodes * alloc_timesteps]
    trade_close_per_sample: CudaSlice<i32>,        // [alloc_episodes * alloc_timesteps]
    trade_profitable_per_sample: CudaSlice<i32>,   // [alloc_episodes * alloc_timesteps]
    /// SP20 Phase 3 Task 3.4 (2026-05-10): per-bar aux_conf scratch for
    /// the replay-buffer schema. Layout `[alloc_episodes ×
    /// alloc_timesteps × cf_mult = 2]` f32 row-major. Producer:
    /// `experience_env_step` writes
    /// `sp20_compute_per_bar_aux_conf_k2(aux_logits_per_env + i * 2)`
    /// at every (i, t) slot (mirrors the H6/Task-0.8 per-sample
    /// race-free pattern — each thread writes only its own out_off
    /// slot). Default 0.0 = K=2 uniform-prior. Consumer: assembled
    /// into `GpuExperienceBatch.aux_conf` at the end of
    /// `collect_experiences_gpu` (post-counterfactual) and threaded
    /// through `insert_batch` → ring → `gather_f32_scalar` →
    /// `GpuBatchPtrs.aux_conf_ptr`. Allocation size matches the other
    /// per-sample buffers (`alloc_episodes * alloc_timesteps`); the
    /// counterfactual extension `× cf_mult` happens at the assembly
    /// site (mirrors `aux_sign_labels` which is also `[total]` =
    /// `alloc_episodes * alloc_timesteps * 2`).
    aux_conf_per_sample: CudaSlice<f32>,           // [alloc_episodes * alloc_timesteps]
    /// Task 0.3 — per-sample distributional variance Kelly scale written by
    /// `experience_env_step` at line 1422 (`1 / (1 + sqrt(Var[Q]))`). Kernel
    /// writes only when `q_variance` is non-NULL AND `total_actions_for_var > 0`,
    /// otherwise the slot stays at its default `0.0f` from alloc_zeros / the
    /// kernel's entry-block default write. Host reduces host-side in
    /// `var_scale_epoch_mean` — averages over slots with `var_scale > 0`, so
    /// unreached / Var[Q]-disabled samples don't dilute the signal.
    var_scale_per_sample: CudaSlice<f32>,          // [alloc_episodes * alloc_timesteps]
    /// `slot_live_per_sample[i*L+t] = 1` when the kernel reaches that (i,t) slot
    /// (written right after the default block, before any early-return). Provides
    /// an unambiguous denominator for firing-rate ratios when episodes terminate
    /// early (capital-floor reset, done flag, etc.) — un-reached slots stay 0
    /// from the allocation-time memset and don't get counted.
    slot_live_per_sample: CudaSlice<i32>,          // [alloc_episodes * alloc_timesteps]

    /// SP15 Wave 2 (2026-05-06): per-step `r_discipline` scalar mirror.
    /// [alloc_episodes * alloc_timesteps] f32 row-major. Producer:
    /// `experience_env_step` writes `isv_signals_ptr[REGRET_EMA_INDEX=423]`
    /// at the end of normal reward composition (right BEFORE
    /// `out_rewards[out_off] = reward;`). Consumer:
    /// `compute_sp15_final_reward_kernel` reads this per-(i,t) value as the
    /// `r_discipline` operand of its α-blend stage. Buffer is
    /// per-step ephemeral — every kernel call defaults the slot to 0.0 at
    /// entry and overwrites with REGRET_EMA on the normal-completion path,
    /// so cross-fold leakage is impossible without an explicit registry
    /// reset (every step is an implicit reset). Pure GPU buffer
    /// (kernel-to-kernel only).
    r_discipline_per_sample: CudaSlice<f32>,       // [alloc_episodes * alloc_timesteps]

    /// SP15 Wave 2 (2026-05-06): slot completion sentinel. [alloc_episodes
    /// * alloc_timesteps] i32 row-major. Producer: `experience_env_step`
    /// defaults to 0 at entry and sets to 1 ONLY on the normal reward
    /// composition path (right BEFORE `out_rewards[out_off] = reward;`).
    /// Consumer: `compute_sp15_final_reward_kernel` reads this per-(i,t)
    /// flag and SKIPS slots where it equals 0 — so SP11's terminal-episode
    /// sentinel rewards (0.0f at data-end at experience_kernels.cu line
    /// 2142, -10.0f at blown-account at line 2203) pass through unmodified.
    /// The flag is shared between on-policy and CF: if on-policy at (i,t)
    /// was an early-return, the CF slot at (i,t) is also skipped. Like
    /// `r_discipline_per_sample`, this buffer is per-step ephemeral
    /// (defaulted at every kernel entry); cross-fold leakage is impossible
    /// without an explicit registry reset. Pure GPU buffer
    /// (kernel-to-kernel only). i32 (not u8) to mirror the existing
    /// `cf_flip_per_sample` / `slot_live_per_sample` int-flag pattern —
    /// no `MappedU8Buffer` exists in `mapped_pinned.rs`, and adding one
    /// for a single use-case is out of scope for Wave 2.
    slot_completed_normally_per_sample: CudaSlice<i32>, // [alloc_episodes * alloc_timesteps]

    /// Last experience count from `collect_experiences_gpu` (episodes * timesteps * cf_mult).
    /// Set after each collection for downstream consumers (e.g. quantile extraction).
    last_experience_count: usize,

    /// Running reward statistics for cross-epoch normalization (Welford's online algorithm)

    // Persistent epoch state — survives across kernel launches.
    epoch_state: CudaSlice<f32>,  // [8]
    /// Bitfield: bit 0 = reset portfolio, bit 1 = reset DSR, bit 2 = reset vol EMA
    reset_flags: u32,

    /// Expert demonstration actions [total_bars] — pre-computed MA crossover signals.
    /// -1 = no expert opinion (use Q-network), >= 0 = expert exposure action index.
    /// Uploaded once at training start, read by action selection kernel.
    /// Mapped pinned: CPU writes once via `host_ptr`, the action kernel reads
    /// via `dev_ptr` — no HtoD copy.
    expert_actions_gpu: Option<MappedI32Buffer>,
    /// Expert demo override probability (0.0 = disabled, decays over epochs).
    expert_ratio: f32,

    // OFI features pre-uploaded to GPU [total_bars * OFI_DIM] — unconditional.
    // Mapped pinned (cuMemHostAlloc DEVICEMAP) — host writes once via host_ptr in
    // `upload_ofi_features` before the first collect, kernel reads via dev_ptr.
    // Avoids DtoD-via-pinned per feedback_no_htod_htoh_only_mapped_pinned.
    ofi_gpu: super::mapped_pinned::MappedF32Buffer,
    ofi_dim: usize,

    // GPU-resident curiosity forward model trainer (None if curiosity disabled)
    curiosity_trainer: Option<GpuCuriosityTrainer>,

    /// #23 Causal feature mask buffer [market_dim] f32 (1.0=keep, 0.0=zero). None = no masking.
    /// Mapped pinned: CPU writes per-epoch via `host_ptr`, state_gather reads
    /// via `dev_ptr` — no HtoD copy on the per-collect (t==0) path.
    feature_mask_buf: Option<MappedF32Buffer>,
    /// HOT path: hindsight relabel `bar_indices` upload buffer. Allocated once
    /// at constructor with capacity `alloc_episodes * alloc_timesteps * 2` (cf
    /// counterfactual doubles total). CPU fills via `host_ptr` per
    /// `collect_experiences_gpu`; the relabel kernel reads via `dev_ptr`.
    bar_indices_pinned: MappedI32Buffer,

    /// #31 Bottleneck hidden buffer [alloc_episodes, bn_dim] f32. None when disabled.
    exp_bn_hidden: CudaSlice<f32>,
    /// #31 Bottleneck concat buffer [alloc_episodes, bn_dim + portfolio_dim] f32.
    exp_bn_concat: CudaSlice<f32>,
    /// #30 F32 activation buffers [alloc_episodes, dim].
    exp_h_s1_f32: CudaSlice<f32>,
    exp_h_s2_f32: CudaSlice<f32>,
    exp_h_v_f32: CudaSlice<f32>,
    exp_h_b0_f32: CudaSlice<f32>,
    exp_h_b1_f32: CudaSlice<f32>,
    exp_h_b2_f32: CudaSlice<f32>,
    exp_h_b3_f32: CudaSlice<f32>,
    /// #30 F32 states buffer [alloc_episodes, state_dim_padded].
    exp_states_f32: CudaSlice<f32>,

    /// GPU-native domain randomization: episode start generation kernel.
    domain_rand_starts_kernel: CudaFunction,
    /// GPU-native domain randomization: sim param generation kernel.
    domain_rand_params_kernel: CudaFunction,
    /// GPU-resident per-episode sim params [alloc_episodes, 5] f32.
    domain_rand_params_buf: CudaSlice<f32>,

    /// #33 GPU-native saboteur: per-episode params [alloc_episodes, 3] f32.
    saboteur_params_buf: CudaSlice<f32>,
    /// #33 Saboteur base params [3] f32 (center of perturbation).
    saboteur_base_buf: CudaSlice<f32>,
    /// #33 Saboteur best output [3] f32 (result of selection).
    saboteur_best_buf: CudaSlice<f32>,
    /// #33 Saboteur best return [1] f32.
    saboteur_best_return_buf: CudaSlice<f32>,
    /// #33 Saboteur generate kernel.
    saboteur_generate_kernel: CudaFunction,
    /// #33 Saboteur select kernel.
    saboteur_select_kernel: CudaFunction,
    /// #33 Whether saboteur is active this epoch.
    saboteur_active: bool,
    /// #33 Perturbation scale (decays over epochs).
    saboteur_perturbation_scale: f32,

    /// #31 Bottleneck dimension (0 = disabled).
    bottleneck_dim: usize,
    /// #31 Market feature dimension for bottleneck separation.
    market_dim_bn: usize,
    /// #31 Bottleneck tanh+concat kernel (loaded from utility cubin). None when bn_dim=0.
    bn_tanh_concat_fn: CudaFunction,

    // ── GPU-resident step counter (Task 8: CUDA Graph prep) ────────────
    /// GPU-resident timestep counter [1] i32 — replaces host-side `t_i32`
    /// parameter in the experience collection loop.  Zeroed before each
    /// epoch and advanced by `step_counter_kernel` after each env_step.
    step_counter_gpu: CudaSlice<i32>,
    /// Single-thread kernel that increments `step_counter_gpu` by 1.
    step_counter_kernel: CudaFunction,
    /// Advantage noise kernel for dueling symmetry breaking.
    /// Per-action Gaussian noise replaces dead NoisyLinear (cuBLAS bypasses Candle).
    noise_kernel: CudaFunction,
    /// SP6 Pearl 3: [4] mapped-pinned device buffer for per-branch noise sigma.
    /// CPU writes via host_ptr before each noise kernel launch; kernel reads via dev_ptr.
    noise_sigma_dev: MappedF32Buffer,

    // Pre-allocated pinned host buffers for DtoH transfers.
    pinned_states: PinnedHostBuf<f32>,
    pinned_actions: PinnedHostBuf<i32>,
    pinned_rewards: PinnedHostBuf<f32>,
    pinned_dones: PinnedHostBuf<i32>,

    /// G2: Transaction cost curriculum — pinned device-mapped anneal scalar [1] f32.
    /// CPU writes to cost_anneal_pinned, GPU reads via cost_anneal_dev_ptr. Zero memcpy.
    cost_anneal_pinned: *mut f32,
    cost_anneal_dev_ptr: u64,

    /// C.2 Plan 3 Task 1: reward_component_ema GPU kernel.
    /// Single-block (6 threads) EMA of mean |r_c| per component into ISV[63..69).
    /// Launched after each collect_experiences_gpu epoch from launch_reward_component_ema_inplace().
    reward_component_ema_kernel: CudaFunction,

    /// SP18 v2 Phase 0 Task 0.1 (2026-05-08): D-leg per-action reward
    /// decomposition diagnostic kernel. Reads
    /// `reward_components_per_sample [n_samples × 6]` + `actions_out
    /// [n_samples]` (factored-action ints) and bins per-direction
    /// (DIR_HOLD / DIR_LONG / DIR_SHORT / DIR_FLAT). Writes a 20-float
    /// per-action-bin diagnostic block (4 bins × 5 stats per bin) into
    /// a host-supplied mapped-pinned `out_buf`. Block tree-reduce —
    /// 4 blocks × 256 threads. Launched BEFORE
    /// `launch_reward_component_ema_inplace` (which zeroes the source
    /// buffer). Pure observability — no production-path consumer.
    /// Plan: SP18 Phase 0 Task 0.1.
    sp18_reward_decomp_diag_kernel: CudaFunction,

    /// SP4 GPU-only Pearls A+D applicator (2026-05-01). Loaded from the
    /// shared `APPLY_PEARLS_AD_CUBIN`; consumed by
    /// `launch_reward_component_ema_inplace` after the
    /// `reward_component_ema` step-observation kernel writes the 6
    /// component scalars to scratch slots [63..69). Replaces the old
    /// host-side `apply_pearls_to_slot` Phase 2 loop per
    /// `feedback_no_cpu_compute_strict.md`.
    apply_pearls_ad_kernel: CudaFunction,

    /// SP13 v3 Phase 0a P0a.T4 (2026-05-04): Hold-rate observer kernel
    /// handle on the collector's stream. Reads the per-bar PACKED
    /// factored `batch_actions [B]` tile (written by
    /// `experience_action_select` to `out_actions[i] = dir*M*O*U + ...`)
    /// and writes the Hold-pick fraction into `hold_rate_buf [1]`.
    /// Same cubin as the trainer's handle (`SP13_HOLD_RATE_OBSERVER_CUBIN`)
    /// — loaded on this stream so the per-step launch chains directly
    /// after `experience_action_select` without a cross-stream sync.
    sp13_hold_rate_observer_kernel: CudaFunction,
    /// SP13 Phase 0a P0a.T4 (2026-05-04): fixed-α EMA applicator kernel
    /// handle on the collector's stream. Reads `hold_rate_buf [1]` and
    /// blends into ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382] with α=0.05
    /// and sentinel 0.0 (matching the registry entry
    /// `sp13_hold_rate_observed_ema`). Same cubin as the trainer's
    /// `apply_fixed_alpha_ema_kernel` field — Pearls A+D would be the
    /// wrong tool here because the spec needs the slow-EMA timescale
    /// (α=0.05) for the host-side controller's gain math. Loaded on
    /// this stream so the per-step EMA fires directly after the
    /// observer launch with no cross-stream sync.
    sp13_apply_fixed_alpha_ema_kernel: CudaFunction,
    /// SP13 v3 Phase 0a P0a.T4 (2026-05-04): mapped-pinned 1-element
    /// scratch for the Hold-rate observer's per-step output. Per
    /// `feedback_no_htod_htoh_only_mapped_pinned.md`: kernel writes via
    /// `dev_ptr` (with `__threadfence_system()`); the chained fixed-α
    /// EMA reads via the same `dev_ptr`. Constructor-zero-initialised;
    /// the kernel overwrites it on every launch.
    sp13_hold_rate_buf: MappedF32Buffer,

    // ── SP14 β-migration (Option B, 2026-05-07): collector-native EGF
    // producer chain. The trainer's `submit_aux_ops` path only fires once
    // per epoch with batches drawn from the replay buffer where Q has
    // converged on Hold/Flat (total_cnt=0 → EMA decays toward zero). The
    // rollout path on this collector's stream sees Thompson-forced
    // Short/Long picks which is where genuine aux↔Q disagreement signal
    // lives. Same cubin handles as the trainer's, loaded on this stream
    // for same-stream producer→consumer ordering. Mirrors the SP13
    // hold-rate observer's collector-native pattern (loaded above).
    /// SP14 β-migration (2026-05-07): SP13 dir-acc reduce kernel handle
    /// on the collector's stream. Reads `exp_aux_nb_softmax_buf [N, 2]` +
    /// `exp_aux_nb_label_buf [N]` and writes 6 floats to
    /// `exp_aux_dir_acc_buf` (mapped-pinned). Same cubin as the trainer's
    /// `aux_dir_acc_reduce` (`SP13_AUX_DIR_ACC_REDUCE_CUBIN`).
    exp_sp13_aux_dir_acc_reduce_kernel: CudaFunction,
    /// SP14 β-migration (2026-05-07): aux-pred → ISV[375] tanh producer
    /// kernel handle on the collector's stream. Reads
    /// `exp_aux_nb_softmax_buf [N, 2]` and writes
    /// `mean(softmax[:,1] - softmax[:,0])` to ISV[AUX_DIR_PREDICTION_INDEX=375].
    /// Same cubin as the trainer's `aux_pred_to_isv_tanh_kernel`
    /// (`SP13_AUX_PRED_TO_ISV_TANH_CUBIN`).
    exp_sp13_aux_pred_to_isv_tanh_kernel: CudaFunction,
    /// SP14 β-migration (2026-05-07): SP14 q-disagreement EMA producer
    /// kernel handle on the collector's stream. Reads
    /// `exp_aux_nb_softmax_buf [N, K=2]` + collector's `q_values [N, q_stride]`
    /// (post-`expected_q_kernel`) and updates ISV[388] +
    /// ISV[Q_DISAGREEMENT_VAR_INDEX=389]. Same cubin as the trainer's
    /// `sp14_q_disagreement_update_kernel` (`SP14_Q_DISAGREEMENT_CUBIN`).
    exp_sp14_q_disagreement_update_kernel: CudaFunction,
    // SP14 Layer C Phase C.1 (2026-05-08): collector α-machinery handle
    // `exp_sp14_alpha_grad_compute_kernel` deleted atomically with the
    // kernel file per `feedback_no_legacy_aliases`. The collector's
    // SP14 q_disagreement_update handle remains as HEALTH_DIAG diagnostic.

    /// SP14 β-migration step 2 (2026-05-07): collector-owned aux next-bar
    /// head forward orchestrator. Loaded on `self.stream`, mirrors the
    /// trainer's `aux_heads_fwd: AuxHeadsForwardOps` field. The collector
    /// uses ONLY the `forward_next_bar` method — regime forward and CE
    /// reduce remain trainer-only because the rollout path doesn't
    /// compute a regime-classification loss. Regime-related kernel
    /// handles inside this struct are loaded but unused by design (cudarc
    /// `Module` returns the whole cubin's symbols at load); per
    /// `feedback_no_stubs.md` this is acceptable because the unused
    /// handles cost nothing at runtime and keeping `AuxHeadsForwardOps`
    /// as the unit of construction preserves the trainer-collector
    /// symmetry.
    exp_aux_heads_fwd: super::gpu_aux_heads::AuxHeadsForwardOps,
    /// SP14 β-migration step 2 (2026-05-07): rollout-sized aux next-bar
    /// hidden tile. Sized `[alloc_episodes × AUX_HIDDEN_DIM=32]`. The
    /// `aux_next_bar_forward` kernel writes the post-ELU hidden state
    /// here. Backward never runs in the collector path (trainer owns
    /// aux training); kept allocated so the forward kernel's hidden_out
    /// arg is non-NULL and the launch contract is satisfied. Per
    /// `feedback_wire_everything_up.md` the buffer is a real tile, not
    /// a NULL or dummy — the kernel writes to it on every step.
    exp_aux_nb_hidden_buf: cudarc::driver::CudaSlice<f32>,
    /// SP14 β-migration step 2 (2026-05-07): rollout-sized aux next-bar
    /// logits tile. Sized `[alloc_episodes × AUX_NEXT_BAR_K=2]`. Saved
    /// for backward parity (trainer pattern) but unread in the
    /// collector path; the production consumer is `exp_aux_nb_softmax_buf`.
    exp_aux_nb_logits_buf: cudarc::driver::CudaSlice<f32>,
    /// SP14 β-migration step 2 (2026-05-07): rollout-sized aux next-bar
    /// softmax tile. Sized `[alloc_episodes × AUX_NEXT_BAR_K=2]`. The
    /// `aux_next_bar_forward` kernel writes the per-sample softmax
    /// output here. Read by the collector's SP14 EGF chain (step 4):
    /// `exp_sp13_aux_dir_acc_reduce_kernel` (vs labels, into 6-float
    /// dir-acc tile), `exp_sp13_aux_pred_to_isv_tanh_kernel` (mean
    /// softmax diff into ISV[375]), `exp_sp14_q_disagreement_update_kernel`
    /// (vs Q-argmax, into ISV[383/384/389]).
    exp_aux_nb_softmax_buf: cudarc::driver::CudaSlice<f32>,

    // ── SP22 H6 vNext Phase B3 (2026-05-14): trade-outcome aux head ──
    // collector-side rollout buffers + ops handle. Mirrors the K=2
    // `exp_aux_nb_*` pattern at K=3. Forward runs per-rollout-step on
    // the collector's stream; the label kernel runs after each
    // `experience_env_step` to extract the K=3 outcome label from the
    // save-for-backward buffers populated at segment_complete.
    //
    /// Forward orchestrator (Phase B0 scaffold). Holds the 3 forward-
    /// side kernel handles: aux_trade_outcome_forward,
    /// aux_trade_outcome_loss_reduce, trade_outcome_label_kernel.
    /// Loss reduce isn't invoked in the collector path (backward runs
    /// in the trainer's replay-batch path; loss numerator only matters
    /// for training); kept on the handle to share the ops struct with
    /// the trainer-side wiring (Phase B4) without two parallel loads.
    exp_aux_to_fwd: super::gpu_aux_heads::AuxTradeOutcomeForwardOps,
    /// `[alloc_episodes × AUX_HIDDEN_DIM=128]` saved post-ELU
    /// activation. Mirrors `exp_aux_nb_hidden_buf` for the K=3 head.
    /// Backward never runs in the collector path; allocated so the
    /// forward kernel's hidden_out arg is non-NULL per
    /// `feedback_wire_everything_up`.
    exp_aux_to_hidden_buf: cudarc::driver::CudaSlice<f32>,
    /// `[alloc_episodes × AUX_OUTCOME_K=3]` saved pre-softmax logits.
    /// Parity with `exp_aux_nb_logits_buf`; backward reads the softmax
    /// tile not the logits, but the buffer is allocated for contract
    /// symmetry.
    exp_aux_to_logits_buf: cudarc::driver::CudaSlice<f32>,
    /// `[alloc_episodes × AUX_OUTCOME_K=3]` saved softmax tile —
    /// THREE eventual consumers:
    ///   - Phase C 3-slot state assembly (state[121..124] = p_Profit,
    ///     p_Stop, p_Timeout) — replaces Phase 2's single-slot
    ///     `prev_aux_dir_prob` for the K=2 head
    ///   - Trainer-side backward (Phase B4): reads via replay-buffer
    ///     scatter into trainer's `aux_to_softmax_buf`
    ///   - Future diagnostic / HEALTH_DIAG slots (Phase F)
    /// Phase B3 (this commit): produced per-step but no consumer yet.
    exp_aux_to_softmax_buf: cudarc::driver::CudaSlice<f32>,
    /// `[alloc_episodes] i32` per-env trade-outcome label in
    /// `{-1=mask, 0=Profit, 1=Stop, 2=Timeout}`. Written per-step by
    /// `trade_outcome_label_kernel` reading
    /// `trade_close_per_sample[env*L+t]` and the save-for-backward
    /// `pnl_vs_target_at_close_per_env` / `pnl_vs_stop_at_close_per_env`
    /// buffers populated at segment_complete (Phase A3 wireup).
    /// Sparse: most bars (~95-99%) emit -1. Consumed (eventual): trainer-
    /// side via replay-buffer label scatter (Phase B4 wireup, mirrors
    /// `aux_sign_labels` per-(i, t) scatter pattern).
    /// Phase B3 (this commit): produced per-step but no replay-buffer
    /// scatter yet.
    exp_aux_to_label_buf: cudarc::driver::CudaSlice<i32>,

    /// SP22 H6 vNext Phase B4b (2026-05-14) — per-(env, t) trade-outcome
    /// label column. Sized `[alloc_episodes × alloc_timesteps]` i32,
    /// row-major: env-major then t. Written by `trade_outcome_label_kernel`
    /// at offset `env*L + t` on every rollout step alongside the per-env
    /// `exp_aux_to_label_buf` write. Sparse (~95-99% mask=-1).
    ///
    /// This column threads through the replay-buffer's `aux_sign_labels`-
    /// equivalent scatter on `insert_batch` and gather on
    /// `sample_proportional` into the trainer's `aux_to_label_buf`
    /// (B4b-2 wireup — replay buffer + trainer setter, deferred to
    /// next commit). For B4b-1 (this commit) the buffer is allocated
    /// and populated by the per-step launch; the consumer wiring is
    /// pending.
    ///
    /// The trade-outcome label kernel's NEW per-sample output arg
    /// (added in this commit) writes here. Per-env output stays in
    /// `exp_aux_to_label_buf` for Phase C's per-step state-assembly
    /// consumer.
    exp_aux_to_label_per_sample: cudarc::driver::CudaSlice<i32>,

    /// SP22 H6 vNext Phase B5b (2026-05-14): plan-conditioned input
    /// buffer for the collector-side K=3 forward. Sized
    /// `[alloc_episodes × (SH2 + PLAN_PARAM_DIM) = N × 262]` f32
    /// row-major.
    ///
    /// **Train/inference asymmetry note**: trainer-side plan_params
    /// come from the trade plan head's output (`plan_params_buf`).
    /// Collector-side has NO trade plan head launch (the trade plan
    /// runs only on the replay batch). The collector therefore passes
    /// `plan_params_ptr = 0` (NULL) to the concat kernel, which fills
    /// the trailing 6 columns with zeros. The K=3 forward at rollout
    /// time evaluates "outcome prediction at plan_params=0", which
    /// is a specific point — not the marginal-over-plans prediction.
    ///
    /// This mismatch means the head is trained on real plan_params
    /// (replay) but queried at plan_params=0 (rollout's state bridge
    /// to next-step state[121..124)). Phase B5b-2 (follow-up) adds a
    /// trade plan head launch to the collector to populate real
    /// plan_params at rollout time, resolving the asymmetry.
    ///
    /// `pub(crate)` so the trainer's `reset_named_state` can reach it
    /// at fold-boundary reset (per the matching `RegistryEntry` in
    /// `state_reset_registry.rs::exp_aux_to_input_buf`).
    pub(crate) exp_aux_to_input_buf: cudarc::driver::CudaSlice<f32>,

    /// SP22 H6 vNext Phase C (2026-05-14) — per-env K=3 trade-outcome
    /// softmax cache. Sized `[alloc_episodes × AUX_OUTCOME_K=3]` f32,
    /// row-major: env-major then class. Written end-of-step by
    /// `aux_outcome_softmax_to_per_env_kernel` (Phase C kernel) from
    /// `exp_aux_to_softmax_buf`. Read start-of-next-step by
    /// `experience_state_gather` (Phase C-2 wireup) to populate state
    /// slots `[AUX_OUTCOME_PROFIT_INDEX..AUX_OUTCOME_TIMEOUT_INDEX+1)`
    /// = `[121, 122, 123]`.
    ///
    /// Replaces the K=2 head's single-slot `prev_aux_dir_prob` semantic
    /// at state slot 121. Both buffers coexist in this commit (K=2 head
    /// remains active; its prev_aux_dir_prob still feeds state[121] via
    /// the current state gather). Phase C-2 flips the state gather to
    /// read from this 3-slot K=3 buffer instead.
    ///
    /// Cold-start sentinel = 0.0 across all 3 slots. The state gather
    /// reads "no Profit/Stop/Timeout signal" on step 0, then this
    /// kernel writes real K=3 softmax probs for step 1+ state assembly.
    ///
    /// `pub(crate)` so the trainer's `reset_named_state` can reach it
    /// at fold-boundary reset (per the matching `RegistryEntry` in
    /// `state_reset_registry.rs::prev_aux_outcome_probs`).
    pub(crate) prev_aux_outcome_probs: cudarc::driver::CudaSlice<f32>,
    /// SP22 H6 vNext Phase C (2026-05-14) — kernel handle for
    /// `aux_outcome_softmax_to_per_env_kernel`. Loaded from the Phase
    /// C cubin. Launched on the collector's stream right after the
    /// K=3 `aux_trade_outcome_forward` populates
    /// `exp_aux_to_softmax_buf`.
    exp_aux_outcome_softmax_to_per_env_kernel: CudaFunction,

    // ── SP22 H6 vNext Phase B5b-2 (2026-05-14) — collector trade plan
    //    forward (resolves Phase B5b train/inference asymmetry) ──
    //
    // Phase B5b's K=3 concat passed `plan_params=NULL` because the
    // collector had no trade plan launch — trainer-side K=3 forward
    // trained on real plan_params while the collector queried at
    // plan_params=0. Phase B5b-2 mirrors the (now-corrected) trainer
    // `launch_trade_plan_forward` chain on the collector: cuBLAS SGEMM
    // (W_fc[AH,SH2] × h_s2) → bias-relu → SGEMM (W_out[6,AH] × hidden)
    // → trade_plan_activate kernel. Output `exp_plan_params[N, 6]`
    // feeds the concat kernel's `plan_params` arg in lieu of NULL.
    //
    // Param tensors at indices [91..95) via the same
    // `f32_weight_ptrs_from_base` array the K=3 forward already uses
    // (no extra weight resolution). Cold-start path (test scaffold,
    // `trainer_params_ptr == 0`) skips the chain — same guard as the
    // existing aux next-bar forward at the start of the rollout step.

    /// Scratch buffer `[alloc_episodes × adv_h]` f32 — layer-1 hidden
    /// output (post bias+ReLU) of the collector's trade plan MLP.
    /// Mirrors the trainer's `trade_plan_hidden_buf` shape. Lifetime:
    /// only valid between the SGEMM that writes it and the SGEMM that
    /// reads it within the same rollout step.
    exp_trade_plan_hidden_buf: cudarc::driver::CudaSlice<f32>,

    /// Scratch buffer `[alloc_episodes × 6]` f32 — layer-2 pre-activation
    /// output of the collector's trade plan MLP. Mirrors the trainer's
    /// `trade_plan_pre_out_buf`. The `trade_plan_activate` kernel reads
    /// this + b_out and writes activated `exp_plan_params`.
    exp_trade_plan_pre_out_buf: cudarc::driver::CudaSlice<f32>,

    /// Collector-side rollout-step plan_params `[alloc_episodes × 6]`
    /// f32, sigmoid/scale-activated per `trade_plan_activate`. Feeds
    /// the B5b concat kernel's `plan_params` arg (replacing the NULL
    /// passed in B5b). Cold-start sentinel = 0.0 (alloc_zeros). The
    /// plan tensors at indices [91..94] still have no backward, so
    /// these values are deterministic outputs of Xavier-init weights
    /// applied to live h_s2 — that's all the symmetry-resolution this
    /// commit can deliver. Adding a real plan-head backward is a
    /// separate follow-up project (out of scope for B5b-2).
    exp_plan_params: cudarc::driver::CudaSlice<f32>,

    /// Kernel handle for `trade_plan_activate` (bias + per-output
    /// sigmoid/scaling activation). Loaded from `EXPERIENCE_KERNELS_CUBIN`
    /// (same cubin used by the rest of `exp_module_extra`). Mirrors
    /// the trainer's `trade_plan_activate_kernel`.
    exp_trade_plan_activate_kernel: CudaFunction,

    /// SP22 H6 Phase 2 (2026-05-12) — per-env aux directional probability
    /// cache, RECENTERED encoding. Sized `[alloc_episodes]` f32 (one slot
    /// per env). Written end-of-step by `exp_aux_softmax_to_per_env_kernel`
    /// (gathers `2*aux_softmax[env, 1] - 1` ∈ [-1, +1]). Read start-of-next
    /// step by `experience_state_gather` for
    /// `state[AUX_DIR_PROB_INDEX = SL_PADDING_START + 0 = 121]`. Cold-start
    /// and FoldReset both initialize to 0.0 (neutral; aux contributes
    /// nothing) via the `fill_f32` GPU kernel — no HtoD per
    /// `feedback_no_htod_htoh_only_mapped_pinned.md`. Pre-Phase-2 the
    /// encoding was raw `p_up ∈ [0, 1]` with sentinel 0.5; recentered for
    /// gradient-init parity with every other state slot per
    /// `pearl_first_observation_bootstrap`.
    prev_aux_dir_prob: cudarc::driver::CudaSlice<f32>,
    /// SP22 H6 (2026-05-12) — copy kernel handle for
    /// `aux_softmax_to_per_env_kernel`. Loaded from
    /// `SP22_AUX_SOFTMAX_TO_PER_ENV_CUBIN`. Launched on the collector's
    /// stream right after `aux_next_bar_forward` populates
    /// `exp_aux_nb_softmax_buf`.
    exp_aux_softmax_to_per_env_kernel: CudaFunction,
    /// SP22 H6 (2026-05-12) — `fill_f32` kernel handle (from
    /// `EPSILON_GREEDY_CUBIN`). Used to initialize `prev_aux_dir_prob` to
    /// 0.5 at construction and at every FoldReset. Pure GPU compute — no
    /// HtoD memcpy of an `init_vec` per
    /// `feedback_no_htod_htoh_only_mapped_pinned.md`.
    exp_fill_f32_kernel: CudaFunction,
    /// SP14 β-migration step 2 (2026-05-07): rollout-sized aux next-bar
    /// label tile. Sized `[alloc_episodes]` i32. Filled per-step by a
    /// thin per-step variant of `aux_sign_label_kernel` that derives
    /// the bar index per env from `episode_starts_buf[ep] + t` (host-
    /// scalar `t`). Sentinel -1 = skip (no lookahead window); 0 =
    /// "not-up" (down/flat); 1 = "up". The full collector-end
    /// `aux_sign_label_kernel` (line 3765) writes the entire trajectory
    /// at the end of `collect_experiences_gpu`; this tile is the
    /// per-step current-slice version for the EGF producers.
    exp_aux_nb_label_buf: cudarc::driver::CudaSlice<i32>,
    /// SP14 β-migration step 2 (2026-05-07): mapped-pinned 6-float
    /// scratch for the dir-acc reducer's per-step output. Layout (per
    /// `aux_dir_acc_reduce_kernel.cu` post-B1.1a):
    ///   [0] dir_acc, [1] pos_pred_frac, [2] pos_label_frac,
    ///   [3] n_down,  [4] n_up,           [5] n_skip
    /// Only `[0]` feeds the downstream `apply_fixed_alpha_ema` chain
    /// into ISV[373/374]; slots `[1..6)` are diagnostic-only (HEALTH_DIAG
    /// observability of the rollout-time label distribution).
    exp_aux_dir_acc_buf: super::mapped_pinned::MappedF32Buffer,

    /// SP14 Layer C Phase C.5a (2026-05-08): rollout-sized aux trunk
    /// final output (`h_s2_aux`). Sized `[alloc_episodes × shared_h2]`
    /// to mirror the trainer's `h_s2_aux [B_max, SH2]`. C.5b atomically
    /// inserts `aux_trunk_forward.launch(... h_s2_aux_out=this ...)`
    /// post-`forward_online_f32` so the collector-native EGF chain
    /// reads from the aux representation pipeline instead of the
    /// Q-trunk's `h_s2`. DEAD until C.5b.
    #[allow(dead_code)]
    exp_h_s2_aux: cudarc::driver::CudaSlice<f32>,
    /// SP14 Layer C Phase C.5a: rollout-sized aux trunk Layer-1 post-ELU
    /// activation. Sized `[alloc_episodes × AUX_TRUNK_H1=256]`. Saved
    /// for parity with the trainer's per-step backward path; the
    /// collector itself only runs forward (no backward), but the buffer
    /// is allocated so the same `aux_trunk_forward.launch` signature
    /// works in both contexts (collector vs trainer). DEAD until C.5b.
    #[allow(dead_code)]
    exp_h_aux1: cudarc::driver::CudaSlice<f32>,
    /// SP14 Layer C Phase C.5a: rollout-sized aux trunk Layer-2 post-ELU
    /// activation. Sized `[alloc_episodes × AUX_TRUNK_H2=128]`. Saved
    /// for forward-API parity; see `exp_h_aux1` rationale.
    #[allow(dead_code)]
    exp_h_aux2: cudarc::driver::CudaSlice<f32>,

    /// SP14 Layer C Phase C.5a-fixup (2026-05-08): zero-copy device pointers
    /// to the trainer's aux trunk parameter tensors. Mirrors the existing
    /// `trainer_params_ptr` field's zero-copy contract: the trainer owns
    /// the authoritative parameter buffers; the collector reads them
    /// directly via these stable raw pointers (no DtoD copy). Set to 0
    /// initially; updated by `set_trainer_aux_trunk_param_ptrs` once the
    /// fused training context is created.
    ///
    /// Order matches `GpuDqnTrainer::aux_trunk_param_ptrs()`:
    /// w1 [SH1, 256] → b1 [256] → w2 [256, 128] → b2 [128]
    ///   → w3 [128, SH2] → b3 [SH2].
    ///
    /// C.5b consumes these via `exp_aux_trunk_forward_ops.launch(...)`
    /// inserted post-`forward_online_f32`. DEAD until C.5b.
    #[allow(dead_code)]
    aux_trunk_w1_ptr: u64,
    #[allow(dead_code)]
    aux_trunk_b1_ptr: u64,
    #[allow(dead_code)]
    aux_trunk_w2_ptr: u64,
    #[allow(dead_code)]
    aux_trunk_b2_ptr: u64,
    #[allow(dead_code)]
    aux_trunk_w3_ptr: u64,
    #[allow(dead_code)]
    aux_trunk_b3_ptr: u64,

    /// SP14 Layer C Phase C.5a-fixup (2026-05-08): collector-side aux trunk
    /// forward orchestrator. Owns the pre-loaded `aux_trunk_forward`
    /// CudaFunction handle on the collector's stream
    /// (per `pearl_no_host_branches_in_captured_graph` — no `load_cubin`
    /// inside per-step body, no cross-stream sync). Mirrors the trainer's
    /// `aux_trunk_forward_ops` field. C.5b's collector-side wire-up
    /// invokes `exp_aux_trunk_forward_ops.launch(...)` post-
    /// `forward_online_f32`, reading the trainer's parameter pointers via
    /// `aux_trunk_{w,b}{1,2,3}_ptr`. DEAD until C.5b.
    #[allow(dead_code)]
    exp_aux_trunk_forward_ops: super::gpu_aux_trunk::AuxTrunkForwardOps,

    /// SP14 Layer C Phase C.6 (2026-05-08): h_s2_aux RMS EMA producer.
    /// Launched immediately after `exp_aux_trunk_forward_ops` so that
    /// `h_s2_aux [B, SH2]` is fully populated before the RMS reduction.
    /// Writes `ISV[H_S2_AUX_RMS_EMA_INDEX=449]` via embedded Pearl-A
    /// bootstrap (sentinel 0.0 → replace) + fixed α=0.05 EMA blend.
    exp_h_s2_aux_rms_ema_ops: super::gpu_aux_trunk::HS2AuxRmsEmaOps,

    /// SP14 β-migration step 3 (2026-05-07): per-rollout-step label
    /// producer kernel handle. Sibling of the trajectory-wide
    /// `aux_sign_label_kernel` (line 3776 above) — same per-thread O(1)
    /// map but indexed by `episode_starts[ep] + t` for the current
    /// rollout step instead of the full `bar_indices` ring. Loaded
    /// from `aux_sign_label_per_step_kernel.cubin` on the collector's
    /// stream so producer→consumer ordering inside the per-step body
    /// is stream-implicit.
    exp_aux_sign_label_per_step_kernel: cudarc::driver::CudaFunction,

    /// SP15 Wave 5 follow-up (2026-05-07): pre-loaded SP15 kernel
    /// handles for the 6 collector hot-path launchers. The launchers
    /// in `gpu_dqn_trainer.rs` previously did `load_cubin` +
    /// `load_function` PER CALL inside their bodies, called from
    /// `collect_experiences_gpu` per-rollout-step (~thousands of
    /// times per epoch × thousands of envs × thousands of timesteps).
    /// Same architectural bug class as commits `5d63762ab` and
    /// `1396b62ec`: not capturable inside `cuStreamBeginCapture` per
    /// `pearl_no_host_branches_in_captured_graph`, and prone to
    /// `CUDA_ERROR_ILLEGAL_ADDRESS` in subprocess child contexts.
    /// Loaded once in `GpuExperienceCollector::new()` on the
    /// collector's stream; per-step launches use these cached handles.
    exp_sp15_dd_state_kernel: CudaFunction,
    exp_sp15_dd_state_reduce_kernel: CudaFunction,
    exp_sp15_dd_trajectory_decreasing_kernel: CudaFunction,
    exp_sp15_alpha_split_producer_kernel: CudaFunction,
    exp_sp15_final_reward_kernel: CudaFunction,
    exp_sp15_plasticity_injection_kernel: CudaFunction,

    /// B.2 Plan 3 Task 3: trade_attempt_rate_ema_update GPU kernel.
    /// Single-block (1 thread) reduction + adaptive EMA of Flat→Positioned
    /// transition rate into ISV[71]. Launched alongside reward_component_ema
    /// from launch_trade_attempt_rate_ema_inplace().
    trade_attempt_rate_ema_kernel: CudaFunction,

    /// B.4 Plan 3 Task 4: plan_threshold_update GPU kernel.
    /// Single-block (1 thread) reduction + adaptive EMA of mean readiness
    /// into ISV[READINESS_EMA_INDEX=75], then derives the threshold stored
    /// in ISV[PLAN_THRESHOLD_INDEX=49]. Launched alongside the other
    /// per-epoch ISV producers from launch_plan_threshold_update_inplace().
    plan_threshold_update_kernel: CudaFunction,

    /// C.3 Plan 3 Task 7: state_kl_moment_match GPU kernel.
    /// Single-block (one thread per monitored OFI dim) reduction +
    /// adaptive EMA of train-vs-val moment-match KL into
    /// ISV[STATE_KL_TRAIN_VAL_EMA_INDEX=78] + ISV[STATE_KL_AMPLIFICATION_INDEX=79].
    /// Cold-path (per validation epoch). Launched from
    /// `launch_state_kl_inplace`.
    state_kl_kernel: CudaFunction,

    /// B.3 Plan 3 Task 8: scripted_policy_select GPU kernel.
    /// Replaces the network-Q action source during the seed phase. 4 policies
    /// (uniform/momentum/mean-rev/vwap-deviation) deterministically mixed
    /// 40/20/20/20 by `i % 5`. Per-thread one sample. Launched from
    /// `launch_scripted_policy_select` only when ISV[SEED_STEPS_DONE] <
    /// ISV[SEED_STEPS_TARGET].
    scripted_policy_kernel: CudaFunction,

    /// B.3 Plan 3 Task 8: seed_step_counter_update GPU kernel.
    /// Single-block single-thread cold-path. Increments
    /// ISV[SEED_STEPS_DONE_INDEX] by `n_samples`, EMAs the derived
    /// `max(0, 1 - DONE/TARGET)` into ISV[SEED_FRAC_EMA_INDEX]. Launched
    /// after every `collect_experiences_gpu`.
    seed_step_counter_kernel: CudaFunction,

    /// C.5 Plan 3 Task 9: cql_alpha_seed_update GPU kernel.
    /// Single-block single-thread cold-path. EMAs ISV[CQL_ALPHA_INDEX=48]
    /// toward `cql_alpha_final × max(0, 1 - ISV[SEED_FRAC_EMA_INDEX])`.
    /// Launched alongside `seed_step_counter_update`.
    cql_alpha_seed_update_kernel: CudaFunction,

    /// E.5 Plan 4 Task 5 Mode A: attention_focus_ema_update GPU kernel.
    /// Single-block single-thread cold-path. Takes 3 host scalars by value
    /// (vsn_mag, vsn_dir, mamba2_retention) and EMA-updates ISV[87..90).
    /// Launched at the per-epoch HEALTH_DIAG site from
    /// `launch_attention_focus_ema_inplace`.
    attention_focus_ema_kernel: CudaFunction,

    /// Plan 4 follow-up: target-drift EMA kernel — replaces legacy CPU-DtoH
    /// `per_branch_target_drift()`. 2-block GPU reduction computing
    /// RMS(target_params − online_params) for mag + dir branches → ISV[92, 93].
    target_drift_kernel: CudaFunction,

    /// ISV signals dev_ptr from trainer — for adaptive hold enforcement.
    /// Set via set_isv_signals_ptr(). 0 = NULL (static hold).
    isv_signals_dev_ptr: u64,

    /// SP22 H6 Phase 3 α Phase C1 (2026-05-13): cached device pointer to
    /// the trainer's `w_aux_to_q_dir [b0_size=4]` buffer. Default 0;
    /// wired in via `set_aux_w_to_q_dir_ptr()` after trainer construction.
    /// When non-zero, the rollout-time `compute_expected_q` +
    /// `quantile_q_select` launchers thread W + `exp_states_f32` for
    /// dir-branch atom-position shift `z_n + W[a]*state_121[b]`.
    aux_w_to_q_dir_dev_ptr: u64,

    /// SP15 Wave 2 (2026-05-06): trainer's `sp15_alpha_warm_count`
    /// mapped-pinned scratch buffer dev pointer ([1] f32). Set via
    /// `set_sp15_alpha_warm_count_ptr()` after trainer construction. 0
    /// = NULL → the per-step SP15 alpha producer + final-reward
    /// composer launches in `collect_experiences_gpu` are skipped (test
    /// scaffold path; the SP11 reward path stays canonical). Both SP15
    /// kernels gate on this pointer being non-zero AND
    /// `isv_signals_dev_ptr` being non-zero.
    sp15_alpha_warm_count_dev_ptr: u64,

    // SP15 Phase 3.5.5.b → Phase 1.3.b-followup-B (2026-05-07):
    // The single-element `sp15_dd_trajectory_prev_dd_dev_ptr` field was
    // deleted when the trainer's `[1]` scratch buffer was deleted in
    // favour of two per-env mapped-pinned tiles owned by THIS struct
    // (`sp15_dd_trajectory_prev_dd_per_env` + `sp15_dd_trajectory_per_env`
    // below). Production wiring is unconditional — both tiles are
    // allocated in this struct's constructor with the same
    // `alloc_episodes` sizing as `sp15_dd_state_per_env`, and the
    // `launch_sp15_dd_trajectory_decreasing` per-step launch threads
    // their dev_ptrs directly (no `setter` indirection through the
    // trainer struct, matching the `sp15_dd_state_per_env` collector-
    // owned pattern).

    /// SP15 Phase 1.3.b-followup (2026-05-07): per-env DD state tile
    /// for the redesigned `dd_state_kernel` + `dd_state_reduce_kernel`
    /// pair (Path A → Path B migration). Mapped-pinned f32 buffer
    /// sized `[alloc_episodes * 6]` (6 stats per env: DD_CURRENT,
    /// DD_MAX, DD_RECOVERY_BARS, DD_PERSISTENCE, CALMAR floor, DD_PCT).
    ///
    /// Per-step launch order on the same stream (CUDA serialises
    /// producer→consumer without explicit event sync):
    ///   1. `dd_state_kernel` runs a per-env grid `[n_envs, 1, 1]`,
    ///      reading PS_PEAK_EQUITY / PS_PREV_EQUITY from each env's
    ///      portfolio-state row and writing 6 scalars to its slot in
    ///      this tile (read-modify-write — running max + counters).
    ///   2. `dd_state_reduce_kernel` aggregates the tile into 6 ISV
    ///      scalar slots [401..407) (mean across envs — HEALTH_DIAG
    ///      diagnostic) plus DD_PERSISTENCE_MAX_INDEX = 443 (max
    ///      across envs — plasticity-injection trigger).
    ///   3. `compute_sp15_final_reward_kernel` reads the tile per-(i,t)
    ///      via `env_id = (idx % (N*L)) / L` so each slot's reward
    ///      shaping uses ITS env's DD context, NOT env-0's canonical
    ///      observable (the Path A limitation).
    ///
    /// FoldReset sentinel 0 across all `alloc_episodes * 6` slots —
    /// every stateful field (running max, counters) bootstraps from
    /// zero on the new fold's first kernel launch (same rationale as
    /// the individual ISV scalars in the original Phase 1.3 reset
    /// arms). Reset path: `host_slice_mut().fill(0.0)` via the
    /// `sp15_dd_state_per_env` registry-arm dispatch in
    /// `training_loop.rs` (allowed under
    /// `feedback_no_htod_htoh_only_mapped_pinned.md`'s allowed-write
    /// rule, NOT a host-side compute).
    pub(crate) sp15_dd_state_per_env: super::mapped_pinned::MappedF32Buffer,

    /// SP15 Phase 1.3.b-followup-B (2026-05-07): per-env DD trajectory
    /// output tile for the recovery-curriculum DD_TRAJECTORY_DECREASING
    /// per-step proxy kernel. Mapped-pinned f32 buffer sized
    /// `[alloc_episodes]` (one trajectory boolean per env).
    ///
    /// Per-step launch order on the same stream:
    ///   1. `dd_state_kernel` writes per-env DD_PCT to
    ///      `sp15_dd_state_per_env[env*6 + 5]`.
    ///   2. `dd_trajectory_decreasing_kernel` (per-env grid)
    ///      reads each env's DD_PCT, reads each env's persistent
    ///      `sp15_dd_trajectory_prev_dd_per_env[env]`, and writes its
    ///      trajectory output to THIS tile at `[env]`.
    ///   3. `dd_state_reduce_kernel` mean-aggregates this tile into
    ///      ISV[DD_TRAJECTORY_DECREASING_INDEX=439] for HEALTH_DIAG
    ///      diagnostic preservation.
    ///   4. The downstream `per_insert_pa` (in
    ///      `GpuReplayBuffer::insert_batch`) reads THIS tile per-
    ///      transition via insert-time
    ///      `env_id = (j % (n_envs * lookback)) / lookback` so each
    ///      transition's recovery boost reflects ITS env's recovery
    ///      context. The fix for the Wave 4.3 statistical bias where
    ///      ISV[439] was read once per insert call and applied
    ///      uniformly to all transitions in the batch.
    ///
    /// FoldReset sentinel 0 across all `alloc_episodes` slots — every
    /// stateful field bootstraps from zero on the new fold's first
    /// kernel launch. Reset path: `host_slice_mut().fill(0.0)` via
    /// the `sp15_dd_trajectory_per_env` registry-arm dispatch in
    /// `training_loop.rs` (mirrors the `sp15_dd_state_per_env`
    /// collector-owned reset pattern).
    pub(crate) sp15_dd_trajectory_per_env: super::mapped_pinned::MappedF32Buffer,

    /// SP15 Phase 1.3.b-followup-B (2026-05-07): per-env DD trajectory
    /// previous-bar DD_PCT scratch buffer. Mapped-pinned f32 sized
    /// `[alloc_episodes]` (one prev_dd_pct per env). Read-modify-written
    /// by `dd_trajectory_decreasing_kernel`: each env's thread reads
    /// its slot to compute the trajectory direction comparison
    /// (`dd_now < dd_prev`), then advances the slot to the current
    /// per-env DD_PCT so the next call sees the right "previous bar"
    /// value.
    ///
    /// Cold-start sentinel 0.0 means the very first call after
    /// construction / fold reset has prev=0.0 < the 0.02 floor
    /// sentinel (ISV[441]), so the floor gate evaluates to false and
    /// trajectory=0 — bootstrapping the state without false-firing on
    /// a synthetic large-step transition.
    ///
    /// Reset path: `host_slice_mut().fill(0.0)` via the
    /// `sp15_dd_trajectory_prev_dd_per_env` registry-arm dispatch in
    /// `training_loop.rs` (mirrors the `sp15_dd_state_per_env` /
    /// `sp15_dd_trajectory_per_env` collector-owned reset pattern;
    /// supersedes the deleted trainer-owned `[1]` scalar pattern from
    /// the Wave 4.3 path).
    pub(crate) sp15_dd_trajectory_prev_dd_per_env: super::mapped_pinned::MappedF32Buffer,

    /// SP15 Phase 3.5.4.c (2026-05-07): trainer's `params_buf`
    /// device pointer offset to `w_b0out` (directional advantage-head
    /// last-Linear weights, tensor index 19 per `compute_param_sizes`).
    /// Set via `set_sp15_plasticity_target()` after trainer
    /// construction. 0 = NULL → the per-step plasticity-injection
    /// launch in step 4-pre is skipped AND `m_warm` is passed as 0.0f
    /// to `experience_action_select` so the OR-gate consumer
    /// degenerates to cooldown-only (bit-identical pre-3.5.4.c
    /// behaviour). Production sets this in `training_loop.rs`
    /// immediately after `set_sp15_dd_trajectory_prev_dd_ptr` —
    /// mirrors the same wiring pattern.
    ///
    /// Plasticity is injected into the directional branch only —
    /// per spec §9.2 (3.5.4) the recovery is about escaping stuck-
    /// in-flat regimes (a directional decision problem), so resetting
    /// the trailing 10% of the directional output projection is
    /// targeted at the exact failure mode. mag/order/urgency branches
    /// are untouched — their conditioning on direction makes them
    /// naturally re-train once direction starts choosing again.
    sp15_w_b0out_dev_ptr:    u64,
    /// SP15 Phase 3.5.4.c: total weight count for `w_b0out`
    /// (= `branch_0_size × num_atoms × adv_h`; default config
    /// 4 × 51 × 128 = 26112). Computed once at wiring time from
    /// the trainer's network config; passed as the `n_weights`
    /// arg to `launch_sp15_plasticity_injection` so the kernel's
    /// `reset_count = n_weights / 10` (~2611 default config)
    /// addresses the trailing-10%-slice within the directional
    /// output projection.
    sp15_w_b0out_n_weights:  i32,
    /// SP15 Phase 3.5.4.c: Kaiming-He fan_in for `w_b0out`. Per
    /// `compute_param_sizes` line 4074 the layout is
    /// `[branch_0_size × num_atoms, adv_h]` (row-major output_dim ×
    /// input_dim) so fan_in = adv_h (default config 128). The Wave
    /// 4.2 audit doc previously documented fan_in =
    /// `adv_h × num_atoms = 6528` — that was incorrect (total
    /// weight count, not input dim per unit) and the Phase 3.5.4.c
    /// audit-doc inline correction restores the right value. With
    /// the correct fan_in the Kaiming-He stddev is
    /// `sqrt(2 / 128) ≈ 0.125` (vs the audit doc's earlier
    /// erroneous `sqrt(2/6528) ≈ 0.0175`).
    sp15_w_b0out_fan_in:     i32,

    /// SP4 Layer A Task A13.5 (2026-05-01); GPU-Pearls refactor (2026-05-01)
    /// — cross-boundary Pearls A+D wiring for
    /// `launch_reward_component_ema_inplace`. The producer kernel writes
    /// the 6 step observations to `producer_step_scratch_buf[63..69)` on
    /// the trainer's mapped-pinned scratch via `scratch_dev_ptr`, and the
    /// GPU `apply_pearls_ad_kernel` reads/writes the trainer's
    /// `wiener_state_buf.dev_ptr` + `isv_signals_dev_ptr` directly via the
    /// `*_dev_ptr` fields. The `_host_ptr` fields are retained for the
    /// post-construction `debug_assert!` validity check; they are NOT
    /// dereferenced on the hot path (the host sync + read_volatile pattern
    /// was eliminated by `feedback_no_cpu_compute_strict.md`). All
    /// pointers are set together by `set_reward_component_pearls_buffers`;
    /// missing wiring is a `debug_assert!` failure.
    reward_component_pearls_wiener_dev_ptr:   u64,
    reward_component_pearls_wiener_host_ptr:  *mut f32,
    reward_component_pearls_scratch_dev_ptr:  u64,
    reward_component_pearls_scratch_host_ptr: *mut f32,
    reward_component_pearls_isv_dev_ptr:      u64,
    reward_component_pearls_isv_pinned_ptr:   *mut f32,

    /// Trade plan params dev_ptr from fused training context.
    /// Set via set_plan_params_ptr(). 0 = NULL (no plan).
    plan_params_dev_ptr: u64,

    /// Model readiness dev_ptr [1] pinned — gates plan enforcement.
    /// readiness < 0.5 → plan disabled. readiness ≥ 0.5 → plan enforces.
    readiness_dev_ptr: u64,

    /// Phase 3 unified-env: pinned device-mapped exploration_scale [1] f32.
    /// 1.0 = full training mode (saboteur, plan_params, counterfactuals on).
    /// 0.0 = deterministic / validation-equivalent (those perturbations off).
    /// Lerped through saboteur multipliers and counterfactual flip probability.
    exploration_scale_pinned: *mut f32,
    exploration_scale_dev_ptr: u64,

    /// Phase 3 unified-env: pinned device-mapped shaping_scale [1] f32.
    /// 1.0 = full reward shaping (drawdown/churn/inventory/micro-reward applied).
    /// 0.0 = pure per-bar P&L only — what the validation kernel measures.
    /// Multiplies the additive shaping bundle in experience_env_step.
    shaping_scale_pinned: *mut f32,
    shaping_scale_dev_ptr: u64,

    /// v8: Per-bar curriculum difficulty scoring kernel.
    difficulty_scores_kernel: CudaFunction,
    /// v8: Hindsight experience relabeling kernel (optimal exit).
    hindsight_relabel_kernel: CudaFunction,
    /// SP13 Layer B Commit B1.1b (2026-05-05): producer kernel for the
    /// aux next-bar sign label `[total] i32` ring column. Pure per-thread
    /// O(1) map reading `targets[bar*6+2]` (raw_close col) at `bar` and
    /// `bar+lookahead`, writing -1/0/1 per experience. Replaces the B0
    /// `alloc_zeros::<i32>(total)` placeholder; consumer is the K=2
    /// softmax CE head wired in B1.1a (`aux_next_bar_loss_reduce` +
    /// `aux_next_bar_backward` + `aux_dir_acc_reduce_kernel` +
    /// `aux_pred_to_isv_tanh_kernel`).
    aux_sign_label_kernel: CudaFunction,
    /// v8: TD(lambda) return computation kernel (loaded from nstep cubin).
    td_lambda_kernel: CudaFunction,

    /// D4/N4: cached learning_health value from the trainer (updated once per epoch).
    learning_health_cache: f32,
    /// D4/N4: last effective cf_ratio computed from learning_health_cache.
    last_cf_ratio_eff: f32,

    /// D7/N7: Contrarian override flag cached from trainer. When non-zero, the
    /// experience kernel negates Q values before Boltzmann softmax, converting
    /// argmax-favoring sampling to argmin-favoring. Used briefly to escape Q-uniform
    /// attractors when the policy is systematically anti-correlated with market.
    contrarian_active_cache: u8,

    /// B.3 Plan 3 Task 8: cached "in seed phase" flag set per-epoch by the
    /// trainer. When `true`, `launch_timestep_loop` dispatches the
    /// `scripted_policy_select` kernel BEFORE the network forward pass and
    /// SKIPS `experience_action_select` for that step (the scripted action
    /// is already in `batch_actions`). When `false`, the network's
    /// `experience_action_select` runs normally. Set via
    /// `set_seed_phase_active`. Cold-path CPU read of ISV slot at the
    /// per-epoch boundary is the trigger; the hot-path action computation
    /// itself is 100% GPU.
    seed_phase_active_cache: bool,

    // ─── SP20 Phase 1.4 (2026-05-09) — fused-producer chain ────────────
    // Per-rollout-step Stats → Aggregate → EMAs → Controllers chain
    // wired directly into the timestep loop (after `experience_env_step`
    // and the existing aux-head forward + dir-acc reduce). Reads
    // per-env GPU arrays (`trade_close_per_sample`, `step_ret_per_sample`,
    // `hold_at_exit_per_sample`, packed factored `actions_out`) at the
    // current step slice + the upstream aux-head outputs; writes 10 ISV
    // slots [510..520) (4 EMAs + 6 derived controllers) every rollout
    // step. Fold-boundary reset zeroes the 4-internal scratch + the
    // 8-counter obs_count + the 9-f32 ema_inputs scratch via the
    // `sp20_emas_*` registry-arm dispatches in `training_loop.rs`.

    /// SP20 Phase 1.1 Kernel 3 (stats producer): aux confidence p50 +
    /// std producer. Reads `aux_logits [B, K=2]` from
    /// `exp_aux_nb_logits_buf` + writes `[p50, std]` into
    /// `sp20_stats_output_buf`. Loaded from `SP20_STATS_COMPUTE_CUBIN`.
    sp20_stats_compute_kernel: CudaFunction,

    /// SP20 Phase 1.4 Path C aggregation kernel: per-env →
    /// `SP20EmaInputs` reducer. Loaded from
    /// `SP20_AGGREGATE_INPUTS_CUBIN`.
    sp20_aggregate_inputs_kernel: CudaFunction,

    /// SP20 Phase 1.2 Kernel 1 (EMA producer): updates 4 ISV EMAs +
    /// 4 internal scratch EMAs from a `SP20EmaInputs` device struct.
    /// Loaded from `SP20_EMAS_COMPUTE_CUBIN`.
    sp20_emas_compute_kernel: CudaFunction,

    /// SP20 Phase 1.3 Kernel 2 (controller producer): derives 6 ISV
    /// controller slots from the EMAs Kernel 1 wrote. Loaded from
    /// `SP20_CONTROLLERS_COMPUTE_CUBIN`.
    sp20_controllers_compute_kernel: CudaFunction,

    /// Mapped-pinned [2] f32 output of `sp20_stats_compute_kernel`:
    /// `[p50, std]`. The aggregation kernel reads `[0]` for p50 and
    /// `[1]` for std (passed as separate dev_ptrs offset by 4 bytes).
    pub(crate) sp20_stats_output_buf: super::mapped_pinned::MappedF32Buffer,

    /// Mapped-pinned [9] f32 byte-aliased to the `SP20EmaInputs` struct.
    /// Output of the aggregation kernel; input to the EMA kernel. The
    /// 9-f32 buffer aliases the device-side 36-byte struct one-for-one
    /// (4 ints in slots [0..4) via bit-cast; 5 floats in slots [4..9)).
    /// FoldReset writes 9 zeros (clears any inherited state — though
    /// the kernel rewrites every byte each step, so this is purely
    /// defensive).
    pub(crate) sp20_ema_inputs_buf: super::mapped_pinned::MappedF32Buffer,

    /// Mapped-pinned [4] f32 internal scratch for the EMA kernel.
    /// Layout per `EMA_INTERNAL_*` constants: [TRADE_DURATION (0),
    /// AUX_P50 (1), AUX_STD (2), AUX_DIR_ACC (3)]. Read by
    /// `sp20_controllers_compute_kernel` to derive N_STEP /
    /// TARGET_HOLD_PCT / AUX_GATE_TEMP / AUX_CONF_THRESHOLD.
    /// FoldReset zeros all 4 slots (Pearl-A bootstrap on next launch).
    pub(crate) sp20_emas_internal_buf: super::mapped_pinned::MappedF32Buffer,

    /// Mapped-pinned [8] i32 per-EMA observation counter for the EMA
    /// kernel. Layout per `OBS_COUNT_*` constants. Counter == 0 ⇒
    /// next firing observation REPLACES sentinel directly; counter >
    /// 0 ⇒ Wiener-blend with α=0.4 floor. FoldReset zeros all 8
    /// counters so the new fold's first per-EMA firing observation
    /// re-bootstraps the EMA from sentinel.
    pub(crate) sp20_emas_obs_count_buf: super::mapped_pinned::MappedI32Buffer,
}

impl Drop for GpuExperienceCollector {
    fn drop(&mut self) {
        // Synchronize stream before CudaSlice fields drop.
        // Pending GPU work (cuBLAS forward, kernel launches) may reference
        // buffers that cudarc's CudaSlice::drop frees. Without sync, the
        // driver keeps allocations alive → 2.5GB VRAM leak per hyperopt trial.
        #[allow(unsafe_code)]
        unsafe {
            cudarc::driver::sys::cuStreamSynchronize(self.stream.cu_stream());
        }
    }
}

impl GpuExperienceCollector {
    /// Create a new GPU experience collector with cuBLAS-based Q-network forward pass.
    ///
    /// Compiles 3 focused NVRTC kernels, creates a `CublasForward` context for
    /// the Q-network, and allocates all GPU buffers.
    ///
    /// The collector reads Q-network weights directly from the trainer's flat
    /// `params_buf` via `trainer_params_ptr` (zero-copy). Call
    /// `set_trainer_params_ptr()` after the fused training context is created.
    ///
    /// # Arguments
    /// * `stream` - CUDA stream for all GPU operations
    /// * `bottleneck_dim` - Temporal causal bottleneck dimension (16 in production, 0 = disabled)
    /// * `market_dim_cfg` - Market feature dimension for bottleneck separation (42)
    /// * `initial_capital` - Starting capital for each episode
    /// * `_avg_spread` - Average bid-ask spread
    /// * `_cash_reserve_pct` - Cash reserve percentage
    /// * `network_dims` - Actual layer dimensions `(shared_h1, shared_h2, value_h, adv_h)`
    /// * `kernel_dims` - Data layout dimensions `(market_dim, num_atoms_max)`.
    /// * `shared_cublas` - Shared cuBLAS handle (shared with the trainer for deterministic single-handle training).
    /// * `n_episodes` - Number of episodes to allocate buffers for (clamped to 32768 max).
    /// * `timesteps_per_episode` - Timesteps per episode to allocate for (clamped to 1000 max).
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        parent_stream: Arc<CudaStream>,
        shared_cublas: Arc<PerStreamCublasHandles>,
        bottleneck_dim: usize,
        market_dim_cfg: usize,
        initial_capital: f32,
        _avg_spread: f32,
        _cash_reserve_pct: f32,
        network_dims: (usize, usize, usize, usize),
        kernel_dims: (usize, usize),
        n_episodes: usize,
        timesteps_per_episode: usize,
        curiosity_weight: f32,
    ) -> Result<Self, MLError> {
        // Use the same stream as training. Experience collection runs between
        // epochs (serialized, no overlap). Forking would contaminate cuBLAS
        // internal state at construction time, breaking determinism.
        let stream = parent_stream;

        // Drain stale CUDA errors from previous test/trainer instances.
        let _ = stream.context().check_err();

        let alloc_episodes = n_episodes.min(MAX_EPISODES_LIMIT);
        let alloc_timesteps = timesteps_per_episode.min(MAX_TIMESTEPS_LIMIT);
        let (shared_h1, shared_h2, value_h, adv_h) = network_dims;
        let (market_dim, num_atoms_max) = kernel_dims;
        let state_dim = ml_core::state_layout::STATE_DIM;
        let _portfolio_dim: usize = 12;  // 8 base + 4 plan progress features
        let ofi_dim: usize = ml_core::state_layout::OFI_DIM;

        // Branch sizes for 4-branch hierarchical DQN (always enabled).
        // [direction(4), magnitude(3), order(3), urgency(3)]
        let branch_sizes: [usize; 4] = [4, 3, 3, 3];
        let total_branch_actions = branch_sizes[0] + branch_sizes[1] + branch_sizes[2] + branch_sizes[3]; // 13

        // ── Step 1: Compile experience kernels ──────────────────────────
        let (state_gather_kernel, action_select_kernel, expert_override_kernel, env_step_kernel, expected_q_kernel, step_counter_kernel, noise_kernel) =
            compile_experience_kernels(&stream, state_dim, market_dim)?;
        let (nstep_kernel, td_lambda_kernel) = compile_nstep_kernel(&stream)?;
        let fill_episode_ids_kernel = compile_fill_episode_ids_kernel(&stream)?;
        let trade_stats_kernel = compile_trade_stats_kernel(&stream)?;

        info!(
            state_dim,
            market_dim,
            num_atoms_max,
            "GPU experience collector: experience kernels compiled (3 focused + expected_q + episode_ids)"
        );

        // ── Step 2: Create cuBLAS forward context ───────────────────────
        // The experience collector uses num_atoms from the config. For
        // non-distributional mode (num_atoms_max=1), the logit outputs
        // are plain Q-values. For C51 (num_atoms_max>1), we compute
        // expected Q via compute_expected_q kernel.
        let num_atoms = num_atoms_max.max(1);
        // s1_input_dim must match the trainer's layout:
        // with bottleneck: compressed market → bn_dim, then concat with portfolio
        //                  + 1 dd_pct column (SP15 Wave 4.1b) → bn_dim + portfolio_dim + 1
        // without bottleneck: raw state_dim
        let s1_input_dim = if bottleneck_dim > 0 {
            bottleneck_dim + state_dim.saturating_sub(market_dim_cfg) + 1
        } else {
            state_dim
        };
        let cublas_forward = CublasGemmSet::new(
            shared_cublas,
            alloc_episodes, // batch_size = number of episodes
            state_dim,
            shared_h1,
            shared_h2,
            value_h,
            adv_h,
            num_atoms,
            branch_sizes[0],
            branch_sizes[1],
            branch_sizes[2],
            branch_sizes[3],
            s1_input_dim,
        )?;
        info!(
            alloc_episodes,
            num_atoms,
            "GPU experience collector: cuBLAS forward context created"
        );

        // ── Step 3: Compute flat parameter layout ───────────────────────
        // Must match the trainer's layout exactly (same bottleneck_dim, market_dim)
        // so that f32_weight_ptrs_from_base() returns correct offsets.
        let train_cfg = GpuDqnTrainConfig {
            state_dim,
            shared_h1,
            shared_h2,
            value_h,
            adv_h,
            num_atoms,
            branch_0_size: branch_sizes[0],
            branch_1_size: branch_sizes[1],
            branch_2_size: branch_sizes[2],
            branch_3_size: 3, // urgency — fixed at 3 (4-branch refactor)
            bottleneck_dim,
            market_dim: market_dim_cfg,
            ..GpuDqnTrainConfig::default()
        };
        let param_sizes = compute_param_sizes(&train_cfg);
        let total_params = compute_total_params(&train_cfg);

        info!(
            total_params,
            total_bytes = total_params * 4,
            bottleneck_dim,
            market_dim_cfg,
            "GPU experience collector: parameter layout computed (zero-copy from trainer)"
        );

        // Curiosity weight set is always zero-initialized here.
        // The DQN trainer copies its trained weights via set_curiosity_weights() after each epoch,
        // so zero init is correct for step 0 — weights arrive before step 1.
        if curiosity_weight > 0.001 {
            info!("GPU experience collector: curiosity enabled (weight={:.4})", curiosity_weight);
        } else {
            info!("GPU experience collector: curiosity disabled (weight=0)");
        }
        let curiosity_weights = CuriosityWeightSet::zeros(&stream)?;

        // ── Step 5: L2 cache persistence hints (Hopper GPUs) ────────────
        {
            let caps = ml_core::gpu::capabilities::cached_capabilities();
            if ml_core::gpu::l2_cache::supports_l2_persistence(&caps.device_name) {
                let total_weight_bytes = total_params * std::mem::size_of::<f32>();
                match ml_core::gpu::l2_cache::pin_weights_in_l2(&caps.device_name, total_weight_bytes) {
                    Ok(reserved) if reserved > 0 => {
                        info!(
                            weight_bytes = total_weight_bytes,
                            reserved_bytes = reserved,
                            "L2 cache: pinned {} MB model weights ({} MB L2 reserved)",
                            total_weight_bytes / 0x0010_0000,
                            reserved / 0x0010_0000,
                        );
                    }
                    Ok(_) => {}
                    Err(e) => {
                        tracing::debug!("L2 cache persistence setup skipped (non-fatal): {e}");
                    }
                }
            }
        }

        // ── Step 6: Allocate per-episode buffers ────────────────────────
        // Portfolio states for experience kernels: [N, PORTFOLIO_STRIDE=40]
        let mut portfolio_states = stream
            .alloc_zeros::<f32>(alloc_episodes * PORTFOLIO_STRIDE)
            .map_err(|e| MLError::ModelError(format!("alloc portfolio_states: {e}")))?;

        // Initialize portfolio state (20 floats per episode)
        let mut portfolio_init = vec![0.0_f32; alloc_episodes * PORTFOLIO_STRIDE];
        for i in 0..alloc_episodes {
            let off = i * PORTFOLIO_STRIDE;
            portfolio_init[off] = 0.0;                // [0] position
            portfolio_init[off + 1] = initial_capital; // [1] cash
            portfolio_init[off + 2] = initial_capital; // [2] portfolio_value
            // [3] dsr_A = 0.0, [4] dsr_B = 0.0 (already zero)
            // [5] pnl_ema = 0.0, [6] pnl_var = 0.0 (already zero)
            portfolio_init[off + 7] = initial_capital; // [7] peak_equity
            // [8] flat_counter = 0.0 (already zero)
            portfolio_init[off + 9] = initial_capital; // [9] prev_equity
            // [10] hold_time = 0.0, [11] realized_pnl = 0.0 (already zero)
        }
        upload_host_to_cuda_f32(&stream, &portfolio_init, &mut portfolio_states, "portfolio_states init")?;

        let episode_starts_buf = stream
            .alloc_zeros::<i32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!("alloc episode_starts_buf: {e}")))?;

        // SP20 Phase 2 Task 2.0 (2026-05-09): per-env label sign captured
        // at trade open, consumed at trade close. Sentinel = 0 ("no
        // position currently open or aux was in skip-window at entry");
        // first entering_trade observation overwrites with mapped aux
        // label sign in {-1, 0, +1}. Sized [alloc_episodes].
        let label_at_open_per_env = stream
            .alloc_zeros::<i32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!(
                "sp20: alloc label_at_open_per_env ({alloc_episodes} i32): {e}"
            )))?;

        // SP20 Phase 2 Task 2.2 (2026-05-09): per-env trade-close alpha
        // (= R_event - hold_baseline). Sentinel = 0.0; updated at each
        // trade close by the SP20 4-quadrant reward kernel block.
        // Consumed by sp20_aggregate_inputs_kernel (mean over closed
        // envs) → SP20EmaInputs.alpha. Sized [alloc_episodes].
        let alpha_per_env = stream
            .alloc_zeros::<f32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!(
                "sp20: alloc alpha_per_env ({alloc_episodes} f32): {e}"
            )))?;

        // SP20 Phase 2 Task 2.2-fix (2026-05-10): per-env segment-level
        // win flag (1 iff segment_return > 0 at trade close, else 0).
        // Sentinel = 0; written by experience_env_step at the same
        // segment_complete site as alpha_per_env. Consumed by the
        // aggregation kernel via the new is_win_per_env arg replacing
        // the broken per-bar step_ret predicate. Sized [alloc_episodes].
        let is_win_per_env = stream
            .alloc_zeros::<i32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!(
                "sp20: alloc is_win_per_env ({alloc_episodes} i32): {e}"
            )))?;

        // SP22 H6 vNext Phase A3 (2026-05-13): save-for-backward buffers
        // for the trade-outcome aux head's label producer. Both written
        // at every segment_complete bar by experience_env_step alongside
        // alpha_per_env. Consumed by the trade_outcome_label_kernel
        // (Phase A4/A5 wireup) to classify each trade-close into one of
        // {Profit=0, Stop=1, Timeout=2}. Sentinel 0.0; sized
        // [alloc_episodes]. FoldReset registered in state_reset_registry
        // mirror of alpha_per_env reset pattern.
        let pnl_vs_target_at_close_per_env = stream
            .alloc_zeros::<f32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext: alloc pnl_vs_target_at_close_per_env \
                 ({alloc_episodes} f32): {e}"
            )))?;
        let pnl_vs_stop_at_close_per_env = stream
            .alloc_zeros::<f32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext: alloc pnl_vs_stop_at_close_per_env \
                 ({alloc_episodes} f32): {e}"
            )))?;

        // SP21 T3.3 (2026-05-10): per-env per-bar Hold opportunity-cost
        // scratch. Sentinel 0.0; written by experience_env_step every
        // step as `per_bar_opp_cost = -aux_conf × cost_scale ∈ [-0.25, 0]`.
        // Consumed by sp20_aggregate_inputs_kernel for envs in DIR_HOLD,
        // emitted as mean → SP20EmaInputs.per_bar_hold_reward → drives
        // HOLD_REWARD_EMA (which was previously hardcoded 0 by the Phase
        // 3.2 forward-reference contract). Sized [alloc_episodes].
        let per_bar_opp_cost_per_env = stream
            .alloc_zeros::<f32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!(
                "sp21: alloc per_bar_opp_cost_per_env ({alloc_episodes} f32): {e}"
            )))?;

        // SP20 Phase 3 Task 3.2 (2026-05-10): per-env Hold opportunity-
        // cost circular buffer. Layout `[alloc_episodes ×
        // HOLD_BASELINE_BUFFER_SIZE = 30]` f32 row-major. Sentinel 0.0
        // — at cold-start (before the controller lifts ISV[513] off
        // sentinel) per-bar writes are 0, so cold-start trade closes
        // sum to 0.0 (bit-identical to the Phase 2 Task 2.2 placeholder
        // semantic per `pearl_first_observation_bootstrap`).
        let hold_baseline_buffer = stream
            .alloc_zeros::<f32>(alloc_episodes * HOLD_BASELINE_BUFFER_SIZE)
            .map_err(|e| MLError::ModelError(format!(
                "sp20: alloc hold_baseline_buffer ({} f32): {e}",
                alloc_episodes * HOLD_BASELINE_BUFFER_SIZE
            )))?;

        // Persistent epoch state
        let epoch_state_init: Vec<f32> = vec![
            0.01,            // vol_ema
            0.01,            // median_vol
            initial_capital, // portfolio_value
            0.0,             // portfolio_position
            initial_capital, // portfolio_cash
            0.0,             // dsr_mean
            1.0,             // dsr_var
            0.0,             // step_count
        ];
        // GPU-mutated by env_step kernel — destination must remain a CudaSlice.
        // Upload via mapped-pinned staging + DtoD per
        // `feedback_no_htod_htoh_only_mapped_pinned.md`.
        let mut epoch_state = stream.alloc_zeros::<f32>(epoch_state_init.len())
            .map_err(|e| MLError::ModelError(format!("alloc epoch_state: {e}")))?;
        upload_host_to_cuda_f32(&stream, &epoch_state_init, &mut epoch_state, "epoch_state init")?;

        // ── Step 7: Allocate output buffers (2x for #7 counterfactual) ───
        let total_output = alloc_episodes * alloc_timesteps;
        let cf_mult = 2; // #7: counterfactual doubles output
        let states_out = stream
            .alloc_zeros::<f32>(total_output * cf_mult * state_dim)  // #30: f32, #7: 2x
            .map_err(|e| MLError::ModelError(format!("alloc states_out: {e}")))?;
        let actions_out = stream
            .alloc_zeros::<i32>(total_output * cf_mult)
            .map_err(|e| MLError::ModelError(format!("alloc actions_out: {e}")))?;
        let rewards_out = stream
            .alloc_zeros::<f32>(total_output * cf_mult)
            .map_err(|e| MLError::ModelError(format!("alloc rewards_out: {e}")))?;
        let done_out = stream
            .alloc_zeros::<f32>(total_output * cf_mult)
            .map_err(|e| MLError::ModelError(format!("alloc done_out: {e}")))?;
        let raw_returns_out = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc raw_returns_out: {e}")))?;
        // H6 diagnostic per-sample buffers — same shape as raw_returns_out.
        // Untouched slots default to 0 (alloc_zeros); kernel overwrites all slots
        // it executes on, and the host reducer filters on traded != 0.
        let trail_triggered_per_sample = stream
            .alloc_zeros::<i32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc trail_triggered_per_sample: {e}")))?;
        let pre_mag_per_sample = stream
            .alloc_zeros::<i32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc pre_mag_per_sample: {e}")))?;
        let traded_per_sample = stream
            .alloc_zeros::<i32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc traded_per_sample: {e}")))?;
        let hold_at_exit_per_sample = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc hold_at_exit_per_sample: {e}")))?;
        // Task 0.8 per-sample reward-term contribution buffers. cf_flip is
        // indexed at cf_off = out_off + N*L so it matches rewards_out's
        // [N, L, cf_mult] layout; the others live at out_off only.
        let cf_flip_per_sample = stream
            .alloc_zeros::<i32>(total_output * cf_mult)
            .map_err(|e| MLError::ModelError(format!("alloc cf_flip_per_sample: {e}")))?;
        let micro_reward_per_sample = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc micro_reward_per_sample: {e}")))?;
        // Task 2.4: loss_aversion_per_sample allocation removed — R6 clamp
        // relocated to c51_loss_kernel.cu::block_bellman_project_f.
        let total_reward_per_sample = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc total_reward_per_sample: {e}")))?;
        // SP11 Fix 39 (2026-05-04, A1.2): per-bar saboteur engagement signal
        // buffer. Same shape as `total_reward_per_sample` (one f32 per (i,t)).
        // Written by `experience_env_step` after the trade lifecycle resolves
        // (line ~3220, post-`total_reward_per_sample[out_off] = reward`).
        // Read by the SP11 saboteur engagement kernel via raw device pointer.
        let saboteur_delta_reward_per_sample = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc saboteur_delta_reward_per_sample: {e}")))?;
        // SP11 Fix 39 B1b fix-up (2026-05-04): per-bar popart-component-
        // specific reward magnitude buffer. Same shape — one f32 per (i,t).
        // Written by `experience_env_step` at the `r_popart = capped_pnl;`
        // voluntary-exit branch (line ~2680); 0 elsewhere (default at
        // entry-point). Read by the SP11 popart-component EMA kernel via
        // raw device pointer. Spec §4 amendment "Why slot 360".
        let popart_component_per_sample = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc popart_component_per_sample: {e}")))?;
        // C.2 Reward-component attribution (Plan 3 Task 1, spec §4.C.2).
        // [total_output * 6] row-major: [popart, cf, trail, micro, opp_cost, bonus] per (i,t).
        // Producer: experience_env_step (writes rc[0], rc[1], rc[3], rc[4], rc[5]; rc[2]=0 placeholder).
        // Consumer: reward_component_ema GPU kernel → ISV[63..69).
        // SP22 H6 Phase 3 (2026-05-13): bumped 6 → 7 for the new aux_align
        // reward component (β producer at trade-close in experience_env_step).
        // Stride must match `experience_kernels.cu`'s `out_off * 7 +` writes;
        // partial migration would produce OOB writes (kernel writes slot 6
        // past row end of a 6-wide buffer). Per `feedback_no_partial_refactor`.
        let reward_components_per_sample = stream
            .alloc_zeros::<f32>(total_output * 7)
            .map_err(|e| MLError::ModelError(format!("alloc reward_components_per_sample: {e}")))?;
        // B.2 Plan 3 Task 3: per-sample Flat→Positioned transition flag.
        // 1 iff (prev_sign == 0 && curr_sign != 0) for this (i,t), 0 otherwise.
        // Producer: experience_env_step (written unconditionally per-slot, race-free).
        // Consumer: trade_attempt_rate_ema_update GPU kernel → ISV[71] adaptive EMA.
        let flat_to_pos_per_sample = stream
            .alloc_zeros::<i32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc flat_to_pos_per_sample: {e}")))?;
        // B.4 Plan 3 Task 4: per-(i,t) readiness value buffer.
        // [total_output] f32 — broadcast scalar captured per-slot, race-free.
        // Consumer: plan_threshold_update GPU kernel reduces + EMA into
        // ISV[READINESS_EMA_INDEX=75]; that kernel derives ISV[PLAN_THRESHOLD_INDEX=49].
        let readiness_per_sample = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc readiness_per_sample: {e}")))?;
        // Task 2.X prerequisite: per-magnitude win-rate + variance instrumentation.
        // Four per-sample buffers mirroring the Task 0.5 / 0.8 pattern. Initial
        // alloc_zeros matches the kernel's default-before-early-return values
        // EXCEPT for action_mag_per_sample, whose sentinel is -1 (Quarter/Half/Full
        // map to 0/1/2). The kernel unconditionally writes -1 before any early
        // return so the initial 0 is overwritten before any reader runs; the
        // zero-init is still required for the host-reducer's reset path.
        let action_mag_per_sample = stream
            .alloc_zeros::<i32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc action_mag_per_sample: {e}")))?;
        let step_ret_per_sample = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc step_ret_per_sample: {e}")))?;
        let trade_close_per_sample = stream
            .alloc_zeros::<i32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc trade_close_per_sample: {e}")))?;
        let trade_profitable_per_sample = stream
            .alloc_zeros::<i32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc trade_profitable_per_sample: {e}")))?;
        // SP20 Phase 3 Task 3.4 (2026-05-10): per-bar aux_conf scratch.
        // Sized `[total_output × cf_mult]` so the kernel writes both
        // on-policy (out_off) AND counterfactual (cf_off) slots — the
        // CF state at cf_off shares the SAME aux signal (same env
        // state, just different action), so both slots get the same
        // value, mirroring `cf_flip_per_sample`'s `total_output ×
        // cf_mult` sizing pattern (`gpu_experience_collector.rs` line
        // ~1945). alloc_zeros → uninitialised slots read as 0.0f =
        // K=2 uniform-prior (no information). Kernel overwrites at
        // every reached (i, t) with `sp20_compute_per_bar_aux_conf_k2(
        // aux_logits + i*K)`; un-reached slots stay 0.0f. Used
        // downstream by the Phase 5 Aux→Q gate at `gate =
        // sigmoid((aux_conf - ISV[AUX_CONF_THRESHOLD]) /
        // ISV[AUX_GATE_TEMP])`.
        let aux_conf_per_sample = stream
            .alloc_zeros::<f32>(total_output * cf_mult)
            .map_err(|e| MLError::ModelError(format!("alloc aux_conf_per_sample: {e}")))?;
        // Task 0.3: per-sample Kelly-from-atoms variance scale, written by
        // experience_env_step (~line 1422). Alloc_zeros → uninitialised slots
        // read as 0.0f, which the host reducer filters out (only positive
        // values count, matching the kernel's write-when-Var[Q]-live semantic).
        let var_scale_per_sample = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc var_scale_per_sample: {e}")))?;
        // Task 0.8: slot-live marker. Kernel writes 1 at the top of each reached
        // (i,t); un-reached slots (episodes that terminated early) stay 0 from
        // this alloc_zeros. Used as the cf_rate denominator so early termination
        // doesn't silently under-report the firing rate.
        let slot_live_per_sample = stream
            .alloc_zeros::<i32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc slot_live_per_sample: {e}")))?;
        // SP15 Wave 2 (2026-05-06): per-step `r_discipline` mirror +
        // slot completion sentinel. Both [total_output] sized; both
        // defaulted by experience_env_step on every kernel entry, so
        // alloc_zeros gives the correct bootstrap state and no fold-
        // boundary reset is needed (every step is an implicit reset).
        let r_discipline_per_sample = stream
            .alloc_zeros::<f32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc r_discipline_per_sample: {e}")))?;
        let slot_completed_normally_per_sample = stream
            .alloc_zeros::<i32>(total_output)
            .map_err(|e| MLError::ModelError(format!("alloc slot_completed_normally_per_sample: {e}")))?;
        let trade_stats_buf = stream
            .alloc_zeros::<f32>(TRADE_STATS_FLOATS)
            .map_err(|e| MLError::ModelError(format!("alloc trade_stats_buf: {e}")))?;

        // ── Step 8: Allocate per-timestep batch buffers ─────────────────
        let n = alloc_episodes;
        // Allocate with pad_sd (pad128(state_dim)) — cuBLAS reads with ldb=state_dim_padded.
        // Extra columns are zero-padded by alloc_zeros, matching CUTLASS K-tile alignment.
        let pad_sd_alloc = (state_dim + 127) & !127;
        let batch_states = stream
            .alloc_zeros::<f32>(n * pad_sd_alloc)  // #30: f32 for SGEMM forward, padded for cuBLAS ldb
            .map_err(|e| MLError::ModelError(format!("alloc batch_states: {e}")))?;
        let batch_actions = stream
            .alloc_zeros::<i32>(n)
            .map_err(|e| MLError::ModelError(format!("alloc batch_actions: {e}")))?;
        let q_gaps_buf = stream
            .alloc_zeros::<f32>(n)
            .map_err(|e| MLError::ModelError(format!("alloc q_gaps_buf: {e}")))?;
        /* Per-sample conviction [N] for adaptive Kelly warmup floor — written by
         * experience_action_select, consumed by experience_env_step. */
        let conviction_buf = stream
            .alloc_zeros::<f32>(n)
            .map_err(|e| MLError::ModelError(format!("alloc conviction_buf: {e}")))?;
        /* Per-sample magnitude-branch conviction [N] — parallel to conviction_buf,
         * threaded through the same kernels for the Kelly cap's max(dir, mag)
         * composer. */
        let magnitude_conviction_buf = stream
            .alloc_zeros::<f32>(n)
            .map_err(|e| MLError::ModelError(format!("alloc magnitude_conviction_buf: {e}")))?;
        let current_timesteps = stream
            .alloc_zeros::<i32>(n)
            .map_err(|e| MLError::ModelError(format!("alloc current_timesteps: {e}")))?;

        let total_branch_atoms = (branch_sizes[0] + branch_sizes[1] + branch_sizes[2] + branch_sizes[3]) * num_atoms;
        let q_values = stream
            .alloc_zeros::<f32>(n * total_branch_actions)
            .map_err(|e| MLError::ModelError(format!("alloc q_values: {e}")))?;
        let q_select_buf = stream
            .alloc_zeros::<f32>(n * total_branch_actions)
            .map_err(|e| MLError::ModelError(format!("alloc q_select_buf: {e}")))?;
        let q_var_buf = stream
            .alloc_zeros::<f32>(n * total_branch_actions)
            .map_err(|e| MLError::ModelError(format!("alloc q_var_buf: {e}")))?;

        // cuBLAS activation scratch buffers
        let exp_h_s1 = stream
            .alloc_zeros::<f32>(n * shared_h1)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_s1: {e}")))?;
        let exp_h_s2 = stream
            .alloc_zeros::<f32>(n * shared_h2)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_s2: {e}")))?;
        let exp_h_v = stream
            .alloc_zeros::<f32>(n * value_h)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_v: {e}")))?;
        let exp_v_logits = stream
            .alloc_zeros::<f32>(n * num_atoms)
            .map_err(|e| MLError::ModelError(format!("alloc exp_v_logits f32: {e}")))?;
        let exp_b_logits = stream
            .alloc_zeros::<f32>(n * total_branch_atoms)
            .map_err(|e| MLError::ModelError(format!("alloc exp_b_logits f32: {e}")))?;

        // ── Step 9: Curiosity trainer — enabled when curiosity_weight > 0 ──
        // Runs outside CUDA graphs (between epochs), so warp-reduced atomicAdd is acceptable.
        // Zero weights are replaced by set_curiosity_weights() after the first epoch.
        let max_samples = alloc_episodes * alloc_timesteps * 2; // 2x for counterfactual
        let curiosity_trainer: Option<GpuCuriosityTrainer> = if curiosity_weight > 0.001 {
            match GpuCuriosityTrainer::new(Arc::clone(&stream), state_dim, max_samples) {
                Ok(trainer) => Some(trainer),
                Err(e) => {
                    tracing::warn!("GPU curiosity trainer init failed (curiosity disabled): {e}");
                    None
                }
            }
        } else {
            None
        };

        // Pinned host buffers for DtoH transfers
        let pinned_states = unsafe { PinnedHostBuf::<f32>::new(total_output * state_dim)? };
        let pinned_actions = unsafe { PinnedHostBuf::<i32>::new(total_output)? };
        let pinned_rewards = unsafe { PinnedHostBuf::<f32>::new(total_output)? };
        let pinned_dones = unsafe { PinnedHostBuf::<i32>::new(total_output)? };

        // OFI buffer — starts as 1-element zero placeholder. `upload_ofi_features`
        // replaces this with the real allocation (`total_bars * OFI_DIM`) before the
        // first collect. Mapped pinned: host-init zero via host_ptr; kernel reads
        // dev_ptr. No DtoD copy per feedback_no_htod_htoh_only_mapped_pinned.
        let ofi_init = unsafe { super::mapped_pinned::MappedF32Buffer::new(1) }
            .map_err(|e| MLError::ModelError(format!("ofi_gpu init alloc (1 f32): {e}")))?;

        // ── Step 10: Log buffer sizes ───────────────────────────────────
        let per_timestep_bytes = n * state_dim * 4     // batch_states
            + n * 4                                      // batch_actions
            + n * 4                                      // current_timesteps
            + n * total_branch_actions * 4               // q_values
            + n * shared_h1 * 4                          // h_s1
            + n * shared_h2 * 4                          // h_s2
            + n * value_h * 4                            // h_v
            + n * adv_h * 4 * 3                          // h_b0 + h_b1 + h_b2
            + n * num_atoms * 4                          // v_logits
            + n * total_branch_atoms * 4;                // b_logits
        let output_bytes = total_output * (state_dim * 4 + 4 + 4 + 4);
        info!(
            alloc_episodes,
            alloc_timesteps,
            per_timestep_scratch_kb = per_timestep_bytes / 1024,
            output_buffer_mb = output_bytes / (1024 * 1024),
            total_params,
            "GPU experience collector initialized (cuBLAS + 3 focused kernels)"
        );

        // #19 Position entropy histogram [alloc_episodes * 13] — dir(4)+mag(3)+order(3)+urgency(3)
        // #30 F32 forward pass buffers for full-precision experience collection
        let pad_sd = (state_dim + 127) & !127;
        let exp_states_f32 = stream.alloc_zeros::<f32>(alloc_episodes * pad_sd)
            .map_err(|e| MLError::ModelError(format!("alloc exp_states_f32: {e}")))?;
        let exp_h_s1_f32 = stream.alloc_zeros::<f32>(alloc_episodes * network_dims.0)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_s1_f32: {e}")))?;
        let exp_h_s2_f32 = stream.alloc_zeros::<f32>(alloc_episodes * network_dims.1)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_s2_f32: {e}")))?;
        let exp_h_v_f32 = stream.alloc_zeros::<f32>(alloc_episodes * network_dims.2)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_v_f32: {e}")))?;
        let exp_h_b0_f32 = stream.alloc_zeros::<f32>(alloc_episodes * network_dims.3)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_b0_f32: {e}")))?;
        let exp_h_b1_f32 = stream.alloc_zeros::<f32>(alloc_episodes * network_dims.3)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_b1_f32: {e}")))?;
        let exp_h_b2_f32 = stream.alloc_zeros::<f32>(alloc_episodes * network_dims.3)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_b2_f32: {e}")))?;
        let exp_h_b3_f32 = stream.alloc_zeros::<f32>(alloc_episodes * network_dims.3)
            .map_err(|e| MLError::ModelError(format!("alloc exp_h_b3_f32: {e}")))?;

        // GPU-native domain randomization + saboteur kernels (ONE cubin load)
        let exp_module_extra = stream.context()
            .load_cubin(EXPERIENCE_KERNELS_CUBIN.to_vec())
            .map_err(|e| MLError::ModelError(format!("exp cubin extra: {e}")))?;
        let domain_rand_starts_kernel = exp_module_extra.load_function("domain_rand_episode_starts")
            .map_err(|e| MLError::ModelError(format!("domain_rand_starts load: {e}")))?;
        let domain_rand_params_kernel = exp_module_extra.load_function("domain_rand_sim_params")
            .map_err(|e| MLError::ModelError(format!("domain_rand_params load: {e}")))?;
        let domain_rand_params_buf = stream.alloc_zeros::<f32>(alloc_episodes * 5)
            .map_err(|e| MLError::ModelError(format!("alloc dr_params: {e}")))?;

        // #33 Saboteur GPU buffers
        let saboteur_params_buf = stream.alloc_zeros::<f32>(alloc_episodes * 3)
            .map_err(|e| MLError::ModelError(format!("alloc saboteur_params: {e}")))?;
        let mut saboteur_base_buf = stream.alloc_zeros::<f32>(3)
            .map_err(|e| MLError::ModelError(format!("alloc saboteur_base: {e}")))?;
        // Init base params to neutral (no adversarial effect). saboteur_select
        // kernel writes back to this buffer, so destination stays CudaSlice;
        // upload via mapped-pinned staging + DtoD.
        upload_host_to_cuda_f32(
            &stream, &[1.0_f32, 0.85, 1.0], &mut saboteur_base_buf, "saboteur_base init")?;
        let saboteur_best_buf = stream.alloc_zeros::<f32>(3)
            .map_err(|e| MLError::ModelError(format!("alloc saboteur_best: {e}")))?;
        let saboteur_best_return_buf = stream.alloc_zeros::<f32>(1)
            .map_err(|e| MLError::ModelError(format!("alloc saboteur_best_return: {e}")))?;
        // Saboteur kernels from same module (no extra cubin load)
        let saboteur_generate_kernel = exp_module_extra.load_function("saboteur_generate_params")
            .map_err(|e| MLError::ModelError(format!("saboteur_generate load: {e}")))?;
        let saboteur_select_kernel = exp_module_extra.load_function("saboteur_select_best")
            .map_err(|e| MLError::ModelError(format!("saboteur_select load: {e}")))?;
        let difficulty_scores_kernel = exp_module_extra.load_function("compute_difficulty_scores")
            .map_err(|e| MLError::ModelError(format!("compute_difficulty_scores load: {e}")))?;
        let hindsight_relabel_kernel = exp_module_extra.load_function("hindsight_relabel_kernel")
            .map_err(|e| MLError::ModelError(format!("hindsight_relabel_kernel load: {e}")))?;
        // SP13 Layer B Commit B1.1b (2026-05-05): aux next-bar sign label
        // producer kernel — loaded from its own dedicated cubin since it
        // is pure map (no dependencies on `experience_kernels.cubin`'s
        // shared headers/state). See `aux_sign_label_kernel.cu`.
        let aux_sign_label_module = stream.context()
            .load_cubin(SP13_AUX_SIGN_LABEL_CUBIN.to_vec())
            .map_err(|e| MLError::ModelError(format!("aux_sign_label cubin load: {e}")))?;
        let aux_sign_label_kernel = aux_sign_label_module
            .load_function("aux_sign_label_kernel")
            .map_err(|e| MLError::ModelError(format!("aux_sign_label_kernel load: {e}")))?;
        let quantile_q_select_kernel = exp_module_extra.load_function("quantile_q_select")
            .map_err(|e| MLError::ModelError(format!("quantile_q_select load: {e}")))?;

        let reward_module = stream.context().load_cubin(
            REWARD_SHAPING_CUBIN.to_vec()
        ).map_err(|e| MLError::ModelError(format!("reward_shaping module: {e}")))?;
        let reward_rank_kernel = reward_module.load_function("reward_rank_normalize")
            .map_err(|e| MLError::ModelError(format!("reward_rank_normalize load: {e}")))?;
        let reward_compute_abs_sharpe_kernel = reward_module.load_function("reward_compute_abs_sharpe")
            .map_err(|e| MLError::ModelError(format!("reward_compute_abs_sharpe load: {e}")))?;
        let bitonic_sort_step_kernel = reward_module.load_function("bitonic_sort_step")
            .map_err(|e| MLError::ModelError(format!("bitonic_sort_step load: {e}")))?;
        let reward_scatter_rank_kernel = reward_module.load_function("reward_scatter_rank")
            .map_err(|e| MLError::ModelError(format!("reward_scatter_rank load: {e}")))?;
        let reward_copy_raw_kernel = reward_module.load_function("reward_copy_raw_for_sort")
            .map_err(|e| MLError::ModelError(format!("reward_copy_raw_for_sort load: {e}")))?;
        let gather_quantiles_kernel = reward_module.load_function("gather_quantiles")
            .map_err(|e| MLError::ModelError(format!("gather_quantiles load: {e}")))?;

        // C.2 Plan 3 Task 1: load reward_component_ema kernel.
        let reward_component_ema_kernel = {
            use super::gpu_dqn_trainer::REWARD_COMPONENT_EMA_CUBIN;
            let rc_module = stream.context()
                .load_cubin(REWARD_COMPONENT_EMA_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("reward_component_ema cubin load: {e}")))?;
            rc_module.load_function("reward_component_ema")
                .map_err(|e| MLError::ModelError(format!("reward_component_ema load: {e}")))?
        };

        // SP18 v2 Phase 0 Task 0.1 (2026-05-08): per-action reward
        // decomposition diagnostic kernel. Loaded on the collector's
        // stream so the launcher chains directly after
        // `experience_action_select` (which writes `actions_out` packed
        // factored action_idx) and BEFORE
        // `launch_reward_component_ema_inplace` (which zeros the
        // reward-component buffer).
        let sp18_reward_decomp_diag_kernel = {
            use super::gpu_dqn_trainer::REWARD_DECOMP_DIAG_CUBIN;
            let m = stream.context()
                .load_cubin(REWARD_DECOMP_DIAG_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp18 reward_decomp_diag cubin load: {e}")))?;
            m.load_function("reward_decomp_diag")
                .map_err(|e| MLError::ModelError(format!(
                    "sp18 reward_decomp_diag load: {e}")))?
        };

        // SP4 GPU-only Pearls A+D applicator (2026-05-01). Loaded
        // separately on this collector's stream so the cross-boundary
        // `launch_reward_component_ema_inplace` can apply Pearls A+D
        // GPU-side immediately after `reward_component_ema` writes the
        // 6 step observations to scratch[63..69). Same cubin as the
        // trainer's handle.
        let apply_pearls_ad_kernel = {
            use super::gpu_dqn_trainer::APPLY_PEARLS_AD_CUBIN;
            let ap_module = stream.context()
                .load_cubin(APPLY_PEARLS_AD_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("apply_pearls_ad cubin load: {e}")))?;
            ap_module.load_function("apply_pearls_ad_kernel")
                .map_err(|e| MLError::ModelError(format!("apply_pearls_ad_kernel load: {e}")))?
        };

        // SP13 v3 Phase 0a P0a.T4 (2026-05-04): Hold-rate observer +
        // fixed-α EMA applicator on the collector's stream. The observer
        // chains directly after `experience_action_select` (which writes
        // `batch_actions [B]` packed factored action_idx) so the kernels
        // MUST live on the same stream — cross-stream sync would defeat
        // the no-host-sync per-step contract. Same cubins as the trainer's
        // handles; loaded twice (once per stream) per the cudarc Function/
        // Module pattern. The 1-element mapped-pinned `sp13_hold_rate_buf`
        // is owned by the collector because it's a per-step transient
        // scratch and the EMA writeback is into the trainer's ISV (read
        // via `isv_signals_dev_ptr` set after construction by
        // `set_isv_signals_ptr`).
        let sp13_hold_rate_observer_kernel = {
            use super::gpu_dqn_trainer::SP13_HOLD_RATE_OBSERVER_CUBIN;
            let m = stream.context()
                .load_cubin(SP13_HOLD_RATE_OBSERVER_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("sp13 hold_rate_observer cubin (collector): {e}")))?;
            m.load_function("hold_rate_observer_kernel")
                .map_err(|e| MLError::ModelError(format!("sp13 hold_rate_observer_kernel load (collector): {e}")))?
        };
        let sp13_apply_fixed_alpha_ema_kernel = {
            use super::gpu_dqn_trainer::SP13_APPLY_FIXED_ALPHA_EMA_CUBIN;
            let m = stream.context()
                .load_cubin(SP13_APPLY_FIXED_ALPHA_EMA_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("sp13 apply_fixed_alpha_ema cubin (collector): {e}")))?;
            m.load_function("apply_fixed_alpha_ema_kernel")
                .map_err(|e| MLError::ModelError(format!("sp13 apply_fixed_alpha_ema_kernel load (collector): {e}")))?
        };
        let sp13_hold_rate_buf = unsafe { MappedF32Buffer::new(1) }
            .map_err(|e| MLError::ModelError(
                format!("SP13 v3 sp13_hold_rate_buf alloc (1 f32, collector): {e}")
            ))?;

        // SP14 β-migration (Option B, 2026-05-07): pre-load the 4 SP14/SP13
        // EGF kernel handles on the collector's stream. The diagnostic
        // motivation lives in commit 9d0c124ce (kernel-gate fix) — even
        // with that gate, the trainer-time `submit_aux_ops` chain feeds
        // the EGF producers a Q-head that has converged to Hold/Flat on
        // replayed batches (total_cnt=0 every batch → EMAs decay to
        // zero). The rollout path with Thompson-forced Short/Long picks
        // is where the genuine aux↔Q disagreement signal lives. Each
        // load mirrors the SP13 hold-rate pattern at line 1820 — same
        // cubin as the trainer, loaded once on this stream so the
        // chained launches submit on `self.stream` and producer→consumer
        // ordering is stream-implicit (no cross-stream race, no host
        // sync). Per `feedback_no_partial_refactor.md`: handles allocate
        // here in step 1 of the β migration, launches wire in step 4.
        let exp_sp13_aux_dir_acc_reduce_kernel = {
            use super::gpu_dqn_trainer::SP13_AUX_DIR_ACC_REDUCE_CUBIN;
            let m = stream.context()
                .load_cubin(SP13_AUX_DIR_ACC_REDUCE_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp14-β: aux_dir_acc_reduce cubin (collector): {e}"
                )))?;
            m.load_function("aux_dir_acc_reduce_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp14-β: aux_dir_acc_reduce_kernel load (collector): {e}"
                )))?
        };
        let exp_sp13_aux_pred_to_isv_tanh_kernel = {
            use super::gpu_dqn_trainer::SP13_AUX_PRED_TO_ISV_TANH_CUBIN;
            let m = stream.context()
                .load_cubin(SP13_AUX_PRED_TO_ISV_TANH_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp14-β: aux_pred_to_isv_tanh cubin (collector): {e}"
                )))?;
            m.load_function("aux_pred_to_isv_tanh_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp14-β: aux_pred_to_isv_tanh_kernel load (collector): {e}"
                )))?
        };
        let exp_sp14_q_disagreement_update_kernel = {
            use super::gpu_dqn_trainer::SP14_Q_DISAGREEMENT_CUBIN;
            let m = stream.context()
                .load_cubin(SP14_Q_DISAGREEMENT_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp14-β: q_disagreement cubin (collector): {e}"
                )))?;
            m.load_function("q_disagreement_update_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp14-β: q_disagreement_update_kernel load (collector): {e}"
                )))?
        };
        // SP14 Layer C Phase C.1 (2026-05-08): collector α-machinery cubin
        // load (`SP14_ALPHA_GRAD_CUBIN` / `alpha_grad_compute_kernel`)
        // deleted atomically with the kernel file. The q_disagreement_update
        // handle loaded above remains for the HEALTH_DIAG diagnostic chain.

        // SP20 Phase 1.4 (2026-05-09, Path C): pre-load the 4 SP20
        // fused-producer kernel handles on the collector's stream.
        // Same loading pattern as the SP13/SP14 EGF chain above —
        // each handle is resolved once at construct time so the
        // per-rollout-step launches add no per-step host work.
        // Per-step ordering on `self.stream` (CUDA stream-implicit
        // producer→consumer):
        //   1. sp20_stats_compute    — aux_logits → [p50, std]
        //   2. sp20_aggregate_inputs — per-env arrays + stats + dir_acc
        //                              → SP20EmaInputs struct
        //   3. sp20_emas_compute     — struct → 4 ISV EMAs + 4 internal
        //   4. sp20_controllers_compute — EMAs → 6 ISV controllers
        let sp20_stats_compute_kernel = {
            use super::gpu_dqn_trainer::SP20_STATS_COMPUTE_CUBIN;
            let m = stream.context()
                .load_cubin(SP20_STATS_COMPUTE_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp20: stats_compute cubin (collector): {e}"
                )))?;
            m.load_function("sp20_stats_compute_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp20: stats_compute_kernel load (collector): {e}"
                )))?
        };
        let sp20_aggregate_inputs_kernel = {
            use super::gpu_dqn_trainer::SP20_AGGREGATE_INPUTS_CUBIN;
            let m = stream.context()
                .load_cubin(SP20_AGGREGATE_INPUTS_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp20: aggregate_inputs cubin (collector): {e}"
                )))?;
            m.load_function("sp20_aggregate_inputs_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp20: aggregate_inputs_kernel load (collector): {e}"
                )))?
        };
        let sp20_emas_compute_kernel = {
            use super::gpu_dqn_trainer::SP20_EMAS_COMPUTE_CUBIN;
            let m = stream.context()
                .load_cubin(SP20_EMAS_COMPUTE_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp20: emas_compute cubin (collector): {e}"
                )))?;
            m.load_function("sp20_emas_compute_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp20: emas_compute_kernel load (collector): {e}"
                )))?
        };
        let sp20_controllers_compute_kernel = {
            use super::gpu_dqn_trainer::SP20_CONTROLLERS_COMPUTE_CUBIN;
            let m = stream.context()
                .load_cubin(SP20_CONTROLLERS_COMPUTE_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp20: controllers_compute cubin (collector): {e}"
                )))?;
            m.load_function("sp20_controllers_compute_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp20: controllers_compute_kernel load (collector): {e}"
                )))?
        };

        // SP20 Phase 1.4 mapped-pinned scratch buffers. Allocate up
        // front so the per-step launches read/write fixed dev pointers
        // (no per-step alloc, no per-step HtoD copy).
        let sp20_stats_output_buf = unsafe {
            super::mapped_pinned::MappedF32Buffer::new(2)
        }.map_err(|e| MLError::ModelError(format!(
            "sp20: alloc sp20_stats_output_buf (2 f32): {e}"
        )))?;
        // Initialise to a sentinel that the EMA kernel reads cleanly
        // on the cold-start path (per-step EMAs always fire, so the
        // first observation REPLACES sentinel directly per
        // `pearl_first_observation_bootstrap`).
        sp20_stats_output_buf.write_from_slice(&[0.0_f32, 0.0_f32]);

        use super::sp20_emas_compute::{
            EMA_INTERNAL_SLOT_COUNT, OBS_COUNT_SLOTS, SP20_EMA_INPUTS_F32_LEN,
        };
        let sp20_ema_inputs_buf = unsafe {
            super::mapped_pinned::MappedF32Buffer::new(SP20_EMA_INPUTS_F32_LEN)
        }.map_err(|e| MLError::ModelError(format!(
            "sp20: alloc sp20_ema_inputs_buf ({SP20_EMA_INPUTS_F32_LEN} f32): {e}"
        )))?;
        sp20_ema_inputs_buf.write_from_slice(&vec![0.0_f32; SP20_EMA_INPUTS_F32_LEN]);

        let sp20_emas_internal_buf = unsafe {
            super::mapped_pinned::MappedF32Buffer::new(EMA_INTERNAL_SLOT_COUNT)
        }.map_err(|e| MLError::ModelError(format!(
            "sp20: alloc sp20_emas_internal_buf ({EMA_INTERNAL_SLOT_COUNT} f32): {e}"
        )))?;
        sp20_emas_internal_buf.write_from_slice(&vec![0.0_f32; EMA_INTERNAL_SLOT_COUNT]);

        let sp20_emas_obs_count_buf = unsafe {
            super::mapped_pinned::MappedI32Buffer::new(OBS_COUNT_SLOTS)
        }.map_err(|e| MLError::ModelError(format!(
            "sp20: alloc sp20_emas_obs_count_buf ({OBS_COUNT_SLOTS} i32): {e}"
        )))?;
        sp20_emas_obs_count_buf.write_from_slice(&vec![0_i32; OBS_COUNT_SLOTS]);

        // SP14 β-migration step 2 (2026-05-07): collector-side aux-head
        // forward infrastructure. The aux next-bar head's forward computes
        // a `[N, K=2]` softmax tile from `h_s2` + the trainer's params at
        // tensor indices [119..123) (nb_w1/b1/w2/b2). Step 4 of the β
        // migration wires the per-step launches; this commit just
        // allocates the buffers + the orchestrator. AuxHeadsForwardOps
        // is the same orchestrator the trainer uses (gpu_aux_heads.rs
        // line 75) — its `forward_next_bar` method takes `&Arc<CudaStream>`
        // so passing `&self.stream` (the collector's stream) bind-binds
        // the launch to this stream without the trainer-stream coupling.
        // Buffers are sized to alloc_episodes (rollout batch dim) NOT
        // batch_size (replay batch dim) per the β migration's contract.
        let exp_aux_heads_fwd = super::gpu_aux_heads::AuxHeadsForwardOps::new(&stream)?;
        use super::gpu_aux_heads::{AUX_HIDDEN_DIM, AUX_NEXT_BAR_K};
        let exp_aux_nb_hidden_buf = stream
            .alloc_zeros::<f32>(alloc_episodes * AUX_HIDDEN_DIM)
            .map_err(|e| MLError::ModelError(format!(
                "sp14-β: alloc exp_aux_nb_hidden_buf ({} f32, collector): {e}",
                alloc_episodes * AUX_HIDDEN_DIM
            )))?;
        let exp_aux_nb_logits_buf = stream
            .alloc_zeros::<f32>(alloc_episodes * AUX_NEXT_BAR_K)
            .map_err(|e| MLError::ModelError(format!(
                "sp14-β: alloc exp_aux_nb_logits_buf ({} f32, collector): {e}",
                alloc_episodes * AUX_NEXT_BAR_K
            )))?;
        let exp_aux_nb_softmax_buf = stream
            .alloc_zeros::<f32>(alloc_episodes * AUX_NEXT_BAR_K)
            .map_err(|e| MLError::ModelError(format!(
                "sp14-β: alloc exp_aux_nb_softmax_buf ({} f32, collector): {e}",
                alloc_episodes * AUX_NEXT_BAR_K
            )))?;

        // ── SP22 H6 vNext Phase B3 (2026-05-14): trade-outcome aux head ──
        // collector-side ops + 4 rollout buffers. Mirrors aux_nb_* allocs
        // at K=3 (AUX_OUTCOME_K=3) instead of K=2.
        //
        // Forward orchestrator loads 3 kernel handles from 3 separate
        // cubins (forward, loss_reduce, label_producer) — each in its
        // own .cu file for diagnostic isolation per Phase A2-A4 design.
        let exp_aux_to_fwd = super::gpu_aux_heads::AuxTradeOutcomeForwardOps::new(&stream)?;
        use super::gpu_aux_heads::AUX_OUTCOME_K;
        let exp_aux_to_hidden_buf = stream
            .alloc_zeros::<f32>(alloc_episodes * AUX_HIDDEN_DIM)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B3: alloc exp_aux_to_hidden_buf ({} f32): {e}",
                alloc_episodes * AUX_HIDDEN_DIM
            )))?;
        let exp_aux_to_logits_buf = stream
            .alloc_zeros::<f32>(alloc_episodes * AUX_OUTCOME_K)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B3: alloc exp_aux_to_logits_buf ({} f32): {e}",
                alloc_episodes * AUX_OUTCOME_K
            )))?;
        let exp_aux_to_softmax_buf = stream
            .alloc_zeros::<f32>(alloc_episodes * AUX_OUTCOME_K)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B3: alloc exp_aux_to_softmax_buf ({} f32): {e}",
                alloc_episodes * AUX_OUTCOME_K
            )))?;
        let exp_aux_to_label_buf = stream
            .alloc_zeros::<i32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B3: alloc exp_aux_to_label_buf ({} i32): {e}",
                alloc_episodes
            )))?;
        // SP22 H6 vNext Phase B4b (2026-05-14): per-(env, t) trade-outcome
        // label column for the replay-buffer scatter chain. Sized
        // `[alloc_episodes × alloc_timesteps]` — same shape as the K=2
        // head's `aux_sign_labels` ring. Sentinel -1 (mask) populated by
        // the alloc_zeros + the per-step kernel writes (mask survives
        // until a trade-close event overwrites the env's slot at that t).
        let exp_aux_to_label_per_sample = stream
            .alloc_zeros::<i32>(alloc_episodes * alloc_timesteps)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B4b: alloc exp_aux_to_label_per_sample ({} i32): {e}",
                alloc_episodes * alloc_timesteps
            )))?;
        // SP22 H6 vNext Phase B5b (2026-05-14): plan-conditioned input
        // buffer for the collector's K=3 forward. SH2_TOTAL = SH2 +
        // PLAN_PARAM_DIM = 262. Populated by the concat kernel at the
        // start of each rollout step's K=3 forward (plan_params source
        // = NULL → zero-fill the trailing 6 cols; see field doc for
        // train/inference asymmetry note).
        let sh2_total_alloc = shared_h2 + super::gpu_aux_heads::PLAN_PARAM_DIM;
        let exp_aux_to_input_buf = stream
            .alloc_zeros::<f32>(alloc_episodes * sh2_total_alloc)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B5b: alloc exp_aux_to_input_buf ({} f32): {e}",
                alloc_episodes * sh2_total_alloc
            )))?;

        // SP22 H6 vNext Phase C (2026-05-14): K=3 per-env softmax cache
        // + producer kernel handle. Sized [alloc_episodes × AUX_OUTCOME_K=3]
        // f32. Cold-start sentinel 0.0 across all 3 slots = "no
        // Profit/Stop/Timeout signal" (the consumer state gather will
        // read this baseline on step 0; subsequent steps populated
        // from the K=3 forward's softmax).
        let prev_aux_outcome_probs = stream
            .alloc_zeros::<f32>(alloc_episodes * AUX_OUTCOME_K)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext C: alloc prev_aux_outcome_probs ({} f32): {e}",
                alloc_episodes * AUX_OUTCOME_K
            )))?;
        let exp_aux_outcome_softmax_to_per_env_kernel = {
            use super::gpu_dqn_trainer::SP22_AUX_OUTCOME_SOFTMAX_TO_PER_ENV_CUBIN;
            let m = stream.context()
                .load_cubin(SP22_AUX_OUTCOME_SOFTMAX_TO_PER_ENV_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp22-vnext C: aux_outcome_softmax_to_per_env cubin (collector): {e}"
                )))?;
            m.load_function("aux_outcome_softmax_to_per_env_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp22-vnext C: aux_outcome_softmax_to_per_env_kernel load (collector): {e}"
                )))?
        };

        // SP22 H6 vNext Phase B5b-2 (2026-05-14) — collector trade plan
        // forward chain (resolves the train/inference plan_params asymmetry
        // documented on `exp_aux_to_input_buf`). Three scratch buffers
        // mirror the trainer's `trade_plan_hidden_buf` / `pre_out_buf` /
        // `plan_params_buf` (sized for `alloc_episodes` rollout envs vs the
        // trainer's batch_size) plus the `trade_plan_activate` kernel handle
        // loaded from `EXPERIENCE_KERNELS_CUBIN` (the same cubin the rest
        // of `exp_module_extra` uses, where the trainer also loads this
        // kernel — single source of truth).
        let exp_trade_plan_hidden_buf = stream
            .alloc_zeros::<f32>(alloc_episodes * adv_h)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B5b-2: alloc exp_trade_plan_hidden_buf ({} f32): {e}",
                alloc_episodes * adv_h
            )))?;
        let exp_trade_plan_pre_out_buf = stream
            .alloc_zeros::<f32>(alloc_episodes * super::gpu_aux_heads::PLAN_PARAM_DIM)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B5b-2: alloc exp_trade_plan_pre_out_buf ({} f32): {e}",
                alloc_episodes * super::gpu_aux_heads::PLAN_PARAM_DIM
            )))?;
        let exp_plan_params = stream
            .alloc_zeros::<f32>(alloc_episodes * super::gpu_aux_heads::PLAN_PARAM_DIM)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B5b-2: alloc exp_plan_params ({} f32): {e}",
                alloc_episodes * super::gpu_aux_heads::PLAN_PARAM_DIM
            )))?;
        let exp_trade_plan_activate_kernel = exp_module_extra
            .load_function("trade_plan_activate")
            .map_err(|e| MLError::ModelError(format!(
                "sp22-vnext B5b-2: trade_plan_activate load (collector): {e}"
            )))?;
        // SP22 H6 (2026-05-12): aux→policy state bridge. Load the per-env
        // softmax extractor (`aux_softmax_to_per_env_kernel`) + the shared
        // `fill_f32` from the epsilon_greedy cubin (for 0.5 sentinel init at
        // cold-start and FoldReset). Allocate `prev_aux_dir_prob [alloc_episodes]`
        // and prime it to 0.5 via fill_f32 — no HtoD per
        // `feedback_no_htod_htoh_only_mapped_pinned.md`.
        let exp_aux_softmax_to_per_env_kernel = {
            use super::gpu_dqn_trainer::SP22_AUX_SOFTMAX_TO_PER_ENV_CUBIN;
            let m = stream.context()
                .load_cubin(SP22_AUX_SOFTMAX_TO_PER_ENV_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp22-h6: aux_softmax_to_per_env cubin (collector): {e}"
                )))?;
            m.load_function("aux_softmax_to_per_env_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp22-h6: aux_softmax_to_per_env_kernel load (collector): {e}"
                )))?
        };
        let exp_fill_f32_kernel = {
            use super::gpu_action_selector::EPSILON_GREEDY_CUBIN;
            let m = stream.context()
                .load_cubin(EPSILON_GREEDY_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp22-h6: epsilon_greedy cubin (collector, for fill_f32): {e}"
                )))?;
            m.load_function("fill_f32")
                .map_err(|e| MLError::ModelError(format!(
                    "sp22-h6: fill_f32 load (collector): {e}"
                )))?
        };
        let prev_aux_dir_prob = stream
            .alloc_zeros::<f32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!(
                "sp22-h6: alloc prev_aux_dir_prob ({} f32, collector): {e}",
                alloc_episodes
            )))?;
        {
            // SP22 H6 Phase 2 (2026-05-12) cold-start fill: write 0.0
            // sentinel (neutral under the recentered `2*p - 1` encoding;
            // matches the "no signal = 0" baseline used by every other
            // state slot per `pearl_first_observation_bootstrap`). Pure
            // GPU compute via `fill_f32` (no HtoD). Matches the launch
            // shape used by `GpuActionSelector::run_branching` for the
            // epsilon buffer.
            let n_envs_i32 = alloc_episodes as i32;
            let sentinel: f32 = 0.0;
            let blocks = ((alloc_episodes as u32).div_ceil(256)).max(1);
            let prev_aux_dir_prob_ptr = prev_aux_dir_prob.raw_ptr();
            unsafe {
                stream
                    .launch_builder(&exp_fill_f32_kernel)
                    .arg(&prev_aux_dir_prob_ptr)
                    .arg(&sentinel)
                    .arg(&n_envs_i32)
                    .launch(LaunchConfig {
                        grid_dim: (blocks, 1, 1),
                        block_dim: (256, 1, 1),
                        shared_mem_bytes: 0,
                    })
                    .map_err(|e| MLError::ModelError(format!(
                        "sp22-h6: fill_f32(prev_aux_dir_prob=0.5) cold-start: {e}"
                    )))?;
            }
        }
        let exp_aux_nb_label_buf = stream
            .alloc_zeros::<i32>(alloc_episodes)
            .map_err(|e| MLError::ModelError(format!(
                "sp14-β: alloc exp_aux_nb_label_buf ({} i32, collector): {e}",
                alloc_episodes
            )))?;
        // 6-float layout: see field doc on `exp_aux_dir_acc_buf` (kernel
        // header in `aux_dir_acc_reduce_kernel.cu`). Mapped-pinned so
        // the chained `apply_fixed_alpha_ema` reads it via dev_ptr with
        // no DtoD copy.
        let exp_aux_dir_acc_buf = unsafe { MappedF32Buffer::new(6) }
            .map_err(|e| MLError::ModelError(format!(
                "sp14-β: alloc exp_aux_dir_acc_buf (6 f32, collector): {e}"
            )))?;

        // SP14 Layer C Phase C.5a (2026-05-08): rollout-sized aux trunk
        // saved-fwd buffers (h_s2_aux, h_aux1, h_aux2). Sized to
        // `alloc_episodes` × per-layer dim (mirrors the β-migration
        // pattern: replay-batch-sized in trainer, rollout-batch-sized
        // here). DEAD until C.5b atomically inserts the
        // `aux_trunk_forward.launch` post-`forward_online_f32` and
        // redirects the EGF chain from Q's `h_s2` to `h_s2_aux`. No
        // production caller reads these in C.5a.
        use super::gpu_aux_trunk::{AUX_TRUNK_H1, AUX_TRUNK_H2};
        let exp_h_s2_aux = stream
            .alloc_zeros::<f32>(alloc_episodes * shared_h2)
            .map_err(|e| MLError::ModelError(format!(
                "sp14-c.5a: alloc exp_h_s2_aux ({} f32, collector): {e}",
                alloc_episodes * shared_h2
            )))?;
        let exp_h_aux1 = stream
            .alloc_zeros::<f32>(alloc_episodes * AUX_TRUNK_H1)
            .map_err(|e| MLError::ModelError(format!(
                "sp14-c.5a: alloc exp_h_aux1 ({} f32, collector): {e}",
                alloc_episodes * AUX_TRUNK_H1
            )))?;
        let exp_h_aux2 = stream
            .alloc_zeros::<f32>(alloc_episodes * AUX_TRUNK_H2)
            .map_err(|e| MLError::ModelError(format!(
                "sp14-c.5a: alloc exp_h_aux2 ({} f32, collector): {e}",
                alloc_episodes * AUX_TRUNK_H2
            )))?;

        // SP14 Layer C Phase C.5a-fixup (2026-05-08): pre-load the
        // collector-side `aux_trunk_forward` CudaFunction handle on the
        // collector's stream. Mirrors the trainer-side
        // `AuxTrunkForwardOps::new(&stream)` instantiation per
        // `pearl_no_host_branches_in_captured_graph` — every per-step
        // launcher uses cached handles instead of `load_cubin` inside the
        // hot path. DEAD until C.5b atomically inserts
        // `exp_aux_trunk_forward_ops.launch(...)` post-`forward_online_f32`.
        let exp_aux_trunk_forward_ops =
            super::gpu_aux_trunk::AuxTrunkForwardOps::new(&stream)?;

        // SP14 Layer C Phase C.6 (2026-05-08): pre-load the h_s2_aux RMS EMA
        // producer. Launched after `exp_aux_trunk_forward_ops` in the
        // per-step hot path to update ISV[H_S2_AUX_RMS_EMA_INDEX=449].
        let exp_h_s2_aux_rms_ema_ops =
            super::gpu_aux_trunk::HS2AuxRmsEmaOps::new(&stream)?;

        // SP14 β-migration step 3 (2026-05-07): per-rollout-step label
        // producer kernel. Loaded on the collector's stream so its launch
        // chains directly after `forward_online_f32` (which populates
        // `exp_h_s2_f32`) without cross-stream sync. The kernel body lives
        // in `aux_sign_label_per_step_kernel.cu`; the trajectory variant
        // (`aux_sign_label_kernel.cu`) is unchanged — its end-of-rollout
        // launch at line 3776 still fires for the trainer's replay-batch
        // labels in `collect_experiences_gpu`. Per
        // `pearl_no_host_branches_in_captured_graph` the load happens
        // here in the constructor; per-step launches use the cached
        // `CudaFunction` handle.
        let exp_aux_sign_label_per_step_kernel = {
            let m = stream.context()
                .load_cubin(SP14_AUX_SIGN_LABEL_PER_STEP_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!(
                    "sp14-β: aux_sign_label_per_step cubin (collector): {e}"
                )))?;
            m.load_function("aux_sign_label_per_step_kernel")
                .map_err(|e| MLError::ModelError(format!(
                    "sp14-β: aux_sign_label_per_step_kernel load (collector): {e}"
                )))?
        };

        // SP15 Wave 5 follow-up (2026-05-07): pre-load 6 SP15 kernel
        // handles on the collector's stream. See field doc-block on
        // `exp_sp15_dd_state_kernel` for graph-capture / child-context
        // rationale. Load order: cubin → function symbol; cached for
        // the lifetime of the collector.
        let exp_sp15_dd_state_kernel = {
            use super::gpu_dqn_trainer::SP15_DD_STATE_CUBIN;
            let m = stream.context()
                .load_cubin(SP15_DD_STATE_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("sp15 dd_state cubin (collector): {e}")))?;
            m.load_function("dd_state_kernel")
                .map_err(|e| MLError::ModelError(format!("sp15 dd_state_kernel load (collector): {e}")))?
        };
        let exp_sp15_dd_state_reduce_kernel = {
            use super::gpu_dqn_trainer::SP15_DD_STATE_REDUCE_CUBIN;
            let m = stream.context()
                .load_cubin(SP15_DD_STATE_REDUCE_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("sp15 dd_state_reduce cubin (collector): {e}")))?;
            m.load_function("dd_state_reduce_kernel")
                .map_err(|e| MLError::ModelError(format!("sp15 dd_state_reduce_kernel load (collector): {e}")))?
        };
        let exp_sp15_dd_trajectory_decreasing_kernel = {
            use super::gpu_dqn_trainer::SP15_DD_TRAJECTORY_CUBIN;
            let m = stream.context()
                .load_cubin(SP15_DD_TRAJECTORY_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("sp15 dd_trajectory cubin (collector): {e}")))?;
            m.load_function("dd_trajectory_decreasing_kernel")
                .map_err(|e| MLError::ModelError(format!("sp15 dd_trajectory_decreasing_kernel load (collector): {e}")))?
        };
        let exp_sp15_alpha_split_producer_kernel = {
            use super::gpu_dqn_trainer::SP15_ALPHA_SPLIT_PRODUCER_CUBIN;
            let m = stream.context()
                .load_cubin(SP15_ALPHA_SPLIT_PRODUCER_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("sp15 alpha_split_producer cubin (collector): {e}")))?;
            m.load_function("alpha_split_producer_kernel")
                .map_err(|e| MLError::ModelError(format!("sp15 alpha_split_producer_kernel load (collector): {e}")))?
        };
        let exp_sp15_final_reward_kernel = {
            use super::gpu_dqn_trainer::SP15_FINAL_REWARD_CUBIN;
            let m = stream.context()
                .load_cubin(SP15_FINAL_REWARD_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("sp15 final_reward cubin (collector): {e}")))?;
            m.load_function("compute_sp15_final_reward_kernel")
                .map_err(|e| MLError::ModelError(format!("sp15 compute_sp15_final_reward_kernel load (collector): {e}")))?
        };
        let exp_sp15_plasticity_injection_kernel = {
            use super::gpu_dqn_trainer::SP15_PLASTICITY_INJECTION_CUBIN;
            let m = stream.context()
                .load_cubin(SP15_PLASTICITY_INJECTION_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("sp15 plasticity_injection cubin (collector): {e}")))?;
            m.load_function("plasticity_injection_kernel")
                .map_err(|e| MLError::ModelError(format!("sp15 plasticity_injection_kernel load (collector): {e}")))?
        };

        // SP15 Phase 1.3.b-followup (2026-05-07): per-env DD state tile
        // sized `[alloc_episodes * 6]`. The redesigned `dd_state_kernel`
        // writes 6 stats per env to this tile (Path A → Path B
        // migration); `dd_state_reduce_kernel` mean-aggregates into the
        // 6 ISV scalar slots [401..407) and max-aggregates into the
        // new DD_PERSISTENCE_MAX_INDEX = 443 slot;
        // `compute_sp15_final_reward_kernel` reads it per-(i,t) so each
        // slot's reward shaping uses ITS env's DD context.
        // Zero-initialised by `MappedF32Buffer::new`'s contract; reset
        // to zero at fold boundary via `host_slice_mut().fill(0.0)`
        // (mirrors the `sp13_hold_rate_buf` / `sp15_alpha_warm_count`
        // non-ISV mapped-pinned reset pattern).
        let sp15_dd_state_per_env =
            unsafe { MappedF32Buffer::new(alloc_episodes * 6) }
                .map_err(|e| MLError::ModelError(
                    format!(
                        "SP15 Phase 1.3.b-followup sp15_dd_state_per_env alloc \
                         ({} f32, collector): {e}",
                        alloc_episodes * 6
                    )
                ))?;

        // SP15 Phase 1.3.b-followup-B (2026-05-07): per-env DD trajectory
        // output tile + per-env persistent prev_dd_pct scratch. Sized
        // `[alloc_episodes]` (one float per env, NOT 6 — these are
        // scalar-per-env unlike the 6-stat dd_state tile). Mapped-pinned
        // f32 buffers, zero-initialised by `MappedF32Buffer::new`'s
        // contract. Reset to zero at fold boundary via
        // `host_slice_mut().fill(0.0)` (mirrors the
        // `sp15_dd_state_per_env` non-ISV mapped-pinned reset pattern).
        let sp15_dd_trajectory_per_env =
            unsafe { MappedF32Buffer::new(alloc_episodes) }
                .map_err(|e| MLError::ModelError(
                    format!(
                        "SP15 Phase 1.3.b-followup-B sp15_dd_trajectory_per_env alloc \
                         ({} f32, collector): {e}",
                        alloc_episodes
                    )
                ))?;
        let sp15_dd_trajectory_prev_dd_per_env =
            unsafe { MappedF32Buffer::new(alloc_episodes) }
                .map_err(|e| MLError::ModelError(
                    format!(
                        "SP15 Phase 1.3.b-followup-B sp15_dd_trajectory_prev_dd_per_env alloc \
                         ({} f32, collector): {e}",
                        alloc_episodes
                    )
                ))?;

        // B.2 Plan 3 Task 3: load trade_attempt_rate_ema_update kernel.
        let trade_attempt_rate_ema_kernel = {
            use super::gpu_dqn_trainer::TRADE_RATE_EMA_CUBIN;
            let tr_module = stream.context()
                .load_cubin(TRADE_RATE_EMA_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("trade_rate_ema cubin load: {e}")))?;
            tr_module.load_function("trade_attempt_rate_ema_update")
                .map_err(|e| MLError::ModelError(format!("trade_attempt_rate_ema_update load: {e}")))?
        };

        // B.4 Plan 3 Task 4: load plan_threshold_update kernel.
        let plan_threshold_update_kernel = {
            use super::gpu_dqn_trainer::PLAN_THRESHOLD_UPDATE_CUBIN;
            let pt_module = stream.context()
                .load_cubin(PLAN_THRESHOLD_UPDATE_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("plan_threshold_update cubin load: {e}")))?;
            pt_module.load_function("plan_threshold_update")
                .map_err(|e| MLError::ModelError(format!("plan_threshold_update load: {e}")))?
        };

        // C.3 Plan 3 Task 7: load state_kl_moment_match kernel.
        let state_kl_kernel = {
            use super::gpu_dqn_trainer::STATE_KL_DIVERGENCE_CUBIN;
            let kl_module = stream.context()
                .load_cubin(STATE_KL_DIVERGENCE_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("state_kl_divergence cubin load: {e}")))?;
            kl_module.load_function("state_kl_moment_match")
                .map_err(|e| MLError::ModelError(format!("state_kl_moment_match load: {e}")))?
        };

        // B.3 Plan 3 Task 8: load scripted_policy_select kernel.
        let scripted_policy_kernel = {
            use super::gpu_dqn_trainer::SCRIPTED_POLICY_CUBIN;
            let sp_module = stream.context()
                .load_cubin(SCRIPTED_POLICY_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("scripted_policy cubin load: {e}")))?;
            sp_module.load_function("scripted_policy_select")
                .map_err(|e| MLError::ModelError(format!("scripted_policy_select load: {e}")))?
        };

        // B.3 Plan 3 Task 8: load seed_step_counter_update kernel.
        let seed_step_counter_kernel = {
            use super::gpu_dqn_trainer::SEED_STEP_COUNTER_CUBIN;
            let sc_module = stream.context()
                .load_cubin(SEED_STEP_COUNTER_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("seed_step_counter cubin load: {e}")))?;
            sc_module.load_function("seed_step_counter_update")
                .map_err(|e| MLError::ModelError(format!("seed_step_counter_update load: {e}")))?
        };

        // C.5 Plan 3 Task 9: load cql_alpha_seed_update kernel.
        let cql_alpha_seed_update_kernel = {
            use super::gpu_dqn_trainer::CQL_ALPHA_SEED_UPDATE_CUBIN;
            let cs_module = stream.context()
                .load_cubin(CQL_ALPHA_SEED_UPDATE_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("cql_alpha_seed_update cubin load: {e}")))?;
            cs_module.load_function("cql_alpha_seed_update")
                .map_err(|e| MLError::ModelError(format!("cql_alpha_seed_update load: {e}")))?
        };

        // E.5 Plan 4 Task 5 Mode A: load attention_focus_ema_update kernel.
        let attention_focus_ema_kernel = {
            use super::gpu_dqn_trainer::ATTENTION_FOCUS_EMA_CUBIN;
            let af_module = stream.context()
                .load_cubin(ATTENTION_FOCUS_EMA_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("attention_focus_ema cubin load: {e}")))?;
            af_module.load_function("attention_focus_ema_update")
                .map_err(|e| MLError::ModelError(format!("attention_focus_ema_update load: {e}")))?
        };

        // Plan 4 follow-up: load target_drift_ema_update kernel.
        let target_drift_kernel = {
            use super::gpu_dqn_trainer::TARGET_DRIFT_CUBIN;
            let td_module = stream.context()
                .load_cubin(TARGET_DRIFT_CUBIN.to_vec())
                .map_err(|e| MLError::ModelError(format!("target_drift cubin load: {e}")))?;
            td_module.load_function("target_drift_ema_update")
                .map_err(|e| MLError::ModelError(format!("target_drift_ema_update load: {e}")))?
        };

        // Sort buffers: sized to next power-of-2 of max possible N (episodes × timesteps × cf_mult).
        // Bitonic sort requires power-of-2 sized arrays.
        let max_reward_n = alloc_episodes * alloc_timesteps * 2; // cf_mult = 2
        let sort_buf_capacity = max_reward_n.next_power_of_two();
        let reward_sort_keys_buf = stream.alloc_zeros::<f32>(sort_buf_capacity)
            .map_err(|e| MLError::ModelError(format!("alloc reward_sort_keys_buf: {e}")))?;
        let reward_sort_indices_buf = stream.alloc_zeros::<i32>(sort_buf_capacity)
            .map_err(|e| MLError::ModelError(format!("alloc reward_sort_indices_buf: {e}")))?;
        // Quantiles scratch: max num_atoms is typically 51-128, allocate 256 as safe upper bound.
        let quantiles_scratch_buf = stream.alloc_zeros::<f32>(256)
            .map_err(|e| MLError::ModelError(format!("alloc quantiles_scratch_buf: {e}")))?;

        // #31 Bottleneck — always loaded and allocated (one production path)
        // SP15 Wave 4.1b: experience collector inference forward shares the
        // trunk's dd_pct-aware concat path. Loads `bn_tanh_concat_dd_kernel`
        // (same cubin, multi-kernel-per-file pattern); the launcher reads
        // ISV[DD_PCT_INDEX=406] via this collector's `isv_signals_dev_ptr`
        // (set by `set_isv_signals_ptr` from the trainer's bus). At step 0
        // the slot is sentinel 0 (per state_reset_registry); the experience
        // collector's `launch_sp15_dd_state` populates it during env_step
        // (which runs AFTER this forward, so step k+1 sees step k's
        // dd_pct — same one-step-lag semantic as mag_concat).
        use super::gpu_dqn_trainer::DQN_UTILITY_CUBIN;
        let util_module = stream.context()
            .load_cubin(DQN_UTILITY_CUBIN.to_vec())
            .map_err(|e| MLError::ModelError(format!("utility cubin load for bn: {e}")))?;
        let bn_tanh_concat_fn = util_module.load_function("bn_tanh_concat_dd_kernel")
            .map_err(|e| MLError::ModelError(format!("bn_tanh_concat_dd_kernel load: {e}")))?;

        let bn_alloc = bottleneck_dim.max(1);
        let portfolio_dim_bn = state_dim.saturating_sub(market_dim_cfg);
        let exp_bn_hidden = stream.alloc_zeros::<f32>(alloc_episodes * bn_alloc + 128)
            .map_err(|e| MLError::ModelError(format!("alloc exp_bn_hidden: {e}")))?;
        // SP15 Wave 4.1b: +1 column for dd_pct. Matches the trainer's
        // bn_concat_buf / tg_bn_concat_buf / on_next_bn_concat_buf
        // reallocation in `gpu_dqn_trainer.rs` (`concat_dim = bn_alloc_dim
        // + portfolio_dim + 1`).
        let exp_bn_concat = stream.alloc_zeros::<f32>(
            alloc_episodes * (bn_alloc + portfolio_dim_bn + 1) + 128
        )
            .map_err(|e| MLError::ModelError(format!("alloc exp_bn_concat: {e}")))?;

        // Phase 2d: per-sample, per-branch C51 support buffer [alloc_episodes, 4, 3]
        // stride-12 — tiled v_min/v_max/delta_z per branch. Filled once per epoch
        // in update_per_sample_support(); consumers compute_expected_q /
        // quantile_q_select / mag_concat_qdir index with (sample, branch, {0,1,2}).
        // Mapped pinned per `feedback_no_htod_htoh_only_mapped_pinned.md`:
        // CPU writes via host_ptr each epoch; kernels read via dev_ptr.
        // Safety: CUDA context active on this thread (stream owns it).
        let per_sample_support_buf = unsafe {
            MappedF32Buffer::new(alloc_episodes * 4 * 3)
                .map_err(|e| MLError::ModelError(format!("alloc per_sample_support_buf (mapped pinned): {e}")))?
        };

        // SP6 Pearl 3: [4] mapped-pinned buffer for per-branch noise sigma.
        // CPU writes the 4 sigma values before each noise kernel launch;
        // kernel reads via dev_ptr. Zero memcpy per feedback_no_htod.
        let noise_sigma_dev = unsafe {
            MappedF32Buffer::new(4)
                .map_err(|e| MLError::ModelError(format!("alloc noise_sigma_dev (mapped pinned): {e}")))?
        };

        // HOT path: hindsight relabel bar_indices buffer.
        // Capacity = alloc_episodes * alloc_timesteps * 2 (cf doubles total).
        let bar_indices_pinned = unsafe {
            MappedI32Buffer::new(alloc_episodes * alloc_timesteps * 2)
                .map_err(|e| MLError::ModelError(format!("alloc bar_indices_pinned: {e}")))?
        };

        // Task 8: GPU-resident step counter for experience collection loop
        let step_counter_gpu = stream.alloc_zeros::<i32>(1)
            .map_err(|e| MLError::ModelError(format!("alloc step_counter_gpu: {e}")))?;

        // G2: Pinned device-mapped cost_anneal scalar — CPU writes, GPU reads, zero memcpy.
        let (cost_anneal_pinned, cost_anneal_dev_ptr) = {
            let mut host_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
            let mut dev_ptr_out: u64 = 0;
            unsafe {
                let rc = cudarc::driver::sys::cuMemAllocHost_v2(&mut host_ptr, std::mem::size_of::<f32>());
                assert_eq!(rc, cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS, "cuMemAllocHost for cost_anneal");
                let rc2 = cudarc::driver::sys::cuMemHostGetDevicePointer_v2(&mut dev_ptr_out, host_ptr, 0);
                assert_eq!(rc2, cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS, "cuMemHostGetDevicePointer for cost_anneal");
                *(host_ptr as *mut f32) = 0.0; // start with zero costs
            }
            (host_ptr as *mut f32, dev_ptr_out)
        };

        // Phase 3 unified-env: exploration_scale + shaping_scale pinned scalars.
        // Same alloc pattern as cost_anneal. Default to 1.0 (full training mode);
        // validation-mode callers flip both to 0.0 by writing through the *mut.
        let alloc_pinned_scalar = |label: &str, init: f32| -> (*mut f32, u64) {
            let mut host_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
            let mut dev_ptr_out: u64 = 0;
            unsafe {
                let rc = cudarc::driver::sys::cuMemAllocHost_v2(&mut host_ptr, std::mem::size_of::<f32>());
                assert_eq!(rc, cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS, "cuMemAllocHost for {label}");
                let rc2 = cudarc::driver::sys::cuMemHostGetDevicePointer_v2(&mut dev_ptr_out, host_ptr, 0);
                assert_eq!(rc2, cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS, "cuMemHostGetDevicePointer for {label}");
                *(host_ptr as *mut f32) = init;
            }
            (host_ptr as *mut f32, dev_ptr_out)
        };
        let (exploration_scale_pinned, exploration_scale_dev_ptr) =
            alloc_pinned_scalar("exploration_scale", 1.0);
        let (shaping_scale_pinned, shaping_scale_dev_ptr) =
            alloc_pinned_scalar("shaping_scale", 1.0);

        Ok(Self {
            stream,
            market_dim,
            network_dims,
            num_atoms,
            per_sample_support_buf, // Phase 2d: [alloc_episodes, 4, 3] stride-12; update_per_sample_support() tiles
            per_sample_epsilon_ptr: 0, // Set via set_per_sample_epsilon_ptr() after IQL init
            branch_sizes,
            alloc_episodes,
            alloc_timesteps,
            cublas_forward,
            exp_fwd_graph: std::ptr::null_mut(),
            trainer_params_ptr: 0, // Set via set_trainer_params_ptr() after fused ctx init
            total_params,
            param_sizes,
            cvar_scales_ptr: 0, // NULL = no CVaR scaling initially
            iqr_tiled_gpu: None,
            ensemble_var_tiled_gpu: None,
            saxpy_f32_kernel: {
                util_module.load_function("dqn_saxpy_f32_kernel")
                    .map_err(|e| MLError::ModelError(format!("dqn_saxpy_f32_kernel load: {e}")))?
            },
            state_gather_kernel,
            action_select_kernel,
            expert_override_kernel,
            env_step_kernel,
            expected_q_kernel,
            quantile_q_select_kernel,
            nstep_kernel,
            fill_episode_ids_kernel,
            trade_stats_kernel,
            trade_stats_buf,
            batch_states,
            batch_actions,
            q_gaps_buf,
            conviction_buf,
            magnitude_conviction_buf,
            current_timesteps,
            q_values,
            q_select_buf,
            q_var_buf,
            iqn_readiness: 0.0,
            util_ema: 1.0,  // assume healthy at start
            exp_h_s1,
            exp_h_s2,
            exp_h_v,
            exp_v_logits,
            exp_b_logits,
            curiosity_weights,
            portfolio_states,
            episode_starts_buf,
            label_at_open_per_env,
            alpha_per_env,
            is_win_per_env,
            pnl_vs_target_at_close_per_env,
            pnl_vs_stop_at_close_per_env,
            per_bar_opp_cost_per_env,
            hold_baseline_buffer,
            states_out,
            actions_out,
            rewards_out,
            done_out,
            raw_returns_out,
            trail_triggered_per_sample,
            pre_mag_per_sample,
            traded_per_sample,
            hold_at_exit_per_sample,
            cf_flip_per_sample,
            micro_reward_per_sample,
            total_reward_per_sample,
            saboteur_delta_reward_per_sample,
            popart_component_per_sample,
            reward_components_per_sample,
            flat_to_pos_per_sample,
            readiness_per_sample,
            action_mag_per_sample,
            step_ret_per_sample,
            trade_close_per_sample,
            aux_conf_per_sample,
            trade_profitable_per_sample,
            var_scale_per_sample,
            slot_live_per_sample,
            r_discipline_per_sample,
            slot_completed_normally_per_sample,
            last_experience_count: 0,
            epoch_state,
            reset_flags: 0,
            expert_actions_gpu: None,
            expert_ratio: 0.0,
            ofi_gpu: ofi_init,
            ofi_dim,
            curiosity_trainer,
            feature_mask_buf: None,
            bar_indices_pinned,
            exp_h_s1_f32,
            exp_h_s2_f32,
            exp_h_v_f32,
            exp_h_b0_f32,
            exp_h_b1_f32,
            exp_h_b2_f32,
            exp_h_b3_f32,
            exp_states_f32,
            domain_rand_starts_kernel,
            domain_rand_params_kernel,
            domain_rand_params_buf,
            saboteur_params_buf,
            saboteur_base_buf,
            saboteur_best_buf,
            saboteur_best_return_buf,
            saboteur_generate_kernel,
            saboteur_select_kernel,
            saboteur_active: false,
            saboteur_perturbation_scale: 0.3,
            exp_bn_hidden,
            exp_bn_concat,
            bottleneck_dim,
            market_dim_bn: market_dim_cfg,
            bn_tanh_concat_fn,
            reward_component_ema_kernel,
            sp18_reward_decomp_diag_kernel,
            apply_pearls_ad_kernel,
            // SP13 v3 P0a.T4 (2026-05-04): Hold-rate observer + fixed-α
            // EMA applicator + 1-elem mapped-pinned scratch — chained
            // after `experience_action_select` per step in
            // `collect_experiences_gpu`.
            sp13_hold_rate_observer_kernel,
            sp13_apply_fixed_alpha_ema_kernel,
            sp13_hold_rate_buf,
            // SP14 β-migration (Option B, 2026-05-07): kernel handles for
            // the collector-native EGF producer chain. Wired in step 4 of
            // the β migration; this commit just allocates the handles
            // (additive, no behavior change).
            exp_sp13_aux_dir_acc_reduce_kernel,
            exp_sp13_aux_pred_to_isv_tanh_kernel,
            exp_sp14_q_disagreement_update_kernel,
            // SP14 β-migration step 2 (Option B, 2026-05-07): aux-head
            // forward orchestrator + 5 rollout-sized buffers. Step 3
            // wires the per-rollout-step forward + label-producer
            // launches; this commit is allocation-only (additive, no
            // forward run yet).
            exp_aux_heads_fwd,
            exp_aux_nb_hidden_buf,
            exp_aux_nb_logits_buf,
            exp_aux_nb_softmax_buf,
            // SP22 H6 vNext Phase B3 (2026-05-14): trade-outcome aux head
            // collector-side ops + rollout buffers (Phase B0 scaffold +
            // K=3 mirror of aux_nb_* pattern).
            exp_aux_to_fwd,
            exp_aux_to_hidden_buf,
            exp_aux_to_logits_buf,
            exp_aux_to_softmax_buf,
            exp_aux_to_label_buf,
            exp_aux_to_label_per_sample,
            // SP22 H6 vNext Phase B5b (2026-05-14): plan-conditioned
            // input buffer [N × 262] for the collector-side K=3
            // forward. Populated by aux_to_fwd.launch_concat with
            // plan_params=NULL (zero-fill the trailing 6 cols).
            exp_aux_to_input_buf,
            // SP22 H6 vNext Phase C (2026-05-14): K=3 trade-outcome
            // per-env softmax cache + producer kernel handle (Phase C
            // scaffolding — populates `prev_aux_outcome_probs` every
            // step; state gather consumer wired in Phase C-2).
            prev_aux_outcome_probs,
            exp_aux_outcome_softmax_to_per_env_kernel,
            // SP22 H6 vNext Phase B5b-2 (2026-05-14): collector trade plan
            // forward chain — 3 scratch buffers + activate kernel handle.
            // Resolves Phase B5b's NULL plan_params train/inference
            // asymmetry by mirroring trainer's `launch_trade_plan_forward`
            // (now reading from the corrected indices [91..94]).
            exp_trade_plan_hidden_buf,
            exp_trade_plan_pre_out_buf,
            exp_plan_params,
            exp_trade_plan_activate_kernel,
            // SP22 H6 (2026-05-12): aux→policy state bridge — per-env p_up
            // cache + extractor kernel + fill_f32 handle for 0.5 sentinel.
            prev_aux_dir_prob,
            exp_aux_softmax_to_per_env_kernel,
            exp_fill_f32_kernel,
            exp_aux_nb_label_buf,
            exp_aux_dir_acc_buf,
            // SP14 Layer C Phase C.5a (2026-05-08): rollout-sized aux trunk
            // saved-fwd buffers (DEAD until C.5b atomic wire-up).
            exp_h_s2_aux,
            exp_h_aux1,
            exp_h_aux2,
            // SP14 Layer C Phase C.5a-fixup (2026-05-08): zero-copy ptrs to
            // trainer's aux trunk params + collector-side forward orchestrator.
            // All DEAD until C.5b atomically inserts the forward launch +
            // wires the setter call from training_loop.rs.
            aux_trunk_w1_ptr: 0,
            aux_trunk_b1_ptr: 0,
            aux_trunk_w2_ptr: 0,
            aux_trunk_b2_ptr: 0,
            aux_trunk_w3_ptr: 0,
            aux_trunk_b3_ptr: 0,
            exp_aux_trunk_forward_ops,
            exp_h_s2_aux_rms_ema_ops,
            exp_aux_sign_label_per_step_kernel,
            // SP15 Wave 5 follow-up (2026-05-07): pre-loaded SP15 kernel handles.
            exp_sp15_dd_state_kernel,
            exp_sp15_dd_state_reduce_kernel,
            exp_sp15_dd_trajectory_decreasing_kernel,
            exp_sp15_alpha_split_producer_kernel,
            exp_sp15_final_reward_kernel,
            exp_sp15_plasticity_injection_kernel,
            trade_attempt_rate_ema_kernel,
            plan_threshold_update_kernel,
            state_kl_kernel,
            scripted_policy_kernel,
            seed_step_counter_kernel,
            cql_alpha_seed_update_kernel,
            attention_focus_ema_kernel,
            target_drift_kernel,
            step_counter_gpu,
            step_counter_kernel,
            noise_kernel,
            noise_sigma_dev,
            pinned_states,
            pinned_actions,
            pinned_rewards,
            pinned_dones,
            cost_anneal_pinned,
            cost_anneal_dev_ptr,
            isv_signals_dev_ptr: 0,  // NULL until trainer sets it
            aux_w_to_q_dir_dev_ptr: 0,  // SP22 H6 Phase 3 α Phase C1 — NULL until trainer sets it
            sp15_alpha_warm_count_dev_ptr: 0,  // NULL until trainer wires it (SP15 Wave 2)
            // SP15 Phase 1.3.b-followup-B (2026-05-07): both per-env tiles
            // owned by this struct (collector), allocated above. The
            // trainer no longer owns a `[1]` scratch buffer — it was
            // deleted as part of this commit.
            sp15_dd_state_per_env,  // SP15 Phase 1.3.b-followup per-env DD tile [alloc_episodes * 6]
            sp15_dd_trajectory_per_env,  // SP15 Phase 1.3.b-followup-B per-env trajectory output [alloc_episodes]
            sp15_dd_trajectory_prev_dd_per_env,  // SP15 Phase 1.3.b-followup-B per-env persistent scratch [alloc_episodes]
            sp15_w_b0out_dev_ptr:    0,  // NULL until trainer wires it (SP15 Phase 3.5.4.c)
            sp15_w_b0out_n_weights:  0,
            sp15_w_b0out_fan_in:     0,
            // SP4 Task A13.5: Pearls A+D buffers for reward_component_ema.
            // All NULL until FusedTrainingCtx wires them post-collector
            // construction (mirrors A14/A15 Pearl C wiring path). 2026-05-01
            // GPU-Pearls refactor added the wiener/isv dev_ptr fields for
            // the GPU `apply_pearls_ad_kernel`.
            reward_component_pearls_wiener_dev_ptr:   0,
            reward_component_pearls_wiener_host_ptr:  std::ptr::null_mut(),
            reward_component_pearls_scratch_dev_ptr:  0,
            reward_component_pearls_scratch_host_ptr: std::ptr::null_mut(),
            reward_component_pearls_isv_dev_ptr:      0,
            reward_component_pearls_isv_pinned_ptr:   std::ptr::null_mut(),
            plan_params_dev_ptr: 0,  // NULL until trainer sets it
            readiness_dev_ptr: 0,    // NULL until trainer sets it
            exploration_scale_pinned,
            exploration_scale_dev_ptr,
            shaping_scale_pinned,
            shaping_scale_dev_ptr,
            difficulty_scores_kernel,
            hindsight_relabel_kernel,
            aux_sign_label_kernel,
            td_lambda_kernel,
            learning_health_cache: 1.0,  // D4/N4: assume healthy at construction
            last_cf_ratio_eff: 0.5,       // D4/N4: standard cf_ratio at healthy state
            contrarian_active_cache: 0,   // D7/N7: off by default
            seed_phase_active_cache: false, // B.3 Plan 3 Task 8: cold-start in network mode; trainer flips on at epoch boundary if SEED_STEPS_DONE < TARGET

            // ── SP20 Phase 1.4 (2026-05-09) — fused-producer chain ──
            sp20_stats_compute_kernel,
            sp20_aggregate_inputs_kernel,
            sp20_emas_compute_kernel,
            sp20_controllers_compute_kernel,
            sp20_stats_output_buf,
            sp20_ema_inputs_buf,
            sp20_emas_internal_buf,
            sp20_emas_obs_count_buf,
            reward_rank_kernel,
            reward_compute_abs_sharpe_kernel,
            bitonic_sort_step_kernel,
            reward_scatter_rank_kernel,
            reward_sort_keys_buf,
            reward_sort_indices_buf,
            sort_buf_capacity,
            reward_copy_raw_kernel,
            gather_quantiles_kernel,
            quantiles_scratch_buf,
        })
    }

    /// Collect experiences and return GPU-resident `CudaSlice` buffers.
    ///
    /// Runs the timestep-level loop: for each timestep, gathers states,
    /// runs cuBLAS Q-forward, selects actions, and steps the environment.
    /// G2: Set transaction cost anneal factor (0.0 = zero costs, 1.0 = full costs).
    /// Writes to pinned device-mapped memory — GPU reads with zero memcpy.
    pub fn set_cost_anneal(&mut self, factor: f32) {
        unsafe { *self.cost_anneal_pinned = factor; }
    }

    /// Phase 3 unified-env: set exploration_scale ∈ [0, 1].
    /// 1.0 = full training mode (saboteur, plan_params, counterfactuals on).
    /// 0.0 = deterministic / validation-equivalent (those perturbations off).
    /// Pinned device-mapped — zero memcpy, kernel reads next launch.
    pub fn set_exploration_scale(&mut self, scale: f32) {
        unsafe { *self.exploration_scale_pinned = scale.clamp(0.0, 1.0); }
    }

    /// Phase 3 unified-env: set shaping_scale ∈ [0, 1].
    /// 1.0 = full reward shaping bundle (drawdown/churn/inventory/micro-reward).
    /// 0.0 = pure per-bar P&L only (validation reward).
    /// Pinned device-mapped — zero memcpy, kernel reads next launch.
    pub fn set_shaping_scale(&mut self, scale: f32) {
        unsafe { *self.shaping_scale_pinned = scale.clamp(0.0, 1.0); }
    }

    /// Set ISV signals device pointer for adaptive hold enforcement.
    /// The experience collector's env_step kernel reads ISV signals to compute
    /// dynamic hold time: base + f(stability, confidence). Pass 0 for static hold.
    pub fn set_isv_signals_ptr(&mut self, dev_ptr: u64) {
        self.isv_signals_dev_ptr = dev_ptr;
    }

    /// SP22 H6 Phase 3 α Phase C1 (2026-05-13): set the trainer's
    /// `w_aux_to_q_dir [4]` device pointer so the collector's rollout-
    /// time `compute_expected_q` + `quantile_q_select` launchers apply
    /// the active atom-shift `z_n_effective = z_n + W[a] * state_121[b]`
    /// for direction-branch action selection. Pass 0 to disable
    /// (test scaffold path; both kernels NULL-safe → bit-identical
    /// pre-Phase-3-α behavior). The collector also reads its own
    /// `exp_states_f32` rollout-state buffer for `state_121` so no
    /// state-pointer setter is needed.
    pub fn set_aux_w_to_q_dir_ptr(&mut self, dev_ptr: u64) {
        self.aux_w_to_q_dir_dev_ptr = dev_ptr;
    }

    /// SP15 Wave 2 (2026-05-06): set the trainer's
    /// `sp15_alpha_warm_count` mapped-pinned scratch buffer dev_ptr
    /// ([1] f32). The collector calls this after construction (mirrors
    /// the `set_isv_signals_ptr` pattern). When set together with a
    /// non-zero `isv_signals_dev_ptr`, the per-step
    /// `launch_sp15_alpha_split_producer` + `launch_sp15_final_reward`
    /// run inside `collect_experiences_gpu`, layering the SP15 §8.2 /
    /// §9.2 reward-axis composition AFTER the SP11 trader-quality
    /// composer's writes to `rewards_out`. Pass 0 to disable (test
    /// scaffold path; the SP11 reward path stays canonical).
    pub fn set_sp15_alpha_warm_count_ptr(&mut self, dev_ptr: u64) {
        self.sp15_alpha_warm_count_dev_ptr = dev_ptr;
    }

    // SP15 Phase 3.5.5.b → Phase 1.3.b-followup-B (2026-05-07):
    // The `set_sp15_dd_trajectory_prev_dd_ptr` setter was DELETED.
    // The `[1]` scratch buffer that lived on the trainer struct was
    // reshaped to two `[alloc_episodes]` per-env tiles owned by THIS
    // collector struct (`sp15_dd_trajectory_per_env` +
    // `sp15_dd_trajectory_prev_dd_per_env`, allocated in the
    // constructor). Production wiring is unconditional — the per-step
    // `launch_sp15_dd_trajectory_decreasing` (in step 5b) threads the
    // dev_ptrs of `self.sp15_dd_trajectory_per_env` and
    // `self.sp15_dd_trajectory_prev_dd_per_env` directly. Mirrors the
    // collector-owned `sp15_dd_state_per_env` pattern.

    /// SP15 Phase 3.5.4.c (2026-05-07): set the trainer's `params_buf`
    /// device pointer offset to `w_b0out` (directional advantage-head
    /// last-Linear weights, tensor index 19 per `compute_param_sizes`)
    /// + the matching `n_weights` (= `branch_0_size × num_atoms ×
    /// adv_h`; default config 4 × 51 × 128 = 26112) + Kaiming-He
    /// `fan_in` (= `adv_h`; default 128). When all three are non-zero
    /// alongside `isv_signals_dev_ptr`, the per-step
    /// `launch_sp15_plasticity_injection` runs in step 4-pre (BEFORE
    /// `experience_action_select`); the trigger writes
    /// `ISV[PLASTICITY_FIRED_THIS_FOLD=436]` + `ISV[PLASTICITY_WARM_BARS_
    /// REMAINING=438]`, the action_select kernel's OR-gate consumer
    /// reads both slots + `m_warm` (defaulted 200.0 per spec §9.2
    /// (3.5.4)) to force `dir_idx = DIR_FLAT` on the fire bar and
    /// `dir_idx = DIR_HOLD` on subsequent warm-up bars. Pass 0 for any
    /// of the three to disable (test scaffold path; the OR-gate
    /// degenerates to cooldown-only — bit-identical to the
    /// pre-3.5.4.c path).
    ///
    /// The trainer derives `n_weights` and `fan_in` from
    /// `compute_param_sizes(&self.config)[19]` and `self.config.adv_h`
    /// respectively (per the param-buffer layout at
    /// `gpu_dqn_trainer.rs:4074` and the Kaiming-He fan-dim layout at
    /// `gpu_dqn_trainer.rs:29946` — the row-dim `branch_0_size ×
    /// num_atoms` is the OUTPUT count, the col-dim `adv_h` is the
    /// INPUT count = fan_in for the per-unit Kaiming-He variance).
    pub fn set_sp15_plasticity_target(
        &mut self,
        w_b0out_dev_ptr: u64,
        n_weights: i32,
        fan_in: i32,
    ) {
        self.sp15_w_b0out_dev_ptr   = w_b0out_dev_ptr;
        self.sp15_w_b0out_n_weights = n_weights;
        self.sp15_w_b0out_fan_in    = fan_in;
    }

    /// Set trade plan params device pointer for plan activation/enforcement.
    /// The env_step kernel reads plan params to copy into portfolio state on
    /// Flat→Positioned transitions. Pass 0 to disable (NULL = no plan).
    pub fn set_plan_params_ptr(&mut self, dev_ptr: u64) {
        self.plan_params_dev_ptr = dev_ptr;
    }

    /// Set model readiness dev_ptr for plan enforcement gating.
    pub fn set_readiness_ptr(&mut self, dev_ptr: u64) {
        self.readiness_dev_ptr = dev_ptr;
    }

    /// D4/N4: propagate learning health from the trainer (called once per epoch).
    /// The next call to collect_experiences_gpu will compute cf_ratio from this value.
    pub fn set_learning_health(&mut self, health: f32) {
        self.learning_health_cache = health.clamp(0.0, 1.0);
    }

    /// B.3 Plan 3 Task 8: propagate the seed-phase flag from the trainer
    /// (called once per epoch boundary). The trainer reads
    /// ISV[SEED_STEPS_DONE_INDEX] vs ISV[SEED_STEPS_TARGET_INDEX] from its
    /// pinned ISV mirror; if DONE < TARGET it sets `active=true` and the
    /// next `launch_timestep_loop` will dispatch the scripted-policy
    /// kernel instead of the network's `experience_action_select`.
    /// Cold-path CPU decision; the action computation itself stays
    /// 100% GPU.
    pub fn set_seed_phase_active(&mut self, active: bool) {
        self.seed_phase_active_cache = active;
    }

    /// Read-only accessor for diagnostics / smoke tests.
    pub fn seed_phase_active(&self) -> bool {
        self.seed_phase_active_cache
    }

    /// D4/N4: return the last effective cf_ratio (set during collect_experiences_gpu).
    pub fn last_cf_ratio_eff(&self) -> f32 {
        self.last_cf_ratio_eff
    }

    /// D7/N7: propagate contrarian flag from trainer (called once per epoch when
    /// the state machine updates). When active, the action-selection kernel negates
    /// Q values before Boltzmann softmax → argmin-favoring sampling.
    pub fn set_contrarian_active(&mut self, active: bool) {
        self.contrarian_active_cache = if active { 1 } else { 0 };
    }

    /// D7/N7: return the cached contrarian flag.
    pub fn contrarian_active(&self) -> u8 {
        self.contrarian_active_cache
    }

    /// D4/N4: compute cf_ratio from cached health.
    fn current_cf_ratio(&self) -> f32 {
        (0.5_f32 + 0.3_f32 * (1.0 - self.learning_health_cache)).clamp(0.0, 1.0)
    }

    /// Set CVaR position scaling from IQN dual-head.
    /// The device pointer will be passed to the env_step kernel.
    /// Call with 0 to disable (NULL pointer = no scaling).
    pub fn set_cvar_scales(&mut self, device_ptr: u64) {
        self.cvar_scales_ptr = device_ptr;
    }

    /// Set IQN readiness for quantile-blended action selection.
    ///
    /// Passed directly to `quantile_q_select` kernel as the `iqn_readiness` scalar.
    /// 0.0 = fully exploring (uses Q_90th / optimistic), 1.0 = fully exploiting
    /// (uses Q_10th / pessimistic). In early training iqn_readiness≈0 so the agent
    /// explores optimistically; as the IQN loss converges it shifts toward exploitation.
    pub fn set_iqn_readiness(&mut self, readiness: f32) {
        self.iqn_readiness = readiness.clamp(0.0, 1.0);
    }

    /// Set atom utilization EMA for quantile Q-select graceful degradation.
    /// When util < 50%, quantile blend shifts toward robust median (Q_50th).
    pub fn set_utilization_ema(&mut self, util: f32) {
        self.util_ema = util.clamp(0.0, 1.0);
    }

    /// Set IQR exploration bonus from IQN quantile spread.
    ///
    /// Reads the per-action IQR values from the GPU device pointer, computes
    /// `alpha * iqr[a]`, tiles across N episodes, and uploads to a GPU buffer.
    /// Each timestep, this tiled buffer is added to q_values via SAXPY before
    /// Boltzmann action selection, encouraging exploration of high-uncertainty
    /// (large-magnitude) actions.
    ///
    /// Call with `device_ptr = 0` or `alpha = 0.0` to disable.
    pub fn set_iqr_bonus(&mut self, device_ptr: u64, alpha: f32) {
        let tba: usize = self.branch_sizes.iter().sum();
        if device_ptr == 0 || alpha <= 0.0 || tba == 0 {
            self.iqr_tiled_gpu = None;
            return;
        }

        // Read per-action IQR from GPU to host (cold path, once per epoch)
        let mut iqr_host = vec![0.0_f32; tba];
        unsafe {
            let r = cudarc::driver::sys::cuMemcpyDtoH_v2(
                iqr_host.as_mut_ptr() as *mut std::ffi::c_void,
                device_ptr,
                tba * std::mem::size_of::<f32>(),
            );
            if r != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
                tracing::warn!("IQR DtoH readback failed: {r:?}, disabling IQR bonus");
                self.iqr_tiled_gpu = None;
                return;
            }
        }

        // Pre-scale by alpha: bonus[a] = alpha * iqr[a]
        for v in iqr_host.iter_mut() {
            *v *= alpha;
        }

        debug!(
            alpha,
            tba,
            iqr_bonus_dir = ?&iqr_host[..self.branch_sizes[0].min(iqr_host.len())],
            "IQR exploration bonus set"
        );

        // Tile across N episodes: [N, q_stride] where q_stride = tba
        let n = self.alloc_episodes;
        let mut tiled = Vec::with_capacity(n * tba);
        for _ in 0..n {
            tiled.extend_from_slice(&iqr_host);
        }

        // Upload tiled bonus to GPU
        match self.stream.clone_htod(&tiled) {
            Ok(buf) => { self.iqr_tiled_gpu = Some(buf); }
            Err(e) => {
                tracing::warn!("IQR tiled HtoD failed: {e}, disabling IQR bonus");
                self.iqr_tiled_gpu = None;
            }
        }
    }

    /// Sets the ensemble variance exploration bonus from the ensemble var buffer.
    ///
    /// Reads [buf_len] f32 values (per-atom C51 logit variance across K heads) from
    /// the GPU device pointer `var_ptr`, computes `bonus = beta * sqrt(mean_var)`,
    /// and tiles it as a constant across all actions and N episodes. Applied via SAXPY
    /// before Boltzmann action selection: Q_select += bonus for all actions uniformly.
    ///
    /// High ensemble disagreement (large mean_var) → larger bonus → more exploration.
    /// Only meaningful when ensemble_count > 1; beta=0.0 or var_ptr=0 disables.
    pub fn set_ensemble_variance_bonus(&mut self, var_ptr: u64, buf_len: usize, beta: f32) {
        let tba: usize = self.branch_sizes.iter().sum();
        if var_ptr == 0 || beta <= 0.0 || tba == 0 || buf_len == 0 {
            self.ensemble_var_tiled_gpu = None;
            return;
        }

        // Read per-atom variance values from GPU to host (cold path, once per epoch)
        let mut var_host = vec![0.0_f32; buf_len];
        unsafe {
            let r = cudarc::driver::sys::cuMemcpyDtoH_v2(
                var_host.as_mut_ptr() as *mut std::ffi::c_void,
                var_ptr,
                buf_len * std::mem::size_of::<f32>(),
            );
            if r != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
                tracing::warn!("Ensemble var DtoH readback failed: {r:?}, disabling ensemble bonus");
                self.ensemble_var_tiled_gpu = None;
                return;
            }
        }

        // Compute mean variance across all atoms and batch samples, then sqrt + scale
        let mean_var: f32 = var_host.iter().sum::<f32>() / buf_len.max(1) as f32;
        let bonus = beta * mean_var.sqrt();

        if bonus <= 0.0 || !bonus.is_finite() {
            self.ensemble_var_tiled_gpu = None;
            return;
        }

        debug!(
            beta,
            mean_var,
            bonus,
            tba,
            "Ensemble variance exploration bonus set"
        );

        // Tile constant bonus across N episodes × tba actions: [N * tba] f32
        let n = self.alloc_episodes;
        let tiled = vec![bonus; n * tba];

        // Upload tiled bonus to GPU
        match self.stream.clone_htod(&tiled) {
            Ok(buf) => { self.ensemble_var_tiled_gpu = Some(buf); }
            Err(e) => {
                tracing::warn!("Ensemble var tiled HtoD failed: {e}, disabling ensemble bonus");
                self.ensemble_var_tiled_gpu = None;
            }
        }
    }

    /// Generate jittered episode starts on GPU. Replaces CPU rand::thread_rng().
    /// Writes directly to the internal episode_starts_buf.
    pub fn generate_episode_starts_gpu(
        &mut self,
        n_episodes: usize,
        stride: i32,
        usable_bars: i32,
        epoch: usize,
    ) -> Result<(), MLError> {
        let n = n_episodes as i32;
        let epoch_i32 = epoch as i32;
        let blocks = ((n as u32 + 255) / 256) as u32;
        unsafe {
            self.stream
                .launch_builder(&self.domain_rand_starts_kernel)
                .arg(&mut self.episode_starts_buf)
                .arg(&n)
                .arg(&stride)
                .arg(&usable_bars)
                .arg(&epoch_i32)
                .launch(LaunchConfig {
                    grid_dim: (blocks, 1, 1),
                    block_dim: (256, 1, 1),
                    shared_mem_bytes: 0,
                })
                .map_err(|e| MLError::ModelError(format!(
                    "domain_rand_episode_starts: {e}"
                )))?;
        }
        Ok(())
    }

    /// Generate per-episode sim params on GPU. Replaces CPU epoch_rng.gen_range().
    pub fn generate_sim_params_gpu(
        &mut self,
        n_episodes: usize,
        base_tx_cost: f32,
        base_spread: f32,
        base_fill_prob: f32,
        base_fill_min: f32,
        base_fill_max: f32,
    ) -> Result<(), MLError> {
        let n = n_episodes as i32;
        let blocks = ((n as u32 + 255) / 256) as u32;
        unsafe {
            self.stream
                .launch_builder(&self.domain_rand_params_kernel)
                .arg(&mut self.domain_rand_params_buf)
                .arg(&n)
                .arg(&base_tx_cost)
                .arg(&base_spread)
                .arg(&base_fill_prob)
                .arg(&base_fill_min)
                .arg(&base_fill_max)
                .launch(LaunchConfig {
                    grid_dim: (blocks, 1, 1),
                    block_dim: (256, 1, 1),
                    shared_mem_bytes: 0,
                })
                .map_err(|e| MLError::ModelError(format!(
                    "domain_rand_sim_params: {e}"
                )))?;
        }
        Ok(())
    }

    /// #33 Enable/disable the GPU-native saboteur for this epoch.
    /// When active, per-episode adversarial parameters are generated by CUDA kernel
    /// and the best (worst for trader) params are selected after collection.
    pub fn set_saboteur_active(&mut self, active: bool) {
        self.saboteur_active = active;
    }

    /// #33 Set perturbation scale for the saboteur's evolutionary search.
    pub fn set_saboteur_perturbation_scale(&mut self, scale: f32) {
        self.saboteur_perturbation_scale = scale;
    }

    /// SP11 Fix 39 (2026-05-04, A1.2): expose the per-bar saboteur engagement
    /// signal buffer to the trainer. Returns `(dev_ptr, n_bars)` where
    /// `n_bars = alloc_episodes × alloc_timesteps`. The trainer caches this
    /// via `GpuDqnTrainer::set_sp11_saboteur_delta_reward_buf` after the
    /// collector is constructed (training_loop.rs init path) so
    /// `launch_sp11_saboteur_engagement_compute` has the consumer-side
    /// pointer ready every step. Per `feedback_wire_everything_up`: this
    /// getter + the trainer setter + the env_step kernel write site land
    /// together in this commit.
    pub fn sp11_saboteur_delta_reward_dev_ptr(&self) -> (u64, usize) {
        let dev_ptr = self.saboteur_delta_reward_per_sample.device_ptr(&self.stream).0;
        let n_bars  = self.alloc_episodes * self.alloc_timesteps;
        (dev_ptr, n_bars)
    }

    /// SP11 Fix 39 B1b fix-up (2026-05-04): expose the popart-component-
    /// specific magnitude per-bar buffer to the trainer. Mirror of
    /// `sp11_saboteur_delta_reward_dev_ptr` — same wire-up pattern. The
    /// trainer caches this via
    /// `GpuDqnTrainer::set_sp11_popart_component_buf` after the collector
    /// is constructed (training_loop.rs init path) so
    /// `launch_sp11_popart_component_ema` has the consumer-side pointer
    /// ready every step. Per `feedback_wire_everything_up`: this getter +
    /// the trainer setter + the env_step kernel write site land together
    /// in this commit.
    pub fn sp11_popart_component_dev_ptr(&self) -> (u64, usize) {
        let dev_ptr = self.popart_component_per_sample.device_ptr(&self.stream).0;
        let n_bars  = self.alloc_episodes * self.alloc_timesteps;
        (dev_ptr, n_bars)
    }

    /// Upload pre-computed expert demonstration actions to GPU.
    ///
    /// `expert_actions[bar_index]` = expert exposure action index (-1 = no opinion).
    /// Generated once from MA crossover + ADX filter, stays GPU-resident for the
    /// entire training run. The action selection kernel reads this at each timestep
    /// and overrides Q-network actions with probability `expert_ratio`.
    pub fn upload_expert_actions(&mut self, actions: &[i32]) -> Result<(), MLError> {
        // Mapped pinned: CPU writes via host_ptr, action-select kernel reads
        // via dev_ptr — no HtoD copy. Safety: CUDA context active here.
        let buf = unsafe {
            MappedI32Buffer::new(actions.len())
                .map_err(|e| MLError::ModelError(format!("alloc expert_actions (mapped pinned): {e}")))?
        };
        buf.write_from_slice(actions);
        self.expert_actions_gpu = Some(buf);
        info!(n_actions = actions.len(), "Expert demo actions uploaded to GPU (mapped pinned)");
        Ok(())
    }

    /// Set the expert demo override ratio for the current epoch.
    pub fn set_expert_ratio(&mut self, ratio: f32) {
        self.expert_ratio = ratio;
    }

    /// H6 diagnostic: per-magnitude trailing-stop fire rate + hold-time at exit.
    /// Returns ([fire_rate_per_mag; 3], [hold_at_exit_mean; 3]) for Quarter/Half/Full bins.
    /// Reads per-sample buffers, reduces deterministically host-side, then resets.
    /// Slots where `pre_mag == -1` (no prior position) or `traded == 0` (no trade
    /// event at this step) are skipped, so untouched alloc_zeros defaults don't
    /// pollute the averages.
    pub fn trail_fire_and_hold_per_mag(&mut self) -> Result<([f32; 3], [f32; 3]), MLError> {
        let n = self.alloc_episodes * self.alloc_timesteps;
        let mut h_trail = vec![0_i32; n];
        let mut h_mag = vec![0_i32; n];
        let mut h_traded = vec![0_i32; n];
        let mut h_hold = vec![0.0_f32; n];
        self.stream.memcpy_dtoh(&self.trail_triggered_per_sample, &mut h_trail)
            .map_err(|e| MLError::ModelError(format!("dtoh trail_triggered_per_sample: {e}")))?;
        self.stream.memcpy_dtoh(&self.pre_mag_per_sample, &mut h_mag)
            .map_err(|e| MLError::ModelError(format!("dtoh pre_mag_per_sample: {e}")))?;
        self.stream.memcpy_dtoh(&self.traded_per_sample, &mut h_traded)
            .map_err(|e| MLError::ModelError(format!("dtoh traded_per_sample: {e}")))?;
        self.stream.memcpy_dtoh(&self.hold_at_exit_per_sample, &mut h_hold)
            .map_err(|e| MLError::ModelError(format!("dtoh hold_at_exit_per_sample: {e}")))?;

        let mut trail_fires = [0_u64; 3];
        let mut trade_counts = [0_u64; 3];
        let mut hold_sum = [0.0_f64; 3];
        let mut exit_counts = [0_u64; 3];
        for i in 0..n {
            if h_traded[i] == 0 { continue; }
            let m_raw = h_mag[i];
            if !(0..=2).contains(&m_raw) { continue; } // -1 = no-position, skip
            let m = m_raw as usize;
            trade_counts[m] += 1;
            if h_trail[i] != 0 { trail_fires[m] += 1; }
            if h_hold[i] > 0.0 {
                hold_sum[m] += h_hold[i] as f64;
                exit_counts[m] += 1;
            }
        }
        let mut rates = [0.0_f32; 3];
        let mut hold_means = [0.0_f32; 3];
        for m in 0..3 {
            rates[m] = if trade_counts[m] > 0 {
                trail_fires[m] as f32 / trade_counts[m] as f32
            } else { 0.0 };
            hold_means[m] = if exit_counts[m] > 0 {
                (hold_sum[m] / exit_counts[m] as f64) as f32
            } else { 0.0 };
        }

        // Reset per-sample buffers so next epoch starts clean (filtered by traded==0,
        // but also keeps memory footprint predictable for CUDA Graph capture).
        self.stream.memset_zeros(&mut self.trail_triggered_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset trail_triggered_per_sample: {e}")))?;
        self.stream.memset_zeros(&mut self.pre_mag_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset pre_mag_per_sample: {e}")))?;
        self.stream.memset_zeros(&mut self.traded_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset traded_per_sample: {e}")))?;
        self.stream.memset_zeros(&mut self.hold_at_exit_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset hold_at_exit_per_sample: {e}")))?;

        Ok((rates, hold_means))
    }

    /// Task 0.8 reward-term contribution audit (Track 2 policy-quality).
    ///
    /// Returns `[popart, cf_flip, trail_r, micro]`:
    /// * `popart`     — slot 0 is populated by the caller (host-side PopArt state
    ///                  lives on the trainer, not the collector). Returned as 0.0.
    /// * `cf_flip`    — firing rate ∈ [0, 1] of the directional counterfactual
    ///                  flip over slots the kernel actually reached this epoch
    ///                  (Σ cf_flip / Σ slot_live). `slot_live_per_sample` is
    ///                  written by the kernel at every reached (i,t), so early
    ///                  termination (capital-floor reset, done) doesn't
    ///                  under-report the rate.
    /// * `trail_r`    — overall trailing-stop firing rate = Σ trail / Σ traded
    ///                  across all magnitude bins. Reads Task 0.5's per-sample
    ///                  buffers without resetting them; caller must invoke
    ///                  `reward_contrib_fractions` BEFORE `trail_fire_and_hold_per_mag`
    ///                  so the trail buffers are still populated.
    /// * `micro`      — magnitude ratio: |Σ pre-transform micro| / |Σ post-transform total|.
    ///                  Diagnostic for relative term scale; NOT a true fractional
    ///                  decomposition. The kernel captures `micro_reward_per_sample`
    ///                  immediately after the micro-branch overwrite, so downstream
    ///                  transforms (drawdown penalty, conviction scaling, CF sign
    ///                  flip) can shrink or flip the final `total_reward_per_sample`.
    ///                  Pre/post mismatch is intentional — we want micro magnitude
    ///                  relative to the final reward scale the network trains on.
    ///                  **Semantic zero:** when the loaded profile sets
    ///                  `micro_reward_scale = 0.0` (e.g. `dqn-smoketest.toml`),
    ///                  the kernel branch is guarded off and `micro_reward_per_sample`
    ///                  stays at its entry-default 0.0f → this slot reads 0.000
    ///                  as a real semantic value ("profile intentionally disables
    ///                  R5"), NOT a wiring regression. Phase 2 Task 2.3 kept the
    ///                  kernel path live per feedback_no_functionality_removal.md.
    ///
    /// Task 2.4: the old `loss_aversion` slot (was slot 4) was removed. The R6
    /// reward-layer clamp was relocated to `c51_loss_kernel.cu::block_bellman_
    /// project_f` (Q-target smoothing) so the reward-layer diagnostic has no
    /// source signal. Tuple shrinks 5→4; downstream HEALTH_DIAG format string
    /// dropped the `la={:.3}` field in the same commit.
    ///
    /// Buffers owned by this accessor (cf_flip, micro, total, slot_live) are
    /// reset via memset_zeros. Task 0.5 buffers (trail_triggered_per_sample,
    /// traded_per_sample) are LEFT UNTOUCHED so the subsequent per-mag reducer
    /// still has data.
    ///
    /// Race-free by construction — kernel writes only its own (i,t) slot.
    pub fn reward_contrib_fractions(&mut self) -> Result<[f32; 4], MLError> {
        let n_main = self.alloc_episodes * self.alloc_timesteps;
        let n_cf = n_main * 2; // cf_mult = 2 (#7 counterfactual doubles output)

        // ── CF-flip firing rate ──────────────────────────────────────────
        // Kernel writes cf_flip_per_sample[cf_off] = do_flip ? 1 : 0 at
        // cf_off = out_off + N*L. Sum the full cf_mult-sized buffer; the
        // non-CF half stays at its alloc_zeros default of 0 and contributes
        // nothing. Denominator is `n_touched` (= Σ slot_live_per_sample), the
        // number of (i,t) slots the kernel actually reached this epoch. Using
        // N*L would silently under-report the rate when episodes terminate
        // early (capital-floor reset, done flag) — un-reached slots keep their
        // alloc_zeros defaults and cannot fire. slot_live is written once per
        // reached slot, unambiguous regardless of reward value or position.
        let mut h_cf = vec![0_i32; n_cf];
        self.stream.memcpy_dtoh(&self.cf_flip_per_sample, &mut h_cf)
            .map_err(|e| MLError::ModelError(format!("dtoh cf_flip_per_sample: {e}")))?;
        let mut h_live = vec![0_i32; n_main];
        self.stream.memcpy_dtoh(&self.slot_live_per_sample, &mut h_live)
            .map_err(|e| MLError::ModelError(format!("dtoh slot_live_per_sample: {e}")))?;
        let cf_sum: u64 = h_cf.iter().map(|&v| if v != 0 { 1_u64 } else { 0 }).sum();
        let n_touched: u64 = h_live.iter().map(|&v| if v != 0 { 1_u64 } else { 0 }).sum();
        let cf_rate = if n_touched > 0 { cf_sum as f32 / n_touched as f32 } else { 0.0 };

        // ── Trail firing rate (peek — do NOT reset Task 0.5 buffers) ────
        let mut h_trail = vec![0_i32; n_main];
        let mut h_traded = vec![0_i32; n_main];
        self.stream.memcpy_dtoh(&self.trail_triggered_per_sample, &mut h_trail)
            .map_err(|e| MLError::ModelError(format!("dtoh trail_triggered_per_sample: {e}")))?;
        self.stream.memcpy_dtoh(&self.traded_per_sample, &mut h_traded)
            .map_err(|e| MLError::ModelError(format!("dtoh traded_per_sample: {e}")))?;
        let (trail_fire_sum, traded_sum) = h_trail.iter().zip(h_traded.iter()).fold(
            (0_u64, 0_u64),
            |(t_acc, tr_acc), (&t, &tr)| {
                let tr_is = if tr != 0 { 1 } else { 0 };
                let fire_is = if t != 0 && tr != 0 { 1 } else { 0 };
                (t_acc + fire_is, tr_acc + tr_is)
            },
        );
        let trail_r = if traded_sum > 0 {
            trail_fire_sum as f32 / traded_sum as f32
        } else {
            0.0
        };

        // ── Additive ratios: Σ|term| / Σ|total| ─────────────────────────
        // Task 2.4: `loss_aversion_per_sample` dtoh removed — R6 clamp moved to
        // c51_loss_kernel.cu::block_bellman_project_f.
        let mut h_micro = vec![0.0_f32; n_main];
        let mut h_total = vec![0.0_f32; n_main];
        self.stream.memcpy_dtoh(&self.micro_reward_per_sample, &mut h_micro)
            .map_err(|e| MLError::ModelError(format!("dtoh micro_reward_per_sample: {e}")))?;
        self.stream.memcpy_dtoh(&self.total_reward_per_sample, &mut h_total)
            .map_err(|e| MLError::ModelError(format!("dtoh total_reward_per_sample: {e}")))?;

        let mut sum_micro = 0.0_f64;
        let mut sum_total = 0.0_f64;
        for i in 0..n_main {
            sum_micro += (h_micro[i].abs()) as f64;
            sum_total += (h_total[i].abs()) as f64;
        }
        let micro_r = if sum_total > 1e-12 {
            (sum_micro / sum_total) as f32
        } else {
            0.0
        };

        // ── Reset owned buffers ONLY (leave Task 0.5 trail buffers alone) ─
        // Task 2.4: `loss_aversion_per_sample` memset removed with the buffer.
        self.stream.memset_zeros(&mut self.cf_flip_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset cf_flip_per_sample: {e}")))?;
        self.stream.memset_zeros(&mut self.micro_reward_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset micro_reward_per_sample: {e}")))?;
        self.stream.memset_zeros(&mut self.total_reward_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset total_reward_per_sample: {e}")))?;
        self.stream.memset_zeros(&mut self.slot_live_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset slot_live_per_sample: {e}")))?;

        // PopArt slot is populated by the caller (training_loop) from the
        // host-side FusedTrainingCtx — it owns `prev_popart_var` + the live
        // popart_var readback. Return 0.0 here; caller overrides.
        Ok([0.0, cf_rate, trail_r, micro_r])
    }

    /// SP18 v2 Phase 0 Task 0.1 (2026-05-08) — D-leg per-action reward
    /// decomposition diagnostic launcher.
    ///
    /// Launches `reward_decomp_diag` (4 blocks × 256 threads, one block
    /// per direction-axis bin) on this collector's stream. Reads
    /// `reward_components_per_sample [n_samples × 6]` + `actions_out
    /// [n_samples]` written by the last `collect_experiences_gpu`
    /// epoch and bins per-direction (DIR_HOLD / DIR_LONG / DIR_SHORT /
    /// DIR_FLAT). Writes a 20-float diagnostic block (4 bins × 5 stats
    /// per bin) into the host-supplied mapped-pinned `out_dev_ptr`.
    ///
    /// Output layout (row-major, 4×5):
    ///
    ///     row 0 = Hold,   row 1 = Long,   row 2 = Short,   row 3 = Flat
    ///     col 0 = mean(r_micro)            (component idx 3)
    ///     col 1 = mean(r_opp_cost)         (component idx 4)
    ///     col 2 = mean(r_popart)           (component idx 0)
    ///     col 3 = mean(|reward|)           (sum of |components|)
    ///     col 4 = fire_rate                (frac bars with non-zero comp)
    ///
    /// **Launch ordering** (CRITICAL): MUST run BEFORE
    /// `launch_reward_component_ema_inplace` because the latter
    /// `memset_zeros` the source `reward_components_per_sample` after
    /// consuming it. Per `pearl_canary_input_freshness_launch_order`.
    ///
    /// Pure observability — does NOT modify any production-path buffer.
    /// `n_samples` should be `alloc_episodes * alloc_timesteps` (base
    /// batch, not CF-doubled). Pass `out_dev_ptr=0` to noop the launch
    /// (e.g. before the trainer wires the diagnostic buffer).
    ///
    /// Plan: docs/superpowers/plans/2026-05-08-sp18-reward-shape-hold-attractor.md
    /// § Phase 0 Task 0.1.
    pub fn launch_sp18_reward_decomp_diag(
        &mut self,
        n_samples: usize,
        b1: usize,
        b2: usize,
        b3: usize,
        out_dev_ptr: u64,
    ) -> Result<(), crate::MLError> {
        if n_samples == 0 || out_dev_ptr == 0 {
            return Ok(());
        }
        let n_i32     = n_samples as i32;
        let b1_i32    = b1 as i32;
        let b2_i32    = b2 as i32;
        let b3_i32    = b3 as i32;
        let rc_ptr    = self.reward_components_per_sample.raw_ptr();
        let act_ptr   = self.actions_out.raw_ptr();
        // Block tree-reduce: 4 blocks × 256 threads. Dynamic shmem =
        // 5 × BLOCK_SIZE × sizeof(f32) for the 5 reduction tiles
        // (micro / opp_cost / popart / abs_sum / count). Matches the
        // SP18_DECOMP_BLOCK_SIZE=256 kernel-side constant.
        const BLOCK_DIM: u32 = 256;
        const NUM_BINS:  u32 = 4;
        let shmem_bytes: u32 = 5 * BLOCK_DIM * std::mem::size_of::<f32>() as u32;
        unsafe {
            self.stream.launch_builder(&self.sp18_reward_decomp_diag_kernel)
                .arg(&rc_ptr)
                .arg(&act_ptr)
                .arg(&n_i32)
                .arg(&b1_i32)
                .arg(&b2_i32)
                .arg(&b3_i32)
                .arg(&out_dev_ptr)
                .launch(LaunchConfig {
                    grid_dim:  (NUM_BINS, 1, 1),
                    block_dim: (BLOCK_DIM, 1, 1),
                    shared_mem_bytes: shmem_bytes,
                })
                .map_err(|e| crate::MLError::ModelError(format!(
                    "sp18 reward_decomp_diag launch: {e}"
                )))?;
        }
        Ok(())
    }

    /// C.2 Plan 3 Task 1 (spec §4.C.2); SP4 Layer A Task A13.5 retrofit
    /// (2026-05-01); GPU-Pearls refactor (2026-05-01): GPU-driven
    /// reward-component step-observation producer + GPU Pearls A+D update.
    ///
    /// Launches `reward_component_ema` (single-block, 6 threads) on this
    /// collector's stream. Reads `reward_components_per_sample [n_samples * 6]`
    /// written by the last `collect_experiences_gpu` epoch, reduces mean
    /// |r_c| per component, writes step observations to
    /// `producer_step_scratch_buf[63..69)` (mapped-pinned scratch owned
    /// by the trainer; threaded via `set_reward_component_pearls_buffers`).
    /// Then launches the GPU `apply_pearls_ad_kernel` (single launch,
    /// n_slots=6) on the same stream which applies Pearls A+D to
    /// ISV[REWARD_POPART_EMA_INDEX..+6) using `wiener_state_buf` —
    /// stream-ordered, no host sync, graph-capture-compatible.
    ///
    /// α is derived adaptively from per-slot Pearls A+D Wiener state — see
    /// `sp4_wiener_ema::pearls_ad_update` (test-only reference).
    ///
    /// After the Pearls A+D update completes, resets
    /// `reward_components_per_sample` to zeros via memset (same as other
    /// per-sample buffers).
    ///
    /// `n_samples` should be `alloc_episodes * alloc_timesteps` (the full
    /// per-epoch batch, not the CF-doubled buffer).
    pub fn launch_reward_component_ema_inplace(
        &mut self,
        n_samples: usize,
    ) -> Result<(), crate::MLError> {
        use crate::cuda_pipeline::gpu_dqn_trainer::{
            REWARD_COMPONENT_COUNT, REWARD_POPART_EMA_INDEX, SP4_PRODUCER_COUNT,
            SCRATCH_SP11_REWARD_COMPONENT_VAR_BASE,
        };
        use crate::cuda_pipeline::sp4_wiener_ema::launch_apply_pearls;
        use crate::cuda_pipeline::sp5_isv_slots::SP5_SLOT_BASE;
        use crate::cuda_pipeline::sp11_isv_slots::REWARD_COMPONENT_VAR_EMA_BASE;

        if n_samples == 0 || self.isv_signals_dev_ptr == 0 {
            return Ok(());
        }

        // SP4 Task A13.5: scratch slot 63 — first of REWARD_COMPONENT_COUNT
        // contiguous reward-component slots. Wiener offsets:
        // (63+c)*3 for c in 0..REWARD_COMPONENT_COUNT.
        const SCRATCH_FIRST: usize = 63;
        // Layout invariant: bumping REWARD_COMPONENT_COUNT requires the
        // kernel block_dim and the reward_components_per_sample row stride
        // to migrate together (see kernel comment in
        // `reward_component_ema_kernel.cu`).
        debug_assert_eq!(REWARD_COMPONENT_COUNT, 6,
            "reward-component layout invariant — kernel block_dim and \
             reward_components_per_sample stride must update together");

        // The trainer's Pearls A+D buffers MUST be wired before the first
        // launch. `FusedTrainingCtx::wire_reward_component_pearls_buffers`
        // is the canonical wire site (called from training_loop.rs's
        // `init_gpu_experience_collector`).
        debug_assert!(
            self.reward_component_pearls_scratch_dev_ptr != 0
                && self.reward_component_pearls_wiener_dev_ptr != 0
                && self.reward_component_pearls_isv_dev_ptr != 0
                && !self.reward_component_pearls_scratch_host_ptr.is_null()
                && !self.reward_component_pearls_wiener_host_ptr.is_null()
                && !self.reward_component_pearls_isv_pinned_ptr.is_null(),
            "launch_reward_component_ema_inplace: Pearls A+D buffers not wired \
             (call FusedTrainingCtx::wire_reward_component_pearls_buffers)"
        );

        let n_i32 = n_samples as i32;
        let scratch_first_i32 = SCRATCH_FIRST as i32;
        // SP11 B1b smoke-recovery: kernel also writes 5 variance outputs
        // for c=1..5 to scratch[SCRATCH_SP11_REWARD_COMPONENT_VAR_BASE..+5).
        // Component 0 (popart) variance is sourced from the separate
        // `popart_component_ema_kernel` (B1b fix-up rationale).
        let var_scratch_first_i32 = SCRATCH_SP11_REWARD_COMPONENT_VAR_BASE as i32;
        let rc_ptr = self.reward_components_per_sample.raw_ptr();
        let scratch_dev = self.reward_component_pearls_scratch_dev_ptr;
        unsafe {
            self.stream.launch_builder(&self.reward_component_ema_kernel)
                .arg(&rc_ptr)
                .arg(&n_i32)
                .arg(&scratch_dev)
                .arg(&scratch_first_i32)
                .arg(&var_scratch_first_i32)
                .launch(LaunchConfig {
                    grid_dim:  (1, 1, 1),
                    block_dim: (REWARD_COMPONENT_COUNT as u32, 1, 1),
                    shared_mem_bytes: 0,
                })
                .map_err(|e| crate::MLError::ModelError(format!("reward_component_ema: {e}")))?;
        }

        // GPU Pearls A+D: scratch [63..69) → ISV [REWARD_POPART_EMA_INDEX..+6),
        // single launch n_slots=6. Retrofit wiener convention: offset =
        // scratch_idx*3 → [189..207). Reward components 2/5 (trail/bonus)
        // are always 0.0 placeholders → applicator's `step_obs == 0` skip
        // preserves the Wiener sentinel. Same logic for cold-start (before
        // first `collect_experiences_gpu` populates the per-sample buffer).
        unsafe {
            launch_apply_pearls(
                &self.stream,
                &self.apply_pearls_ad_kernel,
                scratch_dev,
                SCRATCH_FIRST as i32,
                self.reward_component_pearls_isv_dev_ptr,
                REWARD_POPART_EMA_INDEX as i32,
                self.reward_component_pearls_wiener_dev_ptr,
                (SCRATCH_FIRST * 3) as i32,
                REWARD_COMPONENT_COUNT as i32,
                crate::cuda_pipeline::sp4_wiener_ema::ALPHA_META,
            )?;
        }

        // SP11 B1b smoke-recovery: chained Pearls A+D for the 5 non-popart
        // variance EMAs. Source is scratch[SCRATCH_SP11_REWARD_COMPONENT_VAR_BASE..+5);
        // destination is ISV[REWARD_COMPONENT_VAR_EMA_BASE+1..+6) =
        // ISV[362..367). Wiener offset uses the SP5 base (SP4_PRODUCER_COUNT*3
        // + (isv_idx − SP5_SLOT_BASE) * 3) — matching the convention in
        // `launch_sp11_popart_component_ema` etc., not the SP4 retrofit
        // convention used for the magnitudes above (which use scratch_idx*3
        // offsets because they mapped to slot 63 in the SP4 retrofit block).
        let base_wiener_offset = SP4_PRODUCER_COUNT as i32 * 3;
        let var_isv_base_i32 = (REWARD_COMPONENT_VAR_EMA_BASE + 1) as i32;
        let var_wiener_off   = base_wiener_offset
            + (var_isv_base_i32 - SP5_SLOT_BASE as i32) * 3;
        unsafe {
            launch_apply_pearls(
                &self.stream,
                &self.apply_pearls_ad_kernel,
                scratch_dev,
                var_scratch_first_i32,
                self.reward_component_pearls_isv_dev_ptr,
                var_isv_base_i32,
                self.reward_component_pearls_wiener_dev_ptr,
                var_wiener_off,
                5,
                crate::cuda_pipeline::sp4_wiener_ema::ALPHA_META,
            )?;
        }

        self.stream.memset_zeros(&mut self.reward_components_per_sample)
            .map_err(|e| crate::MLError::ModelError(format!("memset reward_components_per_sample: {e}")))?;
        Ok(())
    }

    /// B.2 Plan 3 Task 3 (spec §4.B.2): GPU-driven Flat→Positioned transition rate EMA.
    ///
    /// Launches `trade_attempt_rate_ema_update` (single-block, 1 thread) on this
    /// collector's stream. Reads `flat_to_pos_per_sample [n_samples]` written by
    /// the last `collect_experiences_gpu` epoch, reduces the sum on-GPU, and
    /// applies an adaptive-rate EMA into ISV[TRADE_ATTEMPT_RATE_EMA_INDEX].
    /// After the kernel fires, resets `flat_to_pos_per_sample` to zeros.
    ///
    /// `isv_dev_ptr` must be non-zero (the trainer's ISV device pointer).
    /// `n_samples` should be `alloc_episodes * alloc_timesteps`. `alpha_base`
    /// ∈ (0, 1) — the same 0.05 convention as `reward_component_ema`.
    pub fn launch_trade_attempt_rate_ema_inplace(
        &mut self,
        n_samples: usize,
        alpha_base: f32,
    ) -> Result<(), crate::MLError> {
        use crate::cuda_pipeline::gpu_dqn_trainer::{
            TRADE_ATTEMPT_RATE_EMA_INDEX, SHARPE_EMA_INDEX,
        };
        if n_samples == 0 || self.isv_signals_dev_ptr == 0 {
            return Ok(());
        }
        let n_i32 = n_samples as i32;
        let attempt_slot = TRADE_ATTEMPT_RATE_EMA_INDEX as i32;
        let sharpe_slot = SHARPE_EMA_INDEX as i32;
        let flag_ptr = self.flat_to_pos_per_sample.raw_ptr();
        let isv_ptr = self.isv_signals_dev_ptr;
        unsafe {
            self.stream.launch_builder(&self.trade_attempt_rate_ema_kernel)
                .arg(&flag_ptr)
                .arg(&n_i32)
                .arg(&isv_ptr)
                .arg(&attempt_slot)
                .arg(&sharpe_slot)
                .arg(&alpha_base)
                .launch(LaunchConfig { grid_dim: (1, 1, 1), block_dim: (1, 1, 1), shared_mem_bytes: 0 })
                .map_err(|e| crate::MLError::ModelError(format!("trade_attempt_rate_ema_update: {e}")))?;
        }
        self.stream.memset_zeros(&mut self.flat_to_pos_per_sample)
            .map_err(|e| crate::MLError::ModelError(format!("memset flat_to_pos_per_sample: {e}")))?;
        Ok(())
    }

    /// B.4 Plan 3 Task 4 (spec §4.B.4): GPU-driven readiness EMA + derived plan threshold.
    ///
    /// Launches `plan_threshold_update` (single-block, 1 thread) on this
    /// collector's stream. Reads `readiness_per_sample [n_samples]` written by
    /// the last `collect_experiences_gpu` epoch, reduces the mean on-GPU, and
    /// applies an adaptive-rate EMA into ISV[READINESS_EMA_INDEX], then
    /// derives `threshold = max(0.1, 0.5 × ema)` and writes it into
    /// ISV[PLAN_THRESHOLD_INDEX]. The kernel resets `readiness_per_sample` to
    /// zeros after the EMA update so the next epoch starts clean.
    ///
    /// `isv_dev_ptr` must be non-zero (the trainer's ISV device pointer).
    /// `n_samples` should be `alloc_episodes * alloc_timesteps`. `alpha_base`
    /// ∈ (0, 1) — the same 0.05 convention as `reward_component_ema` and
    /// `trade_attempt_rate_ema`.
    pub fn launch_plan_threshold_update_inplace(
        &mut self,
        n_samples: usize,
        alpha_base: f32,
    ) -> Result<(), crate::MLError> {
        use crate::cuda_pipeline::gpu_dqn_trainer::{
            READINESS_EMA_INDEX, PLAN_THRESHOLD_INDEX, SHARPE_EMA_INDEX,
        };
        if n_samples == 0 || self.isv_signals_dev_ptr == 0 {
            return Ok(());
        }
        let n_i32 = n_samples as i32;
        let readiness_slot = READINESS_EMA_INDEX as i32;
        let thr_slot       = PLAN_THRESHOLD_INDEX as i32;
        let sharpe_slot    = SHARPE_EMA_INDEX as i32;
        let buf_ptr        = self.readiness_per_sample.raw_ptr();
        let isv_ptr        = self.isv_signals_dev_ptr;
        unsafe {
            self.stream.launch_builder(&self.plan_threshold_update_kernel)
                .arg(&buf_ptr)
                .arg(&n_i32)
                .arg(&isv_ptr)
                .arg(&readiness_slot)
                .arg(&thr_slot)
                .arg(&sharpe_slot)
                .arg(&alpha_base)
                .launch(LaunchConfig { grid_dim: (1, 1, 1), block_dim: (1, 1, 1), shared_mem_bytes: 0 })
                .map_err(|e| crate::MLError::ModelError(format!("plan_threshold_update: {e}")))?;
        }
        self.stream.memset_zeros(&mut self.readiness_per_sample)
            .map_err(|e| crate::MLError::ModelError(format!("memset readiness_per_sample: {e}")))?;
        Ok(())
    }

    /// C.3 Plan 3 Task 7 (spec §4.C.3): GPU-driven train-vs-val state-distribution
    /// KL EMA + Flat-trap escape amplifier.
    ///
    /// Launches `state_kl_moment_match` (single-block, one thread per monitored
    /// dim) on this collector's stream. Reads `n_train` rows from the
    /// experience-collector's `states_out` buffer (post-collect_experiences_gpu)
    /// and `n_val` rows from `val_states_dev_ptr` (the validation evaluator's
    /// `chunked_states_buf` populated during the most recent val pass), computes
    /// per-OFI-dim moment-match KL summed across the OFI block, and EMAs
    /// the total into ISV[STATE_KL_TRAIN_VAL_EMA_INDEX=78]. Derives the amp
    /// `1 + clamp(ratio − 1, 0, 1) ∈ [1, 2]` and EMA-tracks it into
    /// ISV[STATE_KL_AMPLIFICATION_INDEX=79].
    ///
    /// `monitored_dims_begin / monitored_dims_end` should bracket the OFI
    /// block in the assembled state vector (`SL_OFI_START` …
    /// `SL_OFI_START + SL_OFI_DIM`); OFI is the most distribution-stable
    /// signal across folds and yields the cleanest KL.
    ///
    /// Argument convention matches `launch_plan_threshold_update_inplace`:
    /// no-op when ISV pointer is null or sample counts are zero. `alpha_base`
    /// follows the 0.05 convention shared with the other Plan 3 EMAs.
    pub fn launch_state_kl_inplace(
        &mut self,
        val_states_dev_ptr: u64,
        n_train: usize,
        n_val: usize,
        state_dim_padded: usize,
        monitored_dims_begin: usize,
        monitored_dims_end: usize,
        alpha_base: f32,
    ) -> Result<(), crate::MLError> {
        use crate::cuda_pipeline::gpu_dqn_trainer::{
            STATE_KL_TRAIN_VAL_EMA_INDEX, STATE_KL_AMPLIFICATION_INDEX, SHARPE_EMA_INDEX,
        };
        if self.isv_signals_dev_ptr == 0
            || val_states_dev_ptr == 0
            || n_train == 0
            || n_val == 0
            || monitored_dims_end <= monitored_dims_begin
        {
            return Ok(());
        }
        let d_monitored = (monitored_dims_end - monitored_dims_begin) as u32;
        let smem_bytes  = d_monitored * std::mem::size_of::<f32>() as u32;
        let n_t_i32     = n_train as i32;
        let n_v_i32     = n_val as i32;
        let d_total_i32 = state_dim_padded as i32;
        let mb_i32      = monitored_dims_begin as i32;
        let me_i32      = monitored_dims_end as i32;
        let kl_idx_i32  = STATE_KL_TRAIN_VAL_EMA_INDEX as i32;
        let amp_idx_i32 = STATE_KL_AMPLIFICATION_INDEX as i32;
        let sharpe_i32  = SHARPE_EMA_INDEX as i32;
        let train_ptr   = self.states_out.raw_ptr();
        let val_ptr     = val_states_dev_ptr;
        let isv_ptr     = self.isv_signals_dev_ptr;
        unsafe {
            self.stream.launch_builder(&self.state_kl_kernel)
                .arg(&train_ptr)
                .arg(&val_ptr)
                .arg(&n_t_i32)
                .arg(&n_v_i32)
                .arg(&d_total_i32)
                .arg(&mb_i32)
                .arg(&me_i32)
                .arg(&isv_ptr)
                .arg(&kl_idx_i32)
                .arg(&amp_idx_i32)
                .arg(&sharpe_i32)
                .arg(&alpha_base)
                .launch(LaunchConfig {
                    grid_dim:        (1, 1, 1),
                    block_dim:       (d_monitored.max(1), 1, 1),
                    shared_mem_bytes: smem_bytes.max(4),
                })
                .map_err(|e| crate::MLError::ModelError(format!("state_kl_moment_match: {e}")))?;
        }
        Ok(())
    }

    /// B.3 Plan 3 Task 8 (spec §4.B.3): GPU-driven seed-phase progress
    /// tracker. Increments ISV[SEED_STEPS_DONE_INDEX] by `n_samples` and EMAs
    /// the derived `max(0, 1 - DONE/TARGET)` into ISV[SEED_FRAC_EMA_INDEX].
    ///
    /// Single-block single-thread cold-path. Launched alongside the other
    /// per-epoch ISV producers (`launch_reward_component_ema_inplace`,
    /// `launch_trade_attempt_rate_ema_inplace`, …).
    pub fn launch_seed_step_counter_update_inplace(
        &mut self,
        n_samples: usize,
        alpha_base: f32,
    ) -> Result<(), crate::MLError> {
        use crate::cuda_pipeline::gpu_dqn_trainer::{
            SEED_STEPS_DONE_INDEX, SEED_STEPS_TARGET_INDEX,
            SEED_FRAC_EMA_INDEX, SHARPE_EMA_INDEX,
        };
        if self.isv_signals_dev_ptr == 0 {
            return Ok(());
        }
        let done_idx   = SEED_STEPS_DONE_INDEX as i32;
        let target_idx = SEED_STEPS_TARGET_INDEX as i32;
        let frac_idx   = SEED_FRAC_EMA_INDEX as i32;
        let sharpe_idx = SHARPE_EMA_INDEX as i32;
        let n_step_i32 = n_samples as i32;
        let isv_ptr    = self.isv_signals_dev_ptr;
        unsafe {
            self.stream.launch_builder(&self.seed_step_counter_kernel)
                .arg(&isv_ptr)
                .arg(&done_idx)
                .arg(&target_idx)
                .arg(&frac_idx)
                .arg(&sharpe_idx)
                .arg(&alpha_base)
                .arg(&n_step_i32)
                .launch(LaunchConfig { grid_dim: (1, 1, 1), block_dim: (1, 1, 1), shared_mem_bytes: 0 })
                .map_err(|e| crate::MLError::ModelError(format!("seed_step_counter_update: {e}")))?;
        }
        Ok(())
    }

    /// C.5 Plan 3 Task 9 (spec §4.C.5): GPU-driven CQL α ramp coupled to
    /// ISV[SEED_FRAC_EMA]. EMAs ISV[CQL_ALPHA_INDEX=48] toward
    /// `cql_alpha_final × max(0, 1 - seed_frac)`.
    ///
    /// Single-block single-thread cold-path. Launched alongside
    /// `launch_seed_step_counter_update_inplace` after every
    /// `collect_experiences_gpu`.
    pub fn launch_cql_alpha_seed_update_inplace(
        &mut self,
        cql_alpha_final: f32,
        alpha_base: f32,
    ) -> Result<(), crate::MLError> {
        use crate::cuda_pipeline::gpu_dqn_trainer::{
            CQL_ALPHA_INDEX, SEED_FRAC_EMA_INDEX, SHARPE_EMA_INDEX,
        };
        if self.isv_signals_dev_ptr == 0 {
            return Ok(());
        }
        let cql_idx    = CQL_ALPHA_INDEX as i32;
        let frac_idx   = SEED_FRAC_EMA_INDEX as i32;
        let sharpe_idx = SHARPE_EMA_INDEX as i32;
        let isv_ptr    = self.isv_signals_dev_ptr;
        unsafe {
            self.stream.launch_builder(&self.cql_alpha_seed_update_kernel)
                .arg(&isv_ptr)
                .arg(&cql_idx)
                .arg(&frac_idx)
                .arg(&sharpe_idx)
                .arg(&alpha_base)
                .arg(&cql_alpha_final)
                .launch(LaunchConfig { grid_dim: (1, 1, 1), block_dim: (1, 1, 1), shared_mem_bytes: 0 })
                .map_err(|e| crate::MLError::ModelError(format!("cql_alpha_seed_update: {e}")))?;
        }
        Ok(())
    }

    /// E.5 Plan 4 Task 5 Mode A: attention-focus interpretability ISV producer.
    ///
    /// Launches `attention_focus_ema_update` (single-block, single-thread cold
    /// path) on this collector's stream. Takes 3 host scalars by value:
    ///   * `vsn_mag`   — magnitude-branch VSN-weight mean abs
    ///                   (from `FusedTrainingCtx::per_branch_vsn_mean()[1]`).
    ///   * `vsn_dir`   — direction-branch equivalent (`[0]`).
    ///   * `mamba2_retention` — per-batch mean |mamba2_h_enriched| (from
    ///                   `FusedTrainingCtx::mamba2_retention_mean(batch)`).
    ///
    /// Adaptive α matches the Plan 3 producer convention (α_base × (1 +
    /// 0.5 × |clamp(sharpe, −2, 2)|)). Diagnostic only — no consumer reads
    /// these slots in Mode A. No-op when the ISV pointer is null.
    pub fn launch_attention_focus_ema_inplace(
        &mut self,
        params_buf_dev_ptr: u64,
        mag_vsn_start_idx: usize,
        mag_vsn_len:       usize,
        dir_vsn_start_idx: usize,
        dir_vsn_len:       usize,
        mamba2_h_enriched_dev_ptr: u64,
        mamba2_n_elements: usize,
        alpha_base: f32,
    ) -> Result<(), crate::MLError> {
        use crate::cuda_pipeline::gpu_dqn_trainer::{
            VSN_MAG_EMA_INDEX, VSN_DIR_EMA_INDEX,
            MAMBA2_RETENTION_EMA_INDEX, SHARPE_EMA_INDEX,
        };
        if self.isv_signals_dev_ptr == 0 || params_buf_dev_ptr == 0 {
            return Ok(());
        }
        let mag_idx    = VSN_MAG_EMA_INDEX as i32;
        let dir_idx    = VSN_DIR_EMA_INDEX as i32;
        let ret_idx    = MAMBA2_RETENTION_EMA_INDEX as i32;
        let sharpe_idx = SHARPE_EMA_INDEX as i32;
        let isv_ptr    = self.isv_signals_dev_ptr;
        let mag_start  = mag_vsn_start_idx as i32;
        let mag_len    = mag_vsn_len as i32;
        let dir_start  = dir_vsn_start_idx as i32;
        let dir_len    = dir_vsn_len as i32;
        let mamba2_n   = mamba2_n_elements as i32;
        let block_dim  = 256u32;
        let smem_bytes = block_dim * 4;  // f32 per thread
        unsafe {
            self.stream.launch_builder(&self.attention_focus_ema_kernel)
                .arg(&params_buf_dev_ptr)
                .arg(&mag_start).arg(&mag_len)
                .arg(&dir_start).arg(&dir_len)
                .arg(&mamba2_h_enriched_dev_ptr).arg(&mamba2_n)
                .arg(&isv_ptr)
                .arg(&mag_idx).arg(&dir_idx).arg(&ret_idx).arg(&sharpe_idx)
                .arg(&alpha_base)
                .launch(LaunchConfig {
                    grid_dim: (3, 1, 1),       // 3 blocks: mag, dir, mamba2
                    block_dim: (block_dim, 1, 1),
                    shared_mem_bytes: smem_bytes,
                })
                .map_err(|e| crate::MLError::ModelError(format!("attention_focus_ema_update: {e}")))?;
        }
        Ok(())
    }

    /// Plan 4 follow-up: GPU-driven target-drift EMA. Replaces the legacy
    /// CPU-DtoH `per_branch_target_drift()` per
    /// `pearl_cold_path_no_exception_to_gpu_drives.md`. 2-block kernel:
    /// block 0 reduces RMS(target − online) for the magnitude branch slice,
    /// block 1 reduces the direction branch slice. Each block EMA-updates
    /// its ISV slot via pinned device-mapped writes (no DtoH).
    pub fn launch_target_drift_ema_inplace(
        &mut self,
        params_buf_dev_ptr: u64,
        target_params_buf_dev_ptr: u64,
        mag_branch_start_idx: usize,
        mag_branch_len:       usize,
        dir_branch_start_idx: usize,
        dir_branch_len:       usize,
        alpha_base: f32,
    ) -> Result<(), crate::MLError> {
        use crate::cuda_pipeline::gpu_dqn_trainer::{
            TARGET_DRIFT_MAG_EMA_INDEX, TARGET_DRIFT_DIR_EMA_INDEX, SHARPE_EMA_INDEX,
        };
        if self.isv_signals_dev_ptr == 0
            || params_buf_dev_ptr == 0
            || target_params_buf_dev_ptr == 0
        {
            return Ok(());
        }
        let mag_idx    = TARGET_DRIFT_MAG_EMA_INDEX as i32;
        let dir_idx    = TARGET_DRIFT_DIR_EMA_INDEX as i32;
        let sharpe_idx = SHARPE_EMA_INDEX as i32;
        let isv_ptr    = self.isv_signals_dev_ptr;
        let mag_start  = mag_branch_start_idx as i32;
        let mag_len    = mag_branch_len as i32;
        let dir_start  = dir_branch_start_idx as i32;
        let dir_len    = dir_branch_len as i32;
        let block_dim  = 256u32;
        let smem_bytes = block_dim * 4;
        unsafe {
            self.stream.launch_builder(&self.target_drift_kernel)
                .arg(&params_buf_dev_ptr)
                .arg(&target_params_buf_dev_ptr)
                .arg(&mag_start).arg(&mag_len)
                .arg(&dir_start).arg(&dir_len)
                .arg(&isv_ptr)
                .arg(&mag_idx).arg(&dir_idx).arg(&sharpe_idx)
                .arg(&alpha_base)
                .launch(LaunchConfig {
                    grid_dim: (2, 1, 1),       // 2 blocks: mag, dir
                    block_dim: (block_dim, 1, 1),
                    shared_mem_bytes: smem_bytes,
                })
                .map_err(|e| crate::MLError::ModelError(format!("target_drift_ema_update: {e}")))?;
        }
        Ok(())
    }

    /// SP18 v2 Phase 4 (2026-05-08): compute `Q_target(s', argmax_a Q_online(s', a))`
    /// per-sample using DDQN per-branch argmax. Uses target-net params to
    /// evaluate the action selected by the online net on `next_states`.
    ///
    /// Per spec design decisions (B-DD2 + B-DD3 + B-DD8.a):
    ///   - **B-DD2**: DDQN argmax — argmax_a from ONLINE network, max-Q from
    ///     TARGET network at that argmax. Reduces overestimation bias of
    ///     vanilla DQN's `max_a Q_target(s', a)` form.
    ///   - **B-DD3**: per-branch DDQN — 4 separate argmaxes for
    ///     dir/mag/ord/urg, mirroring SP17 dueling-head per-branch separation.
    ///   - **B-DD8.a (scalar)**: returns scalar Q-bootstrap per sample, NOT
    ///     per-atom distributional. The dir-branch's
    ///     `Q_target[s', argmax_a Q_online_dir(s', a)]` is the load-bearing
    ///     contribution; mag/ord/urg branches contribute via reward-shape
    ///     and PER priority, NOT via TD(λ) bootstrap directly. This avoids
    ///     a `td_lambda_kernel` signature change in Phase 5 (per-atom would
    ///     require new arg).
    ///
    /// Returns `CudaSlice<f32>` of length `B` — one scalar Q-bootstrap value
    /// per sample. Phase 5 (`q_next` replacement at line ~4252) consumes the
    /// output via `td_lambda_kernel`'s `q_next` argument; Phase 4 ships this
    /// method as **DEAD CODE** (no production caller) covered only by GPU
    /// oracle tests T8 + T11. NO L40S DISPATCH between Phase 4 close and
    /// Phase 5 (atomic refactor rule per `feedback_no_partial_refactor`).
    ///
    /// # Arguments
    /// * `next_states` - GPU buffer `[B, STATE_DIM]` f32 (post-VSN gating
    ///   contract; built by `build_next_states_f32` post-Task 4.1 hoist).
    /// * `target_params_ptr` - device pointer to the trainer's
    ///   `target_params_buf` (Polyak EMA-tracked target network parameters).
    ///   See `GpuDqnTrainer::target_params_buf_device_ptr()`.
    /// * `online_params_ptr` - device pointer to the trainer's `params_buf`
    ///   (online network master parameters; same buffer the collector reads
    ///   for action selection via `trainer_params_ptr`).
    /// * `batch_size` - `B`, total experience count
    ///   (`n_episodes * timesteps * 2` including counterfactual).
    ///
    /// # Implementation status (Phase 4 sub-tasks)
    /// - **Task 4.1** done — `next_states` build-site relocated before TD(λ)
    ///   launch (caller-side, see comment at line ~4243).
    /// - **Task 4.2** done — this skeleton + dispatch contract (current state).
    /// - **Task 4.3** outstanding — online-net forward on `next_states` for
    ///   DDQN per-branch argmax. Mirror of `submit_forward_ops_ddqn`'s online
    ///   VSN+BN+trunk+branch-head chain via `cublas_forward.forward_online_raw`,
    ///   output: per-branch argmax actions `[B, 4]` (i32).
    /// - **Task 4.4** outstanding — target-net forward on `next_states` via
    ///   `cublas_forward.forward_target_raw`, then `compute_expected_q`
    ///   with target's distributional outputs to produce per-action
    ///   `E[Q]`; gather per-branch argmax and SUM across 4 branches into
    ///   `[B]` scalar (or per B-DD8.a use dir-branch only — final form
    ///   resolved at Task 4.4 close).
    /// - **Task 4.5** outstanding — replace this dead-method early-return
    ///   with full implementation; `cargo check --workspace` clean; GPU
    ///   oracle tests T8 + T11 pass.
    ///
    /// # Why this returns an error in Phase 4 instead of being absent
    ///
    /// Phase 4 lands the method as **dead code** — no production caller
    /// exists yet (the caller is Phase 5 Task 5.1, which replaces the
    /// self-bootstrap clone at line ~4313). Returning a hard error from
    /// the body satisfies both:
    ///
    ///   1. **`feedback_no_stubs`** — no deferred-work macro, no
    ///      return-zero stub; the method explicitly errors with a
    ///      pointer to the plan-decomposed sub-tasks (4.3-4.4) that
    ///      replace this body atomically before any caller is wired.
    ///   2. **`feedback_no_partial_refactor`** — the atomic-refactor
    ///      rule binds Phase 4 → Phase 5: NO L40S DISPATCH between
    ///      Task 4.5 (replaces this body with the real DDQN bootstrap)
    ///      and Task 5.1 (wires the production caller at line ~4313).
    ///      Until Task 4.5 closes, any accidental call (e.g. introduced
    ///      by a mid-refactor mistake) hard-errors with a clear pointer
    ///      to the open sub-task — preventing silent self-bootstrap
    ///      regression.
    ///
    /// Phase 5.1 close-out grep audit asserts the error string is absent
    /// in this method body (replaced by the real implementation).
    pub fn compute_q_next_target_bootstrap(
        &mut self,
        next_states: &cudarc::driver::CudaSlice<f32>,
        target_params_ptr: u64,
        online_params_ptr: u64,
        batch_size: usize,
    ) -> Result<cudarc::driver::CudaSlice<f32>, MLError> {
        let _ = (next_states, target_params_ptr, online_params_ptr, batch_size);
        Err(MLError::ModelError(
            "SP18 v2 Phase 4: compute_q_next_target_bootstrap body is dead-code \
             pending Tasks 4.3-4.5 (online forward + target forward + DDQN gather). \
             Plan reference: docs/superpowers/plans/2026-05-08-sp18-reward-shape-hold-attractor.md \
             §'Phase 4'. Atomic-refactor rule: NO L40S DISPATCH between Phase 4 \
             close (Task 4.5) and Phase 5 (Task 5.1)."
                .to_string(),
        ))
    }

    /// Task 2.X prerequisite (policy-quality scoping): per-magnitude win rate
    /// + realized step-return variance, computed host-side from the per-sample
    /// buffers written by `experience_env_step`.
    ///
    /// Returns `([wr_q, wr_h, wr_f], [var_q, var_h, var_f])` for Quarter/Half/
    /// Full bins indexed by `actual_mag_core` (0/1/2).
    ///
    /// * `wr_x = Σ profitable_trades_x / Σ closing_trades_x` — conditional on
    ///   the sample being a closing event (`exiting_trade || reversing_trade`)
    ///   AND `step_ret_core > 0.0f`. Samples that did not close a trade this
    ///   step are excluded from both numerator and denominator. Bins with
    ///   zero closing events yield `0.0` (honest "no signal yet" — early
    ///   training epochs may have all-zero if no trades closed).
    /// * `var_x = Var[step_ret_core | action_mag == x]` — population variance
    ///   computed from `Σ(step_ret²) / n − (Σ step_ret / n)²`, clamped at 0
    ///   to defend against floating-point round-off driving a near-zero
    ///   result negative. Bins with `n ≤ 1` yield `0.0` (no variance
    ///   defined).
    ///
    /// Slots with `action_mag_per_sample == -1` (kernel wrote the
    /// sentinel before any early return) are excluded — they represent
    /// unreached (i,t) slots and do not correspond to real action samples.
    /// Semantic zero values (`action_mag == 0` on a reached slot where the
    /// model chose Quarter) are correctly included.
    ///
    /// Per the Task 0.5 / 0.8 pattern, the four owned buffers are reset at
    /// the end so the next epoch starts clean. `action_mag_per_sample`
    /// resets to 0 (= Quarter) rather than -1, but the kernel's early-return
    /// default-write path overwrites the slot BEFORE any reader runs, so
    /// there's no semantic corruption — next epoch's reducer will see
    /// either the -1 sentinel (unreached) or the real bin written after
    /// `unified_env_step_core`.
    pub fn per_magnitude_winrate_and_variance(
        &mut self,
    ) -> Result<([f32; 3], [f32; 3]), MLError> {
        let n = self.alloc_episodes * self.alloc_timesteps;
        let mut h_mag = vec![0_i32; n];
        let mut h_ret = vec![0.0_f32; n];
        let mut h_close = vec![0_i32; n];
        let mut h_profit = vec![0_i32; n];
        self.stream
            .memcpy_dtoh(&self.action_mag_per_sample, &mut h_mag)
            .map_err(|e| MLError::ModelError(format!("dtoh action_mag_per_sample: {e}")))?;
        self.stream
            .memcpy_dtoh(&self.step_ret_per_sample, &mut h_ret)
            .map_err(|e| MLError::ModelError(format!("dtoh step_ret_per_sample: {e}")))?;
        self.stream
            .memcpy_dtoh(&self.trade_close_per_sample, &mut h_close)
            .map_err(|e| MLError::ModelError(format!("dtoh trade_close_per_sample: {e}")))?;
        self.stream
            .memcpy_dtoh(&self.trade_profitable_per_sample, &mut h_profit)
            .map_err(|e| MLError::ModelError(format!("dtoh trade_profitable_per_sample: {e}")))?;

        let mut counts = [0_u64; 3];
        let mut close_counts = [0_u64; 3];
        let mut profit_counts = [0_u64; 3];
        let mut sum = [0.0_f64; 3];
        let mut sum_sq = [0.0_f64; 3];

        for i in 0..n {
            let m_raw = h_mag[i];
            if !(0..=2).contains(&m_raw) {
                continue; // -1 = slot unreached this step
            }
            let m = m_raw as usize;
            counts[m] += 1;
            let r = h_ret[i] as f64;
            sum[m] += r;
            sum_sq[m] += r * r;
            if h_close[i] != 0 {
                close_counts[m] += 1;
            }
            if h_profit[i] != 0 {
                profit_counts[m] += 1;
            }
        }

        let mut wr = [0.0_f32; 3];
        let mut var = [0.0_f32; 3];
        for m in 0..3 {
            wr[m] = if close_counts[m] > 0 {
                profit_counts[m] as f32 / close_counts[m] as f32
            } else {
                0.0
            };
            var[m] = if counts[m] > 1 {
                let n_m = counts[m] as f64;
                let mean = sum[m] / n_m;
                let v = sum_sq[m] / n_m - mean * mean;
                v.max(0.0) as f32
            } else {
                0.0
            };
        }

        // Reset for next epoch — per the Task 0.5 / 0.8 pattern.
        // `action_mag_per_sample` resets to 0 (= Quarter), but the kernel's
        // default-write-before-early-return overwrites the slot with -1 on
        // every reached thread, so there's no semantic corruption.
        self.stream
            .memset_zeros(&mut self.action_mag_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset action_mag_per_sample: {e}")))?;
        self.stream
            .memset_zeros(&mut self.step_ret_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset step_ret_per_sample: {e}")))?;
        self.stream
            .memset_zeros(&mut self.trade_close_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset trade_close_per_sample: {e}")))?;
        self.stream
            .memset_zeros(&mut self.trade_profitable_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset trade_profitable_per_sample: {e}")))?;

        Ok((wr, var))
    }

    /// Task 0.3 — epoch-mean `1 / (1 + sqrt(Var[Q_taken]))` scale written by
    /// `experience_env_step` at line 1422.
    ///
    /// The kernel writes a real value only when `q_variance != NULL` and
    /// `total_actions_for_var > 0` for a given `(i, t)` sample; otherwise the
    /// slot stays at its default `0.0f` (written unconditionally at the entry
    /// block). The host reducer averages over slots where `var_scale > 0.0`
    /// — treating the default-zero as "not measured" so disabled / un-reached
    /// samples don't dilute the signal.
    ///
    /// Returns `0.0` when no slot recorded a positive scale this epoch. That
    /// `0.0` is itself a REAL semantic value — "no Var[Q] scaling happened
    /// this epoch" (e.g. distributional head not yet live, or the env step
    /// never consulted the variance pathway) — not a stub. Safe to emit as
    /// the HEALTH_DIAG `mag [var_scale=…]` field: consumers should interpret
    /// `0.0` as "no scaling applied" and `(0, 1]` as the mean shrinkage.
    ///
    /// Resets the buffer after reducing so the next epoch starts clean.
    /// Cold path (epoch boundary) — dtoh cost acceptable.
    pub fn var_scale_epoch_mean(&mut self) -> Result<f32, MLError> {
        let n = self.var_scale_per_sample.len();
        let mut h = vec![0.0_f32; n];
        self.stream
            .memcpy_dtoh(&self.var_scale_per_sample, &mut h)
            .map_err(|e| MLError::ModelError(format!("dtoh var_scale_per_sample: {e}")))?;
        let mut sum = 0.0_f64;
        let mut count = 0_u64;
        for &v in &h {
            if v > 0.0 {
                sum += v as f64;
                count += 1;
            }
        }
        self.stream
            .memset_zeros(&mut self.var_scale_per_sample)
            .map_err(|e| MLError::ModelError(format!("memset var_scale_per_sample: {e}")))?;
        Ok(if count > 0 {
            (sum / count as f64) as f32
        } else {
            0.0
        })
    }

    /// Collect per-epoch trade statistics from GPU portfolio_states.
    ///
    /// Launches `trade_stats_reduce` kernel to sum ps[14:19] across all N episodes,
    /// then downloads the 6-float result (24 bytes) plus rewards_out for step returns.
    ///
    /// Called once per epoch at the boundary — cold path, not in hot training loop.
    pub fn collect_trade_stats(&mut self) -> Result<TradeStats, MLError> {
        let n = self.alloc_episodes;
        let n_i32 = n as i32;

        // Zero the output buffer before reduction. memset_zeros stays GPU-side
        // and avoids any CPU↔GPU traffic — the prior `htod_f32` of an all-zeros
        // host vector was needlessly going through PCIe.
        self.stream.memset_zeros(&mut self.trade_stats_buf)
            .map_err(|e| MLError::ModelError(format!("zero trade_stats_buf: {e}")))?;

        // Launch reduction kernel: 1 block, 256 threads
        let blocks = 1_u32;
        let threads = 256_u32;

        // Safety: portfolio_states has N * PORTFOLIO_STRIDE allocated floats,
        // trade_stats_buf has TRADE_STATS_FLOATS allocated floats,
        // n_i32 matches the allocation size.
        unsafe {
            self.stream
                .launch_builder(&self.trade_stats_kernel)
                .arg(&self.portfolio_states)
                .arg(&mut self.trade_stats_buf)
                .arg(&n_i32)
                .launch(LaunchConfig {
                    grid_dim: (blocks, 1, 1),
                    block_dim: (threads, 1, 1),
                    shared_mem_bytes: 0,
                })
                .map_err(|e| MLError::ModelError(format!("trade_stats_reduce launch: {e}")))?;
        }

        // Download 6-float result (24 bytes)
        let mut host_stats = vec![0.0_f32; TRADE_STATS_FLOATS];
        super::dtoh_f32(&self.stream, &self.trade_stats_buf, &mut host_stats)?;

        let win_count = host_stats[0];
        let loss_count = host_stats[1];
        let sum_wins = host_stats[2] as f64;
        let sum_losses = host_stats[3] as f64;
        let sum_returns = host_stats[4] as f64;
        let sum_sq_returns = host_stats[5] as f64;
        let total_trades = (win_count + loss_count) as usize;

        // Download raw_returns_out for step_returns (once per epoch, cold path).
        // raw_returns_out contains TRUE per-bar portfolio returns (unshaped):
        //   (equity_t - equity_{t-1}) / equity_{t-1}
        // NOT the shaped RL reward (which includes dense*0.1 + sparse*2.0 shaping layers).
        // Total = alloc_episodes * alloc_timesteps floats (~12KB for 3200 experiences).
        // raw_returns_out is NOT doubled by counterfactual (only states/actions/rewards/dones are)
        let base_output = self.alloc_episodes * self.alloc_timesteps;
        let mut host_raw_returns = vec![0.0_f32; base_output];
        super::dtoh_f32(&self.stream, &self.raw_returns_out, &mut host_raw_returns)?;

        let step_returns: Vec<f64> = host_raw_returns.iter().map(|&r| r as f64).collect();

        // Download done_out for episode boundary detection (same size, cold path).
        // done_out[i] = 1.0 when the capital floor circuit breaker fired or
        // the episode ended. Used to reset the equity curve in MaxDD computation.
        // done_out is f32 -- direct DtoH, no f32 conversion needed.
        // Read only base portion of done_out (not counterfactual)
        let done_slice = self.done_out.slice(..base_output);
        let mut host_dones = vec![0.0_f32; base_output];
        self.stream.memcpy_dtoh(&done_slice, &mut host_dones)
            .map_err(|e| MLError::ModelError(format!("dtoh done_out: {e}")))?;

        let done_flags: Vec<f64> = host_dones.iter().map(|&d| d as f64).collect();

        Ok(TradeStats {
            total_trades,
            winning_trades: win_count as usize,
            losing_trades: loss_count as usize,
            sum_wins,
            sum_losses,
            sum_returns,
            sum_sq_returns,
            step_returns,
            done_flags,
        })
    }

    /// All outputs remain GPU-resident for zero-copy training.
    ///
    /// The caller **must** call `stream().synchronize()` before passing
    /// these buffers to ops that run on a different CUDA stream.
    pub fn collect_experiences_gpu(
        &mut self,
        market_features_buf: &super::mapped_pinned::MappedF32Buffer,
        targets_buf: &super::mapped_pinned::MappedF32Buffer,
        episode_starts: &[i32],
        config: &ExperienceCollectorConfig,
    ) -> Result<GpuExperienceBatch, MLError> {
        let (n_episodes, timesteps) =
            self.launch_timestep_loop(market_features_buf, targets_buf, episode_starts, config)?;
        let base_total = n_episodes * timesteps;
        let total = base_total * 2; // #7: counterfactual doubles experiences
        let sd = ml_core::state_layout::STATE_DIM;

        // Clone kernel output buffers (includes counterfactual at offset N*L)
        let states = dtod_clone_f32_native(&self.stream, &self.states_out, total * sd, "states")?;
        let rewards = dtod_clone_f32_native(&self.stream, &self.rewards_out, total, "rewards")?;
        let actions = dtod_clone_i32(&self.stream, &self.actions_out, total, "actions")?;
        let dones = dtod_clone_f32_native(&self.stream, &self.done_out, total, "dones")?;

        // ── N-step return accumulation ───────────────────────────────────
        // Converts 1-step rewards into R_n = sum(gamma^i * r_i) and
        // OR's done flags over n steps. Uses double-buffering to avoid races.
        let n_steps = config.n_steps.max(1);
        if n_steps > 1 {
            let raw_rewards = dtod_clone_f32_native(&self.stream, &rewards, total, "raw_rewards_nstep")?;
            let raw_dones = dtod_clone_f32_native(&self.stream, &dones, total, "raw_dones_nstep")?;

            let gamma_f32 = config.gamma;
            let n_steps_i32 = n_steps;
            let l_i32 = timesteps as i32;
            let n_i32 = n_episodes as i32;
            let blocks = ((total + 255) / 256) as u32;

            unsafe {
                self.stream
                    .launch_builder(&self.nstep_kernel)
                    .arg(&raw_rewards)
                    .arg(&raw_dones)
                    .arg(&rewards)
                    .arg(&dones)
                    .arg(&gamma_f32)
                    .arg(&n_steps_i32)
                    .arg(&l_i32)
                    .arg(&n_i32)
                    .launch(LaunchConfig {
                        grid_dim: (blocks, 1, 1),
                        block_dim: (256, 1, 1),
                        shared_mem_bytes: 0,
                    })
                    .map_err(|e| MLError::ModelError(format!("nstep_accumulate_kernel: {e}")))?;
            }

            debug!(n_steps, gamma = config.gamma, "N-step return accumulation applied");
        }

        // SP18 v2 Phase 4 Task 4.1 (2026-05-08): `next_states` build-site
        // relocation. The TD(λ) launch below now requires `next_states` for
        // the Phase 5 `q_next` replacement (target-Q via DDQN argmax computed
        // by `compute_q_next_target_bootstrap`). Phase 4 lands the build-site
        // hoist as a no-op behavioral change — `next_states` is consumed
        // downstream by `GpuExperienceBatch.next_states` (returned at the end
        // of this fn) AND, in Phase 5, by the new bootstrap method. This
        // hoist has no observer (the only post-TD(λ) reader was the batch
        // assembly at the bottom of the fn — verified via the SP18 audit
        // script `next_states\b` grep). Pure ordering change; tests assert
        // build-site precedes TD(λ) launch via `include_str!` introspection.
        let next_states = build_next_states_f32(
            &self.stream, &states, n_episodes * 2, timesteps, sd, n_steps as usize,
        )?;

        // v8: TD(λ) — overwrites n-step results with geometrically-weighted multi-horizon returns
        let td_lambda = config.td_lambda;
        let max_trace = config.max_trace_length;
        if td_lambda > 0.01 && max_trace > 1 {
            // Clone 1-step rewards/dones as raw inputs (before n-step overwrote them, use
            // the current state — TD(λ) independently accumulates from the same base signals)
            let raw_rewards_td = dtod_clone_f32_native(&self.stream, &self.rewards_out, total, "raw_rewards_td")?;
            let raw_dones_td = dtod_clone_f32_native(&self.stream, &self.done_out, total, "raw_dones_td")?;
            // SP18 v2 Phase 4 (2026-05-08): self-bootstrap retained.
            //
            // The `q_next = rewards` self-bootstrap is the original td_lambda_kernel
            // contract per its header docstring ("When not available, pass raw_rewards
            // as q_next for a self-bootstrap approximation"). Phase 4 lands the
            // additive infrastructure for a real `Q_target(s', argmax_a Q_online(s', a))`
            // bootstrap (B-DD2 + B-DD3 + B-DD8) via `compute_q_next_target_bootstrap`
            // — DEAD until Phase 5 swaps this `q_next` argument to consume the
            // method's output. Per `feedback_no_partial_refactor`, NO L40S
            // DISPATCH between Phase 4 and Phase 5 close.
            let q_next = dtod_clone_f32_native(&self.stream, &self.rewards_out, total, "q_next_bootstrap")?;

            let gamma_f32 = config.gamma;
            let lambda_f32 = td_lambda;
            let max_trace_i32 = max_trace;
            let l_i32 = timesteps as i32;
            let n_i32 = (n_episodes * 2) as i32; // includes counterfactual
            let blocks = ((total + 255) / 256) as u32;

            unsafe {
                self.stream
                    .launch_builder(&self.td_lambda_kernel)
                    .arg(&raw_rewards_td)     // raw 1-step rewards
                    .arg(&raw_dones_td)       // raw 1-step dones
                    .arg(&q_next)             // bootstrapped Q(s')
                    .arg(&rewards)            // output: overwrite with G^λ
                    .arg(&dones)              // output: overwrite with done_n
                    .arg(&gamma_f32)
                    .arg(&lambda_f32)
                    .arg(&max_trace_i32)
                    .arg(&l_i32)
                    .arg(&n_i32)
                    .launch(LaunchConfig {
                        grid_dim: (blocks, 1, 1),
                        block_dim: (256, 1, 1),
                        shared_mem_bytes: 0,
                    })
                    .map_err(|e| MLError::ModelError(format!("td_lambda_kernel: {e}")))?;
            }

            debug!(td_lambda, max_trace, "TD(λ) return accumulation applied (replaces n-step)");
        }

        // Reward normalization: DISABLED for reward v4.
        // Per-bar percentage returns (~0.001) are naturally scaled.
        // Normalizing would scramble the C51 atom distribution.

        // ── Episode ID fill (GPU-native, zero CPU) ────────────────────
        // episode_ids[i] = i / L — enables HER Future and Final strategies.
        // Direct integer arithmetic: no linear scan needed.
        let episode_ids = fill_episode_ids_gpu(
            &self.stream,
            &self.fill_episode_ids_kernel,
            total,
            timesteps,
        )?;

        // SP13 Layer B Commit B1.1b (2026-05-05): bar_indices construction
        // hoisted out of the hindsight branch. Two consumers now need the
        // mapped-pinned buffer populated unconditionally:
        //   1. The aux next-bar sign label producer kernel (this commit) —
        //      reads bar_indices to compute the bar+lookahead lookup against
        //      `targets_buf` for every experience.
        //   2. The hindsight relabel kernel (pre-existing) — same lookup
        //      pattern; only its launch remains conditional on
        //      `config.hindsight_fraction > 0.0`.
        // The `bar_indices_pinned` mapped-pinned buffer is sized to
        // `alloc_episodes * alloc_timesteps * 2` at construction (covers
        // base + counterfactual), so the capacity check below is the same
        // one the pre-existing hindsight branch ran.
        if total > self.bar_indices_pinned.len {
            return Err(MLError::ModelError(format!(
                "bar_indices_pinned capacity {} < required {}",
                self.bar_indices_pinned.len, total
            )));
        }
        // Build bar_indices: bar_idx[ep * L + t] = episode_starts[ep] + t
        // Write directly into the mapped-pinned host_ptr — no HtoD copy.
        let mut bar_indices_cpu = vec![0_i32; total];
        for ep in 0..n_episodes {
            for t in 0..timesteps {
                let bar = episode_starts[ep.min(episode_starts.len() - 1)] + t as i32;
                // Base batch
                bar_indices_cpu[ep * timesteps + t] = bar;
                // Counterfactual batch (offset by base_total)
                bar_indices_cpu[base_total + ep * timesteps + t] = bar;
            }
        }
        self.bar_indices_pinned.write_from_slice(&bar_indices_cpu);
        let bar_indices_gpu_ptr = self.bar_indices_pinned.dev_ptr;

        // SP13 Layer B Commit B1.1b: aux next-bar sign label producer.
        // Pure per-thread O(1) map writing -1/0/1 into a fresh
        // `CudaSlice<i32>` of size `total`. Replaces the B0
        // `alloc_zeros::<i32>(total)` placeholder; consumer is the K=2
        // softmax CE head wired in B1.1a (`aux_next_bar_loss_reduce` +
        // `aux_next_bar_backward` + `aux_dir_acc_reduce_kernel` +
        // `aux_pred_to_isv_tanh_kernel`). Lookahead matches the hindsight
        // window so the "up vs not-up" boundary lines up with the
        // hindsight-relabel reward signal — both encode the same
        // `bar+lookahead` future-price comparison. The skip sentinel -1
        // fires when `bar+lookahead >= total_bars`; the CE loss reduce
        // masks it out of the mean + B_valid count per
        // `aux_heads_kernel.cu`.
        let aux_sign_labels = self.stream
            .alloc_zeros::<i32>(total)
            .map_err(|e| MLError::ModelError(format!("alloc aux_sign_labels: {e}")))?;
        {
            // SP14 Layer C Phase C.4b (2026-05-08): horizon H is no longer
            // a host-passed scalar derived from `config.hindsight_lookahead`
            // — it is read from `ISV[AUX_PRED_HORIZON_BARS_INDEX=450]` on
            // device. The slot is initialized to sentinel 60.0 by the fold-
            // boundary registry and adaptively driven by
            // `aux_horizon_update_kernel` from observed avg winning hold
            // time. See plan §C.4b.
            //
            // The collector's `isv_signals_dev_ptr` is set by the trainer
            // post-construction; if NULL (uncommon — should be set before
            // any aux label launch in production), the kernel would read
            // garbage. Assert non-zero.
            use crate::cuda_pipeline::sp14_isv_slots::AUX_PRED_HORIZON_BARS_INDEX;
            debug_assert!(
                self.isv_signals_dev_ptr != 0,
                "aux_sign_label_kernel: isv_signals_dev_ptr must be set before \
                 collect_experiences_gpu — H is read from ISV[450] on device."
            );
            let total_i32 = total as i32;
            let total_bars_i32 = config.total_bars;
            let isv_h_idx_i32 = AUX_PRED_HORIZON_BARS_INDEX as i32;
            let isv_dev_ptr = self.isv_signals_dev_ptr;
            let blocks = ((total + 255) / 256) as u32;
            unsafe {
                self.stream
                    .launch_builder(&self.aux_sign_label_kernel)
                    .arg(&targets_buf.dev_ptr) // const float* targets (mapped pinned dev_ptr)
                    .arg(&bar_indices_gpu_ptr) // const int* bar_indices (mapped pinned dev_ptr)
                    .arg(&isv_dev_ptr)         // const float* isv
                    .arg(&isv_h_idx_i32)       // int isv_h_idx
                    .arg(&aux_sign_labels)     // int* out_labels
                    .arg(&total_i32)           // int total
                    .arg(&total_bars_i32)      // int total_bars
                    .launch(LaunchConfig {
                        grid_dim: (blocks, 1, 1),
                        block_dim: (256, 1, 1),
                        shared_mem_bytes: 0,
                    })
                    .map_err(|e| MLError::ModelError(format!("aux_sign_label_kernel: {e}")))?;
            }
        }

        // v8: Hindsight relabeling — replace fraction of rewards with optimal exit PnL.
        // bar_indices_pinned (above) is already populated; reuse it for the
        // hindsight kernel's same bar+lookahead lookup pattern.
        if config.hindsight_fraction > 0.0 {
            let b0 = self.branch_sizes[0] as i32;
            let b1 = self.branch_sizes[1] as i32;
            let b2 = self.branch_sizes[2] as i32;
            let b3_hs = self.branch_sizes[3] as i32;
            let max_pos = config.max_position;
            let hl = config.hindsight_lookahead.max(1);
            let tb = config.total_bars;
            let hf = config.hindsight_fraction;
            let bs = total as i32;
            let blocks = ((total + 255) / 256) as u32;

            unsafe {
                self.stream
                    .launch_builder(&self.hindsight_relabel_kernel)
                    .arg(&targets_buf.dev_ptr) // const float* targets (mapped pinned dev_ptr)
                    .arg(&actions)            // const int* actions
                    .arg(&bar_indices_gpu_ptr) // const int* bar_indices (mapped pinned dev_ptr)
                    .arg(&rewards)            // float* rewards
                    .arg(&b0)                 // int b0_size
                    .arg(&b1)                 // int b1_size
                    .arg(&b2)                 // int b2_size
                    .arg(&b3_hs)              // int b3_size
                    .arg(&max_pos)            // float max_position
                    .arg(&hl)                 // int lookahead
                    .arg(&tb)                 // int total_bars
                    .arg(&hf)                 // float hindsight_fraction
                    .arg(&bs)                 // int batch_size
                    .launch(LaunchConfig {
                        grid_dim: (blocks, 1, 1),
                        block_dim: (256, 1, 1),
                        shared_mem_bytes: 0,
                    })
                    .map_err(|e| MLError::ModelError(format!("hindsight_relabel_kernel: {e}")))?;
            }

            debug!(
                hindsight_fraction = config.hindsight_fraction,
                lookahead = config.hindsight_lookahead,
                "Hindsight relabeling applied"
            );
        }

        debug!(
            n_episodes,
            timesteps,
            total_experiences = total,
            "GPU experience collection complete (cuBLAS timestep loop)"
        );

        self.last_experience_count = total;

        // SP20 Phase 3 Task 3.4 (2026-05-10): clone the per-bar
        // aux_conf scratch into a fresh `[total]` CudaSlice for the
        // batch struct. The kernel writes at `out_off` + `cf_off =
        // out_off + N*L` so the first `total = N*L*2` slots of the
        // scratch contain on-policy + CF; same dtod_clone pattern as
        // states/rewards/actions/dones above. NULL-tolerant
        // downstream: if aux_logits_per_env was NULL during the
        // rollout, the kernel left these slots at 0.0f (K=2 uniform
        // prior, no information) — Phase 5 gate will fall back to
        // `mean_a Q(s, a)` baseline at sample time.
        let aux_conf = dtod_clone_f32_native(
            &self.stream, &self.aux_conf_per_sample, total, "aux_conf"
        )?;

        // SP22 H6 vNext Phase B4b-2 (2026-05-14, REVISED for ring-tail
        // mask bug — smoke train-k95mj repro 14:09): build the per-sample
        // trade-outcome label column for the emitted batch. The K=2
        // sibling (`aux_sign_labels`) is filled by `aux_sign_label_kernel`
        // walking `0..total` directly — total = base_total × 2 includes
        // the counterfactual expansion, AND the K=2 kernel writes the
        // same `bar+lookahead` future-price label to both halves
        // (label depends on bar, not action). The K=3 per-step producer
        // (`trade_outcome_label_kernel`, B4b-1) writes only the first
        // `base_total = n_episodes × timesteps` slots of
        // `exp_aux_to_label_per_sample` (sized `[alloc_episodes ×
        // alloc_timesteps]` — pre-cf-mult).
        //
        // History of fixes:
        //   1. Initial B4b-2: `dtod_clone_i32(src, total)` →
        //      `src.slice(..total)` panicked at cudarc safe/core.rs:1648
        //      (Option::unwrap on None) because src.len() = base_total <
        //      total. Workflow train-xzv56 fold-0 repro.
        //   2. First fix (256a5fa5a): alloc `[total]`, memset CF half
        //      with -1 mask, memcpy on-policy half from src. Eliminated
        //      the panic — but introduced a SILENT bug: `insert_batch`
        //      is called with `bs = total = 4_096_000` and replay
        //      `cap = 300_000`, triggering the `off = bs - cap`
        //      tail-clip branch, which slices `aux_outcome.slice(off..)`
        //      = the LAST 300K elements of `aux_outcome_labels[total]`.
        //      Last 300K are entirely in the CF half (base_total =
        //      2_048_000 < 3_796_000 = off). Ring fills with -1 only.
        //      K=3 loss reduce sees valid_count = 0 every batch, returns
        //      loss = 0, Pearl A bootstrap (`if loss > 0`) never fires,
        //      ISV[538] pinned at sentinel 0.000 across all epochs.
        //      Workflow train-k95mj epoch 0 repro (trade_outcome_ce =
        //      0.000e0 in HEALTH_DIAG aux print).
        //   3. THIS fix: duplicate the on-policy half into the CF half
        //      so both halves carry valid {0/1/2 if trade close, 0
        //      default} labels. Mirrors aux_sign_labels' both-halves-
        //      filled pattern. CF samples now train on the SAME outcome
        //      label as their on-policy counterpart (semantically
        //      defensible: the trade-close event at bar t is a property
        //      of the bar, not the action, even though a different
        //      action might never have entered the trade — that's the
        //      same approximation aux_sign_labels makes).
        //
        // Net effect: insert_batch's tail-clip now retains 300K real
        // labels (last 150K from CF half + last 150K from on-policy
        // half), with the on-policy half's ~2.4% real trade-close rate
        // → ~7K real {0/1/2} labels per ring fill. K=3 head trains.
        let aux_outcome_labels = {
            let mut buf = self.stream.alloc_zeros::<i32>(total)
                .map_err(|e| MLError::ModelError(format!(
                    "alloc aux_outcome_labels i32[{total}]: {e}"
                )))?;
            // Copy on-policy half from the per-step scratch.
            let src_view = self.exp_aux_to_label_per_sample.slice(..base_total);
            {
                let mut dst_on_policy = buf.slice_mut(..base_total);
                self.stream.memcpy_dtod(&src_view, &mut dst_on_policy)
                    .map_err(|e| MLError::ModelError(format!(
                        "DtoD aux_outcome_labels on-policy half: {e}"
                    )))?;
            }
            // Duplicate the on-policy half into the CF half. Same pattern
            // as `aux_sign_labels` (both halves filled with the same
            // bar-resolved label). The borrow-checker requires the
            // on-policy `slice` to be re-bound here since `slice_mut`
            // above released its lifetime.
            {
                let src_dup = self.exp_aux_to_label_per_sample.slice(..base_total);
                let mut dst_cf = buf.slice_mut(base_total..total);
                self.stream.memcpy_dtod(&src_dup, &mut dst_cf)
                    .map_err(|e| MLError::ModelError(format!(
                        "DtoD aux_outcome_labels CF half (dup of on-policy): {e}"
                    )))?;
            }
            buf
        };

        Ok(GpuExperienceBatch {
            states,
            next_states,
            actions,
            rewards,
            dones,
            episode_ids,
            aux_sign_labels,
            aux_outcome_labels,
            aux_conf,
            state_dim: sd,
            n_episodes,
            timesteps,
        })
    }

    /// Timestep-level loop replacing the monolithic kernel launch.
    ///
    /// For each timestep:
    ///   1. `experience_state_gather` — assemble [N, state_dim] batch
    ///   2. `CublasForward::forward_online` — Q-network via cuBLAS SGEMM
    ///   3. `compute_expected_q` — C51 logits → expected Q-values (skip if num_atoms==1)
    ///   4. `experience_action_select` — epsilon-greedy on Q-values
    ///   5. `experience_env_step` — portfolio simulation, reward, done, advance timestep
    fn launch_timestep_loop(
        &mut self,
        market_features_buf: &super::mapped_pinned::MappedF32Buffer,
        targets_buf: &super::mapped_pinned::MappedF32Buffer,
        episode_starts: &[i32],
        config: &ExperienceCollectorConfig,
    ) -> Result<(usize, usize), MLError> {
        // ── Validate buffer sizes ───────────────────────────────────────
        let n_episodes = episode_starts.len();
        if n_episodes > self.alloc_episodes {
            return Err(MLError::ModelError(format!(
                "Episode count {} exceeds allocated buffer size {}",
                n_episodes, self.alloc_episodes,
            )));
        }
        if n_episodes == 0 {
            return Err(MLError::ModelError(
                "episode_starts must not be empty".to_owned(),
            ));
        }
        let timesteps = config.timesteps_per_episode as usize;
        if timesteps > self.alloc_timesteps {
            return Err(MLError::ModelError(format!(
                "timesteps_per_episode {} exceeds allocated buffer size {}",
                timesteps, self.alloc_timesteps,
            )));
        }

        // ── Upload episode starts ───────────────────────────────────────
        // episode_starts_buf is GPU-mutated by `domain_rand_episode_starts`,
        // so the destination must remain a CudaSlice. Stage via mapped-pinned
        // + DtoD per `feedback_no_htod_htoh_only_mapped_pinned.md`.
        upload_host_to_cuda_i32(
            &self.stream, episode_starts, &mut self.episode_starts_buf, "episode_starts upload")?;

        // ── Initialize current_timesteps to zeros ───────────────────────
        self.stream
            .memset_zeros(&mut self.current_timesteps)
            .map_err(|e| MLError::ModelError(format!("memset current_timesteps: {e}")))?;

        // ── Compute weight pointers for cuBLAS ──────────────────────────
        let n = n_episodes;
        let blocks_256 = ((n + 255) / 256) as u32;
        let launch_cfg = LaunchConfig {
            grid_dim: (blocks_256, 1, 1),
            block_dim: (256, 1, 1),
            shared_mem_bytes: 0,
        };

        let total_bars = config.total_bars;
        if total_bars <= 0 {
            return Err(MLError::ModelError(format!(
                "total_bars={} — must be set from actual data length before collect", total_bars
            )));
        }
        let sd = ml_core::state_layout::STATE_DIM as i32;
        let md = self.market_dim as i32;
        let n_i32 = n as i32;

        let b0 = self.branch_sizes[0] as i32;
        let b1 = self.branch_sizes[1] as i32;
        let b2 = self.branch_sizes[2] as i32;
        let b3 = self.branch_sizes[3] as i32;

        debug!(
            n_episodes,
            timesteps,
            epsilon = config.epsilon,
            num_atoms = self.num_atoms,
            "Launching timestep loop (cuBLAS forward)"
        );

        let mirror_i32: i32 = if config.mirror_active { 1 } else { 0 };

        // #33 Saboteur: generate per-episode adversarial params on GPU.
        //
        // SP11 Fix 39 B1a (2026-05-04): the host-side `ps` is the BASE
        // perturbation scale only. The SP11 controller's intensity multiplier
        // is applied GPU-side inside `saboteur_generate_params` by reading
        // `isv[SABOTEUR_INTENSITY_MULT_INDEX]` and computing
        // `effective_scale = ps × fmaxf(slot, SABOTEUR_MIN=0.5)`. The
        // sentinel-0 floor defends the very first cold-start step before the
        // controller has run; post-A2 the slot is clamped to
        // [SP11_SABOTEUR_MIN, SP11_SABOTEUR_MAX] = [0.5, 2.0] so the floor
        // does not fire steady-state. Per `feedback_cpu_is_read_only`, no
        // host-side multiplication.
        if self.saboteur_active {
            let ps = self.saboteur_perturbation_scale;
            let isv_ptr = self.isv_signals_dev_ptr;
            let saboteur_slot_i32 =
                crate::cuda_pipeline::sp11_isv_slots::SABOTEUR_INTENSITY_MULT_INDEX as i32;
            unsafe {
                self.stream
                    .launch_builder(&self.saboteur_generate_kernel)
                    .arg(&mut self.saboteur_params_buf)
                    .arg(&self.saboteur_base_buf)
                    .arg(&ps)
                    .arg(&isv_ptr)              // SP11 B1a: ISV signals (0=NULL=static)
                    .arg(&saboteur_slot_i32)    // SP11 B1a: SABOTEUR_INTENSITY_MULT_INDEX
                    .arg(&n_i32)
                    .launch(launch_cfg)
                    .map_err(|e| MLError::ModelError(format!(
                        "saboteur_generate_params: {e}"
                    )))?;
            }
        }

        // Task 8: Zero GPU-resident step counter before timestep loop.
        // env_step reads current_t from this device buffer instead of a
        // host-side scalar, eliminating per-timestep parameter variation.
        self.stream.memset_zeros(&mut self.step_counter_gpu)
            .map_err(|e| MLError::ModelError(format!("memset step_counter_gpu: {e}")))?;

        // Single-thread launch config for step_counter_advance kernel.
        let step_counter_cfg = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (1, 1, 1),
            shared_mem_bytes: 0,
        };

        let gpu_sync_diag = std::env::var("FOXHUNT_GPU_SYNC_DIAG").map_or(true, |v| v != "0");
        for t in 0..timesteps {
            if gpu_sync_diag && (t == 0 || t == timesteps / 2 || t == timesteps - 1) {
                unsafe { cudarc::driver::sys::cuStreamSynchronize(self.stream.cu_stream()); }
                tracing::info!(timestep = t, total = timesteps, n_episodes, "experience collection progress");
            }
            // ── 1. Gather states: [N, state_dim] ────────────────────────
            unsafe {
                // Gems & Pearls kernel params
                let noise_scale = config.feature_noise_scale;
                let vol_norm = config.vol_normalizer;

                // Upload feature mask if provided (once per epoch, not per timestep).
                // Mapped pinned: CPU writes via host_ptr, state_gather kernel
                // reads via dev_ptr — no HtoD copy.
                if t == 0 {
                    if let Some(ref mask_data) = config.feature_mask {
                        // Reallocate only when the mask size differs from a
                        // previously-cached buffer (epoch-boundary cold path).
                        let need_alloc = match &self.feature_mask_buf {
                            Some(buf) => buf.len != mask_data.len(),
                            None => true,
                        };
                        if need_alloc {
                            // Safety: CUDA context active on this thread (we're
                            // inside the surrounding unsafe block already).
                            self.feature_mask_buf = Some(
                                MappedF32Buffer::new(mask_data.len())
                                    .map_err(|e| MLError::ModelError(format!("alloc feature_mask (mapped pinned): {e}")))?
                            );
                        }
                        // SAFETY: feature_mask_buf is Some after the alloc above.
                        let buf = self.feature_mask_buf.as_ref().expect("feature_mask_buf just allocated");
                        buf.write_from_slice(mask_data);
                    } else {
                        self.feature_mask_buf = None;
                    }
                }

                // #23 feature mask pointer (0 = NULL = no masking)
                let mask_ptr: u64 = self.feature_mask_buf.as_ref()
                    .map(|b| b.dev_ptr)
                    .unwrap_or(0u64);

                // Plan params, ISV signals, and OFI features pointers
                let plan_ptr = self.plan_params_dev_ptr;
                let isv_ptr = self.isv_signals_dev_ptr;
                let ofi_ptr = self.ofi_gpu.dev_ptr;
                let ofi_dim_i32 = self.ofi_dim as i32;
                // SP22 H6 Phase 2 (2026-05-12): per-env recentered aux p_up
                // cache (range [-1, +1]; filled by
                // `exp_aux_softmax_to_per_env_kernel` at the END of the prior
                // step; sentinel 0.0 at cold-start / FoldReset via fill_f32).
                let prev_aux_dir_prob_ptr = self.prev_aux_dir_prob.raw_ptr();
                // SP22 H6 vNext Phase C-2 (2026-05-14): per-env K=3 trade-
                // outcome softmax cache [N, 3]. When non-NULL the kernel
                // takes the K=3 path: writes slots [121..124) = (p_Profit,
                // p_Stop, p_Timeout) via assemble_state_outcome_k3; the
                // K=2 single-slot bridge is suppressed. Phase C-1 wired
                // the producer (`aux_outcome_softmax_to_per_env_kernel`)
                // — this is the consumer-side flip that activates K=3
                // → policy state.
                let prev_aux_outcome_probs_ptr = self.prev_aux_outcome_probs.raw_ptr();

                self.stream
                    .launch_builder(&self.state_gather_kernel)
                    .arg(&market_features_buf.dev_ptr) // mapped pinned dev_ptr
                    .arg(&self.episode_starts_buf)
                    .arg(&self.current_timesteps)
                    .arg(&self.portfolio_states)
                    .arg(&mut self.batch_states)
                    .arg(&n_i32)
                    .arg(&total_bars)
                    .arg(&sd)
                    .arg(&md)
                    .arg(&noise_scale)      // #22 feature noise
                    .arg(&mask_ptr)          // #23 causal feature mask (NULL=disabled)
                    .arg(&vol_norm)          // #13 vol normalization
                    .arg(&mirror_i32)        // #10 mirror universe
                    .arg(&config.time_reversal_mod) // #11 time-reversal
                    .arg(&plan_ptr)          // plan_params_ptr (NULL=no plan)
                    .arg(&isv_ptr)           // isv_signals_ptr (NULL=static)
                    .arg(&ofi_ptr)           // ofi_features (NULL=no OFI)
                    .arg(&ofi_dim_i32)       // ofi_dim (0=no OFI)
                    .arg(&prev_aux_dir_prob_ptr) // SP22 H6 Phase 2: K=2 per-env p_up [N]
                    .arg(&prev_aux_outcome_probs_ptr) // SP22 H6 vNext Phase C-2: K=3 outcome probs [N, 3]
                    .launch(launch_cfg)
                    .map_err(|e| MLError::ModelError(format!(
                        "experience_state_gather t={t}: {e}"
                    )))?;
            }

            // ── 2. cuBLAS Q-forward: batch_states → logits ──────────────
            // Graph the ENTIRE forward (bottleneck + Q-network) for deterministic cuBLAS.
            // Ungraphed cublasLtMatmul contaminates cuBLAS internal state, breaking
            // determinism of the training graph replay on subsequent steps.
            let capturing = self.exp_fwd_graph.is_null();
            if capturing {
                self.stream.begin_capture(
                    cudarc::driver::sys::CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_THREAD_LOCAL,
                ).map_err(|e| MLError::ModelError(format!("exp fwd graph begin: {e}")))?;
            }

            // #31 Bottleneck: compress market features → 2D before Q-network
            let q_input: &CudaSlice<f32> = if self.bottleneck_dim > 0 {
                let bn_dim = self.bottleneck_dim;
                let bn_hidden = &self.exp_bn_hidden;
                let bn_concat = &self.exp_bn_concat;
                let bn_hidden_ptr = bn_hidden.raw_ptr();
                let bn_concat_ptr = bn_concat.raw_ptr();
                let states_ptr = self.batch_states.raw_ptr();
                let md = self.market_dim_bn;
                let portfolio_dim = ml_core::state_layout::STATE_DIM - md;
                // SP15 Wave 4.1b: trailing +1 column = dd_pct broadcast from
                // ISV[DD_PCT_INDEX=406]. Mirrors the trainer's
                // bn_tanh_concat_dd_kernel migration.
                let concat_dim = bn_dim + portfolio_dim + 1;

                // #30 F32 SGEMM: batch_states[N, market_dim] @ w_bn^T → h_bn[N, bn_dim]
                {
                    use crate::cuda_pipeline::batched_forward::f32_weight_ptrs_from_base;
                    let f32_w = f32_weight_ptrs_from_base(
                        self.trainer_params_ptr,
                        &self.param_sizes,
                    );
                    self.cublas_forward.sgemm_f32_ldb(
                        &self.stream,
                        f32_w[20], states_ptr, bn_hidden_ptr,
                        bn_dim, n, md,
                        self.cublas_forward.state_dim_padded,
                        "exp_h_bn_f32",
                    )?;
                    // Add f32 bias (no activation — tanh applied by concat kernel below)
                    self.cublas_forward.launch_add_bias_f32_raw(
                        &self.stream, bn_hidden_ptr, f32_w[21], bn_dim, n,
                    )?;
                }

                // Tanh + concat + dd_pct kernel — SP15 Wave 4.1b. Reads
                // ISV[DD_PCT_INDEX=406] via the collector's bus pointer (set
                // by `set_isv_signals_ptr`); when the pointer is 0 (uninit
                // case) the kernel would dereference NULL — guarded by the
                // captured-graph wiring in training_loop.rs:859 which sets
                // the bus pointer BEFORE the first epoch's forward graph
                // capture.
                {
                    let sdp = self.cublas_forward.state_dim_padded as i32;
                    let bn_i32 = bn_dim as i32;
                    let md_i32 = md as i32;
                    let cd_i32 = concat_dim as i32;
                    let n_i32_bn = n as i32;
                    let total_elems = (n * concat_dim) as i64;
                    let blocks = (((total_elems as u64) + 255) / 256).max(1) as u32;
                    unsafe {
                        self.stream
                            .launch_builder(&self.bn_tanh_concat_fn)
                            .arg(&bn_hidden_ptr)
                            .arg(&states_ptr)
                            .arg(&self.isv_signals_dev_ptr)
                            .arg(&bn_concat_ptr)
                            .arg(&n_i32_bn)
                            .arg(&bn_i32)
                            .arg(&md_i32)
                            .arg(&sdp)
                            .arg(&cd_i32)
                            .launch(LaunchConfig {
                                grid_dim: (blocks, 1, 1),
                                block_dim: (256, 1, 1),
                                shared_mem_bytes: 0,
                            })
                            .map_err(|e| MLError::ModelError(format!(
                                "exp bn_tanh_concat_dd: {e}"
                            )))?;
                    }
                }
                bn_concat
            } else {
                // No bottleneck — use batch_states directly (f32)
                &self.batch_states
            };

            // #30 F32 forward pass (part of the outer graph capture)
            {
                use crate::cuda_pipeline::batched_forward::f32_weight_ptrs_from_base;
                let f32_w_ptrs = f32_weight_ptrs_from_base(
                    self.trainer_params_ptr,
                    &self.param_sizes,
                );
                self.cublas_forward.forward_online_f32(
                    &self.stream,
                    q_input.raw_ptr(), &f32_w_ptrs,
                    self.exp_h_s1_f32.raw_ptr(), self.exp_h_s2_f32.raw_ptr(),
                    self.exp_h_v_f32.raw_ptr(),
                    self.exp_h_b0_f32.raw_ptr(), self.exp_h_b1_f32.raw_ptr(),
                    self.exp_h_b2_f32.raw_ptr(), self.exp_h_b3_f32.raw_ptr(),
                    self.exp_v_logits.raw_ptr(), self.exp_b_logits.raw_ptr(),
                    0u64, // mag_concat_ptr: experience collector uses unconditioned forward
                    0u64, // dir_qaux_concat_ptr: SP14 EGF disabled in experience collector (no aux head dependency).
                )?;
            }

            // End graph capture / replay
            if capturing {
                let graph = self.stream.end_capture(
                    cudarc::driver::sys::CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH,
                ).map_err(|e| MLError::ModelError(format!("exp fwd graph end: {e}")))?
                    .ok_or_else(|| MLError::ModelError("exp fwd graph returned None".to_owned()))?;
                self.exp_fwd_graph = graph.cu_graph_exec;
                std::mem::forget(graph);
            } else {
                unsafe {
                    let r = cudarc::driver::sys::cuGraphLaunch(self.exp_fwd_graph, self.stream.cu_stream());
                    if r != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
                        return Err(MLError::ModelError(format!("exp fwd graph replay: {r:?}")));
                    }
                }
            }

            // ── 2b. SP14 β-migration step 3 (Option B, 2026-05-07): aux
            //        next-bar forward + per-step label producer.
            //
            // Runs AFTER the captured forward graph (which populates
            // `exp_h_s2_f32` via `forward_online_f32`) and BEFORE the
            // expected_q kernel + the SP14 EGF chain (step 4) that consume
            // `exp_aux_nb_softmax_buf`. Mirrors the trainer's
            // `aux_heads_forward` pattern (gpu_dqn_trainer.rs:17159) — same
            // orchestrator, same param tensors at indices [119..123), but
            // bound to the collector's stream and operating on rollout-
            // sized buffers (n_episodes rows) instead of the trainer's
            // batch_size.
            //
            // ISV gating: the aux forward itself doesn't touch ISV; it
            // produces the softmax tile that step 4's chain reads. Skip
            // this whole block in the cold-start (test-scaffold) path
            // where the trainer hasn't wired its params yet — without
            // that wire `trainer_params_ptr == 0` and the aux forward
            // would dereference NULL param pointers.
            if self.trainer_params_ptr != 0 {
                use crate::cuda_pipeline::batched_forward::f32_weight_ptrs_from_base;
                use super::gpu_aux_heads::AUX_NEXT_BAR_K;
                // SP22 H6 vNext Phase B3 (2026-05-14): trade-outcome head
                // forward needs K=3 in scope alongside the K=2 sibling's K.
                use super::gpu_aux_heads::AUX_OUTCOME_K;

                // Param tensor pointers via the same offset arithmetic the
                // trainer's `aux_heads_forward` uses (gpu_dqn_trainer.rs:17166).
                // The shared `f32_weight_ptrs_from_base` resolves all
                // NUM_WEIGHT_TENSORS tensors against the trainer's flat
                // params buffer; aux next-bar tensors are at indices
                // [119..123) (nb_w1, nb_b1, nb_w2, nb_b2). Regime-head
                // tensors at [123..127) are not used here — the rollout
                // path doesn't compute a regime classification loss.
                let aux_w_ptrs = f32_weight_ptrs_from_base(
                    self.trainer_params_ptr,
                    &self.param_sizes,
                );
                let nb_w1 = aux_w_ptrs[119];
                let nb_b1 = aux_w_ptrs[120];
                let nb_w2 = aux_w_ptrs[121];
                let nb_b2 = aux_w_ptrs[122];
                let sh2 = self.network_dims.1;

                // Step A: per-step sign-label producer. Writes
                // `exp_aux_nb_label_buf[ep] := sign(p_close[bar+lookahead] − p_close[bar])`
                // for each ep in 0..n_episodes, where `bar = episode_starts[ep] + t`.
                // Sentinel -1 fires when the lookahead window runs past
                // `total_bars`; the CE loss / dir-acc / q_disagreement
                // consumers all mask -1 rows out of denominators (see
                // `aux_dir_acc_reduce_kernel.cu` for the canonical mask
                // semantics). Lookahead matches the trajectory-wide
                // `aux_sign_label_kernel` launch (line ~3776 below) so
                // both encode the same K=2 "up vs not-up" boundary.
                {
                    // SP14 Layer C Phase C.4b (2026-05-08): H is read from
                    // ISV[AUX_PRED_HORIZON_BARS_INDEX=450] on device,
                    // mirrors trajectory kernel migration. See plan §C.4b.
                    use crate::cuda_pipeline::sp14_isv_slots::AUX_PRED_HORIZON_BARS_INDEX;
                    debug_assert!(
                        self.isv_signals_dev_ptr != 0,
                        "aux_sign_label_per_step_kernel: isv_signals_dev_ptr must be set."
                    );
                    let isv_h_idx_i32 = AUX_PRED_HORIZON_BARS_INDEX as i32;
                    let isv_dev_ptr = self.isv_signals_dev_ptr;
                    let labels_dev = self.exp_aux_nb_label_buf.raw_ptr();
                    let targets_dev = targets_buf.dev_ptr;
                    let starts_dev = self.episode_starts_buf.raw_ptr();
                    let t_i32 = t as i32;
                    let blocks = ((n as u32) + 255) / 256;
                    unsafe {
                        self.stream
                            .launch_builder(&self.exp_aux_sign_label_per_step_kernel)
                            .arg(&targets_dev)
                            .arg(&starts_dev)
                            .arg(&t_i32)
                            .arg(&isv_dev_ptr)
                            .arg(&isv_h_idx_i32)
                            .arg(&labels_dev)
                            .arg(&n_i32)
                            .arg(&total_bars)
                            .launch(LaunchConfig {
                                grid_dim: (blocks, 1, 1),
                                block_dim: (256, 1, 1),
                                shared_mem_bytes: 0,
                            })
                            .map_err(|e| MLError::ModelError(format!(
                                "sp14-β: aux_sign_label_per_step t={t}: {e}"
                            )))?;
                    }
                }

                // Step B-1 (SP14-C.5b, 2026-05-08): aux trunk forward.
                // Reads encoder layer-1 output `exp_h_s1_f32`
                // (n_episodes × shared_h1) and writes the aux trunk
                // hidden caches + final `exp_h_s2_aux` (n_episodes × SH2).
                // This SEPARATES the aux head input from Q's `exp_h_s2_f32`
                // — the aux trunk shapes a representation specifically for
                // the next-bar prediction objective without leaking
                // gradient back into Q's encoder. Param tensors come from
                // the trainer's dedicated aux trunk slabs via
                // `aux_trunk_{w,b}{1,2,3}_ptr` (set by
                // `set_trainer_aux_trunk_param_ptrs` once the fused
                // training context is created).
                let enc_out_dim = self.network_dims.0;
                let aux_h1 = super::gpu_aux_trunk::AUX_TRUNK_H1;
                let aux_h2 = super::gpu_aux_trunk::AUX_TRUNK_H2;
                self.exp_aux_trunk_forward_ops.launch(
                    &self.stream,
                    self.exp_h_s1_f32.raw_ptr(),
                    self.aux_trunk_w1_ptr,
                    self.aux_trunk_b1_ptr,
                    self.aux_trunk_w2_ptr,
                    self.aux_trunk_b2_ptr,
                    self.aux_trunk_w3_ptr,
                    self.aux_trunk_b3_ptr,
                    self.exp_h_aux1.raw_ptr(),
                    self.exp_h_aux2.raw_ptr(),
                    self.exp_h_s2_aux.raw_ptr(),
                    n,
                    enc_out_dim,
                    aux_h1,
                    aux_h2,
                    sh2,
                )?;

                // Step B-1b (SP14-C.6, 2026-05-08): h_s2_aux RMS EMA producer.
                // Reads `exp_h_s2_aux [n_episodes, SH2]` (written above by
                // aux trunk forward) and EMA-blends RMS into
                // `ISV[H_S2_AUX_RMS_EMA_INDEX=449]`. Pearl-A first-observation
                // bootstrap (sentinel 0.0 → replace) + fixed α=0.05.
                // Launched on the same stream as aux_trunk_forward so
                // producer→consumer ordering is stream-implicit (no explicit
                // sync required). `isv_signals_dev_ptr` is NULL at cold
                // construction; the guard inside the kernel (sentinel check)
                // is on-device so no host-side guard is needed.
                if self.isv_signals_dev_ptr != 0 {
                    use crate::cuda_pipeline::sp14_isv_slots::H_S2_AUX_RMS_EMA_INDEX;
                    self.exp_h_s2_aux_rms_ema_ops.launch(
                        &self.stream,
                        self.exp_h_s2_aux.raw_ptr(),
                        n as i32,
                        sh2 as i32,
                        self.isv_signals_dev_ptr,
                        H_S2_AUX_RMS_EMA_INDEX as i32,
                        super::gpu_aux_trunk::HS2AuxRmsEmaOps::ALPHA,
                    )?;
                }

                // Step B-2: aux next-bar forward. SP14-C.5b: input redirected
                // from `exp_h_s2_f32` (Q trunk output) to `exp_h_s2_aux`
                // (aux trunk output, written above). Writes hidden + logits
                // + softmax tiles. The softmax tile
                // (`exp_aux_nb_softmax_buf [n_episodes, 2]`) is the
                // production output consumed by step 4's SP14 EGF chain.
                // Per `pearl_no_host_branches_in_captured_graph` this
                // launch is OUTSIDE the captured graph (post
                // `cuGraphLaunch`) — same placement as the trainer's
                // `aux_heads_forward` call relative to the trainer's
                // captured graph.
                self.exp_aux_heads_fwd.forward_next_bar(
                    &self.stream,
                    self.exp_h_s2_aux.raw_ptr(),
                    nb_w1, nb_b1, nb_w2, nb_b2,
                    n,                       // batch dim = n_episodes
                    sh2,
                    AUX_NEXT_BAR_K,
                    self.exp_aux_nb_hidden_buf.raw_ptr(),
                    self.exp_aux_nb_logits_buf.raw_ptr(),
                    self.exp_aux_nb_softmax_buf.raw_ptr(),
                )?;

                // ── SP22 H6 vNext Phase B3 (2026-05-14): trade-outcome ──
                // head forward. Same input (`exp_h_s2_aux`) as the K=2
                // sibling above; reads weights at flat-buffer indices
                // [163..167) (aux_to_w1, aux_to_b1, aux_to_w2, aux_to_b2)
                // added by Phase B1. Writes hidden + logits + softmax
                // tiles (rollout-scratch, sized `[n_episodes × K=3]`).
                //
                // The softmax tile output has no consumer in this commit
                // — Phase C will use it for 3-slot state assembly
                // (state[121..124] = p_Profit, p_Stop, p_Timeout). Phase B4
                // wires the trainer-side scatter into the replay buffer
                // for backward. Per Phase B0's "dead-code scaffolding"
                // discipline, the launch runs every step but produces
                // tiles that nothing reads yet — this lets us validate
                // the forward path doesn't crash / NaN before consumers
                // land.
                let to_w1 = aux_w_ptrs[163];
                let to_b1 = aux_w_ptrs[164];
                let to_w2 = aux_w_ptrs[165];
                let to_b2 = aux_w_ptrs[166];
                // ── SP22 H6 vNext Phase B5b-2 (2026-05-14): collector ──
                // trade plan forward chain. Mirrors trainer's
                // `launch_trade_plan_forward` (gpu_dqn_trainer.rs:10844)
                // using the corrected param indices [91..94]:
                //   1. SGEMM: hidden[N, AH] = h_s2[N, SH2] @ W_fc^T
                //   2. add_bias_relu in-place on hidden
                //   3. SGEMM: pre_out[N, 6] = hidden[N, AH] @ W_out^T
                //   4. trade_plan_activate kernel → exp_plan_params[N, 6]
                //
                // Input is the Q-trunk `exp_h_s2_f32` (NOT the aux trunk
                // `exp_h_s2_aux`) — matches the trainer reading
                // `save_h_s2` (Q-trunk h_s2). The plan head's actual
                // tensors at [91..94] still have no backward, so the
                // output is a deterministic function of cold-start
                // Xavier weights applied to live h_s2. The result feeds
                // the K=3 concat in lieu of NULL, achieving train/
                // inference symmetry on `plan_params` (both sides see
                // the same `f(h_s2)` distribution).
                //
                // Cost: 2 × cuBLAS SGEMM + 1 elementwise + 1 activate
                // launch per rollout step. SGEMM dims for n_episodes=8,
                // AH=256, SH2=512: 8×256×512 = 1M FLOPs (L1) + 8×6×256
                // = 12k FLOPs (L2). Sub-microsecond on L40S; negligible
                // vs the K=3 forward.
                let w_plan_fc = aux_w_ptrs[91];
                let b_plan_fc = aux_w_ptrs[92];
                let w_plan_out = aux_w_ptrs[93];
                let b_plan_out = aux_w_ptrs[94];
                let p_dim = super::gpu_aux_heads::PLAN_PARAM_DIM;
                let trade_plan_hidden_ptr = self.exp_trade_plan_hidden_buf.raw_ptr();
                let trade_plan_pre_out_ptr = self.exp_trade_plan_pre_out_buf.raw_ptr();
                let exp_plan_params_ptr = self.exp_plan_params.raw_ptr();
                let h_s2_q_ptr = self.exp_h_s2_f32.raw_ptr();
                let adv_h = self.network_dims.3;

                // Step 1: hidden[N, AH] = h_s2[N, SH2] @ W_fc[AH, SH2]^T
                self.cublas_forward.sgemm_f32(
                    &self.stream,
                    w_plan_fc, h_s2_q_ptr, trade_plan_hidden_ptr,
                    adv_h, n, sh2, "exp_plan_l1",
                )?;
                // Step 2: hidden += b_fc[AH]; relu (in-place).
                self.cublas_forward.launch_add_bias_relu_f32_raw(
                    &self.stream, trade_plan_hidden_ptr, b_plan_fc, adv_h, n,
                )?;
                // Step 3: pre_out[N, 6] = hidden[N, AH] @ W_out[6, AH]^T
                self.cublas_forward.sgemm_f32(
                    &self.stream,
                    w_plan_out, trade_plan_hidden_ptr, trade_plan_pre_out_ptr,
                    p_dim, n, adv_h, "exp_plan_l2",
                )?;
                // Step 4: bias + per-output activation → exp_plan_params[N, 6].
                {
                    let n_i32 = n as i32;
                    let blocks = ((n as u32).div_ceil(256)).max(1);
                    unsafe {
                        self.stream
                            .launch_builder(&self.exp_trade_plan_activate_kernel)
                            .arg(&trade_plan_pre_out_ptr)
                            .arg(&b_plan_out)
                            .arg(&exp_plan_params_ptr)
                            .arg(&n_i32)
                            .launch(LaunchConfig {
                                grid_dim: (blocks, 1, 1),
                                block_dim: (256, 1, 1),
                                shared_mem_bytes: 0,
                            })
                            .map_err(|e| MLError::ModelError(format!(
                                "exp_trade_plan_activate t={t}: {e}"
                            )))?;
                    }
                }

                // ── SP22 H6 vNext Phase B5b (2026-05-14): plan-conditioned ──
                // input concat for the rollout-step K=3 forward.
                //
                // SH2_TOTAL = SH2 + PLAN_PARAM_DIM = 262. Kernel takes
                // SH2 as a runtime arg so passing sh2_total just works
                // — no kernel surgery needed.
                //
                // B5b-2 update: pass real `exp_plan_params` (just
                // produced above) instead of NULL — resolves the
                // train/inference asymmetry documented on
                // `exp_aux_to_input_buf`.
                let sh2_total = sh2 + p_dim;
                let aux_to_input_ptr = self.exp_aux_to_input_buf.raw_ptr();
                self.exp_aux_to_fwd.launch_concat(
                    &self.stream,
                    self.exp_h_s2_aux.raw_ptr(),
                    exp_plan_params_ptr,
                    n,
                    sh2,
                    p_dim,
                    aux_to_input_ptr,
                )?;
                self.exp_aux_to_fwd.forward(
                    &self.stream,
                    aux_to_input_ptr,
                    to_w1, to_b1, to_w2, to_b2,
                    n,                       // batch dim = n_episodes
                    sh2_total,               // SH2_TOTAL = 262
                    AUX_OUTCOME_K,
                    self.exp_aux_to_hidden_buf.raw_ptr(),
                    self.exp_aux_to_logits_buf.raw_ptr(),
                    self.exp_aux_to_softmax_buf.raw_ptr(),
                )?;

                // ── SP22 H6 vNext Phase C (2026-05-14): K=3 softmax →
                // per-env 3-slot cache. Mirrors the K=2 sibling's
                // `aux_softmax_to_per_env_kernel` launch (Phase 2) but
                // writes 3 slots instead of 1. Reads
                // `exp_aux_to_softmax_buf [n_envs, 3]` (just produced
                // by the K=3 forward above on the same stream — stream-
                // implicit ordering); writes `prev_aux_outcome_probs
                // [n_envs, 3]`. Phase C-2 wires `experience_state
                // _gather` to read this cache and populate state slots
                // [121..124).
                {
                    let n_envs_i32 = n as i32;
                    let k_i32 = AUX_OUTCOME_K as i32;
                    let blocks = ((n as u32).div_ceil(256)).max(1);
                    let softmax_ptr = self.exp_aux_to_softmax_buf.raw_ptr();
                    let cache_ptr = self.prev_aux_outcome_probs.raw_ptr();
                    unsafe {
                        self.stream
                            .launch_builder(&self.exp_aux_outcome_softmax_to_per_env_kernel)
                            .arg(&softmax_ptr)
                            .arg(&cache_ptr)
                            .arg(&n_envs_i32)
                            .arg(&k_i32)
                            .launch(LaunchConfig {
                                grid_dim: (blocks, 1, 1),
                                block_dim: (256, 1, 1),
                                shared_mem_bytes: 0,
                            })
                            .map_err(|e| MLError::ModelError(format!(
                                "aux_outcome_softmax_to_per_env_kernel t={t}: {e}"
                            )))?;
                    }
                }
            }

            // ── 3. Convert logits → expected Q-values ───────────────────
            // When num_atoms == 1, b_logits is already [N, 11] Q-values.
            // When num_atoms > 1, we need softmax + expectation over z-support.
            // Convert f32 logits → expected Q-values (f32) via kernel.
            // Always use expected_q_kernel regardless of num_atoms — the kernel
            // handles num_atoms=1 correctly, and logits are now f32.
            {
                let na = self.num_atoms as i32;
                let support_dev_ptr = self.per_sample_support_buf.dev_ptr;
                unsafe {
                    let null_atom_stats = 0u64;
                    let null_atom_positions = 0u64; // NULL = use linear atom spacing
                    // SP22 H6 Phase 3 α Phase C1 (2026-05-13): atom-shift
                    // active when trainer has wired its W ptr via
                    // set_aux_w_to_q_dir_ptr(). exp_states_f32 holds the
                    // current rollout-step's per-env states with stride
                    // STATE_DIM_PADDED (= 128). NULL-safe in-kernel when
                    // W ptr is 0 → shift collapses to 0 bit-identical.
                    let w_aux_dev_ptr = self.aux_w_to_q_dir_dev_ptr;
                    let exp_states_dev_ptr = self.exp_states_f32.raw_ptr();
                    let aux_dir_prob_index =
                        ml_core::state_layout::AUX_DIR_PROB_INDEX as i32;
                    let state_dim_i32 =
                        ml_core::state_layout::STATE_DIM_PADDED as i32;
                    self.stream
                        .launch_builder(&self.expected_q_kernel)
                        .arg(&self.exp_v_logits)
                        .arg(&self.exp_b_logits)
                        .arg(&mut self.q_values)
                        .arg(&n_i32)
                        .arg(&na)
                        .arg(&b0)
                        .arg(&b1)
                        .arg(&b2)
                        .arg(&b3)
                        .arg(&support_dev_ptr) // [N, 4, 3] stride-12 per-sample per-branch support (mapped pinned)
                        .arg(&null_atom_stats)
                        .arg(&mut self.q_var_buf)
                        .arg(&null_atom_positions)  // atom_positions (NULL = linear)
                        .arg(&w_aux_dev_ptr)
                        .arg(&exp_states_dev_ptr)
                        .arg(&aux_dir_prob_index)
                        .arg(&state_dim_i32)
                        .launch(launch_cfg)
                        .map_err(|e| MLError::ModelError(format!(
                            "compute_expected_q t={t}: {e}"
                        )))?;
                }
                // q_values now contains expected Q-values [N, q_stride] f32
            }

            // ── 3a-β. SP14 β-migration step 4 (Option B, 2026-05-07):
            //         collector-native EGF producer chain.
            //
            // 3 launches on `self.stream` using the collector-owned kernel
            // handles (loaded in step 1 of the β migration). Same kernel
            // semantics as the trainer's `submit_aux_ops` chain — the
            // ONLY difference is the data the kernels read: this path
            // reads `exp_aux_nb_softmax_buf` (rollout-time aux predictions
            // from step 3) and the collector's `q_values` (post-expected-Q,
            // pre-IQR / pre-ensemble / pre-noise — bit-equivalent to the
            // trainer's `q_out_buf` semantic) where Thompson sampling
            // forces Short/Long picks every step. The trainer-time path
            // sees Q-direction picks dominated by Hold/Flat (the natural
            // argmax distribution of the converged 4-way head); rollout-
            // time picks via Thompson are the genuine source of aux↔Q
            // disagreement signal.
            //
            // Producer order (must match trainer `submit_aux_ops`):
            //   1. SP13 dir-acc reduce + 2 EMAs + aux_pred_to_isv
            //   2. SP14 q_disagreement_update (reads aux softmax + q_values)
            //   3. SP14 alpha_grad_compute (pure ISV state machine)
            //
            // q_logits semantic note: the collector's q_values is the
            // post-expected-Q output (computed above by `expected_q_kernel`).
            // The IQR / ensemble / noise SAXPY bonuses below (steps 3b/3c/3d)
            // run AFTER this block so q_values here is noise-free, matching
            // the trainer's q_out_buf semantic at the trainer's
            // launch_sp14_q_disagreement_update call site (gpu_dqn_trainer.rs:8682).
            // q_stride = total_branch_actions = 13 (same as trainer's q_out_buf).
            //
            // Gated on `isv_signals_dev_ptr != 0` (matches SP13 hold-rate
            // pattern at line ~4626 below — without ISV wired the EMAs
            // have nowhere to write) AND `trainer_params_ptr != 0` (the
            // aux forward in step 3 was skipped if NULL, so the softmax
            // tile would be stale `alloc_zeros` data).
            //
            // No `seed_phase_active_cache` gate here — unlike the SP13
            // hold-rate observer (which prices Hold-cost during seed
            // phase as well), the SP14 EGF Schmitt-trigger Gate 1 needs
            // to observe BOTH seed-phase scripted-policy actions and
            // post-seed Q-policy actions to discriminate aux signal
            // quality across the curriculum. The kernel's own
            // total_cnt > 0 gate (commit 9d0c124ce) handles empty
            // contributions correctly.
            if self.isv_signals_dev_ptr != 0 && self.trainer_params_ptr != 0 {
                use crate::cuda_pipeline::sp13_isv_slots::{
                    AUX_DIR_ACC_SHORT_EMA_INDEX, AUX_DIR_ACC_LONG_EMA_INDEX,
                    AUX_DIR_PREDICTION_INDEX, DIR_ACC_EMA_SENTINEL,
                };
                use super::gpu_aux_heads::AUX_NEXT_BAR_K;

                let aux_softmax_dev = self.exp_aux_nb_softmax_buf.raw_ptr();
                let aux_label_dev   = self.exp_aux_nb_label_buf.raw_ptr();
                let dir_acc_out_dev = self.exp_aux_dir_acc_buf.dev_ptr;
                let isv_dev         = self.isv_signals_dev_ptr;
                let n_i32_dir       = n as i32;
                let k_i32_dir       = AUX_NEXT_BAR_K as i32;

                // Producer 1a: dir-acc reduce. Writes 6 floats (dir_acc,
                // pos_pred_frac, pos_label_frac, n_down, n_up, n_skip)
                // to mapped-pinned `exp_aux_dir_acc_buf`. Tree-reduce in
                // shmem; smem layout per `aux_dir_acc_reduce_kernel.cu`:
                // 6 i32 strips × bdim. Single block.
                {
                    let bdim: u32 = 256;
                    let cfg = LaunchConfig {
                        grid_dim:         (1, 1, 1),
                        block_dim:        (bdim, 1, 1),
                        shared_mem_bytes: 6 * bdim * std::mem::size_of::<i32>() as u32,
                    };
                    unsafe {
                        self.stream
                            .launch_builder(&self.exp_sp13_aux_dir_acc_reduce_kernel)
                            .arg(&aux_softmax_dev)
                            .arg(&aux_label_dev)
                            .arg(&n_i32_dir)
                            .arg(&k_i32_dir)
                            .arg(&dir_acc_out_dev)
                            .launch(cfg)
                            .map_err(|e| MLError::ModelError(format!(
                                "sp14-β: aux_dir_acc_reduce t={t}: {e}"
                            )))?;
                    }
                }
                // Producer 1b: short EMA (α=0.3) into ISV[373]. n=1
                // (only out_6[0] = dir_acc feeds the EMA; pos_pred /
                // pos_label / n_down / n_up / n_skip are diagnostic-only).
                // Sentinel 0.5 = random-baseline per
                // `pearl_first_observation_bootstrap`.
                {
                    let n_slots: i32 = 1;
                    let isv_off: i32 = AUX_DIR_ACC_SHORT_EMA_INDEX as i32;
                    let alpha:   f32 = 0.3;
                    let sentinel: f32 = DIR_ACC_EMA_SENTINEL;
                    unsafe {
                        self.stream
                            .launch_builder(&self.sp13_apply_fixed_alpha_ema_kernel)
                            .arg(&dir_acc_out_dev)
                            .arg(&n_slots)
                            .arg(&isv_off)
                            .arg(&alpha)
                            .arg(&sentinel)
                            .arg(&isv_dev)
                            .launch(LaunchConfig {
                                grid_dim: (1, 1, 1),
                                block_dim: (1, 1, 1),
                                shared_mem_bytes: 0,
                            })
                            .map_err(|e| MLError::ModelError(format!(
                                "sp14-β: dir_acc short EMA t={t}: {e}"
                            )))?;
                    }
                }
                // Producer 1c: slow EMA (α=0.05) into ISV[374].
                // Stagnation-detector comparator (compares against ISV[373]).
                {
                    let n_slots: i32 = 1;
                    let isv_off: i32 = AUX_DIR_ACC_LONG_EMA_INDEX as i32;
                    let alpha:   f32 = 0.05;
                    let sentinel: f32 = DIR_ACC_EMA_SENTINEL;
                    unsafe {
                        self.stream
                            .launch_builder(&self.sp13_apply_fixed_alpha_ema_kernel)
                            .arg(&dir_acc_out_dev)
                            .arg(&n_slots)
                            .arg(&isv_off)
                            .arg(&alpha)
                            .arg(&sentinel)
                            .arg(&isv_dev)
                            .launch(LaunchConfig {
                                grid_dim: (1, 1, 1),
                                block_dim: (1, 1, 1),
                                shared_mem_bytes: 0,
                            })
                            .map_err(|e| MLError::ModelError(format!(
                                "sp14-β: dir_acc long EMA t={t}: {e}"
                            )))?;
                    }
                }
                // Producer 1d: aux_pred → ISV[375] tanh. Reads softmax
                // tile, writes `mean(softmax[1] - softmax[0])` ∈ [-1, +1]
                // to the shared scalar slot 375 (per-step overwrite, not
                // an EMA). Single block, tree-reduce; smem = bdim × f32.
                {
                    let bdim: u32 = 256;
                    let cfg = LaunchConfig {
                        grid_dim:         (1, 1, 1),
                        block_dim:        (bdim, 1, 1),
                        shared_mem_bytes: bdim * std::mem::size_of::<f32>() as u32,
                    };
                    let isv_slot_off: i32 = AUX_DIR_PREDICTION_INDEX as i32;
                    unsafe {
                        self.stream
                            .launch_builder(&self.exp_sp13_aux_pred_to_isv_tanh_kernel)
                            .arg(&aux_softmax_dev)
                            .arg(&n_i32_dir)
                            .arg(&k_i32_dir)
                            .arg(&isv_slot_off)
                            .arg(&isv_dev)
                            .launch(cfg)
                            .map_err(|e| MLError::ModelError(format!(
                                "sp14-β: aux_pred_to_isv_tanh t={t}: {e}"
                            )))?;
                    }
                }

                // SP22 H6 (2026-05-12): aux→policy state bridge. Copy
                // per-env p_up = `aux_softmax[env, 1]` into the
                // `prev_aux_dir_prob` cache for the NEXT step's state
                // assembly. Lives in the same gate as Producers 1a–1d
                // because if `trainer_params_ptr == 0` then aux forward
                // was skipped and the softmax tile is stale `alloc_zeros`
                // data — copying it would write zero everywhere, which
                // collapses to a worse-than-sentinel signal (p_up=0 ⇒
                // "down with 100% conviction"). When the gate is false,
                // `prev_aux_dir_prob` keeps its previous value (0.5
                // sentinel at cold-start, or last good p_up otherwise).
                // Pure GPU per `feedback_no_atomicadd` + no-HtoD discipline.
                {
                    let prev_aux_dir_prob_dev = self.prev_aux_dir_prob.raw_ptr();
                    let blocks = ((n as u32).div_ceil(256)).max(1);
                    unsafe {
                        self.stream
                            .launch_builder(&self.exp_aux_softmax_to_per_env_kernel)
                            .arg(&aux_softmax_dev)
                            .arg(&prev_aux_dir_prob_dev)
                            .arg(&n_i32_dir)
                            .arg(&k_i32_dir)
                            .launch(LaunchConfig {
                                grid_dim: (blocks, 1, 1),
                                block_dim: (256, 1, 1),
                                shared_mem_bytes: 0,
                            })
                            .map_err(|e| MLError::ModelError(format!(
                                "sp22-h6: aux_softmax_to_per_env t={t}: {e}"
                            )))?;
                    }
                }

                // Producer 2: SP14 q_disagreement_update. Reads aux
                // softmax (K=2) + q_values (K=4 over the first 4 columns
                // per row, q_stride=13). Writes ISV[383/384/389]
                // (q_dis_short, q_dis_long, var_q EMAs). Single block,
                // 256 threads; shmem = 2 × 256 × f32 (numerator + count
                // tiles per kernel header).
                {
                    let q_logits_dev = self.q_values.raw_ptr();
                    let n_i32_q      = n as i32;
                    let q_stride: i32 = (self.branch_sizes[0]
                        + self.branch_sizes[1]
                        + self.branch_sizes[2]
                        + self.branch_sizes[3]) as i32;
                    let alpha_short: f32 = 0.3;
                    let alpha_long:  f32 = 0.05;
                    let alpha_var:   f32 = 0.05;
                    let bdim: u32 = 256;
                    let shmem_bytes: u32 = 2 * bdim * std::mem::size_of::<f32>() as u32;
                    unsafe {
                        self.stream
                            .launch_builder(&self.exp_sp14_q_disagreement_update_kernel)
                            .arg(&aux_softmax_dev)
                            .arg(&q_logits_dev)
                            .arg(&isv_dev)
                            .arg(&n_i32_q)
                            .arg(&q_stride)
                            .arg(&alpha_short)
                            .arg(&alpha_long)
                            .arg(&alpha_var)
                            .launch(LaunchConfig {
                                grid_dim:         (1, 1, 1),
                                block_dim:        (bdim, 1, 1),
                                shared_mem_bytes: shmem_bytes,
                            })
                            .map_err(|e| MLError::ModelError(format!(
                                "sp14-β: q_disagreement_update t={t}: {e}"
                            )))?;
                    }
                }

                // SP14 Layer C Phase C.1 (2026-05-08): Producer 3
                // (α_grad compute Schmitt-trigger state machine) deleted
                // atomically with the kernel file per
                // `feedback_no_legacy_aliases`. q_disagreement_update
                // (Producer 2 above) remains as HEALTH_DIAG diagnostic.
            }

            // ── 3b. Add IQR exploration bonus to Q-values (anti-collapse) ──
            // IQR bonus is pre-tiled [N, q_stride] and pre-scaled by alpha.
            // SAXPY: q_values[i] += 1.0 * iqr_tiled[i] for all i.
            if let Some(ref iqr_buf) = self.iqr_tiled_gpu {
                let total_q = n_episodes * (self.branch_sizes[0] + self.branch_sizes[1]
                    + self.branch_sizes[2] + self.branch_sizes[3]);
                let saxpy_blocks = (total_q + 255) / 256;
                let saxpy_cfg = LaunchConfig {
                    grid_dim: (saxpy_blocks as u32, 1, 1),
                    block_dim: (256, 1, 1),
                    shared_mem_bytes: 0,
                };
                let saxpy_alpha = 1.0_f32; // scaling already baked into iqr_tiled
                let total_q_i32 = total_q as i32;
                unsafe {
                    self.stream
                        .launch_builder(&self.saxpy_f32_kernel)
                        .arg(&mut self.q_values)
                        .arg(iqr_buf)
                        .arg(&saxpy_alpha)
                        .arg(&total_q_i32)
                        .launch(saxpy_cfg)
                        .map_err(|e| MLError::ModelError(format!(
                            "IQR SAXPY t={t}: {e}"
                        )))?;
                }
            }

            // ── 3c. Add ensemble variance exploration bonus to Q-values ──
            // Ensemble bonus is pre-tiled [N, q_stride] as a constant scalar.
            // SAXPY: q_values[i] += 1.0 * ensemble_var_tiled[i] for all i.
            if let Some(ref ens_buf) = self.ensemble_var_tiled_gpu {
                let total_q = n_episodes * (self.branch_sizes[0] + self.branch_sizes[1]
                    + self.branch_sizes[2] + self.branch_sizes[3]);
                let saxpy_blocks = (total_q + 255) / 256;
                let saxpy_cfg = LaunchConfig {
                    grid_dim: (saxpy_blocks as u32, 1, 1),
                    block_dim: (256, 1, 1),
                    shared_mem_bytes: 0,
                };
                let saxpy_alpha = 1.0_f32; // scaling already baked into ensemble_var_tiled
                let total_q_i32 = total_q as i32;
                unsafe {
                    self.stream
                        .launch_builder(&self.saxpy_f32_kernel)
                        .arg(&mut self.q_values)
                        .arg(ens_buf)
                        .arg(&saxpy_alpha)
                        .arg(&total_q_i32)
                        .launch(saxpy_cfg)
                        .map_err(|e| MLError::ModelError(format!(
                            "Ensemble var SAXPY t={t}: {e}"
                        )))?;
                }
            }

            // ── 3d. Add per-branch advantage noise for dueling symmetry breaking ──
            // SP6 Pearl 3: noise_sigma_per_branch[4] replaces scalar noise_sigma.
            // Per-action Gaussian noise creates non-uniform Q-values → non-uniform
            // Boltzmann → advantage heads get differentiated gradient.
            let any_branch_noise = config.noise_sigma_per_branch.iter().any(|&s| s > 0.0);
            if any_branch_noise {
                // Write per-branch sigma values to mapped-pinned buffer.
                // Kernel reads via dev_ptr — no HtoD copy per feedback_no_htod.
                self.noise_sigma_dev.write_from_slice(&config.noise_sigma_per_branch);
                let sigma_dev_ptr = self.noise_sigma_dev.dev_ptr;

                let total_q = n_episodes * (self.branch_sizes[0] + self.branch_sizes[1]
                    + self.branch_sizes[2] + self.branch_sizes[3]);
                let noise_blocks = ((total_q + 255) / 256) as u32;
                let noise_cfg = LaunchConfig {
                    grid_dim: (noise_blocks, 1, 1),
                    block_dim: (256, 1, 1),
                    shared_mem_bytes: 0,
                };
                let total_actions_i32 = (self.branch_sizes[0] + self.branch_sizes[1]
                    + self.branch_sizes[2] + self.branch_sizes[3]) as i32;
                let b0 = self.branch_sizes[0] as i32;
                let b1 = self.branch_sizes[1] as i32;
                let b2 = self.branch_sizes[2] as i32;
                let b3 = self.branch_sizes[3] as i32;
                let seed = t as u32; // monotonic step counter
                unsafe {
                    self.stream
                        .launch_builder(&self.noise_kernel)
                        .arg(&mut self.q_values)
                        .arg(&sigma_dev_ptr)
                        .arg(&n_i32)
                        .arg(&total_actions_i32)
                        .arg(&seed)
                        .arg(&b0)
                        .arg(&b1)
                        .arg(&b2)
                        .arg(&b3)
                        .launch(noise_cfg)
                        .map_err(|e| MLError::ModelError(format!(
                            "add_advantage_noise t={t}: {e}"
                        )))?;
                }
            }

            // ── 3e. Quantile Q-select: replace Boltzmann-on-E[Q] with uncertainty-driven blend ──
            // Reads C51 atom distributions from v_logits + b_logits, extracts Q_10th/Q_50th/Q_90th
            // from the CDF of each action, and writes quantile-blended Q-values to q_select_buf.
            // iqn_readiness=0: optimistic (Q_90th) for exploration; =1: pessimistic (Q_10th) for exploitation.
            // q_values (E[Q]) is preserved for monitoring, Q-gap computation, and loss.
            {
                let na = self.num_atoms as i32;
                let iqn_r = self.iqn_readiness;
                let util_e = self.util_ema;
                let support_dev_ptr = self.per_sample_support_buf.dev_ptr;
                // SP22 H6 Phase 3 α Phase C1 (2026-05-13): atom-shift
                // active when trainer has wired its W ptr via
                // set_aux_w_to_q_dir_ptr(). Shares the same exp_states_f32
                // rollout-state buffer as compute_expected_q above.
                let w_aux_dev_ptr = self.aux_w_to_q_dir_dev_ptr;
                let exp_states_dev_ptr = self.exp_states_f32.raw_ptr();
                let aux_dir_prob_index = ml_core::state_layout::AUX_DIR_PROB_INDEX as i32;
                let state_dim_i32 = ml_core::state_layout::STATE_DIM_PADDED as i32;
                unsafe {
                    self.stream
                        .launch_builder(&self.quantile_q_select_kernel)
                        .arg(&self.exp_v_logits)
                        .arg(&self.exp_b_logits)
                        .arg(&mut self.q_select_buf)
                        .arg(&n_i32)
                        .arg(&na)
                        .arg(&b0)
                        .arg(&b1)
                        .arg(&b2)
                        .arg(&b3)
                        .arg(&support_dev_ptr) // [N, 4, 3] stride-12 per-sample per-branch support (mapped pinned)
                        .arg(&iqn_r)
                        .arg(&util_e)
                        .arg(&w_aux_dev_ptr)
                        .arg(&exp_states_dev_ptr)
                        .arg(&aux_dir_prob_index)
                        .arg(&state_dim_i32)
                        .launch(launch_cfg)
                        .map_err(|e| MLError::ModelError(format!(
                            "quantile_q_select t={t}: {e}"
                        )))?;
                }
            }

            // ── 4. Action selection: epsilon-greedy + Q-gap conviction filter
            // Uses q_select_buf (quantile-blended Q-values) instead of q_values (E[Q]).
            // E[Q] in q_values is preserved for monitoring, Q-gap computation, and loss.
            //
            // B.3 Plan 3 Task 8: when `seed_phase_active_cache` is true, dispatch
            // the scripted-policy kernel INSTEAD of the network's action_select.
            // The scripted kernel writes the same `batch_actions` + `conviction_buf`
            // outputs the network kernel would have written, so all downstream
            // env_step/cf paths behave identically. Per-epoch cold-path CPU
            // decision; the action computation itself stays 100% GPU.
            //
            // `max_pos` is declared here (outer scope) because the env_step
            // launcher below also reads it; previously it was nested inside
            // the action_select block and the env_step site relied on
            // shadowing through the if-else.
            let max_pos = config.max_position;
            if self.seed_phase_active_cache {
                let sd_i32 = ml_core::state_layout::STATE_DIM_PADDED as i32;
                // Fix 30 Stale-C: pass `targets_buf.dev_ptr`, `episode_starts_buf`,
                // `t` (per-episode timestep), and `total_bars` so the kernel can
                // read raw_close from `targets[bar*6+TARGET_RAW_CLOSE]` (matching
                // env_step's source) instead of `state[MARKET_START]` which is
                // z-normed log-return post Bug 1 (commit `5a5dd0fed`).
                let t_i32 = t as i32;
                let total_bars_i32 = total_bars;
                unsafe {
                    self.stream
                        .launch_builder(&self.scripted_policy_kernel)
                        .arg(&self.batch_states)            // assembled state [N, state_dim]
                        .arg(&self.portfolio_states)         // [N, PORTFOLIO_STRIDE]
                        .arg(&targets_buf.dev_ptr)           // [total_bars, 6] fxcache targets (mapped pinned dev_ptr)
                        .arg(&self.episode_starts_buf)       // [N] global bar index of episode start
                        .arg(&n_i32)
                        .arg(&sd_i32)
                        .arg(&t_i32)
                        .arg(&total_bars_i32)
                        .arg(&mut self.batch_actions)        // [N] factored action ∈ [0, 108)
                        .arg(&mut self.conviction_buf)       // [N] direction conviction ∈ [0, 1]
                        .arg(&mut self.magnitude_conviction_buf) // [N] magnitude conviction ∈ [0, 1]
                        .launch(launch_cfg)
                        .map_err(|e| MLError::ModelError(format!(
                            "scripted_policy_select t={t}: {e}"
                        )))?;
                }
                // q_gaps_buf is normally written by experience_action_select.
                // The scripted policy doesn't compute Q-gaps, so leave the buffer
                // at whatever the previous step wrote (or zero on the first step
                // post-reset). Downstream env_step does NOT read q_gaps for the
                // physics path — it's a diagnostic-only signal — so the seed
                // phase produces no q_gap signal until the network takes over.
            } else {
                let _epsilon = config.epsilon;  // v8: epsilon now computed GPU-side via cosine schedule
                let q_gap = config.q_gap_threshold;
                let min_hold_bars_i32: i32 = 0;  // hold enforcement removed — cost-driven
                // Plan C T2 amendment: direction-branch Thompson sampling consumes raw C51
                // logits + adaptive per-sample support directly. exp_b_logits is BRANCH-MAJOR
                // [N, total_branch_atoms]; the kernel reads only the first N*b0_size*n_atoms
                // region (B0 = direction). per_sample_support is the same mapped-pinned
                // [N, 4, 3] stride-12 buffer threaded into expected_q_kernel above.
                // atom_positions=NULL → kernel falls back to linear v_min + a*delta_z.
                let na_i32 = self.num_atoms as i32;
                let support_dev_ptr = self.per_sample_support_buf.dev_ptr;
                let null_atom_positions = 0u64; // NULL = use linear atom spacing

                // ── 4-pre. SP15 Phase 3.5.4.c (2026-05-07): plasticity-injection
                // production launch — runs BEFORE experience_action_select on the
                // same stream so the trigger's ISV writes (slots 436 / 438) are
                // serialized-visible to the action_select kernel's reads. Closes
                // out the Wave 4.2 (`ef08611d3`) orphan-with-tests-only landing
                // per `feedback_wire_everything_up`.
                //
                // Gated on `sp15_w_b0out_dev_ptr != 0`: the trainer wires this
                // dev_ptr via `set_sp15_plasticity_target` after construction
                // (mirrors `set_sp15_alpha_warm_count_ptr` / `set_sp15_dd_
                // trajectory_prev_dd_ptr`). Until wired, the launch is skipped
                // and the ISV slots stay at their constructor sentinels (warm = 0,
                // fired = 0); the action_select kernel's OR-gate consumer then
                // degenerates to cooldown-only (bit-identical to the
                // pre-3.5.4.c path).
                //
                // `plasticity_m_warm_arg` is the warm-up bar count (default 200
                // per spec §9.2 (3.5.4)) — the trigger writes it to ISV[438] on
                // fire, action_select reads it back to detect the fire bar
                // (warm ≥ m_warm − 1.5f per the kernel's trigger-then-decrement
                // convention). Pass 0.0f when plasticity is unwired so the fire-
                // bar predicate is dead in the kernel.
                //
                // `plasticity_seed` derives from the global FOXHUNT_SEED
                // (`mix_seed`) XOR'd with the per-step timestep `t` so the
                // cuRAND init in the trigger kernel produces deterministic-but-
                // distinct samples each fire (multi-fold runs that fire on
                // different timesteps see different Kaiming-He samples). Same
                // (FOXHUNT_SEED, t) pair always yields the same reset.
                //
                // Out of the exp_fwd_graph capture region (which ends at the
                // cuBLAS forward earlier in this loop body), so per
                // `pearl_no_host_branches_in_captured_graph` the host-side
                // gating compiles cleanly.
                let plasticity_m_warm_arg: f32 =
                    if self.sp15_w_b0out_dev_ptr != 0 && self.isv_signals_dev_ptr != 0 {
                        const M_WARM: f32 = 200.0; // spec §9.2 (3.5.4) default
                        let plasticity_seed = crate::cuda_pipeline::mix_seed(
                            /* SP15 Phase 3.5.4.c historic base — arbitrary
                             * 64-bit constant unique to this launch site so
                             * multi-launcher runs (alpha_split / cooldown /
                             * etc.) get uncorrelated mix outputs even when
                             * FOXHUNT_SEED defaults to 42. */
                            0xC0DE_F00D_5915_3F4Cu64
                        ) ^ (t as u64);
                        crate::cuda_pipeline::gpu_dqn_trainer::launch_sp15_plasticity_injection(
                            &self.stream,
                            &self.exp_sp15_plasticity_injection_kernel,
                            self.isv_signals_dev_ptr,
                            self.sp15_w_b0out_dev_ptr,
                            self.sp15_w_b0out_n_weights,
                            M_WARM,
                            self.sp15_w_b0out_fan_in,
                            plasticity_seed,
                        ).map_err(|e| MLError::ModelError(format!(
                            "sp15 plasticity_injection t={t}: {e}"
                        )))?;
                        M_WARM
                    } else {
                        // Plasticity unwired — pass m_warm = 0.0 so the
                        // action_select fire-bar branch is inactive.
                        0.0f32
                    };

                unsafe {
                    self.stream
                        .launch_builder(&self.action_select_kernel)
                        .arg(&self.q_select_buf)
                        .arg(&mut self.batch_actions)
                        .arg(&mut self.q_gaps_buf)
                        .arg(&config.eps_start)
                        .arg(&config.eps_end)
                        .arg(&config.current_epoch)
                        .arg(&config.total_epochs)
                        .arg(&n_i32)
                        .arg(&b0)
                        .arg(&b1)
                        .arg(&b2)
                        .arg(&b3)
                        .arg(&q_gap)
                        .arg(&self.portfolio_states)       // ABI compat — unused by action_select
                        .arg(&min_hold_bars_i32)            // ABI compat — unused (pass 0)
                        .arg(&max_pos)                      // max_position for direction index
                        .arg(&config.eps_exp_mult)          // per-branch epsilon multiplier: exposure
                        .arg(&config.eps_ord_mult)          // per-branch epsilon multiplier: order
                        .arg(&config.eps_urg_mult)          // per-branch epsilon multiplier: urgency
                        .arg(&(t as i32))                   // timestep for stateless Philox RNG
                        .arg(&self.per_sample_epsilon_ptr)   // IQL expectile gap epsilon (0=cosine schedule)
                        .arg(&self.isv_signals_dev_ptr)      // ISV signals for adaptive hold (0=NULL=static)
                        .arg(&(self.contrarian_active_cache as i32))  // D7/N7: Q-negation flag
                        .arg(&0u64)                          // out_intent_mag: NULL — training path does not collect
                        .arg(&mut self.conviction_buf)       // out_conviction: per-sample direction-branch Kelly warmup signal
                        .arg(&mut self.magnitude_conviction_buf) // out_magnitude_conviction: per-sample magnitude-branch Kelly warmup signal
                        // Plan C T2 amendment buffers (direction-branch Thompson):
                        .arg(&self.exp_b_logits)            // b_logits_dir: [N, b0*n_atoms] used (rest is mag/ord/urg, untouched)
                        // SP17 Commit C: V-head logits row [N, n_atoms] — Thompson now
                        // samples from softmax(V + A_centered) per the dueling-Q mean-zero
                        // contract. exp_v_logits is the same value-head buffer compute_expected_q
                        // and quantile_q_select consume; passing it directly closes the action-
                        // selection contract gap with c51_loss/c51_grad backward.
                        .arg(&self.exp_v_logits)            // v_logits_dir: [N, n_atoms]
                        .arg(&support_dev_ptr)              // per_sample_support: [N, 4, 3] stride-12 mapped pinned
                        .arg(&null_atom_positions)          // atom_positions: NULL → linear support
                        .arg(&na_i32)                       // n_atoms: C51 atom count (i32 to match `int` ABI)
                        // SP15 Phase 3.5.4.c (2026-05-07): plasticity-injection
                        // m_warm. When the per-step plasticity launch above
                        // wired this collector's `sp15_w_b0out_dev_ptr`, the
                        // m_warm passed to the trigger flows into the
                        // consumer here so the action_select kernel can
                        // detect the fire bar (warm ≥ m_warm − 1.5f) and
                        // force DIR_FLAT, then DIR_HOLD on subsequent
                        // warm-up bars via the OR-gate. When unwired
                        // (`sp15_w_b0out_dev_ptr == 0`), m_warm = 0.0f
                        // disables the fire-bar branch (warm == 0 too →
                        // OR-gate degenerates to cooldown-only — bit-
                        // identical to the pre-3.5.4.c path).
                        .arg(&plasticity_m_warm_arg)
                        .launch(launch_cfg)
                        .map_err(|e| MLError::ModelError(format!(
                            "experience_action_select t={t}: {e}"
                        )))?;
                }
            }

            // ── 4b. Expert action override (GPU-native MA crossover + ADX)
            // When expert_ratio > 0, overrides Q-network exposure actions with
            // expert signals. Reads prices and ADX directly from GPU buffers.
            if self.expert_ratio > 0.0 {
                let er = self.expert_ratio;
                let tb = total_bars;
                unsafe {
                    self.stream
                        .launch_builder(&self.expert_override_kernel)
                        .arg(&mut self.batch_actions)
                        .arg(&targets_buf.dev_ptr)        // mapped pinned dev_ptr
                        .arg(&market_features_buf.dev_ptr) // mapped pinned dev_ptr
                        .arg(&self.portfolio_states)
                        .arg(&self.episode_starts_buf)
                        .arg(&self.current_timesteps)
                        .arg(&er)
                        .arg(&n_i32)
                        .arg(&tb)
                        .arg(&md)
                        .arg(&b0)
                        .arg(&b1)
                        .arg(&b2)
                        .arg(&b3)
                        .launch(launch_cfg)
                        .map_err(|e| MLError::ModelError(format!(
                            "expert_action_override t={t}: {e}"
                        )))?;
                }
            }

            // ── 4c. SP13 v3 Phase 0a P0a.T4 (2026-05-04): Hold-rate
            // observer + fixed-α EMA into ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382].
            //
            // Per-step on the SAME stream as `experience_action_select` (and
            // the optional expert override above) so the observer reads
            // `batch_actions [N]` AFTER both producers have written. Single-
            // block tree-reduce → 1-elem mapped-pinned `sp13_hold_rate_buf`,
            // then a 1-thread fixed-α (α=0.05, sentinel 0.0) EMA blend into
            // the trainer's ISV via `isv_signals_dev_ptr` (set after
            // construction by `set_isv_signals_ptr`). Skip both launches
            // when ISV is not yet wired (test scaffold path) — the EMA
            // would have nowhere to write.
            //
            // Slot 382 feeds the SP16-P1 MIN_HOLD_TEMPERATURE producer
            // (slot 460) at the per-epoch boundary; the SP18 D-leg
            // structural Hold opportunity-cost reward (slots [483..493))
            // is the consumer chain that actually shapes Hold-action
            // rewards.
            if self.isv_signals_dev_ptr != 0 && !self.seed_phase_active_cache {
                use crate::cuda_pipeline::sp13_isv_slots::HOLD_RATE_OBSERVED_EMA_INDEX;

                let actions_dev = self.batch_actions.raw_ptr();
                let out_dev     = self.sp13_hold_rate_buf.dev_ptr;
                let bdim: u32   = 256;

                // Step 1: count(decoded_dir == DIR_HOLD) / N → sp13_hold_rate_buf[0].
                unsafe {
                    self.stream
                        .launch_builder(&self.sp13_hold_rate_observer_kernel)
                        .arg(&actions_dev)
                        .arg(&n_i32)
                        .arg(&out_dev)
                        .launch(LaunchConfig {
                            grid_dim:         (1, 1, 1),
                            block_dim:        (bdim, 1, 1),
                            shared_mem_bytes: bdim * std::mem::size_of::<i32>() as u32,
                        })
                        .map_err(|e| MLError::ModelError(format!(
                            "sp13 v3 hold_rate_observer t={t}: {e}"
                        )))?;
                }

                // Step 2: fixed-α EMA into ISV[382]. n=1 (single scalar);
                // sentinel 0.0 matches the registry entry for slot 382;
                // α=0.05 matches the spec's slow-EMA timescale for the
                // host-side Hold-cost controller.
                let n_slots:   i32 = 1;
                let isv_off:   i32 = HOLD_RATE_OBSERVED_EMA_INDEX as i32;
                let alpha:     f32 = 0.05;
                let sentinel:  f32 = 0.0;
                let isv_dev = self.isv_signals_dev_ptr;
                unsafe {
                    self.stream
                        .launch_builder(&self.sp13_apply_fixed_alpha_ema_kernel)
                        .arg(&out_dev)
                        .arg(&n_slots)
                        .arg(&isv_off)
                        .arg(&alpha)
                        .arg(&sentinel)
                        .arg(&isv_dev)
                        .launch(LaunchConfig {
                            grid_dim:         (1, 1, 1),
                            block_dim:        (1, 1, 1),
                            shared_mem_bytes: 0,
                        })
                        .map_err(|e| MLError::ModelError(format!(
                            "sp13 v3 hold_rate apply_fixed_alpha_ema t={t}: {e}"
                        )))?;
                }
            }

            // ── 5. Environment step (reward v5: trade-aware hybrid) ──────
            // max_pos already defined above (action_select block)
            // D4/N4: adaptive counterfactual ratio — 0.5 at healthy, 0.8 at collapse.
            let cf_ratio = self.current_cf_ratio();
            self.last_cf_ratio_eff = cf_ratio;
            let tx_cost = config.tx_cost_multiplier;
            let l_i32 = timesteps as i32;
            let total_actions_env = (self.branch_sizes[0] + self.branch_sizes[1]
                + self.branch_sizes[2] + self.branch_sizes[3]) as i32;
            // Task 8: current_t is now GPU-resident — pass device pointer
            // instead of host scalar.  step_counter_gpu[0] == t at this point.
            unsafe {
                self.stream
                    .launch_builder(&self.env_step_kernel)
                    .arg(&targets_buf.dev_ptr) // mapped pinned dev_ptr
                    .arg(&self.episode_starts_buf)
                    .arg(&mut self.current_timesteps)
                    .arg(&self.batch_actions)
                    .arg(&mut self.portfolio_states)
                    .arg(&mut self.states_out)
                    .arg(&mut self.actions_out)
                    .arg(&mut self.rewards_out)
                    .arg(&mut self.done_out)
                    .arg(&self.batch_states)
                    .arg(&max_pos)
                    .arg(&tx_cost)
                    .arg(&market_features_buf.dev_ptr) // mapped pinned dev_ptr
                    .arg(&md)
                    .arg(&l_i32)
                    .arg(&n_i32)
                    .arg(&total_bars)
                    .arg(&sd)
                    .arg(&b0)
                    .arg(&b1)
                    .arg(&b2)
                    .arg(&b3)
                    .arg(&self.step_counter_gpu) // GPU-resident current_t [1]
                    .arg(&self.cvar_scales_ptr) // CVaR position scaling (0 = NULL = no scaling)
                    .arg(&self.q_gaps_buf)     // Q-gap conviction scaling
                    .arg(&mut self.raw_returns_out) // Raw portfolio returns (unshaped) for Sharpe/MaxDD
                    // H6 diagnostic: per-sample trail fire / pre-mag bin / traded flag / hold-at-exit
                    .arg(&mut self.trail_triggered_per_sample)
                    .arg(&mut self.pre_mag_per_sample)
                    .arg(&mut self.traded_per_sample)
                    .arg(&mut self.hold_at_exit_per_sample)
                    // Task 0.8 reward-term contribution: cf_flip / micro / total / slot_live
                    // Task 2.4: loss_aversion arg removed (R6 clamp moved to C51 Bellman).
                    .arg(&mut self.cf_flip_per_sample)
                    .arg(&mut self.micro_reward_per_sample)
                    .arg(&mut self.total_reward_per_sample)
                    .arg(&mut self.slot_live_per_sample)
                    // Task 2.X prerequisite: per-magnitude win-rate + variance instrumentation
                    // (action_mag / step_ret / trade_close / trade_profitable)
                    .arg(&mut self.action_mag_per_sample)
                    .arg(&mut self.step_ret_per_sample)
                    .arg(&mut self.trade_close_per_sample)
                    .arg(&mut self.trade_profitable_per_sample)
                    // Task 0.3 — per-sample Var[Q] Kelly scale (from env_step line 1422).
                    .arg(&mut self.var_scale_per_sample)
                    .arg(&config.holding_cost_rate)    // continuous inventory penalty
                    .arg(&config.churn_threshold)      // churn penalty threshold (bars)
                    .arg(&config.churn_penalty_scale)  // churn penalty scale
                    .arg(&config.spread_cost)         // bid-ask spread cost (matches backtest)
                    .arg(&config.contract_multiplier) // futures contract multiplier (e.g. 50 for ES)
                    .arg(&config.margin_pct)          // initial margin fraction (e.g. 0.06 = 6%)
                    // Class A audit-fix Batch 4-A (2026-05-08): legacy
                    // `dd_threshold` and `w_dd` arg passes DELETED
                    // atomically with the legacy compute_drawdown_penalty
                    // path. SP15's compute_sp15_final_reward_kernel reads
                    // λ_dd / DD threshold directly from ISV slots 420/421.
                    // `config.dd_threshold` survives — still consumed by
                    // launch_sp15_dd_state at line 5750 as the DD_PCT
                    // budget. `config.w_dd` field is also dropped from
                    // the config struct (was consumed only here).
                    .arg(&mirror_i32)                      // #10 mirror universe
                    .arg(&config.micro_reward_scale)   // v8: dense directional micro-reward (Gem 1)
                    // #33 Per-episode saboteur params (0 = NULL = disabled)
                    .arg(&{
                        if self.saboteur_active {
                            self.saboteur_params_buf.device_ptr(&self.stream).0
                        } else {
                            0u64
                        }
                    })
                    .arg(&self.q_var_buf)  // Distributional Var[Q] position sizing
                    .arg(&total_actions_env) // total branch actions for variance indexing
                    .arg(&self.cost_anneal_dev_ptr) // G2: transaction cost curriculum
                    .arg(&self.isv_signals_dev_ptr)  // ISV signals for adaptive hold (0 = NULL = static hold)
                    .arg(&self.plan_params_dev_ptr)  // trade plan params (0 = NULL = no plan)
                    .arg(&self.readiness_dev_ptr)    // model readiness [0,1] — gates plan enforcement
                    // full_batch_states: [N, state_dim] full state with OFI at [66..74).
                    // Reuse batch_states (same buffer state_gather wrote) so env_step can
                    // read OFI features and store prev-bar OFI in ps[30..37].
                    .arg(&self.batch_states.raw_ptr())  // full_batch_states ptr (non-NULL)
                    .arg(&sd)  // full_state_dim = state_dim (logical stride, not padded)
                    .arg(&config.price_confirm_weight)      // micro-reward: price confirmation weight
                    .arg(&config.book_aggression_weight)    // micro-reward: book aggression weight
                    .arg(&config.hold_quality_weight)       // micro-reward: hold quality weight
                    .arg(&config.micro_reward_temp)         // micro-reward: tanh temperature
                    .arg(&cf_ratio)                         // D4/N4: adaptive counterfactual flip ratio
                    .arg(&self.exploration_scale_dev_ptr)   // Phase 3 unified-env: gates saboteur/plan/CF flips
                    .arg(&self.shaping_scale_dev_ptr)       // Phase 3 unified-env: gates additive reward shaping
                    .arg(&self.conviction_buf)              // conviction_ptr: direction-branch Kelly warmup signal
                    .arg(&self.magnitude_conviction_buf)    // magnitude_conviction_ptr: magnitude-branch Kelly warmup signal
                    // C.2: per-component reward buffer [N*L * 6] — popart/cf/trail/micro/opp_cost/bonus
                    .arg(&mut self.reward_components_per_sample)
                    // B.2 Plan 3 Task 3: Flat→Positioned transition flag + ISV slot indices
                    // for the novelty-scaled bonus read-site in experience_env_step.
                    .arg(&mut self.flat_to_pos_per_sample)
                    // B.4 Plan 3 Task 4: per-(i,t) readiness value buffer.
                    // Consumer: plan_threshold_update GPU kernel reduces +
                    // EMA into ISV[READINESS_EMA_INDEX=75], deriving
                    // ISV[PLAN_THRESHOLD_INDEX=49].
                    .arg(&mut self.readiness_per_sample)
                    .arg(&{
                        use crate::cuda_pipeline::gpu_dqn_trainer::TRADE_ATTEMPT_RATE_EMA_INDEX;
                        TRADE_ATTEMPT_RATE_EMA_INDEX as i32
                    })
                    .arg(&{
                        use crate::cuda_pipeline::gpu_dqn_trainer::TRADE_TARGET_RATE_INDEX;
                        TRADE_TARGET_RATE_INDEX as i32
                    })
                    // C.4 / D.4b bonus optimism break (2026-04-29):
                    // direction-branch Q-scale EMA slot index. The C.4 timing
                    // bonus and D.4b regime penalty bound their |reward|/|pnl|
                    // multiplicands by ISV[Q_DIR_ABS_REF_INDEX] to break
                    // the cross-trade compounding optimism loop. ISV[21] is
                    // produced by q_stats_kernel.cu — no new producer needed.
                    // Per pearl_one_unbounded_signal_per_reward.md +
                    // feedback_isv_for_adaptive_bounds.md.
                    .arg(&{
                        use crate::cuda_pipeline::gpu_dqn_trainer::Q_DIR_ABS_REF_INDEX;
                        Q_DIR_ABS_REF_INDEX as i32
                    })
                    // SP11 Fix 39 (2026-05-04, A1.2): per-bar saboteur
                    // engagement signal output. The kernel writes
                    // `traded × |reward| × max(|eff_spread − 1|, |eff_slip − 1|)`
                    // here at the END of the bar processing. The SP11
                    // saboteur engagement kernel consumes this buffer via
                    // raw device pointer (see `set_sp11_saboteur_delta_reward_buf`
                    // wire-up in training_loop.rs init).
                    .arg(&mut self.saboteur_delta_reward_per_sample)
                    // SP11 Fix 39 B1b fix-up (2026-05-04): per-bar popart-
                    // component-specific reward magnitude output. Kernel
                    // writes `r_popart` (vol-normalized capped P&L on
                    // voluntary-exit bars; 0 elsewhere). Consumed by the
                    // SP11 popart-component EMA kernel via raw device
                    // pointer (see `set_sp11_popart_component_buf` wire-up
                    // in training_loop.rs init). Spec §4 amendment "Why
                    // slot 360".
                    .arg(&mut self.popart_component_per_sample)
                    // SP12 v3 (2026-05-04): per-trade event-driven reward
                    // composition parameters. The kernel applies a soft
                    // min-hold penalty on voluntary exits (deficit/(deficit+T)
                    // saturation). Class A P0-C (2026-05-08): the effective
                    // min-hold target inside the kernel is ISV-driven from
                    // ISV[AVG_WIN_HOLD_TIME_BARS_INDEX=451]; `min_hold_target`
                    // here is the cold-start fallback only (used when ISV[451]
                    // is still at sentinel 0.0 or isv_signals_ptr is NULL).
                    // Class A audit-fix Batch 4-B (2026-05-08, Item 4):
                    // `min_hold_temperature` is now signal-driven via ISV
                    // slot 460 (sourced from
                    // ISV[AUX_DIR_ACC_SHORT_EMA_INDEX=373] via
                    // `min_hold_temperature_update_kernel`); the
                    // training loop seeds this scalar per-epoch via
                    // `DQNTrainer::read_min_hold_temperature_from_isv`,
                    // which falls back to MIN_HOLD_TEMPERATURE_FALLBACK
                    // =50.0 when the slot is at sentinel.
                    // SP20 Phase 2 Task 2.2 (2026-05-09): the SP12 v3
                    // min-hold-penalty kernel args (min_hold_target /
                    // min_hold_penalty_max / min_hold_temperature) were
                    // DELETED atomically with the SP12 v3 reward block
                    // replacement at experience_kernels.cu (the SP20
                    // 4-quadrant reward has no patience-via-soft-penalty
                    // notion). config.min_hold_* fields remain in the
                    // Rust struct pending the Task 2.4 follow-up cleanup
                    // (orphaned producer chain removal).
                    // SP15 Wave 2 (2026-05-06): per-step `r_discipline`
                    // mirror + slot completion sentinel. Both [N*L]
                    // sized; defaulted at kernel entry. The fused
                    // post-modifier `compute_sp15_final_reward_kernel`
                    // (launched immediately after the α producer below)
                    // reads both to apply the SP15 §8.2 / §9.2
                    // composition over the SP11-finalised reward in
                    // `rewards_out` while skipping early-return slots.
                    .arg(&mut self.r_discipline_per_sample)
                    .arg(&mut self.slot_completed_normally_per_sample)
                    // SP20 Phase 2 Task 2.0 (2026-05-09): aux next-bar
                    // label tile (input — already populated by the
                    // `aux_sign_label_per_step_kernel` launch in step
                    // 3a-β above on the same stream; stream-implicit
                    // producer→consumer ordering) + per-env trade-open
                    // label-sign scratch (output — written at the
                    // `entering_trade` branch, consumed at `is_close`
                    // by Task 2.2's `sp20_compute_event_reward` call).
                    .arg(&self.exp_aux_nb_label_buf)
                    .arg(&mut self.label_at_open_per_env)
                    // SP20 Phase 2 Task 2.2 (2026-05-09): per-env
                    // trade-close alpha buffer (output) + ISV slot
                    // indices for the 4-quadrant reward block at the
                    // segment_complete site. Slot indices passed as
                    // kernel args so the kernel stays decoupled from
                    // SP14 slot-number drift (mirrors the
                    // `q_dir_abs_ref_idx` arg pattern — single source of
                    // truth at `sp14_isv_slots.rs` constants).
                    .arg(&mut self.alpha_per_env)
                    .arg(&{
                        use crate::cuda_pipeline::sp14_isv_slots::LOSS_CAP_INDEX;
                        LOSS_CAP_INDEX as i32
                    })
                    .arg(&{
                        use crate::cuda_pipeline::sp14_isv_slots::ALPHA_EMA_INDEX;
                        ALPHA_EMA_INDEX as i32
                    })
                    // SP20 Phase 2 Task 2.2-fix (2026-05-10): segment-level
                    // win flag i32 buffer. Written at segment_complete with
                    // `(segment_return > 0.0f) ? 1 : 0`. Replaces the broken
                    // per-bar `step_ret > 0` predicate in sp20_aggregate_
                    // inputs_kernel's wins_count accumulation (the per-bar
                    // tick at close bars is dominated by tx_cost deduction
                    // → systematically negative even on winning trades).
                    .arg(&mut self.is_win_per_env)
                    // SP21 T3.3 (2026-05-10): per-env per-bar Hold
                    // opportunity-cost scratch buffer. Producer:
                    // experience_env_step writes per_bar_opp_cost = -aux_conf
                    // × cost_scale at line ~3823 alongside the existing
                    // hold_baseline_buffer write. Consumer:
                    // sp20_aggregate_inputs_kernel sums over Hold-direction
                    // envs and emits mean → SP20EmaInputs.per_bar_hold_reward
                    // → HOLD_REWARD_EMA (defrosts the previously-hardcoded
                    // Phase 3.2 forward-reference 0.0f).
                    .arg(&mut self.per_bar_opp_cost_per_env)
                    // SP20 Phase 3 Task 3.2 (2026-05-10): Hold
                    // opportunity-cost dual emission (Component 2). Six
                    // new args at the end of the kernel signature:
                    //   - aux_logits_per_env [N, K=2] f32 input —
                    //     reuses `exp_aux_nb_logits_buf` (the same
                    //     buffer the SP20 stats kernel reads at
                    //     line 6435 above). Stream-implicit
                    //     producer→consumer: aux head writes at step 3
                    //     (line ~5328), env_step reads here at step 5.
                    //   - hold_baseline_buffer [N × 30] f32
                    //     scratch — per-env circular ring written at
                    //     per-bar branches, summed at trade close.
                    //   - hold_buffer_size = 30 (HOLD_BASELINE_BUFFER_SIZE).
                    //   - aux_k = 2 (AUX_NEXT_BAR_K — defensive
                    //     companion to the K=2 hardcoded helper).
                    //   - hold_cost_scale_idx, hold_reward_ema_idx —
                    //     ISV slot indices (mirror the loss_cap_idx /
                    //     alpha_ema_idx pattern above; single source
                    //     of truth at sp14_isv_slots.rs constants).
                    .arg(&self.exp_aux_nb_logits_buf.raw_ptr())
                    .arg(&mut self.hold_baseline_buffer)
                    .arg(&{ HOLD_BASELINE_BUFFER_SIZE as i32 })
                    .arg(&{
                        use super::gpu_aux_heads::AUX_NEXT_BAR_K;
                        AUX_NEXT_BAR_K as i32
                    })
                    .arg(&{
                        use crate::cuda_pipeline::sp14_isv_slots::HOLD_COST_SCALE_INDEX;
                        HOLD_COST_SCALE_INDEX as i32
                    })
                    .arg(&{
                        use crate::cuda_pipeline::sp14_isv_slots::HOLD_REWARD_EMA_INDEX;
                        HOLD_REWARD_EMA_INDEX as i32
                    })
                    // SP20 Phase 3 Task 3.4 (2026-05-10): per-bar aux_conf
                    // output for the replay-buffer schema. Threaded as
                    // a `[N × L]` f32 buffer (counterfactual extension
                    // happens at the `aux_conf` assembly site at the
                    // end of `collect_experiences_gpu`, mirroring the
                    // `aux_sign_labels` `[N × L × 2]` final shape).
                    .arg(&mut self.aux_conf_per_sample)
                    // SP22 H6 Phase 3 β (2026-05-13): two new args for the
                    // event-driven aux-aligned trade-close bonus.
                    // - aux_dir_prob_per_env: same per-env recentered p_up
                    //   buffer that experience_state_gather reads (Phase 1+2
                    //   H6 wiring); β reads at segment_complete to compute
                    //   alignment. Reuses the existing prev_aux_dir_prob
                    //   buffer; no new allocation needed.
                    // - aux_align_scale_idx: ISV slot index for the
                    //   SP11-controller-emitted adaptive scale_β. In
                    //   Phase 3a (this commit) the SP11 controller has not
                    //   yet been extended, so the slot stays at sentinel 0
                    //   and β is functionally a no-op (r_aux_align = 0).
                    //   Phase 3b lands the controller extension to bring
                    //   scale_β non-zero.
                    .arg(&self.prev_aux_dir_prob.raw_ptr())
                    .arg(&{
                        use crate::cuda_pipeline::sp22_isv_slots::SP22_AUX_ALIGN_SCALE_INDEX;
                        SP22_AUX_ALIGN_SCALE_INDEX as i32
                    })
                    // SP22 H6 vNext Phase A3 (2026-05-13): save-for-backward
                    // buffers feeding the trade-outcome aux head's label
                    // producer. Written at every segment_complete bar
                    // alongside alpha_per_env with
                    // `plan_isv[PLAN_ISV_PNL_VS_TARGET]` and
                    // `plan_isv[PLAN_ISV_PNL_VS_STOP]` — the trade's
                    // realized P&L ratios vs profit_target / stop_loss
                    // at the exact close moment. Consumed by the label
                    // kernel (Phase A4/A5 wireup) to classify each
                    // trade-close as {Profit, Stop, Timeout}.
                    .arg(&mut self.pnl_vs_target_at_close_per_env)
                    .arg(&mut self.pnl_vs_stop_at_close_per_env)
                    .launch(launch_cfg)
                    .map_err(|e| MLError::ModelError(format!(
                        "experience_env_step t={t}: {e}"
                    )))?;
            }

            // ── SP22 H6 vNext Phase B3 (2026-05-14): trade-outcome label ──
            // Producer launched IMMEDIATELY after experience_env_step on
            // the same stream so the save-for-backward buffers
            // (`pnl_vs_target_at_close_per_env`, `pnl_vs_stop_at_close
            // _per_env`) and the per-(env, t) `trade_close_per_sample`
            // flag are valid for reading (stream-implicit producer →
            // consumer ordering).
            //
            // Per-env semantics: kernel emits `out_labels[env]` ∈
            // `{-1, 0, 1, 2}` where -1 = no trade close at this bar
            // (the common case ~95-99%). Labels persist across rollout
            // steps until the next trade-close overwrites them; the
            // sparse-CE consumer reads on `label != -1` so stale values
            // don't reach the loss numerator.
            //
            // Phase B3 (this commit): producer wired and runs every
            // step. The labels populate `exp_aux_to_label_buf` but
            // have no consumer yet — Phase B4 will wire the trainer-
            // side replay-buffer scatter alongside the trainer's
            // backward chain. Dead-code semantics: the launch is real
            // (validates kernel-arg threading + producer ordering)
            // but the label buffer is overwritten every step without
            // being consumed.
            {
                let t_i32 = t as i32;
                let l_i32 = timesteps as i32;
                // Phase B4b (2026-05-14): pass per-(env, t) buffer so the
                // kernel writes BOTH the per-env tile (consumed by Phase
                // C's per-step state assembly producer) AND the per-(env,
                // t) ring column (consumed by the replay-buffer scatter
                // chain in B4b-2).
                self.exp_aux_to_fwd.compute_label(
                    &self.stream,
                    self.trade_close_per_sample.raw_ptr(),
                    self.pnl_vs_target_at_close_per_env.raw_ptr(),
                    self.pnl_vs_stop_at_close_per_env.raw_ptr(),
                    t_i32,
                    n,           // n_envs in this rollout slice
                    timesteps,
                    self.exp_aux_to_label_buf.raw_ptr(),
                    self.exp_aux_to_label_per_sample.raw_ptr(),
                )?;
                let _ = l_i32; // documents the stride contract — consumer reads `env*L + t`
            }

            // ── 5b. SP15 Wave 2 (2026-05-06): per-step ISV-driven α
            // producer + fused post-SP11 reward-axis composer.
            //
            // The α producer (which already ran in pre-Wave-2 builds
            // as part of the deferred-consumer scaffolding) reads
            // grad-norm slots 418/419 + the `sp15_alpha_warm_count`
            // mapped-pinned scratch buffer, writes ALPHA_SPLIT (slot
            // 417) clamped to [0.05, 0.95] post-warmup, and OWNS the
            // warm-count increment (Q4 Wave 2 ownership: this fires
            // once per step from here, the fused composer has 2*N*L
            // threads and must NOT increment).
            //
            // The fused composer reads SP11's `rewards_out[0..N*2*L]`
            // in place, applies the SP15 §8.2 / §9.2 four-stage
            // composition (α-blend split → DD asymmetric reward →
            // quadratic DD penalty → SP12 v3 asymmetric cap via
            // state_layout.cuh macros REWARD_NEG_CAP=-10 /
            // REWARD_POS_CAP=+5 per Q1 resolution), and writes the
            // result back. Both on-policy AND CF slots get the same
            // DD-aware shaping (Q2 resolution); slots flagged
            // `slot_completed_normally=0` (early-return) are skipped
            // so SP11's terminal-episode sentinel rewards (0.0f /
            // -10.0f) pass through unmodified (Q3 resolution).
            //
            // Both launches are gated on `isv_signals_dev_ptr != 0`
            // and the trainer's pinned alpha_warm_count buffer
            // pointer being available — test scaffolds may run
            // experience_env_step without ISV wiring, in which case
            // both buffers receive their default values and the
            // fused composer would be a no-op anyway.
            // ── 5b. SP15 Phase 1.3.b-followup (2026-05-07): per-env DD ──
            // Order MUST be: dd_state (per-env tile producer) →
            // dd_state_reduce (ISV scalar consumer) → alpha producer →
            // final_reward (per-(i,t) consumer of the per-env tile).
            //
            // Path A → Path B migration: the original Phase 1.3.b
            // per-step launch wrote 6 ISV scalars from env-0 directly;
            // Path B writes a per-env tile then reduces. Each env in
            // production has its OWN portfolio + DD trajectory: when
            // env-3 is in 30% DD and env-0 is at ATH, the per-env
            // reward shaping in `compute_sp15_final_reward_kernel`
            // looks up env-3's `dd_pct=0.3` (not env-0's `dd_pct=0`)
            // and applies the recovery shaping correctly.
            //
            // Ordering: launched on the same stream as env_step → CUDA
            // serializes the four on-stream, so each consumer sees the
            // producers' writes (no event sync required). Outside the
            // exp-fwd graph capture region (which ends at line ~3829,
            // well before this point), so per
            // `pearl_no_host_branches_in_captured_graph.md` no graph
            // capture interaction.
            //
            // dd_budget = config.dd_threshold (0.02 default) — same
            // signal env_step uses for its w_dd penalty term, kept in
            // sync so the DD_PCT slot is comparable across consumers.
            let dd_state_per_env_ptr = self.sp15_dd_state_per_env.dev_ptr;
            // SP15 Phase 1.3.b-followup-B (2026-05-07): per-env trajectory
            // tile + persistent prev_dd_pct scratch are owned by THIS
            // collector struct (replaces the trainer's deleted [1]
            // scalar `sp15_dd_trajectory_prev_dd`).
            let dd_trajectory_per_env_ptr =
                self.sp15_dd_trajectory_per_env.dev_ptr;
            let dd_trajectory_prev_dd_per_env_ptr =
                self.sp15_dd_trajectory_prev_dd_per_env.dev_ptr;
            {
                let pos_state_ptr = self.portfolio_states.raw_ptr();
                let n_envs_i32 = self.alloc_episodes as i32;
                crate::cuda_pipeline::gpu_dqn_trainer::launch_sp15_dd_state(
                    &self.stream,
                    &self.exp_sp15_dd_state_kernel,
                    config.dd_threshold,
                    n_envs_i32,
                    dd_state_per_env_ptr,
                    pos_state_ptr,
                )?;
                // SP15 Phase 1.3.b-followup-B (2026-05-07): per-env
                // recovery-curriculum DD trajectory kernel.  Runs
                // BEFORE the reduce kernel so the reduce can also
                // mean-aggregate the per-env trajectory output into
                // ISV[DD_TRAJECTORY_DECREASING_INDEX=439] for HEALTH_DIAG
                // diagnostic preservation.  Reads:
                //   * `dd_state_per_env[env*6 + 5]` (per-env DD_PCT just
                //     written by `launch_sp15_dd_state`).
                //   * `sp15_dd_trajectory_prev_dd_per_env[env]`
                //     (persistent prev_dd_pct, fold-reset to 0.0).
                //   * `ISV[DD_TRAJECTORY_FLOOR_INDEX=441]` (structural
                //     0.02 anchor; same threshold across all envs).
                // Writes:
                //   * `sp15_dd_trajectory_per_env[env]` (per-env
                //     trajectory boolean — read by `per_insert_pa`
                //     per-transition via insert-time
                //     `env_id = (j % (n_envs * lookback)) / lookback`
                //     lookup).
                //   * advances `sp15_dd_trajectory_prev_dd_per_env[env]`
                //     to current per-env DD_PCT.
                // Gated on `isv_signals_dev_ptr` being wired (the
                // kernel reads ISV[441] for the floor — same gate as
                // the reduce + final-reward kernels). Test scaffolds
                // that don't wire ISV skip this kernel and the per-env
                // trajectory tile stays at its FoldReset 0.0 sentinel
                // (no recovery boost).
                if self.isv_signals_dev_ptr != 0 {
                    crate::cuda_pipeline::gpu_dqn_trainer::launch_sp15_dd_trajectory_decreasing(
                        &self.stream,
                        &self.exp_sp15_dd_trajectory_decreasing_kernel,
                        n_envs_i32,
                        dd_state_per_env_ptr,
                        dd_trajectory_prev_dd_per_env_ptr,
                        dd_trajectory_per_env_ptr,
                        self.isv_signals_dev_ptr,
                    )?;
                }
                // Reduce the per-env tile into the 6 scalar ISV slots
                // [401..407) (mean across envs — HEALTH_DIAG diagnostic)
                // plus DD_PERSISTENCE_MAX_INDEX=443 (max across envs —
                // plasticity-injection trigger), AND the per-env
                // trajectory tile into ISV[DD_TRAJECTORY_DECREASING_INDEX
                // =439] (mean across envs — HEALTH_DIAG diagnostic
                // preservation per Phase 1.3.b-followup-B). Gated on
                // `isv_signals_dev_ptr` being wired (test scaffolds may
                // run env_step without ISV; the per-env tile still gets
                // populated for the per-(i,t) lookup in the final-reward
                // kernel below, which is itself gated on the same ptr).
                if self.isv_signals_dev_ptr != 0 {
                    crate::cuda_pipeline::gpu_dqn_trainer::launch_sp15_dd_state_reduce(
                        &self.stream,
                        &self.exp_sp15_dd_state_reduce_kernel,
                        n_envs_i32,
                        dd_state_per_env_ptr,
                        dd_trajectory_per_env_ptr,
                        self.isv_signals_dev_ptr,
                    )?;
                }
            }

            if self.isv_signals_dev_ptr != 0 && self.sp15_alpha_warm_count_dev_ptr != 0 {
                use crate::cuda_pipeline::gpu_dqn_trainer::{
                    launch_sp15_alpha_split_producer, launch_sp15_final_reward,
                };
                launch_sp15_alpha_split_producer(
                    &self.stream,
                    &self.exp_sp15_alpha_split_producer_kernel,
                    self.isv_signals_dev_ptr,
                    self.sp15_alpha_warm_count_dev_ptr,
                ).map_err(|e| MLError::ModelError(format!(
                    "sp15 alpha_split_producer t={t}: {e}"
                )))?;

                let r_disc_dev = self.r_discipline_per_sample.device_ptr(&self.stream).0;
                let flag_dev   = self.slot_completed_normally_per_sample
                                     .device_ptr(&self.stream).0;
                let rewards_dev = self.rewards_out.device_ptr(&self.stream).0;
                launch_sp15_final_reward(
                    &self.stream,
                    &self.exp_sp15_final_reward_kernel,
                    rewards_dev,
                    r_disc_dev,
                    flag_dev,
                    self.isv_signals_dev_ptr,
                    dd_state_per_env_ptr,
                    n_i32,
                    l_i32,
                ).map_err(|e| MLError::ModelError(format!(
                    "sp15 final_reward t={t}: {e}"
                )))?;
            }

            // SP15 Phase 1.3.b-followup-B (2026-05-07): the previous
            // standalone step 5b `launch_sp15_dd_trajectory_decreasing`
            // call was MOVED into the dd_state block above (between
            // `launch_sp15_dd_state` and `launch_sp15_dd_state_reduce`)
            // so the reduction kernel can mean-aggregate the per-env
            // trajectory output into ISV[439] for HEALTH_DIAG
            // diagnostic preservation in the same launch. Production
            // wiring is now unconditional via the collector-owned
            // per-env tiles (`sp15_dd_trajectory_per_env` +
            // `sp15_dd_trajectory_prev_dd_per_env`) — the
            // `sp15_dd_trajectory_prev_dd_dev_ptr` setter / pointer
            // gate were deleted as part of this commit.

            // ── 5c. SP20 Phase 1.4 (2026-05-09, Path C) — fused-producer chain
            //
            // Stats → Aggregate → EMAs → Controllers, all on
            // `self.stream` after `experience_env_step` so the per-env
            // arrays we read (trade_close, step_ret, hold_at_exit,
            // actions_out) are already populated for the current `t`.
            // The aux-head outputs (exp_aux_nb_logits_buf for stats,
            // exp_aux_dir_acc_buf[0] for dir_acc) are populated by
            // step 3a-β above. Stream-implicit producer→consumer
            // ordering: every kernel here reads the prior kernel's
            // outputs without explicit event sync.
            //
            // Gated on `isv_signals_dev_ptr != 0` (the EMAs + controller
            // launches both write/read ISV; without it wired the EMAs
            // would have no destination). Also requires
            // `trainer_params_ptr != 0` so the aux head forward in
            // step 3 wrote real logits (not the alloc_zeros sentinel).
            if self.isv_signals_dev_ptr != 0 && self.trainer_params_ptr != 0 {
                use crate::cuda_pipeline::sp20_aggregate_inputs::launch_sp20_aggregate_inputs;
                use crate::cuda_pipeline::sp20_emas_compute::launch_sp20_emas_compute;
                use crate::cuda_pipeline::sp20_controllers_compute::launch_sp20_controllers_compute;
                use crate::cuda_pipeline::sp20_stats_compute::launch_sp20_stats_compute;

                let n_envs_i32_sp20 = n_episodes as i32;
                let aux_logits_dev = self.exp_aux_nb_logits_buf.raw_ptr();
                let stats_out_dev  = self.sp20_stats_output_buf.dev_ptr;
                // The stats kernel writes [p50, std] as 2 f32s; the
                // aggregate kernel reads p50 from offset 0 and std
                // from offset 1*sizeof(f32) = 4 bytes.
                let stats_p50_dev: u64 = stats_out_dev;
                let stats_std_dev: u64 = stats_out_dev
                    .checked_add(std::mem::size_of::<f32>() as u64)
                    .expect("sp20: stats_std_dev offset overflow");

                // 1. sp20_stats_compute — aux_logits → [p50, std].
                unsafe {
                    launch_sp20_stats_compute(
                        &self.stream,
                        &self.sp20_stats_compute_kernel,
                        aux_logits_dev,
                        stats_out_dev,
                        n_envs_i32_sp20,
                    )?;
                }

                // 2. sp20_aggregate_inputs — per-env arrays + stats +
                //    dir_acc → SP20EmaInputs struct.
                //
                // Pointer arithmetic: per-sample buffers are flat
                // `[N, L]` with `out_off = i*L + t`. The kernel strides
                // by `env_stride = L = alloc_timesteps` between envs,
                // so we pass `base_ptr + t * sizeof(elem)` for each
                // array (placing env-0's slice at element 0).
                let l = self.alloc_timesteps;
                let env_stride: i32 = l as i32;
                let t_offset_i32: u64 =
                    (t as u64).checked_mul(std::mem::size_of::<i32>() as u64)
                        .expect("sp20: t_offset_i32 overflow");
                let t_offset_f32: u64 =
                    (t as u64).checked_mul(std::mem::size_of::<f32>() as u64)
                        .expect("sp20: t_offset_f32 overflow");
                let trade_close_t_dev = self.trade_close_per_sample.raw_ptr()
                    .checked_add(t_offset_i32)
                    .expect("sp20: trade_close_t_dev overflow");
                let step_ret_t_dev = self.step_ret_per_sample.raw_ptr()
                    .checked_add(t_offset_f32)
                    .expect("sp20: step_ret_t_dev overflow");
                let hold_at_exit_t_dev = self.hold_at_exit_per_sample.raw_ptr()
                    .checked_add(t_offset_f32)
                    .expect("sp20: hold_at_exit_t_dev overflow");
                let actions_t_dev = self.actions_out.raw_ptr()
                    .checked_add(t_offset_i32)
                    .expect("sp20: actions_t_dev overflow");

                // dir_divisor = MAGNITUDES * ORD * URG (branch_sizes[1..4]).
                // hold_action_id = DIR_HOLD = 1 (per state_layout.cuh —
                // SHORT=0, HOLD=1, LONG=2, FLAT=3). Same decoding as
                // `hold_rate_observer_kernel.cu`.
                let dir_divisor: i32 = (self.branch_sizes[1]
                    * self.branch_sizes[2]
                    * self.branch_sizes[3]) as i32;
                let hold_action_id: i32 = 1; // DIR_HOLD

                // aux_dir_acc lives at the existing exp_aux_dir_acc_buf
                // [0] (populated by aux_dir_acc_reduce_kernel up at
                // step 3a-β). Reused as Phase 1.4's dir_acc input.
                let aux_dir_acc_dev = self.exp_aux_dir_acc_buf.dev_ptr;

                let ema_inputs_dev = self.sp20_ema_inputs_buf.dev_ptr;

                // SP20 Phase 2 Task 2.2 (2026-05-09): per-env alpha
                // input pointer. `alpha_per_env` is `[alloc_episodes]`
                // f32 device-resident, populated at every trade-close
                // by the SP20 4-quadrant reward block in
                // `experience_env_step` (launched immediately above on
                // the same stream — stream-implicit producer→consumer
                // ordering). Replaces the Phase 1.4 hardcoded
                // `out_inputs->alpha = 0.0f` placeholder atomically per
                // `feedback_no_partial_refactor`.
                let alpha_per_env_dev = self.alpha_per_env.raw_ptr();
                // SP20 Phase 2 Task 2.2-fix (2026-05-10): per-env
                // trade-close win flag pointer. `is_win_per_env` is
                // `[alloc_episodes]` i32 device-resident, populated at
                // every trade close by the SP20 4-quadrant reward block
                // in `experience_env_step` (launched immediately above
                // on the same stream — stream-implicit producer→
                // consumer ordering). Replaces the broken per-bar
                // `step_ret > 0` predicate that pinned WR_EMA at 0 in
                // production.
                let is_win_per_env_dev = self.is_win_per_env.raw_ptr();
                // SP21 T3.3 (2026-05-10): per-env per-bar Hold opp-cost
                // pointer. Populated every step by `experience_env_step`
                // (launched immediately above on the same stream — stream-
                // implicit producer→consumer ordering). Replaces the
                // hardcoded `out_inputs->per_bar_hold_reward = 0.0f`
                // placeholder atomically per `feedback_no_partial_refactor`.
                let per_bar_opp_cost_per_env_dev = self.per_bar_opp_cost_per_env.raw_ptr();
                unsafe {
                    launch_sp20_aggregate_inputs(
                        &self.stream,
                        &self.sp20_aggregate_inputs_kernel,
                        trade_close_t_dev,
                        step_ret_t_dev,
                        hold_at_exit_t_dev,
                        actions_t_dev,
                        env_stride,
                        stats_p50_dev,
                        stats_std_dev,
                        aux_dir_acc_dev,
                        alpha_per_env_dev,
                        is_win_per_env_dev,
                        per_bar_opp_cost_per_env_dev,
                        n_envs_i32_sp20,
                        dir_divisor,
                        hold_action_id,
                        ema_inputs_dev,
                    )?;
                }

                // 3. sp20_emas_compute — struct → 4 ISV EMAs +
                //    4 internal scratch.
                let emas_internal_dev = self.sp20_emas_internal_buf.dev_ptr;
                let emas_obs_count_dev = self.sp20_emas_obs_count_buf.dev_ptr;
                unsafe {
                    launch_sp20_emas_compute(
                        &self.stream,
                        &self.sp20_emas_compute_kernel,
                        self.isv_signals_dev_ptr,
                        ema_inputs_dev,
                        emas_internal_dev,
                        emas_obs_count_dev,
                    )?;
                }

                // 4. sp20_controllers_compute — EMAs → 6 ISV
                //    controllers.
                unsafe {
                    launch_sp20_controllers_compute(
                        &self.stream,
                        &self.sp20_controllers_compute_kernel,
                        self.isv_signals_dev_ptr,
                        emas_internal_dev,
                    )?;
                }
            }

            // ── 6. Advance GPU-resident step counter ────────────────────
            // Single-thread kernel: step_counter_gpu[0] += 1.
            // Runs on the same stream — serialized after env_step, before
            // the next iteration's state_gather.
            unsafe {
                self.stream
                    .launch_builder(&self.step_counter_kernel)
                    .arg(&mut self.step_counter_gpu)
                    .launch(step_counter_cfg)
                    .map_err(|e| MLError::ModelError(format!(
                        "step_counter_advance t={t}: {e}"
                    )))?;
            }
        }

        // #33 Saboteur: select best (worst for trader) params from this epoch
        if self.saboteur_active {
            let l_i32 = timesteps as i32;
            unsafe {
                self.stream
                    .launch_builder(&self.saboteur_select_kernel)
                    .arg(&self.saboteur_params_buf)
                    .arg(&self.raw_returns_out)
                    .arg(&mut self.saboteur_best_buf)
                    .arg(&mut self.saboteur_best_return_buf)
                    .arg(&n_i32)
                    .arg(&l_i32)
                    .launch(LaunchConfig {
                        grid_dim: (1, 1, 1),
                        block_dim: (256, 1, 1),
                        shared_mem_bytes: 0,
                    })
                    .map_err(|e| MLError::ModelError(format!(
                        "saboteur_select_best: {e}"
                    )))?;
            }
            // Copy best params → base for next epoch's perturbation center (GPU DtoD)
            let best_ptr = self.saboteur_best_buf.raw_ptr();
            let base_ptr = self.saboteur_base_buf.raw_ptr();
            unsafe {
                cudarc::driver::sys::cuMemcpyDtoDAsync_v2(
                    base_ptr, best_ptr,
                    3 * std::mem::size_of::<f32>(),
                    self.stream.cu_stream(),
                );
            }
            // Decay perturbation scale
            self.saboteur_perturbation_scale *= 0.995;
            self.saboteur_perturbation_scale = self.saboteur_perturbation_scale.max(0.05);
        }

        // Auto-clear reset flags after launch
        self.reset_flags = 0;

        Ok((n_episodes, timesteps))
    }

    // ═══════════════════════════════════════════════════════════════════════
    // Weight pointer management (zero-copy from trainer)
    // ═══════════════════════════════════════════════════════════════════════

    /// Set the raw device pointer to the trainer's flat params buffer.
    ///
    /// Must be called after the fused training context is created (the pointer
    /// is stable for the lifetime of the fused context). The experience
    /// collector reads weights directly from this address — no DtoD copy.
    pub fn set_trainer_params_ptr(&mut self, ptr: u64) {
        self.trainer_params_ptr = ptr;
        info!(trainer_params_ptr = ptr, "Experience collector: zero-copy trainer params pointer set");
    }

    /// SP14 Layer C Phase C.5a-fixup (2026-05-08): set the raw device
    /// pointers to the trainer's 6 aux trunk parameter tensors.
    ///
    /// Mirrors `set_trainer_params_ptr`'s zero-copy contract for the
    /// SP14 aux trunk (3-layer Linear→ELU→Linear→ELU→Linear MLP allocated
    /// in `GpuDqnTrainer::new` per Phase C.2). Pointers are stable for
    /// the lifetime of the trainer's `aux_trunk_{w,b}{1,2,3}` `CudaSlice`
    /// fields; called once from `training_loop.rs` adjacent to the
    /// existing `set_trainer_params_ptr` call site.
    ///
    /// Argument order matches `GpuDqnTrainer::aux_trunk_param_ptrs()`:
    /// `(w1, b1, w2, b2, w3, b3)`.
    ///
    /// DEAD in C.5a-fixup — the populated ptrs are not yet read by any
    /// collector-side launch (the `aux_trunk_forward.launch(...)` call
    /// is inserted atomically in C.5b alongside the
    /// `aux_heads_fwd input → h_s2_aux` kernel-rename + zero-fill revert).
    pub fn set_trainer_aux_trunk_param_ptrs(
        &mut self,
        w1: u64, b1: u64,
        w2: u64, b2: u64,
        w3: u64, b3: u64,
    ) {
        self.aux_trunk_w1_ptr = w1;
        self.aux_trunk_b1_ptr = b1;
        self.aux_trunk_w2_ptr = w2;
        self.aux_trunk_b2_ptr = b2;
        self.aux_trunk_w3_ptr = w3;
        self.aux_trunk_b3_ptr = b3;
        info!(
            aux_trunk_w1_ptr = w1, aux_trunk_b1_ptr = b1,
            aux_trunk_w2_ptr = w2, aux_trunk_b2_ptr = b2,
            aux_trunk_w3_ptr = w3, aux_trunk_b3_ptr = b3,
            "Experience collector: zero-copy aux trunk param pointers set (SP14-C.5a-fixup, DEAD until C.5b)"
        );
    }

    /// Fill per_sample_support_buf [alloc_episodes, 4, 3] with uniform v_min/v_max/delta_z
    /// broadcast across all 4 branches.
    ///
    /// Called once per epoch (not hot-path) when the eval v_range changes. The
    /// experience collector does not own per-branch Q-stats, so all 4 branches
    /// receive the same (v_min, v_max, delta_z) triple here; this preserves
    /// existing training-path semantics while making the buffer layout match the
    /// [N, 4, 3] stride-12 contract that compute_expected_q / quantile_q_select /
    /// mag_concat_qdir read (Phase 2d).
    pub fn update_per_sample_support(&mut self, v_min: f32, v_max: f32) -> Result<(), MLError> {
        let n = self.alloc_episodes;
        let delta_z = (v_max - v_min) / (self.num_atoms as f32 - 1.0).max(1.0);
        let data: Vec<f32> = (0..n)
            .flat_map(|_| (0..4).flat_map(|_| [v_min, v_max, delta_z]))
            .collect();
        // Direct host_ptr write into the mapped-pinned buffer — no HtoD copy.
        // The kernel reads via dev_ptr after the next stream sync barrier.
        self.per_sample_support_buf.write_from_slice(&data);
        debug!(v_min, v_max, delta_z, n, "per_sample_support tiled [N, 4, 3] for experience collector (mapped pinned)");
        Ok(())
    }
    pub fn set_per_sample_epsilon_ptr(&mut self, ptr: u64) {
        self.per_sample_epsilon_ptr = ptr;
    }

    // ═══════════════════════════════════════════════════════════════════════
    // Episode management
    // ═══════════════════════════════════════════════════════════════════════

    /// Reset all per-episode state for a new training epoch.
    pub fn reset_episodes(
        &mut self,
        initial_capital: f32,
        _avg_spread: f32,
        _cash_reserve_pct: f32,
    ) -> Result<(), MLError> {
        // Reset portfolio states [N, PORTFOLIO_STRIDE=40]
        let mut portfolio_init = vec![0.0_f32; self.alloc_episodes * PORTFOLIO_STRIDE];
        for i in 0..self.alloc_episodes {
            let off = i * PORTFOLIO_STRIDE;
            portfolio_init[off] = 0.0;                // [0] position
            portfolio_init[off + 1] = initial_capital; // [1] cash
            portfolio_init[off + 2] = initial_capital; // [2] portfolio_value
            // [3..6] dsr_A, dsr_B, pnl_ema, pnl_var = 0.0 (already zero)
            portfolio_init[off + 7] = initial_capital; // [7] peak_equity
            // [8] flat_counter = 0.0 (already zero)
            portfolio_init[off + 9] = initial_capital; // [9] prev_equity
            // [10] hold_time = 0.0, [11] realized_pnl = 0.0 (already zero)
        }
        upload_host_to_cuda_f32(
            &self.stream, &portfolio_init, &mut self.portfolio_states, "portfolio_states reset")?;

        // SP22 H6 Phase 2 (2026-05-12): FoldReset of the per-env aux
        // directional probability cache. Re-seed every slot to the 0.0
        // neutral sentinel (under the recentered `2*p - 1` encoding) so
        // the first state-gather call of the new fold reads "no aux
        // signal yet" with the same zero baseline used by every other
        // state slot. Pure GPU compute via fill_f32 — no HtoD per
        // `feedback_no_htod_htoh_only_mapped_pinned.md`.
        {
            let n_envs_i32 = self.alloc_episodes as i32;
            let sentinel: f32 = 0.0;
            let blocks = ((self.alloc_episodes as u32).div_ceil(256)).max(1);
            let prev_aux_dir_prob_ptr = self.prev_aux_dir_prob.raw_ptr();
            unsafe {
                self.stream
                    .launch_builder(&self.exp_fill_f32_kernel)
                    .arg(&prev_aux_dir_prob_ptr)
                    .arg(&sentinel)
                    .arg(&n_envs_i32)
                    .launch(LaunchConfig {
                        grid_dim: (blocks, 1, 1),
                        block_dim: (256, 1, 1),
                        shared_mem_bytes: 0,
                    })
                    .map_err(|e| MLError::ModelError(format!(
                        "sp22-h6: fill_f32(prev_aux_dir_prob=0.5) FoldReset: {e}"
                    )))?;
            }
        }

        debug!(
            initial_capital,
            "Episode state reset complete"
        );

        Ok(())
    }

    /// Set epoch-boundary reset flags for next kernel launch.
    /// Bit 0: reset portfolio. Bit 1: reset DSR. Bit 2: reset vol EMA.
    pub fn set_reset_flags(&mut self, flags: u32) {
        self.reset_flags = flags;
    }

    /// Clear reset flags (called automatically after kernel launch).
    pub fn clear_reset_flags(&mut self) {
        self.reset_flags = 0;
    }

    /// Get a reference to the epoch state GPU buffer (for monitoring kernel).
    pub fn epoch_state_gpu(&self) -> &CudaSlice<f32> {
        &self.epoch_state
    }

    /// Read DSR EMA state from GPU after experience collection.
    pub fn read_epoch_dsr_state(&self) -> Result<(f32, f32), MLError> {
        let mut host = vec![0.0_f32; 8];
        super::dtoh_f32(&self.stream, &self.epoch_state, &mut host)?;
        Ok((host[5], host[6]))
    }

    /// Allocated episode buffer capacity.
    pub fn alloc_episodes(&self) -> usize { self.alloc_episodes }
    /// Number of timesteps per episode in the pre-allocated output buffers.
    pub fn alloc_timesteps(&self) -> usize { self.alloc_timesteps }

    /// SP20 Phase 2 Task 2.2-fix (2026-05-10) — read-back of the per-env
    /// `is_win_per_env` `[alloc_episodes]` i32 trade-close win flag scratch.
    /// Producer site is the segment_complete branch in `experience_env_step`
    /// (writes `(segment_return > 0.0f) ? 1 : 0` per close); consumer is
    /// `sp20_aggregate_inputs_kernel` for the WR_EMA wins_count reduction.
    /// This getter exists for the production-path diagnostic test that
    /// validates the producer side end-to-end (the existing kernel oracle
    /// test only validates the consumer side).
    pub fn read_is_win_per_env_for_test(&self) -> Result<Vec<i32>, MLError> {
        let mut host = vec![0_i32; self.alloc_episodes];
        self.stream
            .memcpy_dtoh(&self.is_win_per_env, &mut host)
            .map_err(|e| MLError::ModelError(format!(
                "read_is_win_per_env_for_test memcpy_dtoh: {e}"
            )))?;
        Ok(host)
    }

    /// SP20 Phase 2 Task 2.2-fix (2026-05-10) — read-back of the per-env
    /// `alpha_per_env` `[alloc_episodes]` f32 trade-close alpha scratch.
    /// Companion to `read_is_win_per_env_for_test` — both are written at
    /// the same segment_complete site so the test asserts the producer
    /// fires by checking that BOTH buffers received writes (alpha as f32
    /// non-zero, is_win as i32 with at least one slot == 1 if any trade
    /// closed profitably).
    pub fn read_alpha_per_env_for_test(&self) -> Result<Vec<f32>, MLError> {
        let mut host = vec![0_f32; self.alloc_episodes];
        self.stream
            .memcpy_dtoh(&self.alpha_per_env, &mut host)
            .map_err(|e| MLError::ModelError(format!(
                "read_alpha_per_env_for_test memcpy_dtoh: {e}"
            )))?;
        Ok(host)
    }

    /// Raw device pointer to the portfolio_state buffer [alloc_episodes * PS_STRIDE].
    /// Used by the kelly_cap_update kernel (Plan 1 Task 11) to aggregate Kelly
    /// win/loss stats across environments at epoch boundaries without a GPU→CPU roundtrip.
    pub fn portfolio_states_dev_ptr(&self) -> u64 {
        let (ptr, _guard) = self.portfolio_states.device_ptr(&self.stream);
        ptr
    }

    /// SP14 Layer C Phase C.4b (2026-05-08): raw device pointer to
    /// `hold_at_exit_per_sample` `[alloc_episodes * alloc_timesteps]` —
    /// `saved_hold_time` on exit/reverse, else 0.0. Consumed by
    /// `avg_win_hold_time_update_kernel` at the per-epoch boundary to
    /// compute the EMA of winning-trade hold time.
    pub fn hold_at_exit_per_sample_dev_ptr(&self) -> u64 {
        let (ptr, _guard) = self.hold_at_exit_per_sample.device_ptr(&self.stream);
        ptr
    }

    /// SP14 Layer C Phase C.4b (2026-05-08): raw device pointer to
    /// `trade_profitable_per_sample` `[alloc_episodes * alloc_timesteps]`
    /// — 1 iff (trade_close && step_ret > 0). Consumed by
    /// `avg_win_hold_time_update_kernel` to mask the hold-time mean to
    /// winning trades only.
    pub fn trade_profitable_per_sample_dev_ptr(&self) -> u64 {
        let (ptr, _guard) = self.trade_profitable_per_sample.device_ptr(&self.stream);
        ptr
    }

    /// Class A P0-A (2026-05-08): raw device pointer to
    /// `step_ret_per_sample` `[alloc_episodes * alloc_timesteps]` —
    /// `step_ret_core` raw signed per-step return (populated by
    /// `unified_env_step_core` in `experience_kernels.cu`, line ~2779).
    /// Consumed by `reward_cap_update_kernel` at the per-epoch boundary
    /// to compute the p99 of winning realized returns for the adaptive
    /// REWARD_POS/NEG_CAP ISV slots.
    pub fn step_ret_per_sample_dev_ptr(&self) -> u64 {
        let (ptr, _guard) = self.step_ret_per_sample.device_ptr(&self.stream);
        ptr
    }

    /// Class A P0-A (2026-05-08): raw device pointer to
    /// `trade_close_per_sample` `[alloc_episodes * alloc_timesteps]` —
    /// 1 iff (exiting_trade || reversing_trade) (populated by
    /// `unified_env_step_core` in `experience_kernels.cu`, line ~2780).
    /// Consumed by `reward_cap_update_kernel` to mask the realized-return
    /// distribution to actual trade-close events.
    pub fn trade_close_per_sample_dev_ptr(&self) -> u64 {
        let (ptr, _guard) = self.trade_close_per_sample.device_ptr(&self.stream);
        ptr
    }

    /// Class A audit-fix Batch 4-A (2026-05-08): raw device pointer to
    /// the per-env DD state tile `sp15_dd_state_per_env[n_envs * 6]`
    /// (populated by `dd_state_kernel` at the same boundary). Consumed
    /// by `dd_saturation_floor_update_kernel` to aggregate per-env
    /// DD_MAX (offset 1) into the adaptive saturation floor in
    /// `ISV[DD_SATURATION_FLOOR_ADAPTIVE_INDEX=458]`. The buffer is
    /// owned by this collector (mirrors the
    /// `step_ret_per_sample_dev_ptr` / `trade_close_per_sample_dev_ptr`
    /// pattern from P0-A).
    pub fn dd_state_per_env_dev_ptr(&self) -> u64 {
        self.sp15_dd_state_per_env.dev_ptr
    }

    /// Last experience count from the most recent `collect_experiences_gpu` call.
    pub fn last_experience_count(&self) -> usize { self.last_experience_count }

    /// Get a reference to the CUDA stream used by this collector.
    pub fn stream(&self) -> &Arc<CudaStream> {
        &self.stream
    }

    /// Get a reference to the rewards output GPU buffer (f32).
    pub fn rewards_gpu(&self) -> &CudaSlice<f32> {
        &self.rewards_out
    }

    /// G13: Sharpe-aware rank-normalize rewards — sort-based O(N log N).
    /// Computes sharpe[i] = (r[i] - mean) / std, then ranks by |sharpe|.
    /// r_shaped[i] = sign(sharpe[i]) * rank(|sharpe[i]|).
    /// Uses rewards_out as read-only source, writes shaped values to `rewards_dst`.
    ///
    /// Three-kernel pipeline (replaces O(N²) tiled kernel):
    ///   1. reward_compute_abs_sharpe — compute |sharpe| keys + indices, pad to power-of-2
    ///   2. bitonic_sort_step — O(log²N) passes of compare-swap
    ///   3. reward_scatter_rank — write rank from sorted position to original index
    pub fn shape_rewards(&self, rewards_dst: &mut CudaSlice<f32>, n: usize, reward_std: f32, reward_mean: f32) -> Result<(), MLError> {
        if n == 0 { return Ok(()); }

        let n_padded = n.next_power_of_two();
        if n_padded > self.sort_buf_capacity {
            return Err(MLError::ModelError(format!(
                "reward sort buffer overflow: n_padded={n_padded} > capacity={}",
                self.sort_buf_capacity
            )));
        }

        let n_i32 = n as i32;
        let n_padded_i32 = n_padded as i32;

        // ── Step 1: Compute |sharpe| keys + original indices ──────────
        let pad_blocks = ((n_padded + 255) / 256) as u32;
        unsafe {
            self.stream
                .launch_builder(&self.reward_compute_abs_sharpe_kernel)
                .arg(&self.rewards_out)          // raw rewards source
                .arg(&self.reward_sort_keys_buf)  // |sharpe| keys out
                .arg(&self.reward_sort_indices_buf) // original indices out
                .arg(&n_i32)
                .arg(&n_padded_i32)
                .arg(&reward_std)
                .arg(&reward_mean)
                .launch(LaunchConfig {
                    grid_dim: (pad_blocks, 1, 1),
                    block_dim: (256, 1, 1),
                    shared_mem_bytes: 0,
                })
                .map_err(|e| MLError::ModelError(format!("reward_compute_abs_sharpe: {e}")))?;
        }

        // ── Step 2: Bitonic sort — O(log²N) kernel launches ───────────
        let half_n = n_padded / 2;
        let sort_blocks = ((half_n + 255) / 256) as u32;

        let mut stage: i32 = 1;
        while (stage as usize) < n_padded {
            let mut step = stage;
            while step >= 1 {
                unsafe {
                    self.stream
                        .launch_builder(&self.bitonic_sort_step_kernel)
                        .arg(&self.reward_sort_keys_buf)
                        .arg(&self.reward_sort_indices_buf)
                        .arg(&n_padded_i32)
                        .arg(&stage)
                        .arg(&step)
                        .launch(LaunchConfig {
                            grid_dim: (sort_blocks, 1, 1),
                            block_dim: (256, 1, 1),
                            shared_mem_bytes: 0,
                        })
                        .map_err(|e| MLError::ModelError(format!("bitonic_sort_step: {e}")))?;
                }
                step >>= 1;
            }
            stage <<= 1;
        }

        // ── Step 3: Scatter rank from sorted positions ────────────────
        let data_blocks = ((n + 255) / 256) as u32;
        unsafe {
            self.stream
                .launch_builder(&self.reward_scatter_rank_kernel)
                .arg(&self.rewards_out)               // raw rewards for sign recomputation
                .arg(&self.reward_sort_indices_buf)    // sorted indices
                .arg(rewards_dst)                      // output shaped rewards
                .arg(&n_i32)
                .arg(&reward_std)
                .arg(&reward_mean)
                .launch(LaunchConfig {
                    grid_dim: (data_blocks, 1, 1),
                    block_dim: (256, 1, 1),
                    shared_mem_bytes: 0,
                })
                .map_err(|e| MLError::ModelError(format!("reward_scatter_rank: {e}")))?;
        }

        Ok(())
    }

    /// Sort collected rewards and extract `num_atoms` evenly-spaced quantiles.
    ///
    /// Returns a `Vec<f32>` of quantile positions on the HOST (small DtoH: num_atoms floats).
    /// Uses the bitonic sort infrastructure to sort raw rewards, then a gather kernel
    /// to extract evenly-spaced positions from the sorted array.
    ///
    /// Used to warm-start C51 adaptive atom positions each epoch.
    pub fn compute_reward_quantiles(&mut self, n: usize, num_atoms: usize) -> Result<Vec<f32>, MLError> {
        if n == 0 || num_atoms == 0 {
            return Ok(vec![0.0; num_atoms]);
        }
        if num_atoms > 256 {
            return Err(MLError::ModelError(format!(
                "compute_reward_quantiles: num_atoms={num_atoms} > 256 scratch limit"
            )));
        }

        let n_padded = n.next_power_of_two();
        if n_padded > self.sort_buf_capacity {
            return Err(MLError::ModelError(format!(
                "reward sort buffer overflow: n_padded={n_padded} > capacity={}",
                self.sort_buf_capacity
            )));
        }

        let n_i32 = n as i32;
        let n_padded_i32 = n_padded as i32;
        let num_atoms_i32 = num_atoms as i32;

        // Step 1: Copy raw rewards to sort keys + fill indices (no |sharpe| transform)
        let pad_blocks = ((n_padded + 255) / 256) as u32;
        unsafe {
            self.stream
                .launch_builder(&self.reward_copy_raw_kernel)
                .arg(&self.rewards_out)
                .arg(&self.reward_sort_keys_buf)
                .arg(&self.reward_sort_indices_buf)
                .arg(&n_i32)
                .arg(&n_padded_i32)
                .launch(LaunchConfig {
                    grid_dim: (pad_blocks, 1, 1),
                    block_dim: (256, 1, 1),
                    shared_mem_bytes: 0,
                })
                .map_err(|e| MLError::ModelError(format!("reward_copy_raw_for_sort: {e}")))?;
        }

        // Step 2: Bitonic sort — O(log^2 N) kernel launches
        let half_n = n_padded / 2;
        let sort_blocks = ((half_n + 255) / 256) as u32;

        let mut stage: i32 = 1;
        while (stage as usize) < n_padded {
            let mut step = stage;
            while step >= 1 {
                unsafe {
                    self.stream
                        .launch_builder(&self.bitonic_sort_step_kernel)
                        .arg(&self.reward_sort_keys_buf)
                        .arg(&self.reward_sort_indices_buf)
                        .arg(&n_padded_i32)
                        .arg(&stage)
                        .arg(&step)
                        .launch(LaunchConfig {
                            grid_dim: (sort_blocks, 1, 1),
                            block_dim: (256, 1, 1),
                            shared_mem_bytes: 0,
                        })
                        .map_err(|e| MLError::ModelError(format!("bitonic_sort_step (quantiles): {e}")))?;
                }
                step >>= 1;
            }
            stage <<= 1;
        }

        // Step 3: Gather evenly-spaced quantile positions from sorted array
        let gather_blocks = ((num_atoms + 255) / 256) as u32;
        unsafe {
            self.stream
                .launch_builder(&self.gather_quantiles_kernel)
                .arg(&self.reward_sort_keys_buf)  // sorted rewards
                .arg(&self.quantiles_scratch_buf)  // output [num_atoms]
                .arg(&n_i32)
                .arg(&num_atoms_i32)
                .launch(LaunchConfig {
                    grid_dim: (gather_blocks, 1, 1),
                    block_dim: (256, 1, 1),
                    shared_mem_bytes: 0,
                })
                .map_err(|e| MLError::ModelError(format!("gather_quantiles: {e}")))?;
        }

        // Step 4: DtoH — read num_atoms floats from quantiles scratch
        let quantile_slice = self.quantiles_scratch_buf.slice(..num_atoms);
        let mut host_quantiles = vec![0.0_f32; num_atoms];
        self.stream.memcpy_dtoh(&quantile_slice, &mut host_quantiles)
            .map_err(|e| MLError::ModelError(format!("dtoh quantiles: {e}")))?;

        Ok(host_quantiles)
    }

    /// Extract median, Q25, Q75 from the already-sorted reward_sort_keys_buf.
    ///
    /// MUST be called AFTER `compute_reward_quantiles()` which sorts the rewards.
    /// Reads 3 specific positions from the sorted GPU buffer (tiny DtoH: 3 floats).
    /// Returns `(median, iqr)` where `iqr = Q75 - Q25`.
    pub fn extract_median_iqr(&self, n: usize) -> Result<(f32, f32), MLError> {
        if n < 4 {
            return Ok((0.0, 1.0)); // degenerate: skip normalization
        }
        let idx_q25 = n / 4;
        let idx_median = n / 2;
        let idx_q75 = 3 * n / 4;

        // Read 3 specific floats from sorted GPU buffer
        let mut q25_host = [0.0_f32];
        let mut median_host = [0.0_f32];
        let mut q75_host = [0.0_f32];

        let q25_slice = self.reward_sort_keys_buf.slice(idx_q25..idx_q25 + 1);
        let median_slice = self.reward_sort_keys_buf.slice(idx_median..idx_median + 1);
        let q75_slice = self.reward_sort_keys_buf.slice(idx_q75..idx_q75 + 1);

        self.stream.memcpy_dtoh(&q25_slice, &mut q25_host)
            .map_err(|e| MLError::ModelError(format!("dtoh Q25: {e}")))?;
        self.stream.memcpy_dtoh(&median_slice, &mut median_host)
            .map_err(|e| MLError::ModelError(format!("dtoh median: {e}")))?;
        self.stream.memcpy_dtoh(&q75_slice, &mut q75_host)
            .map_err(|e| MLError::ModelError(format!("dtoh Q75: {e}")))?;

        let iqr = q75_host[0] - q25_host[0];
        Ok((median_host[0], iqr))
    }

    /// Get a reference to the actions output GPU buffer.
    pub fn actions_gpu(&self) -> &CudaSlice<i32> {
        &self.actions_out
    }

    /// Mutable access to the episode starts GPU buffer (for curriculum learning).
    pub fn episode_starts_buf_mut(&mut self) -> &mut CudaSlice<i32> {
        &mut self.episode_starts_buf
    }

    /// Train curiosity forward model directly on GPU using experience data.
    ///
    /// `weight_clamp_max_abs` is SP3 Mech 9 (close-out v2): ISV-driven |p|
    /// bound for `curiosity_adam_step`. The collector doesn't own ISV — the
    /// caller (training_loop) reads `100 × ISV[Q_ABS_REF].max(1.0)` from the
    /// FusedTrainingCtx and threads it through here. 0=disabled (debug only).
    ///
    /// `cur_beta1/cur_beta2/cur_epsilon` are SP5 Pearl 4: ISV-driven Adam
    /// hyperparameters for ParamGroup::Curiosity (index 7). Caller reads
    /// ISV[ADAM_BETA1_BASE+7], ISV[ADAM_BETA2_BASE+7], ISV[ADAM_EPS_BASE+7]
    /// from FusedTrainingCtx and threads through here.
    pub fn train_curiosity_gpu(
        &mut self,
        n_episodes: usize,
        timesteps: usize,
        weight_clamp_max_abs: f32,
        cur_beta1: f32,
        cur_beta2: f32,
        cur_epsilon: f32,
    ) -> Result<(), MLError> {
        if let Some(ref mut trainer) = self.curiosity_trainer {
            let total = n_episodes.saturating_mul(timesteps);
            if total < 2 {
                return Ok(());
            }
            trainer.train_on_collector_buffers(
                &mut self.curiosity_weights,
                &self.states_out,
                &self.actions_out,
                total,
                weight_clamp_max_abs,
                cur_beta1,
                cur_beta2,
                cur_epsilon,
            )?;
        }
        Ok(())
    }

    /// Whether the GPU curiosity trainer is active.
    pub fn has_curiosity_trainer(&self) -> bool {
        self.curiosity_trainer.is_some()
    }

    /// Access the curiosity weight set (for wiring to the fused trainer Q-penalty).
    /// The returned reference has stable CudaSlice addresses — values are updated
    /// in-place by the curiosity trainer, so raw pointers captured in CUDA Graphs
    /// remain valid.
    pub fn curiosity_weight_set(&self) -> &super::gpu_weights::CuriosityWeightSet {
        &self.curiosity_weights
    }

    /// Access the GPU curiosity trainer (grad + Adam state). Returns `None`
    /// when curiosity is disabled at collector init or after `disable_curiosity`.
    /// SP4 Layer A param-group oracle wiring pairs this with `curiosity_weight_set`
    /// to populate the curiosity sub-buffer descriptor in `SP4AuxBuffers`.
    pub fn curiosity_trainer(&self) -> Option<&GpuCuriosityTrainer> {
        self.curiosity_trainer.as_ref()
    }

    /// SP4 Task A14: thread the main DQN trainer's mapped-pinned Pearl C
    /// buffers (`nan_flags_buf`, `clamp_engage_per_block_buf`) into the
    /// curiosity trainer. Called once post-construction by
    /// `FusedTrainingCtx`. No-op when curiosity is disabled.
    pub fn set_curiosity_pearl_c_buffers(&mut self, nan_flags: u64, engage_buf: u64) {
        if let Some(ref mut trainer) = self.curiosity_trainer {
            trainer.set_pearl_c_buffers(nan_flags, engage_buf);
        }
    }

    /// SP4 Layer A Task A13.5 (2026-05-01); GPU-Pearls refactor (2026-05-01):
    /// thread the main DQN trainer's mapped-pinned Pearls A+D buffer
    /// pointers (`producer_step_scratch_buf` + `wiener_state_buf` +
    /// `isv_signals_pinned`, both host and dev sides for each) into this
    /// experience collector. Called once post-construction by
    /// `FusedTrainingCtx::wire_reward_component_pearls_buffers`, mirroring
    /// the A14/A15 Pearl C wiring path.
    ///
    /// The collector's `launch_reward_component_ema_inplace` uses these
    /// to (1) tell the producer kernel where to write step observations
    /// (`scratch_dev_ptr`), and (2) tell the GPU `apply_pearls_ad_kernel`
    /// where to read/write Wiener state + ISV bound slots
    /// (`wiener_state_dev_ptr` + `isv_dev_ptr`). The host-side pointers
    /// are retained ONLY for the post-construction `debug_assert!`
    /// validity check; they are NOT dereferenced on the hot path
    /// (host-side compute eliminated by `feedback_no_cpu_compute_strict.md`).
    #[allow(clippy::too_many_arguments)]
    pub fn set_reward_component_pearls_buffers(
        &mut self,
        wiener_state_dev_ptr: u64,
        wiener_state_host_ptr: *mut f32,
        scratch_dev_ptr: u64,
        scratch_host_ptr: *mut f32,
        isv_dev_ptr: u64,
        isv_pinned_ptr: *mut f32,
    ) {
        self.reward_component_pearls_wiener_dev_ptr   = wiener_state_dev_ptr;
        self.reward_component_pearls_wiener_host_ptr  = wiener_state_host_ptr;
        self.reward_component_pearls_scratch_dev_ptr  = scratch_dev_ptr;
        self.reward_component_pearls_scratch_host_ptr = scratch_host_ptr;
        self.reward_component_pearls_isv_dev_ptr      = isv_dev_ptr;
        self.reward_component_pearls_isv_pinned_ptr   = isv_pinned_ptr;
    }

    /// Disable curiosity training (e.g. after an async kernel crash).
    pub fn disable_curiosity(&mut self) {
        self.curiosity_trainer = None;
    }

    /// Upload OFI features to GPU for kernel-side state construction.
    pub fn upload_ofi_features(&mut self, ofi_flat: &[f32]) -> Result<(), MLError> {
        if self.ofi_dim == 0 {
            return Err(MLError::ModelError(
                "OFI_DIM=0 but OFI upload requested — state_dim must accommodate 20 OFI features".into()
            ));
        }
        if ofi_flat.is_empty() {
            return Err(MLError::ModelError("OFI data empty — cannot upload empty features to GPU".into()));
        }
        // Mapped pinned: host-write via host_ptr; kernel reads via dev_ptr.
        // Replaces the 1-element placeholder allocated in the constructor.
        // No DtoD copy per feedback_no_htod_htoh_only_mapped_pinned.
        let gpu_buf = unsafe { super::mapped_pinned::MappedF32Buffer::new(ofi_flat.len()) }
            .map_err(|e| MLError::ModelError(format!("upload_ofi_features alloc ({} f32): {e}", ofi_flat.len())))?;
        gpu_buf.write_from_slice(ofi_flat);
        info!(
            "OFI features uploaded to GPU: {} bars x {} dims ({:.1} KB)",
            ofi_flat.len() / self.ofi_dim,
            self.ofi_dim,
            (ofi_flat.len() * 4) as f64 / 1024.0,
        );
        self.ofi_gpu = gpu_buf;
        Ok(())
    }

    /// v8: Compute per-bar difficulty scores for curriculum learning.
    ///
    /// Analyses target price movements to assign a difficulty score to each bar.
    /// Higher scores indicate more volatile / harder-to-trade bars. The output
    /// can be used to bias episode start selection toward harder regions.
    pub fn compute_difficulty_scores(
        &self,
        targets: &super::mapped_pinned::MappedF32Buffer,
        total_bars: usize,
    ) -> Result<CudaSlice<f32>, MLError> {
        let scores = self.stream.alloc_zeros::<f32>(total_bars)
            .map_err(|e| MLError::ModelError(format!("alloc difficulty_scores: {e}")))?;
        // Mapped-pinned dev_ptr aliases the host pages — kernels read it directly.
        let targets_ptr = targets.dev_ptr;
        let scores_ptr = scores.raw_ptr();
        let n = total_bars as i32;
        let blocks = ((total_bars + 255) / 256) as u32;
        unsafe {
            self.stream.launch_builder(&self.difficulty_scores_kernel)
                .arg(&targets_ptr).arg(&scores_ptr).arg(&n)
                .launch(LaunchConfig { grid_dim: (blocks, 1, 1), block_dim: (256, 1, 1), shared_mem_bytes: 0 })
                .map_err(|e| MLError::ModelError(format!("compute_difficulty_scores: {e}")))?;
        }
        Ok(scores)
    }

    /// v8: Hindsight relabel episode transitions with optimal exit points.
    ///
    /// Scans completed episodes and replaces the reward signal with the
    /// reward from the best possible exit point (hindsight optimal action).
    /// This improves sample efficiency by showing the agent what it *could*
    /// have earned, reducing the credit assignment delay.
    pub fn hindsight_relabel(
        &self,
        rewards: &mut CudaSlice<f32>,
        dones: &CudaSlice<f32>,
        n_transitions: usize,
    ) -> Result<(), MLError> {
        let rewards_ptr = rewards.raw_ptr();
        let dones_ptr = dones.raw_ptr();
        let n = n_transitions as i32;
        let blocks = ((n_transitions + 255) / 256) as u32;
        unsafe {
            self.stream.launch_builder(&self.hindsight_relabel_kernel)
                .arg(&rewards_ptr)
                .arg(&dones_ptr)
                .arg(&n)
                .launch(LaunchConfig { grid_dim: (blocks, 1, 1), block_dim: (256, 1, 1), shared_mem_bytes: 0 })
                .map_err(|e| MLError::ModelError(format!("hindsight_relabel: {e}")))?;
        }
        Ok(())
    }

    /// v8: Compute TD(lambda) returns from 1-step rewards and value estimates.
    ///
    /// Replaces n-step fixed-horizon returns with exponentially-weighted
    /// lambda-returns that blend 1-step, 2-step, ... n-step estimates.
    /// This reduces bias/variance tradeoff sensitivity compared to fixed n.
    pub fn compute_td_lambda_returns(
        &self,
        rewards: &CudaSlice<f32>,
        values: &CudaSlice<f32>,
        dones: &CudaSlice<f32>,
        output: &mut CudaSlice<f32>,
        n_episodes: usize,
        n_timesteps: usize,
        gamma: f32,
        lambda: f32,
    ) -> Result<(), MLError> {
        let rewards_ptr = rewards.raw_ptr();
        let values_ptr = values.raw_ptr();
        let dones_ptr = dones.raw_ptr();
        let output_ptr = output.raw_ptr();
        let n_ep = n_episodes as i32;
        let n_ts = n_timesteps as i32;
        let blocks = ((n_episodes + 255) / 256) as u32;
        unsafe {
            self.stream.launch_builder(&self.td_lambda_kernel)
                .arg(&rewards_ptr)
                .arg(&values_ptr)
                .arg(&dones_ptr)
                .arg(&output_ptr)
                .arg(&n_ep)
                .arg(&n_ts)
                .arg(&gamma)
                .arg(&lambda)
                .launch(LaunchConfig { grid_dim: (blocks, 1, 1), block_dim: (256, 1, 1), shared_mem_bytes: 0 })
                .map_err(|e| MLError::ModelError(format!("td_lambda: {e}")))?;
        }
        Ok(())
    }

}

// ---------------------------------------------------------------------------
// Experience kernel compilation
// ---------------------------------------------------------------------------

/// Compile the 3 focused experience kernels + compute_expected_q from NVRTC source.
///
/// Injects STATE_DIM and MARKET_DIM as #defines before the kernel source
/// (which uses #ifndef guards for fallback defaults).
/// Precompiled experience kernels cubin, embedded at compile time by build.rs.
static EXPERIENCE_KERNELS_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/experience_kernels.cubin"));
/// Precompiled nstep kernel cubin.
static NSTEP_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/nstep_kernel.cubin"));
/// Precompiled HER episode kernel cubin.
static HER_EPISODE_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/her_episode_kernel.cubin"));
/// Precompiled reward rank normalization kernel cubin.
static REWARD_SHAPING_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/reward_shaping_kernel.cubin"));
/// SP13 Layer B Commit B1.1b (2026-05-05): producer kernel cubin for the
/// aux next-bar sign label `[total] i32` ring column. See
/// `aux_sign_label_kernel.cu` for the kernel body and
/// `gpu_experience_collector.rs::collect_experiences_gpu` for the launch
/// site (after `bar_indices_pinned` is filled, before the
/// `GpuExperienceBatch` return).
static SP13_AUX_SIGN_LABEL_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/aux_sign_label_kernel.cubin"));

/// SP14 β-migration step 3 (Option B, 2026-05-07): per-rollout-step
/// variant of `aux_sign_label_kernel`. The trajectory kernel writes
/// `[total]` after the step loop; this kernel writes `[n_episodes]`
/// every step using `bar = episode_starts[ep] + t`. See
/// `aux_sign_label_per_step_kernel.cu` for the kernel body.
static SP14_AUX_SIGN_LABEL_PER_STEP_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/aux_sign_label_per_step_kernel.cubin"));

fn compile_experience_kernels(
    stream: &Arc<CudaStream>,
    _state_dim: usize,
    _market_dim: usize,
) -> Result<(CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction), MLError> {
    // Load precompiled cubin (state_dim/market_dim are runtime kernel params)
    let context = stream.context();
    let module = context
        .load_cubin(EXPERIENCE_KERNELS_CUBIN.to_vec())
        .map_err(|e| MLError::ModelError(format!("experience kernel module load: {e}")))?;

    let state_gather = module
        .load_function("experience_state_gather")
        .map_err(|e| MLError::ModelError(format!("load experience_state_gather: {e}")))?;
    let action_select = module
        .load_function("experience_action_select")
        .map_err(|e| MLError::ModelError(format!("load experience_action_select: {e}")))?;
    let expert_override = module
        .load_function("expert_action_override")
        .map_err(|e| MLError::ModelError(format!("load expert_action_override: {e}")))?;
    let env_step = module
        .load_function("experience_env_step")
        .map_err(|e| MLError::ModelError(format!("load experience_env_step: {e}")))?;
    let expected_q = module
        .load_function("compute_expected_q")
        .map_err(|e| MLError::ModelError(format!("load compute_expected_q: {e}")))?;
    let step_counter = module
        .load_function("step_counter_advance")
        .map_err(|e| MLError::ModelError(format!("load step_counter_advance: {e}")))?;
    let noise_kernel = module
        .load_function("add_advantage_noise")
        .map_err(|e| MLError::ModelError(format!("load add_advantage_noise: {e}")))?;

    Ok((state_gather, action_select, expert_override, env_step, expected_q, step_counter, noise_kernel))
}

/// Compile the n-step return accumulation kernel.
///
/// Converts 1-step (s,a,r,s',d) transitions into n-step (s,a,R_n,s_{t+n},d_n)
/// by accumulating discounted rewards and OR-ing done flags over n steps.
fn compile_nstep_kernel(
    stream: &Arc<CudaStream>,
) -> Result<(CudaFunction, CudaFunction), MLError> {
    let context = stream.context();
    let module = context
        .load_cubin(NSTEP_CUBIN.to_vec())
        .map_err(|e| MLError::ModelError(format!("nstep_kernel module load: {e}")))?;
    let nstep = module
        .load_function("nstep_accumulate_kernel")
        .map_err(|e| MLError::ModelError(format!("load nstep_accumulate_kernel: {e}")))?;
    let td_lambda = module
        .load_function("td_lambda_kernel")
        .map_err(|e| MLError::ModelError(format!("td_lambda_kernel load: {e}")))?;
    // regime_scale_td_errors — loaded from dqn_utility_kernels.cu (trainer module),
    // not from nstep_kernel.cu. The trainer's version uses STATE_DIM compile-time constant.
    Ok((nstep, td_lambda))
}

/// Compile the HER episode boundary kernels (fill_episode_ids, future, final).
fn compile_fill_episode_ids_kernel(
    stream: &Arc<CudaStream>,
) -> Result<CudaFunction, MLError> {
    let context = stream.context();
    let module = context
        .load_cubin(HER_EPISODE_CUBIN.to_vec())
        .map_err(|e| MLError::ModelError(format!("her_episode_kernel module load: {e}")))?;
    module
        .load_function("fill_episode_ids_kernel")
        .map_err(|e| MLError::ModelError(format!("load fill_episode_ids_kernel: {e}")))
}

/// Load the trade_stats_reduce kernel from precompiled cubin (ZERO runtime nvcc).
fn compile_trade_stats_kernel(
    stream: &Arc<CudaStream>,
) -> Result<CudaFunction, MLError> {
    static TRADE_STATS_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/trade_stats_kernel.cubin"));
    let context = stream.context();
    let module = context
        .load_cubin(TRADE_STATS_CUBIN.to_vec())
        .map_err(|e| MLError::ModelError(format!("trade_stats cubin load: {e}")))?;
    module
        .load_function("trade_stats_reduce")
        .map_err(|e| MLError::ModelError(format!("load trade_stats_reduce: {e}")))
}

/// Fill episode_ids[i] = i / L on GPU. Zero CPU in hot path.
///
/// Returns a fresh `CudaSlice<i32>` of length `total` with episode IDs.
fn fill_episode_ids_gpu(
    stream: &Arc<CudaStream>,
    kernel: &CudaFunction,
    total: usize,
    timesteps_per_episode: usize,
) -> Result<CudaSlice<i32>, MLError> {
    let mut episode_ids = stream.alloc_zeros::<i32>(total)
        .map_err(|e| MLError::ModelError(format!("alloc episode_ids[{total}]: {e}")))?;

    let l_i32 = timesteps_per_episode as i32;
    let total_i32 = total as i32;
    let blocks = ((total + 255) / 256) as u32;

    // Safety: episode_ids is a valid GPU allocation of `total` i32 elements.
    // l_i32 and total_i32 are non-negative and consistent with the allocation.
    unsafe {
        stream
            .launch_builder(kernel)
            .arg(&mut episode_ids)
            .arg(&l_i32)
            .arg(&total_i32)
            .launch(LaunchConfig {
                grid_dim: (blocks, 1, 1),
                block_dim: (256, 1, 1),
                shared_mem_bytes: 0,
            })
            .map_err(|e| MLError::ModelError(format!("fill_episode_ids_kernel: {e}")))?;
    }

    Ok(episode_ids)
}

// ---------------------------------------------------------------------------
// Pure cudarc DtoD helpers
// ---------------------------------------------------------------------------

/// Allocate a fresh `CudaSlice<f32>` and DtoD-copy `n_elems` f32 elements from `src`.
fn dtod_clone_f32_native(
    stream: &Arc<CudaStream>,
    src: &CudaSlice<f32>,
    n_elems: usize,
    label: &str,
) -> Result<CudaSlice<f32>, MLError> {
    let mut dst = stream.alloc_zeros::<f32>(n_elems)
        .map_err(|e| MLError::ModelError(format!("alloc {label} f32[{n_elems}]: {e}")))?;
    let src_view = src.slice(..n_elems);
    stream.memcpy_dtod(&src_view, &mut dst)
        .map_err(|e| MLError::ModelError(format!("DtoD {label}: {e}")))?;
    Ok(dst)
}

/// Allocate a fresh `CudaSlice<i32>` and DtoD-copy `n_elems` ints from `src`.
fn dtod_clone_i32(
    stream: &Arc<CudaStream>,
    src: &CudaSlice<i32>,
    n_elems: usize,
    label: &str,
) -> Result<CudaSlice<i32>, MLError> {
    let mut dst = stream.alloc_zeros::<i32>(n_elems)
        .map_err(|e| MLError::ModelError(format!("alloc {label} i32[{n_elems}]: {e}")))?;
    let src_view = src.slice(..n_elems);
    stream.memcpy_dtod(&src_view, &mut dst)
        .map_err(|e| MLError::ModelError(format!("DtoD {label}: {e}")))?;
    Ok(dst)
}

/// #30 Build next-states via GPU kernel (f32 version).
/// Build next_states via GPU kernel — zero CPU loops, single kernel launch.
///
/// For each element (ep, t, d):
///   if t < shifted_count: dst[ep,t,d] = src[ep, t+shift, d]
///   else:                 dst[ep,t,d] = src[ep, timesteps-1, d]
#[allow(unsafe_code)]
fn build_next_states_f32(
    stream: &Arc<CudaStream>,
    states: &CudaSlice<f32>,
    n_episodes: usize,
    timesteps: usize,
    sd: usize,
    shift: usize,
) -> Result<CudaSlice<f32>, MLError> {
    let total = n_episodes * timesteps;
    let total_elems = total * sd;
    let shift = shift.max(1).min(timesteps);

    if timesteps <= shift {
        return dtod_clone_f32_native(stream, states, total_elems, "next_states_trivial_f32");
    }

    let mut dst = stream.alloc_zeros::<f32>(total_elems)
        .map_err(|e| MLError::ModelError(format!("alloc next_states f32[{total_elems}]: {e}")))?;

    // Single GPU kernel replaces ~795K individual memcpy_dtod calls at 4096 episodes
    let context = stream.context();
    let module = context.load_cubin(
        super::gpu_dqn_trainer::DQN_UTILITY_CUBIN.to_vec()
    ).map_err(|e| MLError::ModelError(format!("build_next_states cubin load: {e}")))?;
    let kernel = module.load_function("build_next_states_kernel")
        .map_err(|e| MLError::ModelError(format!("build_next_states_kernel load: {e}")))?;

    let n_ep = n_episodes as i32;
    let ts = timesteps as i32;
    let sd_i32 = sd as i32;
    let shift_i32 = shift as i32;
    let blocks = ((total_elems + 255) / 256) as u32;

    unsafe {
        use cudarc::driver::PushKernelArg;
        stream.launch_builder(&kernel)
            .arg(&mut dst)
            .arg(states)
            .arg(&n_ep)
            .arg(&ts)
            .arg(&sd_i32)
            .arg(&shift_i32)
            .launch(cudarc::driver::LaunchConfig {
                grid_dim: (blocks, 1, 1),
                block_dim: (256, 1, 1),
                shared_mem_bytes: 0,
            })
            .map_err(|e| MLError::ModelError(format!("build_next_states_kernel launch: {e}")))?;
    }

    Ok(dst)
}

/// Upload `host` (f32) into `dst` via mapped-pinned staging + DtoD copy.
///
/// `dst` is a long-lived `CudaSlice` mutated by GPU kernels (so the
/// destination type cannot itself be a `MappedF32Buffer`). The staging
/// `MappedF32Buffer` provides the only allowed CPU↔GPU path per
/// `feedback_no_htod_htoh_only_mapped_pinned.md`; the DtoD copy is fully on-
/// device and trivially compliant.
fn upload_host_to_cuda_f32(
    stream: &Arc<CudaStream>,
    host: &[f32],
    dst: &mut CudaSlice<f32>,
    label: &str,
) -> Result<(), MLError> {
    debug_assert!(host.len() <= dst.len(), "{label}: host overflow");
    // Safety: a CUDA context is active on the calling thread.
    let staging = unsafe {
        MappedF32Buffer::new(host.len())
            .map_err(|e| MLError::ModelError(format!("{label} staging alloc: {e}")))?
    };
    staging.write_from_slice(host);

    let nbytes = host.len() * std::mem::size_of::<f32>();
    let src_ptr = staging.dev_ptr;
    let (dst_ptr, _dst_guard) = dst.device_ptr_mut(stream);
    unsafe {
        cudarc::driver::result::memcpy_dtod_async(
            dst_ptr, src_ptr, nbytes, stream.cu_stream(),
        ).map_err(|e| MLError::ModelError(format!("{label} DtoD: {e}")))?;
    }
    // Sync so the staging buffer can drop safely after the copy completes.
    stream.synchronize()
        .map_err(|e| MLError::ModelError(format!("{label} sync: {e}")))?;
    Ok(())
}

/// `i32` counterpart to `upload_host_to_cuda_f32`.
fn upload_host_to_cuda_i32(
    stream: &Arc<CudaStream>,
    host: &[i32],
    dst: &mut CudaSlice<i32>,
    label: &str,
) -> Result<(), MLError> {
    debug_assert!(host.len() <= dst.len(), "{label}: host overflow");
    // Safety: a CUDA context is active on the calling thread.
    let staging = unsafe {
        MappedI32Buffer::new(host.len())
            .map_err(|e| MLError::ModelError(format!("{label} staging alloc: {e}")))?
    };
    staging.write_from_slice(host);

    let nbytes = host.len() * std::mem::size_of::<i32>();
    let src_ptr = staging.dev_ptr;
    let (dst_ptr, _dst_guard) = dst.device_ptr_mut(stream);
    unsafe {
        cudarc::driver::result::memcpy_dtod_async(
            dst_ptr, src_ptr, nbytes, stream.cu_stream(),
        ).map_err(|e| MLError::ModelError(format!("{label} DtoD: {e}")))?;
    }
    stream.synchronize()
        .map_err(|e| MLError::ModelError(format!("{label} sync: {e}")))?;
    Ok(())
}

