//! Integrated RL trainer: BCE + DQN/C51 Q + PPO π + V + aux on a shared
//! Mamba2+CfC encoder.
//!
//! Phase E.2 + E.2-DEFER (this commit closes 4 deferred items):
//!   1. `PerceptionTrainer::backward_encoder_with_grad_h_t` —
//!      the combined Q+π+V grad_h_t is folded into the encoder backward
//!      path via the new entry point, so the encoder is no longer
//!      BCE+aux-only.
//!   2. Bellman target projection kernel —
//!      replaces the host-side `build_synthetic_bellman_target` stand-in
//!      with `bellman_target_projection.cu`, reading γ from ISV[400] and
//!      bootstrapping atom probabilities from the target-network output.
//!   3. Per-head LR ISV controller —
//!      `rl_lr_controller.cu` emits per-head learning rates to
//!      ISV[412..417]; the host reads them via the ISV mirror and
//!      mutates each `AdamW.lr` field before the step. The
//!      `PHASE_E2_DEFAULT_LR` constant is DELETED.
//!   4. PPO V-loss canonicalisation —
//!      `ppo_clipped_surrogate_fwd` no longer touches `returns_/v_pred/
//!      loss_v`; the V signal flows ONLY through the dedicated
//!      `v_head_fwd_bwd` kernels per
//!      `feedback_single_source_of_truth_no_duplicates`.
//!
//! Architecture (synthetic-data path):
//!
//!   1. `perception.forward_encoder(snapshots)` produces `h_t [B × HIDDEN_DIM]`
//!      via the captured-graph encoder forward path.
//!   2. `dqn_head.forward(h_t, ...)`          → `q_logits  [B × N_ACTIONS × Q_N_ATOMS]`
//!      `policy_head.forward_logits(h_t, ...)` → `pi_logits [B × N_ACTIONS]`
//!      `value_head.forward(h_t, ...)`         → `v_pred    [B]`
//!   3. `policy_head.surrogate_forward(...)` consumes pi_logits + synthetic
//!      log π_old + advantages + ISV(ε,coef) → π loss + entropy loss + per-
//!      batch log π / entropy diagnostics.  (V-loss path moved out — see #4)
//!   4. Bellman target build (kernel path, item 2):
//!        a. `dqn_head.forward_target(...)` → `q_target_logits [B × N_ACTIONS × Q_N_ATOMS]`
//!        b. `dqn_head.fused_select_and_project_bellman(...)` → `target_dist [B × Q_N_ATOMS]`
//!           (action-atom extraction + C51 projection in one kernel launch,
//!           reading γ from ISV[RL_GAMMA_INDEX=400])
//!   5. `dqn_head.backward_logits(...)` → `q_loss [1]` + `grad_logits`.
//!   6. `dqn_head.backward_to_w_b_h(...)` → per-batch grad_w / grad_b
//!      scratch + grad_h_t (OVERWRITE).
//!   7. `rl_q_pi_distill_grad` kernel → `pi_grad_logits` (target-Q distill + SAC entropy).
//!   8. `policy_head.backward_to_w_b_h(...)` → per-batch grad_w / grad_b
//!      + grad_h_t (OVERWRITE).
//!   9. `value_head.backward(...)` → per-batch loss + grad_w / grad_b
//!      + grad_h_t (OVERWRITE).
//!  10. `reduce_axis0` collapses per-batch grad_w / grad_b → final
//!      `grad_w [...]` / `grad_b [...]` for each head.
//!  11. LR ISV controller (item 3): emit ISV[412..417], DtoH-mirror, and
//!      mutate per-head AdamW.lr.
//!  12. Per-head `AdamW.step()` runs on the reduced gradients. Each head
//!      owns 2 Adam instances (one for `w_d`, one for `b_d`).
//!  13. `grad_h_accumulate_scaled` folds Q/π/V grad_h_t (loss-balance λ
//!      weighted) into the encoder grad slot `grad_h_t_combined_d`.
//!  14. `perception.backward_encoder_with_grad_h_t(grad_h_t_combined_d)`
//!      runs the CfC + Mamba2 bwd + encoder Adam step (item 1).
//!  15. Per-batch losses (V head + DQN loss scalar) are reduced/read back
//!      via mapped-pinned and returned in `IntegratedStepStats`.
//!
//! Constraints honoured:
//!   * `feedback_no_stubs.md`         — every kernel runs real math, no
//!     placeholder host losses.
//!   * `feedback_no_atomicadd.md`     — new kernels use per-batch scratch
//!     + `reduce_axis0` reduction (DQN/PPO bwd loss scalars still atomic
//!     — Phase C/D deferral, kept loud-flagged in their kernel headers).
//!   * `feedback_no_htod_htoh_only_mapped_pinned.md` — all CPU↔GPU paths
//!     via `MappedF32Buffer` from `pinned_mem.rs`. ISV + loss accumulators
//!     are mapped-pinned (GPU writes via dev_ptr, host reads via host_ptr,
//!     zero DtoD copies, zero syncs).
//!   * `feedback_no_nvrtc.md`         — pre-compiled cubins via build.rs.
//!   * `pearl_controller_anchors_isv_driven` — λs + per-head LRs read
//!     from ISV; ε / coef read inside the surrogate kernel from
//!     `ISV[402,403]`; γ read inside the Bellman projection from
//!     `ISV[RL_GAMMA_INDEX=400]`.
//!   * `feedback_single_source_of_truth_no_duplicates` — V-loss path now
//!     lives ONLY in the v_head kernels; PPO surrogate dropped its
//!     redundant loss_v accumulator.

use std::sync::Arc;

use anyhow::{Context, Result};
use cudarc::driver::{
    CudaEvent, CudaFunction, CudaGraph, CudaModule, CudaSlice, CudaStream,
};
use cudarc::driver::sys::{CUgraphInstantiate_flags, CUstream, CUstreamCaptureMode};
use ml_core::device::MlDevice;

use crate::cfc::snap_features::Mbp10RawInput;
use crate::heads::HIDDEN_DIM;
use crate::pinned_mem::{MappedF32Buffer, MappedI32Buffer};
use rand::{Rng, SeedableRng};

use crate::rl::common::{Q_N_ATOMS, Q_V_MAX, Q_V_MIN};
use crate::rl::dqn::{DqnHead, DqnHeadConfig};
use crate::rl::iqn::EMBED_DIM;
use crate::rl::noisy::{NoisyLinear, NoisyLinearConfig};
use crate::rl::isv_slots::{
    RL_ACTION_ENTROPY_EMA_INDEX, RL_ADVANTAGE_VAR_RATIO_EMA_INDEX,
    RL_ADV_VAR_RATIO_CLAMP_INDEX, RL_ADV_VAR_RATIO_TARGET_INDEX,
    RL_ADV_VAR_STREAM_M2_INDEX, RL_ADV_VAR_STREAM_MEAN_INDEX,
    RL_ANTIMARTINGALE_KAPPA_INDEX,
    RL_AVG_LOSS_USD_EMA_INDEX, RL_AVG_WIN_USD_EMA_INDEX,
    RL_C51_V_MAX_INDEX, RL_C51_V_MIN_INDEX,
    RL_CONF_GATE_FIRED_COUNT_INDEX, RL_CONSEC_LOSS_COUNT_INDEX,
    RL_CONSEC_LOSS_LIMIT_INDEX, RL_COOLDOWN_DURATION_INDEX,
    RL_COOLDOWN_REMAINING_STEPS_INDEX, RL_CUMULATIVE_DONES_INDEX,
    RL_DIV_TARGET_INDEX, RL_ENTROPY_COEF_INDEX, RL_ENTROPY_COEF_MIN_INDEX,
    RL_ENTROPY_OBSERVED_EMA_INDEX, RL_ENTROPY_TARGET_FRAC_INDEX,
    RL_EPS_BOOTSTRAP_INDEX,
    RL_EVAL_ENTROPY_COEF_MIN_DEFENSIVE_INDEX, RL_EVAL_ENTROPY_COEF_MIN_NORMAL_INDEX,
    RL_EVAL_IQN_TAU_MIN_DEFENSIVE_INDEX, RL_EVAL_IQN_TAU_MIN_NORMAL_INDEX,
    RL_EVAL_KELLY_SAFETY_DEFENSIVE_INDEX, RL_EVAL_KELLY_SAFETY_NORMAL_INDEX,
    RL_EVAL_PPO_CLIP_EPS_MIN_DEFENSIVE_INDEX, RL_EVAL_PPO_CLIP_EPS_MIN_NORMAL_INDEX,
    RL_FRD_GATE_FIRED_COUNT_INDEX,
    RL_GAMMA_INDEX, RL_HEAT_CAP_FIRED_COUNT_INDEX, RL_HEAT_CAP_MAX_LOTS_INDEX,
    RL_IMPROVEMENT_THRESHOLD_INDEX,
    RL_INVENTORY_PENALTY_BETA_INDEX, RL_INVENTORY_VARIANCE_EMA_INDEX,
    RL_IQN_ACTION_TAU_DD_SENSITIVITY_INDEX, RL_IQN_ACTION_TAU_INDEX,
    RL_IQN_ACTION_TAU_MIN_INDEX,
    RL_IQN_TAU_TAIL_BOOST_FACTOR_INDEX, RL_IQN_TAU_TAIL_BOOST_N_WINDOW_INDEX,
    RL_KELLY_EPS_RECOVERY_LIVE_INDEX, RL_KELLY_EPS_RECOVERY_MAX_INDEX,
    RL_KELLY_EPS_RECOVERY_MIN_INDEX, RL_KELLY_EPS_RECOVERY_N_RECOVERY_INDEX,
    RL_KELLY_FRACTION_INDEX, RL_KELLY_MIN_TRADES_FOR_RELEASE_INDEX,
    RL_KELLY_SAFETY_FRAC_INDEX, RL_KL_PI_EMA_INDEX, RL_KL_TARGET_INDEX,
    RL_KURT_GAUSSIAN_INDEX, RL_KURT_LIFT_SCALE_INDEX, RL_KURT_NOISE_FLOOR_INDEX,
    RL_K_LOOP_DIVISOR_INDEX, RL_K_LOOP_MAX_INDEX,
    RL_LOSS_LAMBDA_AUX_INDEX, RL_LR_AUX_INDEX, RL_LR_BCE_INDEX,
    RL_LR_BOOTSTRAP_INDEX, RL_LR_DECAY_FACTOR_INDEX,
    RL_LR_LOSS_EMA_ALPHA_INDEX, RL_LR_MAX_INDEX, RL_LR_MIN_INDEX,
    RL_LR_PI_BEST_LOSS_INDEX, RL_LR_PI_INDEX, RL_LR_PI_LOSS_EMA_INDEX,
    RL_LR_PI_STEPS_SINCE_BEST_INDEX, RL_LR_PI_WARMUP_COUNTER_INDEX,
    RL_LR_Q_BEST_LOSS_INDEX, RL_LR_Q_INDEX, RL_LR_Q_LOSS_EMA_INDEX,
    RL_LR_Q_STEPS_SINCE_BEST_INDEX, RL_LR_Q_WARMUP_COUNTER_INDEX,
    RL_LR_SCALE_IQN_INDEX, RL_LR_SCALE_PI_INDEX, RL_LR_SCALE_Q_INDEX,
    RL_LR_SCALE_V_INDEX,
    RL_LR_V_BEST_LOSS_INDEX, RL_LR_V_INDEX, RL_LR_V_LOSS_EMA_INDEX,
    RL_LR_V_STEPS_SINCE_BEST_INDEX, RL_LR_V_WARMUP_COUNTER_INDEX,
    RL_LR_WARMUP_STEPS_INDEX,
    RL_MAX_ABS_SCALED_REWARD_PRE_CLAMP_INDEX, RL_MAX_OPEN_UNITS_INDEX,
    RL_MEAN_ABS_PNL_EMA_INDEX, RL_MEAN_TRADE_DURATION_EMA_INDEX,
    RL_NEG_SCALED_REWARD_MAX_EMA_INDEX, RL_NEG_SCALED_REWARD_MAX_INDEX,
    RL_NET_INVENTORY_LIMIT_USD_INDEX, RL_N_ROLLOUT_STEPS_INDEX,
    RL_OUTCOME_AUX_LAMBDA_INDEX, RL_PER_ALPHA_INDEX,
    RL_PI_GRAD_NORM_EMA_INDEX, RL_PLATEAU_PATIENCE_INDEX,
    RL_POPART_MAX_ABS_REWARD_EMA_INDEX, RL_POPART_MAX_DECAY_ALPHA_INDEX,
    RL_POPART_MEAN_INDEX, RL_POPART_SIGMA_INDEX, RL_POPART_VAR_INDEX,
    RL_POS_SCALED_REWARD_MAX_EMA_INDEX, RL_POS_SCALED_REWARD_MAX_INDEX,
    RL_PPO_CLAMP_MARGIN_INDEX, RL_PPO_CLIP_EPS_MIN_INDEX, RL_PPO_CLIP_INDEX,
    RL_PPO_LOG_RATIO_ABS_MAX_INDEX, RL_PPO_RATIO_CLAMP_BOOTSTRAP_INDEX,
    RL_PPO_RATIO_CLAMP_MAX_INDEX, RL_PYRAMID_ADD_COUNT_INDEX,
    RL_Q_ARG_VS_PI_AGREE_INDEX, RL_Q_BIAS_CORRECTION_INDEX, RL_Q_BIAS_EMA_INDEX,
    RL_Q_DISTILL_KL_EMA_INDEX, RL_Q_DISTILL_KL_TARGET_INDEX,
    RL_Q_DISTILL_LAMBDA_INDEX, RL_Q_DISTILL_TEMPERATURE_INDEX,
    RL_Q_DIVERGENCE_EMA_INDEX, RL_Q_GRAD_NORM_EMA_INDEX,
    RL_REGIME_DEAD_ZONE_DURATION_INDEX, RL_REGIME_DEAD_ZONE_FLAG_INDEX,
    RL_REGIME_DEAD_ZONE_MAX_DURATION_INDEX,
    RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX, RL_REGIME_RECOVERY_FACTOR_INDEX,
    RL_REGIME_SESSION_PNL_VARIANCE_EMA_INDEX,
    RL_REGIME_SESSION_PNL_VAR_COUNT_INDEX, RL_REGIME_TAIL_EVENT_RECENCY_INDEX,
    RL_REGIME_TAIL_SIGMA_THRESHOLD_INDEX,
    RL_REGIME_TRANSITION_DECAY_STEPS_CONFIG_INDEX,
    RL_REGIME_TRANSITION_REMAINING_INDEX, RL_REGIME_TRANSITION_STEPS_CONFIG_INDEX,
    RL_REWARD_CLAMP_CLIP_RATE_EMA_INDEX, RL_REWARD_CLAMP_CLIP_RATE_TARGET_INDEX,
    RL_REWARD_CLAMP_LOSS_INDEX, RL_REWARD_CLAMP_MARGIN_INDEX,
    RL_REWARD_CLAMP_RATIO_INDEX, RL_REWARD_CLAMP_WIN_INDEX,
    RL_REWARD_SCALE_BOOTSTRAP_INDEX, RL_REWARD_SCALE_INDEX,
    RL_REWARD_SCALE_MIN_INDEX, RL_ROLLOUT_BOOTSTRAP_INDEX,
    RL_SAC_ALPHA_INDEX, RL_SAC_ENTROPY_TARGET_INDEX,
    RL_SCHULMAN_ADJUST_RATE_INDEX, RL_SCHULMAN_TOLERANCE_INDEX,
    RL_SESSION_DD_LIMIT_USD_INDEX, RL_SESSION_DD_TRIGGERED_INDEX,
    RL_SESSION_PNL_USD_INDEX, RL_SESSION_PNL_WORST_INDEX,
    RL_SLOTS_END, RL_SPECTRAL_DECOUPLE_LAMBDA_INDEX,
    RL_SPECTRAL_NORM_MAX_INDEX, RL_STREAM_ALPHA_INDEX,
    RL_TARGET_TAU_INDEX, RL_TAU_BOOTSTRAP_INDEX,
    RL_TD_KURTOSIS_CLAMP_INDEX, RL_TD_KURTOSIS_EMA_INDEX,
    RL_TD_KURT_STREAM_M2_INDEX, RL_TD_KURT_STREAM_M4_INDEX,
    RL_TD_KURT_STREAM_MEAN_INDEX,
    RL_TRAIL_LOOSEN_FACTOR_INDEX, RL_TRAIL_TIGHTEN_FACTOR_INDEX,
    RL_V_GRAD_NORM_EMA_INDEX, RL_WIN_RATE_EMA_INDEX,
};
use crate::rl::common::{FRD_N_ATOMS, FRD_N_HORIZONS, N_ACTIONS};
use crate::rl::frd::FRD_OUT_DIM;
use crate::rl::loss_balance::{read_loss_lambdas_from_isv, LossLambdas};
use crate::rl::multi_head_policy::{MultiHeadPolicy, MultiHeadPolicyConfig};
use crate::rl::ppo::{PolicyHead, PpoHeadsConfig, ValueHead};
use crate::rl::reward::RlLobBackend;
use crate::trainer::optim::AdamW;
use crate::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig};
use crate::trainer::raw_launch::{
    RawArgs, raw_launch, raw_graph_launch, raw_event_record,
    raw_stream_wait_event, raw_stream_sync, raw_memset_d8_zero,
    raw_memcpy_dtod_async,
};

const GRAD_H_ACCUMULATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/grad_h_accumulate.cubin"));
const REDUCE_AXIS0_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/reduce_axis0.cubin"));
const RL_LR_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_lr_controller.cubin"));

// Phase R1: cubin includes for the 7 RL adaptive controllers
// (γ / τ / ε / entropy_coef / n_rollout_steps / per_α / reward_scale).
// All 7 share the `(isv*, alpha, scalar_input)` signature and the
// `pearl_first_observation_bootstrap` sentinel-zero path. Loaded in
// `IntegratedTrainer::new` + bootstrap-launched once at construction so
// every consumer kernel sees the canonical default in its ISV slot
// before any in-loop access.
const RL_GAMMA_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_gamma_controller.cubin"));
const RL_TARGET_TAU_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_target_tau_controller.cubin"));
const RL_PPO_CLIP_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_ppo_clip_controller.cubin"));
const RL_ENTROPY_COEF_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_entropy_coef_controller.cubin"));
const RL_ROLLOUT_STEPS_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_rollout_steps_controller.cubin"));
const RL_PER_ALPHA_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_per_alpha_controller.cubin"));
const RL_REWARD_SCALE_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_reward_scale_controller.cubin"));

// Phase R3: GPU-resident EMA + advantage/return kernels. Generic over
// the ISV slot they update (one kernel serves multiple controller-input
// EMAs). Replace the host-side EMA + advantage loops the flawed Phase F
// shipped in step_with_lobsim (which violated `feedback_cpu_is_read_only`).
const EMA_UPDATE_ON_DONE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/ema_update_on_done.cubin"));
const EMA_UPDATE_PER_STEP_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/ema_update_per_step.cubin"));
const COMPUTE_ADVANTAGE_RETURN_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/compute_advantage_return.cubin"));
const ACTION_ENTROPY_PER_STEP_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/action_entropy_per_step.cubin"));

// Phase R4: GPU-resident action sampling kernels. Replace the host
// Thompson + host argmax + host log-softmax loops the flawed Phase F
// shipped in step_with_lobsim (which violated `feedback_cpu_is_read_only`).
const RL_ACTION_KERNEL_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_action_kernel.cubin"));
const ARGMAX_EXPECTED_Q_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/argmax_expected_q.cubin"));
const LOG_PI_AT_ACTION_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/log_pi_at_action.cubin"));

// Phase R6: GPU-pure env interaction kernels. apply_reward_scale multiplies
// rewards by ISV[406]; actions_to_market_targets translates the 9-action
// grid to LobSim's market_targets[B*2] format on device.
const APPLY_REWARD_SCALE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/apply_reward_scale.cubin"));
// Adaptive reward-clamp controller — audit fix 2026-05-24. Consumes the
// per-step positive-tail max written by apply_reward_scale into slot
// 478 and emits adaptive WIN/LOSS bounds into slots 452/453. See the
// kernel header for the design rationale (rmgm5 clamp-firing audit).
const RL_REWARD_CLAMP_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_reward_clamp_controller.cubin"));
// C51 atom-support updater — audit 2026-05-24 followup. Refreshes
// `atom_supports_d` from the ISV V_MIN/V_MAX slots that the reward
// clamp controller writes (with ratchet semantics). Launched right
// after the reward clamp controller so atom_supports_d reflects the
// just-updated V_MIN/V_MAX before any C51 forward / Bellman target
// projection in the next step. 21-thread one-block kernel.
const RL_ATOM_SUPPORT_UPDATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_atom_support_update.cubin"));
// Q→π distillation gradient — audit 2026-05-24 vj5f6 followup. ADDS
// λ × (π_new - π_target) to pi_grad_logits after the PPO surrogate
// backward, where π_target = softmax(E_Q[s,*] / τ). Couples Q's
// improved C51 calibration to π's action selection (was decoupled
// per Option B / `pearl_q_thompson_actor_makes_pi_dead_weight`).
// C51+IQN ensemble action-value kernel (audit 2026-05-25). Combines
// E_C51 and E_IQN into a single ensemble Q vector via ISV α blend.
// Feeds Q→π agreement diag and future ensemble-level selection.
const RL_ENSEMBLE_ACTION_VALUE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_ensemble_action_value.cubin"));
/// Phase 4.4 adaptive V blend kernels.
const RL_V_BLEND_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_v_blend.cubin"));
const RL_V_BLEND_ALPHA_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_v_blend_alpha_controller.cubin"));
/// Phase 4.5 per-batch advantage normalization.
const RL_ADVANTAGE_NORMALIZE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_advantage_normalize.cubin"));
/// Welford online variance updater for adaptive controller-input floors
/// (spec `2026-05-30-adaptive-controller-floor-design`). Single-thread,
/// single-block. Caller passes ISV bus + input/count/mean/M² slot indices.
const RL_SIGNAL_VARIANCE_UPDATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_signal_variance_update.cubin"));
// Adaptive risk management — layered stack (spec 2026-05-30-adaptive-risk-management).
// 4 controllers + 3 EMA producers feeding Layers 1-4. Layer D (trail
// tighten/loosen) is read-only ISV consumed by the existing rl_trail_mutate.
const RL_CMDP_CONSTRAINTS_CHECK_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_cmdp_constraints_check.cubin"));
const RL_IQN_ACTION_TAU_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_iqn_action_tau_controller.cubin"));
const RL_INVENTORY_BETA_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_inventory_beta_controller.cubin"));
const RL_KELLY_FRACTION_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_kelly_fraction_controller.cubin"));
const RL_SURFER_SCAFFOLD_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_surfer_scaffold_controller.cubin"));
const RL_GATE_LR_MULTIPLIER_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_gate_lr_multiplier_controller.cubin"));
const RL_EVAL_WARMUP_DECAY_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_eval_warmup_decay.cubin"));
// Regime observer (F1.2 + F1.3) — flat-count block-reduce + state-machine
// signal emitter. Launched each env step: flat_count first (writes
// flat_count_d), then regime_observer (reads flat_count_d + ISV state,
// emits 6 surface signals + Welford state + ε_recovery_live).
const RL_REGIME_FLAT_COUNT_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_regime_flat_count.cubin"));
const RL_REGIME_OBSERVER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_regime_observer.cubin"));
const RL_WIN_RATE_EMA_UPDATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_win_rate_ema_update.cubin"));
const RL_AVG_WIN_LOSS_EMA_UPDATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_avg_win_loss_ema_update.cubin"));
const RL_INVENTORY_VARIANCE_UPDATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_inventory_variance_update.cubin"));
const RL_Q_PI_DISTILL_GRAD_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_q_pi_distill_grad.cubin"));
// Phase 7b F5 (2026-06-05): state-conditional action availability mask.
// Sets pi_logits[b][a] = -INFINITY for state-illegal actions BEFORE the
// `rl_pi_action_kernel` samples. Master-gated at slot 823 (bootstrap 0.0
// = OFF). Composes with F2 (Q-distill hinged advantage): softmax(pi_logits)
// has exactly zero mass on masked actions, so the distill gradient
// `(π_θ - π_target) = (0 - π_target_masked)` naturally drives π_target off
// masked actions. Spec docs/superpowers/specs/2026-06-04-bellman-target-foundation-reshape.md §3.5 + §5.2.
const RL_STATE_ACTION_MASK_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_state_action_mask.cubin"));
// Phase 3D-C (2026-06-03): PPO surrogate × Q-distill blend operator.
// Scales `ss_pi_grad_logits_d` by ISV[RL_PPO_SURROGATE_WEIGHT_INDEX] when
// enabled (slot 798 > 0.5), else zeros it. Run BETWEEN
// `policy_head.surrogate_backward_logits` and `rl_q_pi_distill_grad`
// (which `+=` adds on top), producing:
//   pi_grad = w_ppo · grad_PPO + grad_Q_distill
const RL_PI_GRAD_BLEND_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_pi_grad_blend.cubin"));
// λ_distill adaptive controller (rljzl followup 2026-05-24).
// Drives λ via Schulman bounded step on KL_EMA toward target.
const RL_Q_DISTILL_LAMBDA_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_q_distill_lambda_controller.cubin"));
// SP20 P1+P5 — audit-wiring catch on TrailTighten/Loosen + foundational
// per-unit trade state machine. Three kernels work together: state
// machine on position transitions (open/close/reverse), trail mutation
// from a7/a8 actions, trail breach check that overrides action to flat.
const RL_UNIT_STATE_UPDATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_unit_state_update.cubin"));
const RL_TRAIL_MUTATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_trail_mutate.cubin"));
const RL_POSITION_HEAT_CHECK_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_position_heat_check.cubin"));
const RL_CONFIDENCE_GATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_confidence_gate.cubin"));
const RL_FRD_GATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_frd_gate.cubin"));
const RL_GATE_THRESHOLD_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_gate_threshold_controller.cubin"));
// Fused kernel: 10 RL ISV controllers in one launch — saves 9 kernel
// launch overheads (~40-80μs/step). Replaces individual launches of
// gamma, tau, ppo_clip, entropy_coef, rollout_steps, per_alpha,
// reward_scale, ppo_ratio_clamp, gate_threshold, q_distill_lambda.
const RL_FUSED_CONTROLLERS_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_fused_controllers.cubin"));
const RL_ASYMMETRIC_TRAIL_DECAY_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_asymmetric_trail_decay.cubin"));
const RL_SESSION_RISK_CHECK_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_session_risk_check.cubin"));
const RL_MIN_HOLD_CHECK_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_min_hold_check.cubin"));
const RL_TRADE_CONTEXT_UPDATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_trade_context_update.cubin"));
const RL_MULTIRES_FEATURES_UPDATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_multires_features_update.cubin"));
const RL_TRAIL_STOP_CHECK_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_trail_stop_check.cubin"));
const ACTIONS_TO_MARKET_TARGETS_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/actions_to_market_targets.cubin"));
// Device-resident step counter bump — ISV[548] += 1.0 each step.
// Prerequisite for CUDA Graph capture: removes scalar current_step
// from all downstream kernel argument lists.
const RL_INCREMENT_STEP_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_increment_step.cubin"));
const RL_SAMPLE_TAU_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_sample_tau.cubin"));
const RL_WRITE_U64_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_write_u64.cubin"));
const RL_FUSED_REWARD_PIPELINE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_fused_reward_pipeline.cubin"));

// P1.1 PopArt reward normalization (replaces apply_reward_scale).
const RL_POPART_NORMALIZE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_popart_normalize.cubin"));
const RL_POPART_V_CORRECT_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_popart_v_correct.cubin"));
// P1.2 Spectral normalization + decoupling.
const RL_SPECTRAL_NORM_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_spectral_norm.cubin"));
const RL_SPECTRAL_DECOUPLE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_spectral_decouple.cubin"));
// P2.1 Q-bias correction.
const RL_Q_BIAS_CORRECTION_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_q_bias_correction.cubin"));
// P2.2 Per-branch LR scaling.
const RL_PER_BRANCH_LR_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_per_branch_lr.cubin"));

// Element-wise in-place add: dst[i] += src[i]. Used by noisy
// exploration to fold NoisyLinear output into ensemble_q_d before
// action selection. Loaded from the same cubin that perception.rs
// uses for aux→encoder gradient accumulation.
const AUX_VEC_ADD_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/aux_vec_add.cubin"));

// Phase R7a: element-wise abs-copy kernel for the |reward| signal
// feeding ema_update_on_done's MEAN_ABS_PNL_EMA slot.
const ABS_COPY_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/abs_copy.cubin"));

// Reduction kernels that derive scalar inputs for the controller-input
// EMAs (variance ratio + kurtosis). entropy_observed reuses
// ema_update_per_step's built-in mean reduce directly on entropy_d.
const RL_VAR_OVER_ABS_MEAN_STREAMING_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_var_over_abs_mean_streaming.cubin"));
const RL_KURTOSIS_STREAMING_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_kurtosis_streaming.cubin"));

// Derivation kernels for the EMA inputs that need a new signal
// computation (not just a reduction over an existing buffer):
// per-batch KL approximation, Q weight-divergence L2 norm, and the
// trade-duration step counter.
const RL_KL_APPROX_B_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_kl_approx_b.cubin"));
const RL_L2_DIFF_NORM_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_l2_diff_norm.cubin"));
const RL_L2_NORM_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_l2_norm.cubin"));
// Determinism foundation Phase 1 (2026-06-02): per-step component checksum
// kernels — provably deterministic sum-of-squares (single block / single
// thread / double-precision accumulator). See spec
// `docs/superpowers/specs/2026-06-02-determinism-foundation.md` §1.1.
const RL_DETERMINISTIC_CHECKSUM_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_deterministic_checksum.cubin"));
// audit — PPO ratio clamp controller (emits ISV[440]) + per-step
// log-ratio max diagnostic kernel (writes ISV[441]).
const RL_PPO_RATIO_CLAMP_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_ppo_ratio_clamp_controller.cubin"));
const PPO_LOG_RATIO_ABS_MAX_B_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/ppo_log_ratio_abs_max_b.cubin"));
const RL_STREAMING_CLAMP_INIT_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_streaming_clamp_init.cubin"));
const RL_ISV_WRITE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_isv_write.cubin"));
const RL_Q_PI_AGREE_B_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_q_pi_agree_b.cubin"));
const RL_PI_ACTION_KERNEL_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_pi_action_kernel.cubin"));

// GPU-resident PER kernels (T2/T3 → T4 wiring).
const RL_PER_PUSH_RING_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_per_push_ring.cubin"));
const RL_PER_PUSH_FLUSH_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_per_push_flush.cubin"));
const RL_PER_SAMPLE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_per_sample.cubin"));
const RL_PER_UPDATE_PRIORITY_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_per_update_priority.cubin"));
const RL_PER_TREE_REBUILD_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_per_tree_rebuild.cubin"));

// Bidirectional HER kernels — track (mid-price ring + peak), backward
// inject (synthetic push on done if peak >> actual), forward (evaluate
// closed trades after lookahead window).
const RL_HINDSIGHT_TRACK_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_hindsight_track.cubin"));
const RL_HINDSIGHT_INJECT_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_hindsight_inject.cubin"));
const RL_HINDSIGHT_FORWARD_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/rl_hindsight_forward.cubin"));

/// Per-head LR controller Wiener-α target. The controller kernel floors
/// this at 0.4 per `pearl_wiener_alpha_floor_for_nonstationary` so the
/// effective blend rate stays ≥ 0.4 regardless of this seed value. Kept
/// host-side so the trainer can tune the diagnostic-input weighting in
/// Phase E.3+ without touching the kernel.
const RL_LR_CONTROLLER_ALPHA: f32 = 0.4;

/// Spectral norm power iteration period. Full power iteration (175us per
/// head) runs every SPECTRAL_NORM_PERIOD replay steps; intermediate steps
/// skip entirely. By Weyl's inequality, sigma_max shifts by at most
/// ||Delta_W||_2 per Adam step, so the guard remains valid between
/// full iterations. 100 steps at 175us/head x 3 heads = 843us/step
/// amortized to 8.4us/step. Always runs (not a feature flag).
const SPECTRAL_NORM_PERIOD: u64 = 100;

/// Pre-extracted raw CUdeviceptr values for hot-path buffers.
/// All pointers come from pre-allocated `CudaSlice` fields whose
/// device addresses are stable for the trainer's lifetime.
/// Reading these cached u64s avoids cudarc's `device_ptr()` /
/// `device_ptr_mut()` event-tracking path which internally does
/// `cuStreamWaitEvent` + `cuEventRecord` per call — responsible
/// for ~23 `cuStreamSynchronize` per step (89% wall time per nsys).
struct HotPathPtrs {
    /// perception.h_t_d — stable (pre-allocated at PerceptionTrainer init).
    h_t_encoder: u64,
    h_tp1: u64,
    trade_context: u64,
    multires_output: u64,
}

/// Configuration for [`IntegratedTrainer`]. Wraps a `PerceptionTrainerConfig`
/// (the encoder + BCE + aux side) plus RL-specific overrides for the new
/// heads.
#[derive(Clone, Debug)]
pub struct IntegratedTrainerConfig {
    /// Inner perception trainer config. Encoder dims (hidden_dim, n_batch,
    /// seq_len) are read from this struct.
    pub perception: PerceptionTrainerConfig,
    /// Seed for DQN head Xavier init.
    pub dqn_seed: u64,
    /// Seed for PPO heads (policy + value share derived seeds).
    pub ppo_seed: u64,
    /// PER buffer capacity. At b_size=16, capacity/16 = unique steps of
    /// history retained. 32768/16 = 2048 steps (~34 minutes of ES data
    /// at event rate). Naive O(N) sampling is fast enough at this scale.
    pub per_capacity: usize,
    /// PER seed for deterministic sampling per
    /// `pearl_scoped_init_seed_for_reproducibility`. Fed to the GPU PER
    /// buffer at construction time.
    pub per_seed: u64,
}

impl Default for IntegratedTrainerConfig {
    /// Per `pearl_first_observation_bootstrap` + R1 controller defaults:
    /// every adaptive knob sources from ISV; the only knobs here are
    /// structural (encoder dims via PerceptionTrainerConfig) or
    /// reproducibility (seeds).
    fn default() -> Self {
        Self {
            perception: PerceptionTrainerConfig::default(),
            dqn_seed: 0xDA_DA_DA_DA,
            ppo_seed: 0xBE_BE_BE_BE,
            per_capacity: 32768,
            per_seed: 0x9E37_79B9_7F4A_7C15,
        }
    }
}

/// Stats returned from `step_synthetic`. Useful for tests + diagnostics.
#[derive(Clone, Debug, Default)]
pub struct IntegratedStepStats {
    pub l_bce: f32,
    pub l_q: f32,
    pub l_pi: f32,
    pub l_v: f32,
    pub l_aux: f32,
    /// SP20 P3 FRD head loss — sum of CE across (batch, horizon),
    /// normalized by `B × FRD_N_HORIZONS`. Zero whenever all labels
    /// are sentinel (-1); becomes the head's training signal once
    /// the loader feeds real forward-return labels.
    pub l_frd: f32,
    pub l_total: f32,
    pub lambdas: LossLambdas,
}

/// Host-side per-step inputs consumed by `IntegratedTrainer::build_diag_value`.
///
/// Bundles everything the diag JSONL needs that the trainer does not own:
///   * `stats` — the just-completed step's loss components.
///   * Host-side reads of the diag staging buffers (rewards, dones, actions,
///     raw_rewards, trade_duration, outcome_ema, position_lots, pyramid_count,
///     unit_entry_price, unit_entry_step, unit_lots, unit_trail, close_unit_index,
///     frd_logits).  All slices are borrowed (zero copies).
///   * Running counters (shaped_reward_close_event_cum, realized_pnl_usd_*,
///     total_trades, win_count, …) maintained by the caller across steps.
///
/// The struct is consumed by both the train loop (in `alpha_rl_train.rs`)
/// and the eval loop, guaranteeing schema parity between `diag.jsonl` and
/// `eval_diag.jsonl` per `feedback_single_source_of_truth_no_duplicates`.
pub struct DiagInputs<'a> {
    pub b_size: usize,
    pub stats: &'a IntegratedStepStats,
    // ── DiagStaging host-side reads ─────────────────────────────────
    // All slices are host-side per the struct-level docstring above;
    // the `_host` suffix is redundant inside `DiagInputs`. (The
    // `*_host_ptr` suffixes on `DiagStaging` remain meaningful — those
    // refer to actual host pointers, not host-side data.)
    pub rewards: &'a [f32],
    pub dones: &'a [f32],
    pub actions: &'a [i32],
    pub raw_rewards: &'a [f32],
    pub trade_duration: &'a [f32],
    pub outcome_ema: &'a [f32],
    pub position_lots: &'a [i32],
    pub pyramid_count: &'a [i32],
    pub unit_entry_price: &'a [f32],
    pub unit_entry_step: &'a [i32],
    pub unit_lots: &'a [i32],
    pub unit_trail: &'a [f32],
    pub close_unit_index: &'a [i32],
    pub frd_logits: &'a [f32],
    // ── Running counters (caller-maintained across steps) ───────────
    // Phase 0 reward-redesign (2026-06-02): cumulative SHAPED reward
    // summed over close events. NOT in USD — sum of `raw_rewards[b]`
    // (Phase-5-shaped reward post pnl-aligned envelope) at done-event
    // steps. Renamed from `realized_pnl_cum_usd` because the underlying
    // values are pts × lots × shaping, not USD. Investigate divergence
    // from `realized_pnl_usd_cum` (authoritative USD) when comparing
    // gradient-signal cumulatives against ground-truth pnl.
    pub shaped_reward_close_event_cum: f64,
    // Phase 0 reward-redesign (2026-06-02): authoritative USD pnl delta
    // for THIS step, summed across batches. Source is the per-batch
    // `pnl_step_close_usd_d` buffer written by `pnl_track_step`'s close
    // branch (realised_pnl_usd_fp / 100). Same data path as
    // `eval_summary.total_pnl_usd`; cross-source consistent within
    // single-trade fp/100 precision ($0.01).
    pub realized_pnl_usd_delta: f64,
    // Phase 0 reward-redesign (2026-06-02): cumulative authoritative
    // USD pnl since start of phase (train or eval). Reset at the
    // train→eval boundary so it tracks the eval window only. Last-row
    // value MUST match `eval_summary.total_pnl_usd` within $50.
    pub realized_pnl_usd_cum: f64,
    pub total_trades: u64,
    pub win_count: u64,
    pub hold_time_sum: f64,
    pub trail_fired_total: u64,
    pub trail_tighten_total: u64,
    pub trail_loosen_total: u64,
    pub pyramid_added_total: u64,
    pub partial_flat_total: u64,
    pub partial_flat_long_total: u64,
    pub partial_flat_short_total: u64,
    pub conf_gate_total: u64,
    pub frd_gate_total: u64,
    pub heat_cap_total: u64,
    /// EMA-smoothed normalised action histogram used for the
    /// `action_entropy` field.  At b_size=1 the per-step act_hist is
    /// one-hot (H≡0), so the diag's exploration signal comes from
    /// this windowed distribution (see `WINDOWED_ACT_ALPHA` in the
    /// caller).
    pub windowed_act_hist: &'a [f32],
}

pub struct IntegratedTrainer {
    #[allow(dead_code)]
    cfg: IntegratedTrainerConfig,

    /// Inner trainer owns the encoder weights + BCE/aux heads + optimizer.
    pub perception: PerceptionTrainer,

    /// DQN distributional Q-head (Phase C).
    pub dqn_head: DqnHead,

    /// IQN distributional Q-head — runs alongside C51 for ensemble
    /// action selection. Forward produces `Q(s, τ, a)` for N_TAU
    /// quantile samples; expected Q is combined with C51's E[Q] via
    /// ISV[RL_IQN_ENSEMBLE_ALPHA_INDEX].
    pub iqn_head: crate::rl::iqn::IqnHead,

    /// PPO policy head (Phase D).
    pub policy_head: PolicyHead,

    /// PPO value head (Phase D).
    pub value_head: ValueHead,

    /// Phase 4-A (2026-06-03) No-transaction-band head — produces per-batch
    /// `(b_l, b_u)` bounds in lots space; `rl_band_mask.cu` overrides
    /// `actions[b] → Hold` whenever `position_lots[b] ∈ [b_l, b_u]`,
    /// encoding the Davis-Norman optimal no-trade region as an
    /// architectural default. Master gate at `RL_BAND_ENABLED_INDEX`
    /// (slot 799) bootstraps to OFF so the foundation preserves
    /// bit-equality with Phase 3D baseline `bd811a774` until the
    /// operator enables A/B. Spec:
    /// docs/superpowers/specs/2026-06-03-no-transaction-band-architecture.md.
    pub band_head: crate::rl::band_head::BandHead,

    /// Phase 4 (2026-05-30) Independent Dueling Q head — runs in parallel
    /// to C51/IQN with ZERO shared state with downstream consumers
    /// (ensemble, distill, action selection). Trains its own V + A
    /// weights via scalar Huber Bellman loss. V_dq output feeds PPO
    /// advantage baseline in Phase 4.3 (currently diagnostic-only).
    /// Per spec docs/superpowers/specs/2026-05-30-phase4-independent-dueling-head-design.md.
    pub dueling_q_head: crate::rl::dueling_q::DuelingQHead,

    /// Per-head Adam optimisers — Phase E.2. One pair (w + b) per head.
    /// All share the existing project-wide `adamw_step` cubin via the
    /// `AdamW` wrapper; each instance owns its own m / v / step counter.
    pub dqn_w_adam: AdamW,
    pub dqn_b_adam: AdamW,
    /// IQN head Adam optimisers — w_embed/b_embed/w_out/b_out.
    pub iqn_w_embed_adam: AdamW,
    pub iqn_b_embed_adam: AdamW,
    pub iqn_w_out_adam: AdamW,
    pub iqn_b_out_adam: AdamW,
    pub policy_w_adam: AdamW,
    pub policy_b_adam: AdamW,
    pub value_w_adam: AdamW,
    pub value_b_adam: AdamW,
    /// Phase 4-B (2026-06-04) — no-transaction-band head Adam pair. LR
    /// sourced from `RL_LR_PI_INDEX` (same scale as the policy head — the
    /// band is a structural action-selection prior, conceptually paired
    /// with π). Flag-gated by `RL_BAND_ENABLED_INDEX > 0.5` at the host;
    /// when off the Adam step is still called but with zero grads from
    /// the backward kernel's device-side guard, so weight-only momentum
    /// decay applies (analogous to the FRD sentinel-zero path).
    pub band_w_adam: AdamW,
    pub band_b_adam: AdamW,

    /// Phase 2A-C (2026-06-03) — multi-head policy gate (FOXHUNT_USE_MULTI_HEAD_POLICY).
    ///
    /// Cached `bool` snapshot of the env flag captured once at construction
    /// time. Must not change for the lifetime of the trainer because the
    /// training / prefill graphs bake whichever branch is selected at first
    /// capture into the replayed graph (host-side `if` runs only during
    /// capture; afterwards the captured kernel stream is replayed verbatim).
    ///
    /// Flag-off path is bit-equal to commit `e22da61cf` (legacy single-head
    /// `policy_head` drives π).
    pub use_multi_head_policy: bool,
    /// `Some(_)` iff `use_multi_head_policy == true`. Owns K policy heads +
    /// the regime-gated mixing head + all backward scratch + reduced-grad
    /// buffers ready for an Adam step. Allocated on the same stream as the
    /// rest of the trainer (`dev.cuda_stream()`); no extra synchronization.
    /// See `crates/ml-alpha/src/rl/multi_head_policy.rs` for the kernel
    /// pipeline. Spec: docs/superpowers/specs/2026-06-02-multi-head-policy-with-r-multiple.md
    /// ADDENDUM 2026-06-03.
    pub multi_head_policy: Option<MultiHeadPolicy>,
    /// Adam optimisers for the four `MultiHeadPolicy` weight tensors —
    /// `W_heads [K × N_ACTIONS × HIDDEN_DIM]`, `b_heads [K × N_ACTIONS]`,
    /// `W_gate [K × REGIME_DIM=6]`, `b_gate [K]`. `Some(_)` iff
    /// `use_multi_head_policy == true`. LR reads slot `RL_LR_PI_INDEX`
    /// (mirrors the legacy `policy_w_adam` / `policy_b_adam` LR sourcing).
    pub mhp_w_heads_adam: Option<AdamW>,
    pub mhp_b_heads_adam: Option<AdamW>,
    pub mhp_w_gate_adam: Option<AdamW>,
    pub mhp_b_gate_adam: Option<AdamW>,
    /// Phase 4 dueling-head Adam optimisers (4 weight tensors).
    pub dueling_q_w_v_adam: AdamW,
    pub dueling_q_b_v_adam: AdamW,
    pub dueling_q_w_a_adam: AdamW,
    pub dueling_q_b_a_adam: AdamW,
    /// SP20 P3 FRD head Adam optimisers (W1/b1/W2/b2). LR from
    /// `RL_FRD_LR_INDEX` (slot 499), default 1e-3.
    pub frd_w1_adam: AdamW,
    pub frd_b1_adam: AdamW,
    pub frd_w2_adam: AdamW,
    pub frd_b2_adam: AdamW,

    /// Mapped-pinned ISV buffer (RL-allocated, length `RL_SLOTS_END`).
    /// GPU writes via `dev_ptr`, host reads via `host_ptr` — zero DtoD
    /// copies, zero syncs. Seeded by the 7 RL controllers' bootstrap
    /// launches in `new()` so every consumer kernel sees the canonical
    /// default at its slot before any read.
    /// Per `pearl_first_observation_bootstrap` + `feedback_isv_for_adaptive_bounds`
    /// + `feedback_no_htod_htoh_only_mapped_pinned`.
    pub isv_mapped: MappedF32Buffer,

    /// Cached `CUdeviceptr` for `isv_mapped` — avoids repeated field
    /// access in the hot path. Stable pointer: mapped-pinned allocations
    /// never move, so CUDA graph capture is safe.
    pub isv_dev_ptr: u64,

    pub stream: Arc<CudaStream>,

    /// Second CUDA stream for training (PER sample, priority update,
    /// tree rebuild). Runs concurrently with the next env-step on
    /// `self.stream`, synced at the start of the next step_with_lobsim
    /// to ensure priority_tree coherence before the next PER push.
    pub train_stream: Arc<CudaStream>,

    /// Cached raw `CUstream` handle for `stream` — avoids repeated
    /// `stream.cu_stream()` in the hot path (each call does an Arc deref
    /// + field access; caching removes even that overhead).
    raw_stream: CUstream,

    /// Cached raw `CUstream` handle for `train_stream`.
    raw_train_stream: CUstream,

    // ── Cross-stream CUDA events (lightweight inter-stream sync) ────
    // Replace full `cuStreamSynchronize` with event-based waits for
    // cross-stream dependencies. Events are ~10× cheaper than a full
    // stream sync because they don't block the host — only the
    // consuming stream waits on the GPU timeline.
    /// Recorded on `self.stream` after PER push_ring + push_flush;
    /// `train_stream` waits on this before `rl_per_sample`.
    push_done_event: CudaEvent,
    /// Recorded on `train_stream` after `rl_per_sample`; `self.stream`
    /// waits on this before step_synthetic / dqn_replay_step.
    sample_done_event: CudaEvent,
    /// Recorded on `self.stream` after step_synthetic (k_iter == 0) or
    /// dqn_replay_step (k_iter > 0); `train_stream` waits on this before
    /// the PER priority update in every K-loop iteration.
    replay_done_event: CudaEvent,
    /// Recorded on `train_stream` after the last kernel in the K-loop
    /// (priority tree rebuild). `step_with_lobsim` waits on this event
    /// instead of `train_stream.synchronize()` — event wait is GPU-side
    /// only (no host stall), saving ~2ms/step host idle time.
    train_done_event: CudaEvent,

    // ── CUDA Graph capture for the RL step pipeline ─────────────────
    // Three-state machine: first step = warmup (eager), second =
    // capture, third+ = replay. Split around apply_snapshot (HtoD
    // barrier that can't be captured).
    prefill_graph: Option<CudaGraph>,
    postfill_graph: Option<CudaGraph>,
    reward_graph: Option<CudaGraph>,
    graph_warmup_done: bool,
    training_graph: Option<CudaGraph>,
    training_warmup_done: bool,

    // ── Kernel handles for grad combine + cross-batch reduce ──────────
    _grad_h_module: Arc<CudaModule>,
    grad_h_accumulate_fn: CudaFunction,
    _reduce_axis0_module: Arc<CudaModule>,
    reduce_axis0_fn: CudaFunction,

    // ── Per-head LR ISV controller (Phase E.2-DEFER item 3) ───────────
    /// Owns the `rl_lr_controller` cubin lifetime.
    _rl_lr_controller_module: Arc<CudaModule>,
    /// Kernel handle for `rl_lr_controller` — emits per-head LRs to
    /// `ISV[412..417]` via the Wiener-α blend defined in
    /// `cuda/rl_lr_controller.cu`. Bootstraps on first observation
    /// (sentinel zero → `LR_BOOTSTRAP = 1e-3`).
    rl_lr_controller_fn: CudaFunction,

    // ── 7 RL adaptive controllers (Phase R1) ──────────────────────────
    // Each controller emits ONE float into its dedicated ISV slot
    // (γ→400, τ→401, ε→402, entropy_coef→403, n_rollout_steps→404,
    // per_α→405, reward_scale→406). Bootstrap-launched once at the end
    // of `new()` so every consumer kernel reads a canonical value on
    // first access — eliminates the Phase F defect where sparse-reward
    // production paths read sentinel-zero γ/ε/entropy and trained
    // against degenerate Bellman / surrogate targets. Per-step launches
    // (with real EMA inputs sourced from ISV[417..424]) land in Phase R5.
    _rl_gamma_controller_module: Arc<CudaModule>,
    rl_gamma_controller_fn: CudaFunction,
    _rl_target_tau_controller_module: Arc<CudaModule>,
    rl_target_tau_controller_fn: CudaFunction,
    _rl_ppo_clip_controller_module: Arc<CudaModule>,
    rl_ppo_clip_controller_fn: CudaFunction,
    _rl_entropy_coef_controller_module: Arc<CudaModule>,
    rl_entropy_coef_controller_fn: CudaFunction,
    _rl_rollout_steps_controller_module: Arc<CudaModule>,
    rl_rollout_steps_controller_fn: CudaFunction,
    _rl_per_alpha_controller_module: Arc<CudaModule>,
    rl_per_alpha_controller_fn: CudaFunction,
    _rl_reward_scale_controller_module: Arc<CudaModule>,
    rl_reward_scale_controller_fn: CudaFunction,

    // ── Phase R3: GPU-resident EMA + advantage/return kernels ─────────
    _ema_update_on_done_module: Arc<CudaModule>,
    ema_update_on_done_fn: CudaFunction,
    _ema_update_per_step_module: Arc<CudaModule>,
    ema_update_per_step_fn: CudaFunction,
    _compute_advantage_return_module: Arc<CudaModule>,
    compute_advantage_return_fn: CudaFunction,
    _action_entropy_per_step_module: Arc<CudaModule>,
    action_entropy_per_step_fn: CudaFunction,

    // ── Phase R4: GPU action sampling ─────────────────────────────────
    _rl_action_kernel_module: Arc<CudaModule>,
    rl_action_kernel_fn: CudaFunction,
    _argmax_expected_q_module: Arc<CudaModule>,
    argmax_expected_q_fn: CudaFunction,
    _log_pi_at_action_module: Arc<CudaModule>,
    log_pi_at_action_fn: CudaFunction,

    /// Per-batch xorshift32 PRNG state for `rl_action_kernel`. Allocated
    /// + host-seeded once at trainer init from `cfg.dqn_seed` via
    /// `rand_chacha::ChaCha8Rng` per `pearl_scoped_init_seed_for_reproducibility`.
    /// The kernel advances state in place each call. Length = `n_batch`.
    pub prng_state_d: CudaSlice<u32>,

    /// Atom support vector `[Q_V_MIN, Q_V_MIN + step, …, Q_V_MAX]` of
    /// length `Q_N_ATOMS`. Allocated + host-uploaded once at init;
    /// read by `rl_action_kernel` (Thompson sample lookup) and
    /// `argmax_expected_q` (expected-value accumulator). Per
    /// `feedback_isv_for_adaptive_bounds`: structural constant, not
    /// adaptive (atom span is fixed [Q_V_MIN, Q_V_MAX] = [-1, +1] per
    /// `crate::rl::common`). Per-branch adaptive atom span (per
    /// `pearl_per_branch_c51_atom_span`) is a Phase R-future extension.
    pub atom_supports_d: CudaSlice<f32>,

    // ── Phase R6: GPU-pure env-step kernels + per-step buffers ────────
    _apply_reward_scale_module: Arc<CudaModule>,
    apply_reward_scale_fn: CudaFunction,
    // Adaptive reward-clamp controller (audit 2026-05-24).
    _rl_reward_clamp_controller_module: Arc<CudaModule>,
    rl_reward_clamp_controller_fn: CudaFunction,
    // C51 atom-support updater (audit 2026-05-24 followup).
    _rl_atom_support_update_module: Arc<CudaModule>,
    rl_atom_support_update_fn: CudaFunction,
    // Q→π distillation gradient (audit 2026-05-24 vj5f6 followup).
    _rl_q_pi_distill_grad_module: Arc<CudaModule>,
    rl_q_pi_distill_grad_fn: CudaFunction,
    // Phase 7b F5 (2026-06-05) — state-conditional action availability mask.
    // Sets pi_logits[b][a] = -INFINITY for state-illegal actions BEFORE
    // rl_pi_action_kernel samples. Master-gated at slot 823 (bootstrap 0.0
    // = OFF). See `launch_rl_state_action_mask`.
    _rl_state_action_mask_module: Arc<CudaModule>,
    rl_state_action_mask_fn: CudaFunction,
    // Phase 3D-C (2026-06-03): PPO surrogate × Q-distill blend operator
    // (replaces zero-fill before `+=` Q-distill). Scales `ss_pi_grad_logits_d`
    // by ISV[RL_PPO_SURROGATE_WEIGHT_INDEX] when enabled, else zeros it.
    _rl_pi_grad_blend_module: Arc<CudaModule>,
    rl_pi_grad_blend_fn: CudaFunction,
    _rl_kl_reference_module: Arc<CudaModule>,
    #[allow(dead_code)]
    _rl_kl_reference_grad_fn: CudaFunction,
    // C51+IQN ensemble action-value (audit 2026-05-25).
    _rl_ensemble_action_value_module: Arc<CudaModule>,
    rl_ensemble_action_value_fn: CudaFunction,
    // Phase 4.4 adaptive V blend.
    _rl_v_blend_module: Arc<CudaModule>,
    rl_v_blend_fn: CudaFunction,
    _rl_v_blend_alpha_controller_module: Arc<CudaModule>,
    rl_v_blend_alpha_controller_fn: CudaFunction,
    _rl_advantage_normalize_module: Arc<CudaModule>,
    rl_advantage_normalize_fn: CudaFunction,
    // Welford variance updater for adaptive controller floors (spec
    // 2026-05-30-adaptive-controller-floor-design). One launch per
    // controller-input EMA per step, sequenced after EMA producers and
    // before controller consumers.
    _rl_signal_variance_update_module: Arc<CudaModule>,
    rl_signal_variance_update_fn: CudaFunction,
    // Adaptive risk management — layered stack (spec 2026-05-30-adaptive-risk-management).
    // CMDP gates run as a standalone kernel per step (reads pnl + outcomes).
    // The 3 layer-2/3/4 controllers are also fused into rl_fused_controllers
    // for graph-capture efficiency, but the standalone modules are loaded
    // so unit tests can drive each layer in isolation.
    _rl_cmdp_constraints_check_module: Arc<CudaModule>,
    rl_cmdp_constraints_check_fn: CudaFunction,
    _rl_iqn_action_tau_controller_module: Arc<CudaModule>,
    rl_iqn_action_tau_controller_fn: CudaFunction,
    _rl_inventory_beta_controller_module: Arc<CudaModule>,
    rl_inventory_beta_controller_fn: CudaFunction,
    _rl_kelly_fraction_controller_module: Arc<CudaModule>,
    rl_kelly_fraction_controller_fn: CudaFunction,
    _rl_surfer_scaffold_controller_module: Arc<CudaModule>,
    rl_surfer_scaffold_controller_fn: CudaFunction,
    // Phase 2A-D fix B1.3 (2026-06-03): adaptive gate-LR multiplier
    // controller. Reads gate_entropy_mean (slot 781) via EMA → drives
    // RL_POLICY_GATE_LR_MULTIPLIER_INDEX (slot 790) up when the gate is
    // stuck near uniform, down when at risk of collapse. Launched per
    // step in the flag-on path inside `step_with_lobsim_reward_and_train`.
    _rl_gate_lr_multiplier_controller_module: Arc<CudaModule>,
    rl_gate_lr_multiplier_controller_fn: CudaFunction,
    // v9 (2026-05-31): defensive eval-boundary calibration kernel.
    _rl_eval_warmup_decay_module: Arc<CudaModule>,
    rl_eval_warmup_decay_fn: CudaFunction,
    // Regime observer (F1.4) — emits unified state-machine signals consumed
    // by Kelly (F2), v9 (F3), popart (F4), IQN τ (F5).
    _rl_regime_flat_count_module: Arc<CudaModule>,
    rl_regime_flat_count_fn: CudaFunction,
    _rl_regime_observer_module: Arc<CudaModule>,
    rl_regime_observer_fn: CudaFunction,
    // Per-step flat-count helper buffer: holds the block-reduce output
    // (number of accounts with lots==0) for one step. Length 1.
    flat_count_d: CudaSlice<i32>,
    // EMA producers feeding the new controllers.
    _rl_win_rate_ema_update_module: Arc<CudaModule>,
    rl_win_rate_ema_update_fn: CudaFunction,
    _rl_avg_win_loss_ema_update_module: Arc<CudaModule>,
    rl_avg_win_loss_ema_update_fn: CudaFunction,
    _rl_inventory_variance_update_module: Arc<CudaModule>,
    rl_inventory_variance_update_fn: CudaFunction,
    /// Phase 4.4 blended V baselines fed to compute_advantage_return.
    pub v_blended_d: CudaSlice<f32>,
    pub v_blended_tp1_d: CudaSlice<f32>,
    // λ_distill adaptive controller (rljzl followup 2026-05-24).
    // Retained for bootstrap / testing; per-step launch fused into
    // rl_fused_controllers.
    _rl_q_distill_lambda_controller_module: Arc<CudaModule>,
    #[allow(dead_code)]
    rl_q_distill_lambda_controller_fn: CudaFunction,
    // SP20 P1+P5 (audit-wiring fix for dead a7/a8).
    _rl_unit_state_update_module: Arc<CudaModule>,
    rl_unit_state_update_fn: CudaFunction,
    _rl_trail_mutate_module: Arc<CudaModule>,
    rl_trail_mutate_fn: CudaFunction,
    _rl_trail_stop_check_module: Arc<CudaModule>,
    rl_trail_stop_check_fn: CudaFunction,
    _rl_position_heat_check_module: Arc<CudaModule>,
    rl_position_heat_check_fn: CudaFunction,
    _rl_confidence_gate_module: Arc<CudaModule>,
    rl_confidence_gate_fn: CudaFunction,
    _rl_frd_gate_module: Arc<CudaModule>,
    rl_frd_gate_fn: CudaFunction,
    _rl_fused_reward_pipeline_module: Arc<CudaModule>,
    rl_fused_reward_pipeline_fn: CudaFunction,
    _rl_gate_threshold_controller_module: Arc<CudaModule>,
    // Retained for testing; per-step launch fused into
    // rl_fused_controllers.
    #[allow(dead_code)]
    rl_gate_threshold_controller_fn: CudaFunction,
    // Fused kernel: 10 RL ISV controllers in one launch.
    _rl_fused_controllers_module: Arc<CudaModule>,
    rl_fused_controllers_fn: CudaFunction,
    /// Device buffer holding the 7 ISV input-slot indices for the fused
    /// controller kernel. Allocated once at init, never mutated.
    fused_ctrl_input_slots_d: CudaSlice<i32>,
    _rl_asymmetric_trail_decay_module: Arc<CudaModule>,
    rl_asymmetric_trail_decay_fn: CudaFunction,
    _rl_session_risk_check_module: Arc<CudaModule>,
    rl_session_risk_check_fn: CudaFunction,
    _rl_min_hold_check_module: Arc<CudaModule>,
    rl_min_hold_check_fn: CudaFunction,
    _rl_trade_context_update_module: Arc<CudaModule>,
    rl_trade_context_update_fn: CudaFunction,
    _rl_multires_features_update_module: Arc<CudaModule>,
    rl_multires_features_update_fn: CudaFunction,
    // Device-resident step counter (ISV[548] += 1.0 per step).
    _rl_increment_step_module: Arc<CudaModule>,
    rl_increment_step_fn: CudaFunction,
    // Device-side tau ~ U(0,1) for IQN (graph-safe, no host RNG).
    // Retained for potential standalone use; per-step launches fused
    // into rl_iqn_forward.
    _rl_sample_tau_module: Arc<CudaModule>,
    _rl_sample_tau_fn: CudaFunction,
    // Device-resident ts_ns for graph-captured multires kernel.
    _rl_write_u64_module: Arc<CudaModule>,
    rl_write_u64_fn: CudaFunction,
    ts_ns_d: CudaSlice<u64>,
    pub outcome_ema_d: CudaSlice<f32>,
    /// CMDP Layer 1 per-batch state (spec 2026-05-30-adaptive-risk-management).
    /// Each batch element is an independent backtest "session"; one session
    /// hitting its DD limit or losing-streak cooldown must not gate the
    /// other 1023 sessions. Canonical-summary aggregates (worst-account
    /// PnL, max cooldown) are mirrored into ISV[662/664/667/668] for diag
    /// + IQN-τ consumption.
    pub session_pnl_per_batch_d: CudaSlice<f32>,
    pub session_dd_triggered_per_batch_d: CudaSlice<f32>,
    pub consec_loss_per_batch_d: CudaSlice<f32>,
    pub cooldown_remaining_per_batch_d: CudaSlice<f32>,
    // Edge-decay Phase 1 (2026-06-01): Page-Hinkley per-batch state.
    // Read+written ONLY by rl_cmdp_constraints_check kernel; reset_session_state
    // zero-memsets all 5 at fold/eval boundaries per predictive-reset discipline.
    pub ph_mu_per_batch_d: CudaSlice<f32>,
    pub ph_count_per_batch_d: CudaSlice<f32>,
    pub ph_m_per_batch_d: CudaSlice<f32>,
    pub ph_mmin_per_batch_d: CudaSlice<f32>,
    pub ph_stat_per_batch_d: CudaSlice<f32>,
    pub trade_context_d: CudaSlice<f32>,
    pub multires_output_d: CudaSlice<f32>,
    multires_state_d: CudaSlice<f32>,
    multires_prev_mid_d: CudaSlice<f32>,
    multires_prev_ts_ns_d: CudaSlice<u64>,

    // Per-batch per-unit trade state (SP20 P1).
    // MAX_UNITS=4 reserved; only slot 0 used until SP20 P7 ships
    // pyramiding semantics. Diag exposes all 4 slots — slots 1-3
    // hold zeros pre-P7 (audit-diag tolerates per-batch-buffer
    // arrays of any shape so long as values are non-NaN).
    pub unit_entry_price_d: CudaSlice<f32>,        // [B * 4]
    pub unit_entry_step_d: CudaSlice<i32>,         // [B * 4]
    pub unit_lots_d: CudaSlice<i32>,               // [B * 4]
    pub unit_initial_r_d: CudaSlice<f32>,          // [B * 4]
    pub unit_trail_distance_d: CudaSlice<f32>,     // [B * 4]
    pub unit_active_d: CudaSlice<u8>,              // [B * 4]
    pub pyramid_units_count_d: CudaSlice<i32>,     // [B]
    pub close_unit_index_d: CudaSlice<i32>,        // [B] — trail-stop override
                                                    //   (-1 = auto oldest). Reset to
                                                    //   -1 each step before trail check.
    pub unit_prev_pos_lots_d: CudaSlice<i32>,      // [B] — separate from
                                                    //   extract_realized_pnl_delta's
                                                    //   prev_position_lots_d so unit
                                                    //   state machine isn't coupled
                                                    //   to reward extraction order.
    _actions_to_market_targets_module: Arc<CudaModule>,
    actions_to_market_targets_fn: CudaFunction,

    // ── Phase R7a: abs-copy + persistent per-step device buffers ──────
    _abs_copy_module: Arc<CudaModule>,
    abs_copy_fn: CudaFunction,

    // ── Per-batch reducers for controller-input EMAs ──
    _rl_var_over_abs_mean_streaming_module: Arc<CudaModule>,
    rl_var_over_abs_mean_streaming_fn: CudaFunction,
    _rl_kurtosis_streaming_module: Arc<CudaModule>,
    rl_kurtosis_streaming_fn: CudaFunction,
    /// 1-float scratch for the reduce kernels' scalar output. Reused
    /// across per-step launches (reductions serialised by stream order).
    /// Feeds `ema_update_per_step` with `b_size=1` for the controller
    /// EMA slots whose input requires a transform (variance ratio,
    /// kurtosis, KL approximation, weight L2 divergence).
    pub ema_input_scratch_d: CudaSlice<f32>,

    // ── Derivation kernels + per-batch state for the remaining EMAs ──
    _rl_kl_approx_b_module: Arc<CudaModule>,
    rl_kl_approx_b_fn: CudaFunction,
    _rl_l2_diff_norm_module: Arc<CudaModule>,
    rl_l2_diff_norm_fn: CudaFunction,
    _rl_l2_norm_module: Arc<CudaModule>,
    rl_l2_norm_fn: CudaFunction,
    // Determinism Phase 1 (2026-06-02): per-step component checksums.
    // Two entry points share the same cubin; both are
    // (1,1,1)/(1,1,1) launches with f64 accumulators. See spec
    // `docs/superpowers/specs/2026-06-02-determinism-foundation.md`.
    _rl_deterministic_checksum_module: Arc<CudaModule>,
    rl_deterministic_checksum_f32_fn: CudaFunction,
    rl_deterministic_checksum_i32_fn: CudaFunction,
    /// 28 f64 slots: 15 final leaves emitted under diag `checksums`
    /// + 12 staging slots for the 6 Adam first-moment + 6 second-moment
    /// sub-buffers (folded into slots 11 and 12 on host after dtoh) +
    /// 1 reserve. One-time alloc; reused every diag step. Per spec
    /// `docs/superpowers/specs/2026-06-02-determinism-foundation.md` §1.2.
    checksum_scratch_d: CudaSlice<f64>,
    /// Host mirror of `checksum_scratch_d`; populated by
    /// `compute_diag_checksums()` via `memcpy_dtoh` after the launches
    /// finish. The values are consumed only by `build_diag_value` —
    /// no cross-step state survives.
    checksum_host: Vec<f64>,
    // PPO ratio clamp + log-ratio diag.
    _rl_ppo_ratio_clamp_controller_module: Arc<CudaModule>,
    rl_ppo_ratio_clamp_controller_fn: CudaFunction,
    _ppo_log_ratio_abs_max_b_module: Arc<CudaModule>,
    ppo_log_ratio_abs_max_b_fn: CudaFunction,
    _rl_streaming_clamp_init_module: Arc<CudaModule>,
    rl_streaming_clamp_init_fn: CudaFunction,
    _rl_isv_write_module: Arc<CudaModule>,
    rl_isv_write_fn: CudaFunction,
    _rl_q_pi_agree_b_module: Arc<CudaModule>,
    rl_q_pi_agree_b_fn: CudaFunction,
    _rl_pi_action_kernel_module: Arc<CudaModule>,
    rl_pi_action_kernel_fn: CudaFunction,
    /// Per-batch step counter for `trade_duration_ema` — increments
    /// every step, resets on done. Zero-init at construction.
    pub steps_since_done_d: CudaSlice<i32>,
    /// Per-batch duration emit buffer — populated by
    /// `rl_step_counter_update` with the counter value AT done
    /// (zero otherwise). Consumed by `ema_update_on_done` gated on
    /// dones_d so only real trade closes contribute to the EMA.
    pub trade_duration_emit_d: CudaSlice<f32>,

    /// Trainer-owned snapshot of `lobsim.pos.realized_pnl` taken at end
    /// of previous step. `extract_realized_pnl_delta` reads
    /// `lobsim.pos_d` post-fill, computes
    /// `reward = pos.realized_pnl − prev_realized_pnl_d`, and updates
    /// `prev_realized_pnl_d` for the next step. Separate from
    /// `LobSimCuda::prev_realized_pnl_d` (which serves the production
    /// decision pipeline, not the RL trainer).
    pub prev_realized_pnl_d: CudaSlice<f32>,
    /// Phase 5 (2026-06-04) per-batch snapshot of `unrealized_pnl` taken
    /// at end of previous step. Used by `rl_fused_reward_pipeline.cu`
    /// Phase 1.6 MTM reward: `reward += w_mtm · (unrealized_now −
    /// unrealized_prev)`. Sentinel 0.0 at init — first step's MTM
    /// component is `unrealized_now − 0` = current unrealized, which
    /// is the right boundary condition when the agent starts flat
    /// (unrealized=0 from a flat state). When ENABLED_INDEX (slot 815)
    /// ≤ 0.5 this buffer is untouched and reward equals the legacy
    /// close-event delta exactly (bit-equality A/B).
    pub prev_unrealized_pnl_d: CudaSlice<f32>,
    pub raw_rewards_d: CudaSlice<f32>,
    /// Trainer-owned snapshot of `lobsim.pos.position_lots` for the
    /// done-flag detection: `done = (prev != 0 && current == 0)`.
    pub prev_position_lots_d: CudaSlice<i32>,
    /// Per-step rewards buffer (output of `extract_realized_pnl_delta`,
    /// in-place modified by `apply_reward_scale`).
    pub rewards_d: CudaSlice<f32>,
    /// Per-step dones buffer (output of `extract_realized_pnl_delta`).
    pub dones_d: CudaSlice<f32>,
    /// Per-step |reward| scratch (output of `abs_copy(rewards_d)`).
    /// Feeds `ema_update_on_done` for the `MEAN_ABS_PNL_EMA` slot —
    /// keeping signed `rewards_d` intact for the
    /// `compute_advantage_return` + `apply_reward_scale` pipeline.
    pub reward_abs_d: CudaSlice<f32>,
    /// Per-step actions (Thompson sample). R7a keeps the host Thompson
    /// loop and uploads here; R7b lifts to R4's `rl_action_kernel`.
    pub actions_d: CudaSlice<i32>,
    /// Per-step next-state argmax actions for the Bellman target.
    /// R7a keeps the host argmax loop and uploads here; R7b lifts to
    /// R4's `argmax_expected_q` on h_{t+1}.
    pub next_actions_d: CudaSlice<i32>,
    /// Per-step log π_old at the sampled action. R7a keeps the host
    /// log-softmax loop and uploads here; R7b lifts to R4's
    /// `log_pi_at_action`.
    pub log_pi_old_d: CudaSlice<f32>,
    /// Per-step advantages A_t. Output of R3's
    /// `compute_advantage_return`.
    pub advantages_d: CudaSlice<f32>,
    /// Per-step returns R_t (V regression target). Output of R3's
    /// `compute_advantage_return`.
    pub returns_d: CudaSlice<f32>,

    /// Phase R7c (data correctness): trainer-owned `h_{t+1}` buffer
    /// (`[B × HIDDEN_DIM]`). Populated by a SECOND
    /// `perception.forward_encoder(next_snapshots)` call in
    /// `step_with_lobsim`, with a stream-ordered DtoD copy out of
    /// `perception.h_t_d` BEFORE the third `forward_encoder(snapshots)`
    /// call overwrites it. Closes the long-standing
    /// `// FUTURE: pass next_h_t separately` approximation noted in
    /// step_synthetic's Bellman target build (which had been using
    /// `h_t` as a stand-in for `s_{t+1}`'s encoder representation since
    /// Phase E.2). Three consumers downstream:
    ///   1. `value_head.forward(&self.h_tp1_d) → v_pred_tp1_d`, which
    ///      compute_advantage_return reads as the true `V(s_{t+1})`
    ///      (replaces R7b's `v_tp1_d_ref = &v_pred_d` alias).
    ///   2. `dqn_head.forward(&self.h_tp1_d) → q_logits_tp1_d`, fed to
    ///      `argmax_expected_q` for `next_actions_d` — the Double-DQN
    ///      argmax must be on `h_{t+1}`, not `h_t` (R7b's q_logits_d
    ///      was on h_t, so the argmax was wrong since R4).
    ///   3. `dqn_head.forward_target(&self.h_tp1_d)` inside
    ///      step_synthetic's Bellman target build (replaces the
    ///      h_t-as-proxy comment at the call site).
    pub h_tp1_d: CudaSlice<f32>,

    // ── IQN per-step device buffers ──────────────────────────────────
    /// Per-step quantile fractions `[B × N_TAU]` from U(0,1).
    /// Populated each step by `rl_sample_tau` kernel on device.
    pub iqn_tau_d: CudaSlice<f32>,
    /// IQN forward output `[B × N_TAU × N_ACTIONS]`.
    pub iqn_q_values_d: CudaSlice<f32>,
    /// IQN expected Q `[B × N_ACTIONS]` = mean over tau dimension.
    pub iqn_expected_q_d: CudaSlice<f32>,
    /// Ensemble Q `[B × N_ACTIONS]` = α × E_C51 + (1-α) × E_IQN.
    /// Output of `rl_ensemble_action_value` kernel. Feeds the Q→π
    /// agreement diagnostic (`rl_q_pi_agree_b`).
    pub ensemble_q_d: CudaSlice<f32>,

    // ── Phase 4 DuelingQHead per-step buffers ────────────────────────
    /// Online V(s) from DuelingQHead — used as PPO advantage baseline
    /// in Phase 4.3 (currently diagnostic-only).
    pub dueling_v_d: CudaSlice<f32>,
    /// Online V(s_{t+1}) for PPO advantage's next-state baseline.
    pub dueling_v_tp1_d: CudaSlice<f32>,
    /// A logits at h_t_borrow (diag only).
    pub dueling_a_d: CudaSlice<f32>,
    /// Composed Q at sampled_h_t — loss input. [B × N_ACTIONS]
    pub dueling_q_composed_d: CudaSlice<f32>,
    /// Composed Q from target net at sampled_h_tp1 — Bellman build.
    pub dueling_q_target_composed_d: CudaSlice<f32>,
    /// V at sampled_h_tp1 from target net (compute-only scratch).
    pub dueling_v_target_tp1_d: CudaSlice<f32>,
    /// A at sampled_h_tp1 from target net (compute-only scratch).
    pub dueling_a_target_tp1_d: CudaSlice<f32>,
    /// V at sampled_h_t from online net (loss-path scratch — not the
    /// same as dueling_v_d which is at h_t_borrow for PPO use).
    pub dueling_v_loss_d: CudaSlice<f32>,
    pub dueling_a_loss_d: CudaSlice<f32>,
    /// Bellman target scalar value per batch.
    pub dueling_target_value_d: CudaSlice<f32>,
    /// Per-batch Huber loss.
    pub dueling_loss_pb_d: CudaSlice<f32>,
    /// Gradient w.r.t. composed Q output [B × N_ACTIONS] (only taken
    /// action cell nonzero — scalar single-Q regression).
    pub dueling_grad_composed_d: CudaSlice<f32>,
    /// Per-batch weight gradient scratch.
    pub dueling_grad_w_v_pb_d: CudaSlice<f32>,    // [B × HIDDEN_DIM]
    pub dueling_grad_b_v_pb_d: CudaSlice<f32>,    // [B]
    pub dueling_grad_w_a_pb_d: CudaSlice<f32>,    // [B × HIDDEN_DIM × N_ACTIONS]
    pub dueling_grad_b_a_pb_d: CudaSlice<f32>,    // [B × N_ACTIONS]
    /// Reduced (axis-0) weight gradients fed to Adam.
    pub dueling_grad_w_v_d: CudaSlice<f32>,       // [HIDDEN_DIM]
    pub dueling_grad_b_v_d: CudaSlice<f32>,       // [1]
    pub dueling_grad_w_a_d: CudaSlice<f32>,       // [HIDDEN_DIM × N_ACTIONS]
    pub dueling_grad_b_a_d: CudaSlice<f32>,       // [N_ACTIONS]

    // ── Persistent per-step head output buffers (CUDA Graph stable) ──
    // Pre-allocated at init so device pointers are stable across steps,
    // enabling CUDA Graph capture of the RL step pipeline.

    /// C51 Q logits at h_t `[B × N_ACTIONS × Q_N_ATOMS]`.
    pub q_logits_d: CudaSlice<f32>,
    /// Target-network Q logits at h_t `[B × N_ACTIONS × Q_N_ATOMS]`.
    /// Populated by `dqn_head.forward_target(h_t)` in the prefill paths
    /// (step_with_lobsim / step_with_lobsim_gpu_body). Consumed by the
    /// `rl_q_pi_distill_grad` kernel in `step_synthetic_body` so the
    /// distillation reads slow-moving target Q, avoiding phase lag
    /// between online Q and π.
    pub q_logits_target_st_d: CudaSlice<f32>,
    /// C51 Q logits at h_{t+1} `[B × N_ACTIONS × Q_N_ATOMS]`.
    pub q_logits_tp1_d: CudaSlice<f32>,
    /// Scalar value prediction at h_t `[B]`.
    pub v_pred_d: CudaSlice<f32>,
    /// Scalar value prediction at h_{t+1} `[B]`.
    pub v_pred_tp1_d: CudaSlice<f32>,
    /// Policy logits at h_t `[B × N_ACTIONS]`.
    pub pi_logits_d: CudaSlice<f32>,

    /// Device-resident xorshift32 PRNG `[B]` for tau sampling.
    iqn_prng_state_d: CudaSlice<u32>,

    // ── NoisyNet state-dependent exploration ─────────────────────────
    // Factored noisy linear layer (Fortunato et al. 2017) that maps
    // h_t → [B, N_ACTIONS] state-dependent exploration noise. Added
    // to ensemble_q_d before action selection so the policy sees
    // noisy Q values during rollout. Replaces the P_MIN probability
    // floor in rl_pi_action_kernel. Target network stays deterministic
    // (noise buffers zeroed = no resample_noise calls on target path).

    /// NoisyLinear layer: h_t [B, HIDDEN_DIM] → noise [B, N_ACTIONS].
    /// Resampled once per step_with_lobsim via resample_noise().
    pub noisy_exploration: NoisyLinear,
    /// Output buffer for the NoisyLinear forward [B × N_ACTIONS].
    pub noisy_output_d: CudaSlice<f32>,
    /// Adam optimiser for NoisyLinear mu_w [N_ACTIONS × HIDDEN_DIM].
    pub noisy_mu_w_adam: AdamW,
    /// Adam optimiser for NoisyLinear sigma_w [N_ACTIONS × HIDDEN_DIM].
    pub noisy_sigma_w_adam: AdamW,
    /// Adam optimiser for NoisyLinear mu_b [N_ACTIONS].
    pub noisy_mu_b_adam: AdamW,
    /// Adam optimiser for NoisyLinear sigma_b [N_ACTIONS].
    pub noisy_sigma_b_adam: AdamW,
    /// Kernel handle for `aux_vec_add_inplace` — folds noisy_output_d
    /// into ensemble_q_d before the π action sampler reads it.
    _aux_vec_add_module: Arc<CudaModule>,
    aux_vec_add_fn: CudaFunction,

    // ── P1.1 PopArt reward normalization ─────────────────────────────
    _rl_popart_normalize_module: Arc<CudaModule>,
    rl_popart_normalize_fn: CudaFunction,
    _rl_popart_v_correct_module: Arc<CudaModule>,
    rl_popart_v_correct_fn: CudaFunction,

    // ── P1.2 Spectral normalization + decoupling ─────────────────────
    _rl_spectral_norm_module: Arc<CudaModule>,
    rl_spectral_norm_fn: CudaFunction,
    _rl_spectral_decouple_module: Arc<CudaModule>,
    rl_spectral_decouple_fn: CudaFunction,
    /// Persistent v_buffer for spectral norm power iteration on DQN w_d.
    spectral_v_buffer_dqn: CudaSlice<f32>,
    /// Persistent v_buffer for spectral norm power iteration on IQN w_out_d.
    spectral_v_buffer_iqn: CudaSlice<f32>,
    /// Persistent v_buffer for spectral norm power iteration on policy w_d.
    spectral_v_buffer_pi: CudaSlice<f32>,

    // ── P1.3 Outcome auxiliary head ──────────────────────────────────
    pub outcome_head: crate::rl::outcome_head::OutcomeHead,
    pub outcome_w_adam: AdamW,
    pub outcome_b_adam: AdamW,

    // ── P2.1 Q-bias correction ───────────────────────────────────────
    _rl_q_bias_correction_module: Arc<CudaModule>,
    rl_q_bias_correction_fn: CudaFunction,

    // ── P2.2 Per-branch LR scaling ───────────────────────────────────
    _rl_per_branch_lr_module: Arc<CudaModule>,
    rl_per_branch_lr_fn: CudaFunction,

    // ── Phase R7d: PER + off-policy Q replay ──────────────────────────

    /// Per-step gather buffer for PER-sampled `h_t`. Populated each step
    /// by the GPU PER gather kernel from the device-resident buffer.
    /// Length `B × HIDDEN_DIM`.
    pub sampled_h_t_d: CudaSlice<f32>,

    /// Per-step gather buffer for PER-sampled `h_{t+1}`. Same pattern as
    /// `sampled_h_t_d`. Length `B × HIDDEN_DIM`.
    pub sampled_h_tp1_d: CudaSlice<f32>,

    /// Per-step gather buffer for PER-sampled actions (HtoD per step
    /// from the host metadata replay stores alongside each transition).
    /// Length `B`.
    pub sampled_actions_d: CudaSlice<i32>,

    /// Per-step gather buffer for PER-sampled rewards. Length `B`.
    pub sampled_rewards_d: CudaSlice<f32>,

    /// Per-step gather buffer for PER-sampled done flags. Length `B`.
    pub sampled_dones_d: CudaSlice<f32>,
    pub sampled_n_step_gammas_d: CudaSlice<f32>,

    /// Per-step gather buffer for PER-sampled log π_old. Length `B`.
    pub sampled_log_pi_old_d: CudaSlice<f32>,

    /// GPU-resident PER buffer — all state on device, zero host-side
    /// PER operations per `feedback_cpu_is_read_only`.
    pub gpu_replay: crate::rl::gpu_replay::GpuReplayBuffer,

    // ── GPU PER kernel handles ──────────────────────────────────────
    _rl_per_push_ring_module: Arc<CudaModule>,
    rl_per_push_ring_fn: CudaFunction,
    _rl_per_push_flush_module: Arc<CudaModule>,
    rl_per_push_flush_fn: CudaFunction,
    _rl_per_sample_module: Arc<CudaModule>,
    rl_per_sample_fn: CudaFunction,
    _rl_per_update_priority_module: Arc<CudaModule>,
    rl_per_update_priority_fn: CudaFunction,
    _rl_per_tree_rebuild_module: Arc<CudaModule>,
    rl_per_tree_rebuild_fn: CudaFunction,

    // ── Bidirectional HER kernel handles ───────────────────────────
    pub hindsight: crate::rl::gpu_hindsight::GpuHindsight,
    _rl_hindsight_track_module: Arc<CudaModule>,
    rl_hindsight_track_fn: CudaFunction,
    _rl_hindsight_inject_module: Arc<CudaModule>,
    rl_hindsight_inject_fn: CudaFunction,
    _rl_hindsight_forward_module: Arc<CudaModule>,
    rl_hindsight_forward_fn: CudaFunction,

    /// Per-step gather buffer for PER-sampled next-state argmax actions
    /// (Double-DQN online-Q argmax on `sampled_h_tp1`). Computed
    /// per-step inside `dqn_offpolicy_step` via `argmax_expected_q` —
    /// not stored in the replay buffer (online net weights drift, so
    /// the argmax is recomputed each step). Length `B`.
    pub sampled_next_actions_d: CudaSlice<i32>,

    /// Per-step CE loss output of `dqn_distributional_q_bwd` (R7d
    /// kernel signature addition). Length `B`. Fed directly to
    /// `rl_per_update_priority` kernel as absolute TD-error input for
    /// priority tree leaf updates (fully device-resident path).
    pub td_per_sample_d: CudaSlice<f32>,

    /// Combined encoder grad slot — folded from Q + π + V `grad_h_t`
    /// contributions via `grad_h_accumulate_scaled` (item 1). Consumed by
    /// `PerceptionTrainer::backward_encoder_with_grad_h_t`. Size
    /// `B × HIDDEN_DIM`; zeroed at the start of each
    /// `step_synthetic` call.
    grad_h_t_combined_d: CudaSlice<f32>,

    /// Last-read PPO π loss host scalar — internal scratch threading the
    /// surrogate kernel's atomic-write loss readback through the
    /// borrow-checker dance in `step_synthetic`. NOT a public API.
    last_pi_loss: f32,
    /// Last step's Q-head loss (Q CE, host scalar) — fed to the LR
    /// controller at the start of NEXT step_synthetic for plateau
    /// detection. Zero at construction; populated at the end of
    /// step_synthetic after the Q backward's scalar readback.
    last_q_loss: f32,
    /// Last step's BCE loss from supervised `step_batched_from_device`.
    last_bce_loss: f32,
    last_aux_loss: f32,
    /// Last step's V-head loss (MSE, host scalar) — same pattern as
    /// `last_q_loss`. Zero at construction.
    last_v_loss: f32,

    /// Composed `IntegratedStepStats` from the PREVIOUS step's mapped-
    /// pinned loss reads — returned by `step_synthetic`. One-step delay
    /// is negligible for EMA-based LR controllers and JSONL diagnostics.
    last_step_stats: IntegratedStepStats,

    /// audit — number of training iterations performed in the most-recent
    /// `step_with_lobsim` call. Derived from `isv[RL_N_ROLLOUT_STEPS_INDEX]`
    /// via `K = clamp(isv[404] / 1024, 1, 8)`. Surfaces in the diag
    /// JSONL via `pub` accessor so post-hoc analysis can see how the
    /// `n_rollout_steps` controller is driving training intensity per
    /// env step. Zero at construction (first step overwrites).
    pub last_k_updates: usize,

    /// Host-side step counter for Thompson-sampling RNG salt (Phase E.3b).
    /// Increments once per `step_with_lobsim` call so consecutive calls
    /// draw different actions from the same Q-distribution. Per
    /// `pearl_scoped_init_seed_for_reproducibility`: the RNG seed is
    /// `cfg.dqn_seed.wrapping_add(step_counter)`, deterministic given
    /// the same config + step sequence.
    step_counter: u64,

    /// When true, `step_synthetic` dispatches the encoder forward via
    /// `forward_encoder_from_device()` (GPU-resident SoA buffers
    /// pre-filled by the caller) instead of `forward_encoder(snapshots)`.
    /// Set by `step_with_lobsim_gpu` before the K-loop, cleared after.
    /// This avoids duplicating the 850-line reward + K-loop pipeline
    /// between the host and GPU data paths. NOT a feature flag — it's
    /// call-context state consumed within the same step.
    gpu_encoder_active: bool,

    /// Mapped-pinned staging for per-file `pos_fraction` gathered by
    /// `gpu_gather_pos_fraction`. Length `2 * N_HORIZONS`. Host-read after
    /// a stream sync to compute clamped `pos_weight` for the supervised
    /// BCE loss in `step_batched_from_device`.
    pos_fraction_staging: Option<MappedF32Buffer>,

    /// Host-side counter for spectral norm amortization. Spectral norm
    /// (power iteration on full weight matrices) costs 175us x 3 heads =
    /// 843us/step. Running every step is wasteful because per-step weight
    /// changes are O(lr * grad) = small. Amortizing to every
    /// SPECTRAL_NORM_PERIOD steps via Weyl's inequality: sigma_max
    /// shifts by at most ||DeltaW||_2 per step, so the guard remains
    /// valid between full power iterations.
    spectral_norm_counter: u64,

    /// SP20 P3 Forward-Return-Distribution head (forecast over 3
    /// horizons × 21 return-bucket atoms). Forward kernel runs every
    /// `step_with_lobsim`; backward + label-supervised loss arrive in
    /// F.3 / loader label generation.
    pub frd_head: crate::rl::frd::FrdHead,

    /// SP20 P3 — cached post-ReLU hidden activation buffer for the FRD
    /// head's backward pass (`B × FRD_HIDDEN_DIM`). Owned per-trainer
    /// so we don't allocate per step.
    pub frd_hidden_d: CudaSlice<f32>,

    /// SP20 P3 — FRD output logits (`B × FRD_OUT_DIM` where
    /// FRD_OUT_DIM = FRD_N_HORIZONS × FRD_N_ATOMS = 63). Public so
    /// callers can read for diag/inference; backward kernel writes
    /// gradients into this buffer in F.3.
    pub frd_logits_d: CudaSlice<f32>,

    /// SP20 P3 — FRD per-(batch, horizon) labels for the supervised
    /// CE loss. Atom index `[0, FRD_N_ATOMS)` selects the correct
    /// return bucket; sentinel `-1` marks "missing horizon" (the
    /// forward return at h-ticks-ahead isn't realized yet at the
    /// rightmost edge of the snapshot stream). All entries initialise
    /// to -1; F.5 will populate before each `step_with_lobsim` from
    /// the loader's forward-snapshot lookahead.
    pub frd_labels_d: CudaSlice<i32>,
    pub frd_labels_staging: crate::pinned_mem::MappedI32Buffer,

    // ── Persistent replay-step scratch buffers (CUDA Graph stable) ────
    // Pre-allocated at init so device pointers are stable across calls,
    // enabling CUDA Graph capture of the RL replay training step.
    // Zeroed via memset_zeros at the start of each step_synthetic /
    // dqn_replay_step call.

    // Loss accumulators — mapped-pinned. GPU writes via dev_ptr
    // (atomicAdd), host reads via host_ptr. Zero DtoD, zero syncs.
    pub ss_q_loss_mapped: MappedF32Buffer,
    pub ss_q_loss_dev_ptr: u64,
    pub ss_pi_loss_mapped: MappedF32Buffer,
    pub ss_pi_loss_dev_ptr: u64,
    pub ss_pi_loss_entropy_mapped: MappedF32Buffer,
    pub ss_pi_loss_entropy_dev_ptr: u64,
    pub ss_v_loss_sum_mapped: MappedF32Buffer,
    pub ss_v_loss_sum_dev_ptr: u64,

    // Per-batch diagnostic scratch.
    pub ss_v_loss_per_batch_d: CudaSlice<f32>,
    pub ss_pi_log_prob_d: CudaSlice<f32>,
    pub ss_entropy_d: CudaSlice<f32>,
    // F4.1 (2026-05-31): per-batch PPO loss + entropy-loss slots. The
    // surrogate fwd kernel writes [B] values single-writer-per-slot here;
    // `ppo_loss_reduce_b` then tree-reduces each to the scalar mapped-
    // pinned diagnostics. Replaces the prior atomicAdd-into-scalar
    // pattern that caused the step-count l_pi staleness bug.
    pub ss_pi_loss_per_b_d: CudaSlice<f32>,
    pub ss_pi_loss_entropy_per_b_d: CudaSlice<f32>,

    // Q-head gradient buffers. backward_gemm produces grad_w / grad_b
    // directly reduced (no per-batch scratch needed — cuBLAS SGEMM
    // computes grad_logits^T @ h_t as a single GEMM).
    pub ss_q_grad_logits_d: CudaSlice<f32>,
    pub ss_q_grad_h_t_d: CudaSlice<f32>,
    pub ss_q_grad_w_d: CudaSlice<f32>,
    pub ss_q_grad_b_d: CudaSlice<f32>,

    // π-head per-batch + reduced gradient buffers.
    pub ss_pi_grad_logits_d: CudaSlice<f32>,
    pub ss_pi_grad_w_per_batch_d: CudaSlice<f32>,
    pub ss_pi_grad_b_per_batch_d: CudaSlice<f32>,
    pub ss_pi_grad_h_t_d: CudaSlice<f32>,
    pub ss_pi_grad_w_d: CudaSlice<f32>,
    pub ss_pi_grad_b_d: CudaSlice<f32>,

    // Phase 7a F2-diag (2026-06-04) per-batch scratch for kernel-reduced
    // F2 mechanism counters. Populated by `rl_q_pi_distill_grad.cu`'s
    // end-of-kernel single-thread reduction block (one block per batch,
    // thread a == 0 writes its own batch index — no atomicAdd). Host
    // reduces to mean across batches at diag emit (build_diag_value).
    pub f2_diag_baseline_per_b_d: CudaSlice<f32>,
    pub f2_diag_adv_max_per_b_d: CudaSlice<f32>,
    pub f2_diag_hinge_zero_per_b_d: CudaSlice<f32>,
    pub f2_diag_target_argmax_per_b_d: CudaSlice<i32>,

    // V-head per-batch + reduced gradient buffers.
    pub ss_v_grad_w_per_batch_d: CudaSlice<f32>,
    pub ss_v_grad_b_per_batch_d: CudaSlice<f32>,
    pub ss_v_grad_h_t_d: CudaSlice<f32>,
    pub ss_v_grad_w_d: CudaSlice<f32>,
    pub ss_v_grad_b_d: CudaSlice<f32>,

    // FRD encoder-upstream gradient.
    pub ss_frd_grad_h_t_d: CudaSlice<f32>,

    // Outcome head backward scratch buffers.
    pub ss_outcome_grad_w_pb_d: CudaSlice<f32>,  // [B, HIDDEN_DIM, K_CLASSES]
    pub ss_outcome_grad_b_pb_d: CudaSlice<f32>,  // [B, K_CLASSES]
    pub ss_outcome_grad_h_t_d: CudaSlice<f32>,   // [B, HIDDEN_DIM]
    pub ss_outcome_grad_w_d: CudaSlice<f32>,      // [HIDDEN_DIM, K_CLASSES]
    pub ss_outcome_grad_b_d: CudaSlice<f32>,      // [K_CLASSES]

    // Bellman projection scratch.
    pub ss_q_target_full_d: CudaSlice<f32>,
    pub ss_target_dist_d: CudaSlice<f32>,
    // B-9 (2026-06-01): per-batch saturation tally scratch for the
    // Bellman target projection diagnostic. Each `[B] f32` array is
    // written by whichever bellman variant ran this step and consumed by
    // `dqn_head.launch_saturation_reduce` immediately after. No state
    // survives across steps; not preserved across reset_session_state.
    pub bellman_sat_top_per_batch_d: CudaSlice<f32>,
    pub bellman_sat_bot_per_batch_d: CudaSlice<f32>,
    pub bellman_max_pre_per_batch_d: CudaSlice<f32>,
    pub bellman_min_pre_per_batch_d: CudaSlice<f32>,
    // B-10 (2026-06-01): per-batch observability scratches.
    // G1 (Q-distribution informativeness) — 3 [B] f32 arrays.
    pub q_dist_entropy_pb_d: CudaSlice<f32>,
    pub q_dist_range_pb_d:   CudaSlice<f32>,
    pub q_dist_abs_max_pb_d: CudaSlice<f32>,
    // G3+G4 (PPO advantage + surrogate decomposition) — 4 [B] f32 arrays
    // written by `ppo_clipped_surrogate_fwd` alongside the existing
    // loss_pi_per_b output. Consumed by
    // `policy_head.launch_ppo_diagnostic_stats_reduce` immediately after.
    pub ppo_a_norm_pb_d:        CudaSlice<f32>,
    pub ppo_ratio_dev_pb_d:     CudaSlice<f32>,
    pub ppo_ratio_clipped_pb_d: CudaSlice<f32>,
    pub ppo_surrogate_pb_d:     CudaSlice<f32>,

    // FRD backward chain scratch (step_synthetic only).
    pub ss_frd_grad_logits_d: CudaSlice<f32>,
    pub ss_frd_loss_mapped: MappedF32Buffer,
    pub ss_frd_loss_dev_ptr: u64,
    pub ss_frd_grad_w2_pb_d: CudaSlice<f32>,
    pub ss_frd_grad_b2_pb_d: CudaSlice<f32>,
    pub ss_frd_grad_hidden_d: CudaSlice<f32>,
    pub ss_frd_grad_w1_pb_d: CudaSlice<f32>,
    pub ss_frd_grad_b1_pb_d: CudaSlice<f32>,
    pub ss_frd_grad_w1_d: CudaSlice<f32>,
    pub ss_frd_grad_b1_d: CudaSlice<f32>,
    pub ss_frd_grad_w2_d: CudaSlice<f32>,
    pub ss_frd_grad_b2_d: CudaSlice<f32>,

    /// Cached raw CUdeviceptr values — zero `device_ptr()` calls in hot path.
    hot: HotPathPtrs,

    // IQN replay-step scratch (dqn_replay_step only).
    pub ss_iqn_tau_target_d: CudaSlice<f32>,
    pub ss_iqn_target_q_d: CudaSlice<f32>,
    pub ss_iqn_loss_pb_d: CudaSlice<f32>,
    pub ss_iqn_grad_q_d: CudaSlice<f32>,
    pub ss_iqn_grad_w_out_pb_d: CudaSlice<f32>,
    pub ss_iqn_grad_b_out_pb_d: CudaSlice<f32>,
    pub ss_iqn_grad_w_embed_pb_d: CudaSlice<f32>,
    pub ss_iqn_grad_b_embed_pb_d: CudaSlice<f32>,
    pub ss_iqn_grad_w_out_d: CudaSlice<f32>,
    pub ss_iqn_grad_b_out_d: CudaSlice<f32>,
    pub ss_iqn_grad_w_embed_d: CudaSlice<f32>,
    pub ss_iqn_grad_b_embed_d: CudaSlice<f32>,
}

impl IntegratedTrainer {
    pub fn new(dev: &MlDevice, cfg: IntegratedTrainerConfig) -> Result<Self> {
        let stream: Arc<CudaStream> = dev.cuda_stream().context("integrated stream")?.clone();
        let train_stream: Arc<CudaStream> = {
            let ctx = dev.cuda_context().context("train_stream ctx")?;
            ctx.new_stream().map_err(|e| anyhow::anyhow!("train_stream: {e}"))?
        };

        let perception =
            PerceptionTrainer::new(dev, &cfg.perception).context("PerceptionTrainer::new")?;

        // Encoder hidden dim — single source of truth via heads::HIDDEN_DIM
        // (the same const the perception trainer uses).
        let hidden_dim = HIDDEN_DIM;

        let dqn_head = DqnHead::new(
            dev,
            DqnHeadConfig {
                hidden_dim,
                seed: cfg.dqn_seed,
            },
        )
        .context("DqnHead::new")?;

        let iqn_head = crate::rl::iqn::IqnHead::new(
            dev,
            crate::rl::iqn::IqnHeadConfig {
                hidden_dim: HIDDEN_DIM,
                seed: cfg.dqn_seed.wrapping_add(100),
                n_tau: 32,
                max_batch_size: cfg.perception.n_batch,
            },
        )
        .context("IqnHead::new")?;

        let policy_head = PolicyHead::new(
            dev,
            PpoHeadsConfig {
                hidden_dim,
                seed: cfg.ppo_seed,
            },
        )
        .context("PolicyHead::new")?;

        let value_head = ValueHead::new(
            dev,
            PpoHeadsConfig {
                hidden_dim,
                seed: cfg.ppo_seed.wrapping_add(0xA1),
            },
        )
        .context("ValueHead::new")?;

        // Phase 4-A (2026-06-03) No-transaction-band head. Bias init values
        // mirror the bootstrap slots `RL_BAND_LOWER_INIT_INDEX`(=-0.5) and
        // `RL_BAND_UPPER_INIT_INDEX`(=+0.5), expressed in the [-1, +1]
        // tanh-normalised space. The activation applies `±|tanh| × N_max_eff`,
        // so a pre-activation bias of `atanh(0.5) ≈ 0.549` yields
        // `|tanh| = 0.5` → initial band = `[-0.5·N_max, +0.5·N_max]`
        // (= `[-4, +4]` at `N_max=8`). Per spec §1.3.
        //
        // Master gate at `RL_BAND_ENABLED_INDEX` (slot 799) bootstraps to
        // 0.0 (OFF) so the head exists but never fires until the operator
        // flips the slot for A/B.
        let band_init_bias_pre: f32 = 0.5_f32.atanh();
        let band_head = crate::rl::band_head::BandHead::new(
            dev,
            crate::rl::band_head::BandHeadConfig {
                hidden_dim,
                b_size: cfg.perception.n_batch,
                // Independent init stream — fixed-offset salt away from
                // PolicyHead / ValueHead / MultiHeadPolicy seeds.
                seed: cfg.ppo_seed.wrapping_add(0xBA_5E),
                b_l_init_bias: band_init_bias_pre,
                b_u_init_bias: band_init_bias_pre,
            },
        )
        .context("BandHead::new (Phase 4-A)")?;

        // Phase 4 DuelingQHead — fully encapsulated dueling Q learner.
        // Seed derived from ppo_seed for reproducibility; distinct
        // offset (0xDE17 = "DELI" leet) so init draws are independent
        // of other heads' init streams.
        let dueling_q_head = crate::rl::dueling_q::DuelingQHead::new(
            dev,
            crate::rl::dueling_q::DuelingQHeadConfig {
                hidden_dim,
                seed: cfg.ppo_seed.wrapping_add(0xDE17),
            },
        )
        .context("DuelingQHead::new")?;

        // Per-head Adam optimisers — one per (head, w | b) pair. Each owns
        // independent m/v buffers and a device-resident step counter (per
        // pearl_no_host_branches_in_captured_graph: counter advancement
        // happens inside a captured kernel, not in host code).
        //
        // LR is sourced from ISV[RL_LR_*_INDEX] per step (item 3); the
        // construction-time value passed to AdamW::new is a sentinel-zero-
        // bootstrap PLACEHOLDER overwritten before the first AdamW.step.
        // Per `pearl_first_observation_bootstrap` the controller seeds
        // 1e-3 on the first ISV emit, then the trainer mirrors that value
        // into each AdamW.lr field before the Adam step launches.
        let lr_placeholder = 0.0_f32;
        let dqn_w_adam =
            AdamW::new(dev, dqn_head.w_d.len(), lr_placeholder).context("dqn_w_adam")?;
        let dqn_b_adam =
            AdamW::new(dev, dqn_head.b_d.len(), lr_placeholder).context("dqn_b_adam")?;
        let iqn_w_embed_adam =
            AdamW::new(dev, iqn_head.w_embed_d.len(), lr_placeholder).context("iqn_w_embed_adam")?;
        let iqn_b_embed_adam =
            AdamW::new(dev, iqn_head.b_embed_d.len(), lr_placeholder).context("iqn_b_embed_adam")?;
        let iqn_w_out_adam =
            AdamW::new(dev, iqn_head.w_out_d.len(), lr_placeholder).context("iqn_w_out_adam")?;
        let iqn_b_out_adam =
            AdamW::new(dev, iqn_head.b_out_d.len(), lr_placeholder).context("iqn_b_out_adam")?;
        let policy_w_adam =
            AdamW::new(dev, policy_head.w_d.len(), lr_placeholder).context("policy_w_adam")?;
        let policy_b_adam =
            AdamW::new(dev, policy_head.b_d.len(), lr_placeholder).context("policy_b_adam")?;
        // Phase 4-B (2026-06-04) band-head Adam pair.
        let band_w_adam =
            AdamW::new(dev, band_head.w_band_d.len(), lr_placeholder).context("band_w_adam")?;
        let band_b_adam =
            AdamW::new(dev, band_head.b_band_d.len(), lr_placeholder).context("band_b_adam")?;

        // ── Phase 2A-C (2026-06-03) MultiHeadPolicy gate ─────────────
        // `FOXHUNT_USE_MULTI_HEAD_POLICY` env flag follows the
        // `FOXHUNT_USE_ROLLOUT` precedent from commit 779c03b9d. Read
        // ONCE here so the value is invariant across captured-graph
        // replay (the `if self.use_multi_head_policy` branches at the
        // π forward / backward sites run only during graph CAPTURE; at
        // replay time the selected kernel stream is baked in).
        //
        // Gate semantics:
        //   * `FOXHUNT_USE_MULTI_HEAD_POLICY` unset or `=0`  → legacy
        //     single-head `policy_head` drives π. `multi_head_policy` is
        //     `None`, no extra device buffers, bit-equal to HEAD `e22da61cf`.
        //   * `FOXHUNT_USE_MULTI_HEAD_POLICY=1`              → construct
        //     `MultiHeadPolicy` with K from ISV slot 761 (bootstrap K=3).
        //     Forward routes `h_t + step_regime_d` through the K-head
        //     mixture; backward distributes Q-distill grad through
        //     mixture + per-head softmax + gating softmax Jacobians;
        //     Adam updates the four new weight tensors.
        //
        // Per `pearl_foxhunt_pi_trained_by_q_distillation_not_ppo`:
        // the wiring target is the post-Q-distill `ss_pi_grad_logits_d`
        // write site (`integrated.rs:~5607`). PPO-surrogate grad is still
        // zeroed at line 5577; nothing changes for that path.
        let use_multi_head_policy: bool = std::env::var("FOXHUNT_USE_MULTI_HEAD_POLICY")
            .ok()
            .and_then(|v| v.parse::<u8>().ok())
            .map(|n| n > 0)
            .unwrap_or(false);
        let (
            multi_head_policy,
            mhp_w_heads_adam,
            mhp_b_heads_adam,
            mhp_w_gate_adam,
            mhp_b_gate_adam,
        ): (
            Option<MultiHeadPolicy>,
            Option<AdamW>,
            Option<AdamW>,
            Option<AdamW>,
            Option<AdamW>,
        ) = if use_multi_head_policy {
            // K = 3 is the bootstrap value documented in
            // `RL_POLICY_NUM_HEADS_INDEX` (slot 761) and seeded in the
            // controller-bootstrap table below. Reading host-side ISV here
            // is safe because the table runs strictly BEFORE this point —
            // however `with_controllers_bootstrapped` runs AFTER `new()`,
            // so the slot is still zero at construction. We therefore use
            // the hardcoded bootstrap constant `K=3` to match the bootstrap
            // table and per the spec's "K=3 production default"; runtime K
            // changes would require re-allocation anyway (weight tensors
            // are sized by K).
            const K_BOOTSTRAP: usize = 3;
            // Distinct seed from the legacy `policy_head` (offset 0xC1A55 =
            // "CLASS" — independent head initialisation streams per
            // `feedback_no_shared_init_streams`).
            let mhp_seed: u64 = cfg.ppo_seed.wrapping_add(0xC1A55);
            let mhp = MultiHeadPolicy::new(
                dev,
                MultiHeadPolicyConfig {
                    k: K_BOOTSTRAP,
                    b_size: cfg.perception.n_batch,
                    seed: mhp_seed,
                },
            )
            .context("MultiHeadPolicy::new (FOXHUNT_USE_MULTI_HEAD_POLICY=1)")?;
            // Mirror legacy policy Adam pattern: lr_placeholder (overwritten
            // every step from ISV[RL_LR_PI_INDEX]). One Adam per weight
            // tensor — gate weights get their own Adam (different shape +
            // potentially different LR scale in a future controller).
            let w_heads_adam = AdamW::new(dev, mhp.w_heads_d.len(), lr_placeholder)
                .context("mhp_w_heads_adam")?;
            let b_heads_adam = AdamW::new(dev, mhp.b_heads_d.len(), lr_placeholder)
                .context("mhp_b_heads_adam")?;
            let w_gate_adam = AdamW::new(dev, mhp.w_gate_d.len(), lr_placeholder)
                .context("mhp_w_gate_adam")?;
            let b_gate_adam = AdamW::new(dev, mhp.b_gate_d.len(), lr_placeholder)
                .context("mhp_b_gate_adam")?;
            eprintln!(
                "[multi-head-policy] FOXHUNT_USE_MULTI_HEAD_POLICY=1 → MultiHeadPolicy active \
                 (K={} B={} REGIME_DIM=6, seed={:#x})",
                K_BOOTSTRAP, cfg.perception.n_batch, mhp_seed
            );
            (
                Some(mhp),
                Some(w_heads_adam),
                Some(b_heads_adam),
                Some(w_gate_adam),
                Some(b_gate_adam),
            )
        } else {
            eprintln!(
                "[multi-head-policy] FOXHUNT_USE_MULTI_HEAD_POLICY unset/=0 → legacy single-head \
                 policy_head path (bit-equal to HEAD e22da61cf)"
            );
            (None, None, None, None, None)
        };
        let value_w_adam =
            AdamW::new(dev, value_head.w_d.len(), lr_placeholder).context("value_w_adam")?;
        let value_b_adam =
            AdamW::new(dev, value_head.b_d.len(), lr_placeholder).context("value_b_adam")?;
        // Phase 4 dueling-head Adam states.
        let dueling_q_w_v_adam =
            AdamW::new(dev, dueling_q_head.w_v_d.len(), lr_placeholder).context("dueling_q_w_v_adam")?;
        let dueling_q_b_v_adam =
            AdamW::new(dev, dueling_q_head.b_v_d.len(), lr_placeholder).context("dueling_q_b_v_adam")?;
        let dueling_q_w_a_adam =
            AdamW::new(dev, dueling_q_head.w_a_d.len(), lr_placeholder).context("dueling_q_w_a_adam")?;
        let dueling_q_b_a_adam =
            AdamW::new(dev, dueling_q_head.b_a_d.len(), lr_placeholder).context("dueling_q_b_a_adam")?;

        // ISV buffer — mapped-pinned, zero-init by MappedRecordBuffer::new.
        // GPU writes via dev_ptr, host reads via host_ptr. Sentinel-
        // bootstrap on first controller emit. Clamp-ceiling seeding
        // happens via the rl_streaming_clamp_init kernel launched
        // alongside the controller bootstraps below.
        let isv_mapped = unsafe { MappedF32Buffer::new(RL_SLOTS_END) }
            .map_err(|e| anyhow::anyhow!("isv_mapped: {e}"))?;
        let isv_dev_ptr = isv_mapped.dev_ptr;

        // Combined-encoder-grad slot for item 1 (perception backward).
        // Sized [B × HIDDEN_DIM]; the perception config holds n_batch.
        let grad_h_t_combined_d = stream
            .alloc_zeros::<f32>(cfg.perception.n_batch * hidden_dim)
            .context("alloc grad_h_t_combined_d")?;

        // Load helper kernels.
        let ctx = dev.cuda_context().context("integrated ctx")?;
        let grad_h_module = ctx
            .load_cubin(GRAD_H_ACCUMULATE_CUBIN.to_vec())
            .context("load grad_h_accumulate cubin")?;
        let grad_h_accumulate_fn = grad_h_module
            .load_function("grad_h_accumulate_scaled")
            .context("load grad_h_accumulate_scaled")?;
        let reduce_axis0_module = ctx
            .load_cubin(REDUCE_AXIS0_CUBIN.to_vec())
            .context("load reduce_axis0 cubin")?;
        let reduce_axis0_fn = reduce_axis0_module
            .load_function("reduce_axis0")
            .context("load reduce_axis0")?;
        let rl_lr_controller_module = ctx
            .load_cubin(RL_LR_CONTROLLER_CUBIN.to_vec())
            .context("load rl_lr_controller cubin")?;
        let rl_lr_controller_fn = rl_lr_controller_module
            .load_function("rl_lr_controller")
            .context("load rl_lr_controller")?;

        // Phase R1: load the 7 RL adaptive controllers.
        let rl_gamma_controller_module = ctx
            .load_cubin(RL_GAMMA_CONTROLLER_CUBIN.to_vec())
            .context("load rl_gamma_controller cubin")?;
        let rl_gamma_controller_fn = rl_gamma_controller_module
            .load_function("rl_gamma_controller")
            .context("load rl_gamma_controller")?;
        let rl_target_tau_controller_module = ctx
            .load_cubin(RL_TARGET_TAU_CONTROLLER_CUBIN.to_vec())
            .context("load rl_target_tau_controller cubin")?;
        let rl_target_tau_controller_fn = rl_target_tau_controller_module
            .load_function("rl_target_tau_controller")
            .context("load rl_target_tau_controller")?;
        let rl_ppo_clip_controller_module = ctx
            .load_cubin(RL_PPO_CLIP_CONTROLLER_CUBIN.to_vec())
            .context("load rl_ppo_clip_controller cubin")?;
        let rl_ppo_clip_controller_fn = rl_ppo_clip_controller_module
            .load_function("rl_ppo_clip_controller")
            .context("load rl_ppo_clip_controller")?;
        let rl_entropy_coef_controller_module = ctx
            .load_cubin(RL_ENTROPY_COEF_CONTROLLER_CUBIN.to_vec())
            .context("load rl_entropy_coef_controller cubin")?;
        let rl_entropy_coef_controller_fn = rl_entropy_coef_controller_module
            .load_function("rl_entropy_coef_controller")
            .context("load rl_entropy_coef_controller")?;
        let rl_rollout_steps_controller_module = ctx
            .load_cubin(RL_ROLLOUT_STEPS_CONTROLLER_CUBIN.to_vec())
            .context("load rl_rollout_steps_controller cubin")?;
        let rl_rollout_steps_controller_fn = rl_rollout_steps_controller_module
            .load_function("rl_rollout_steps_controller")
            .context("load rl_rollout_steps_controller")?;
        let rl_per_alpha_controller_module = ctx
            .load_cubin(RL_PER_ALPHA_CONTROLLER_CUBIN.to_vec())
            .context("load rl_per_alpha_controller cubin")?;
        let rl_per_alpha_controller_fn = rl_per_alpha_controller_module
            .load_function("rl_per_alpha_controller")
            .context("load rl_per_alpha_controller")?;
        let rl_reward_scale_controller_module = ctx
            .load_cubin(RL_REWARD_SCALE_CONTROLLER_CUBIN.to_vec())
            .context("load rl_reward_scale_controller cubin")?;
        let rl_reward_scale_controller_fn = rl_reward_scale_controller_module
            .load_function("rl_reward_scale_controller")
            .context("load rl_reward_scale_controller")?;

        // Phase R3 kernels.
        let ema_update_on_done_module = ctx
            .load_cubin(EMA_UPDATE_ON_DONE_CUBIN.to_vec())
            .context("load ema_update_on_done cubin")?;
        let ema_update_on_done_fn = ema_update_on_done_module
            .load_function("ema_update_on_done")
            .context("load ema_update_on_done")?;
        let ema_update_per_step_module = ctx
            .load_cubin(EMA_UPDATE_PER_STEP_CUBIN.to_vec())
            .context("load ema_update_per_step cubin")?;
        let ema_update_per_step_fn = ema_update_per_step_module
            .load_function("ema_update_per_step")
            .context("load ema_update_per_step")?;
        let action_entropy_per_step_module = ctx
            .load_cubin(ACTION_ENTROPY_PER_STEP_CUBIN.to_vec())
            .context("load action_entropy_per_step cubin")?;
        let action_entropy_per_step_fn = action_entropy_per_step_module
            .load_function("action_entropy_per_step")
            .context("load action_entropy_per_step")?;
        let compute_advantage_return_module = ctx
            .load_cubin(COMPUTE_ADVANTAGE_RETURN_CUBIN.to_vec())
            .context("load compute_advantage_return cubin")?;
        let compute_advantage_return_fn = compute_advantage_return_module
            .load_function("compute_advantage_return")
            .context("load compute_advantage_return")?;
        // Phase R4 kernels.
        let rl_action_kernel_module = ctx
            .load_cubin(RL_ACTION_KERNEL_CUBIN.to_vec())
            .context("load rl_action_kernel cubin")?;
        let rl_action_kernel_fn = rl_action_kernel_module
            .load_function("rl_action_kernel")
            .context("load rl_action_kernel")?;
        let argmax_expected_q_module = ctx
            .load_cubin(ARGMAX_EXPECTED_Q_CUBIN.to_vec())
            .context("load argmax_expected_q cubin")?;
        let argmax_expected_q_fn = argmax_expected_q_module
            .load_function("argmax_expected_q")
            .context("load argmax_expected_q")?;
        let log_pi_at_action_module = ctx
            .load_cubin(LOG_PI_AT_ACTION_CUBIN.to_vec())
            .context("load log_pi_at_action cubin")?;
        let log_pi_at_action_fn = log_pi_at_action_module
            .load_function("log_pi_at_action")
            .context("load log_pi_at_action")?;

        // Phase R6 kernels.
        let apply_reward_scale_module = ctx
            .load_cubin(APPLY_REWARD_SCALE_CUBIN.to_vec())
            .context("load apply_reward_scale cubin")?;
        let apply_reward_scale_fn = apply_reward_scale_module
            .load_function("apply_reward_scale")
            .context("load apply_reward_scale")?;
        let rl_reward_clamp_controller_module = ctx
            .load_cubin(RL_REWARD_CLAMP_CONTROLLER_CUBIN.to_vec())
            .context("load rl_reward_clamp_controller cubin")?;
        let rl_reward_clamp_controller_fn = rl_reward_clamp_controller_module
            .load_function("rl_reward_clamp_controller")
            .context("load rl_reward_clamp_controller")?;
        let rl_atom_support_update_module = ctx
            .load_cubin(RL_ATOM_SUPPORT_UPDATE_CUBIN.to_vec())
            .context("load rl_atom_support_update cubin")?;
        let rl_atom_support_update_fn = rl_atom_support_update_module
            .load_function("rl_atom_support_update")
            .context("load rl_atom_support_update")?;
        let rl_q_pi_distill_grad_module = ctx
            .load_cubin(RL_Q_PI_DISTILL_GRAD_CUBIN.to_vec())
            .context("load rl_q_pi_distill_grad cubin")?;
        let rl_q_pi_distill_grad_fn = rl_q_pi_distill_grad_module
            .load_function("rl_q_pi_distill_grad")
            .context("load rl_q_pi_distill_grad")?;
        // Phase 7b F5 (2026-06-05) — state-conditional action availability mask.
        let rl_state_action_mask_module = ctx
            .load_cubin(RL_STATE_ACTION_MASK_CUBIN.to_vec())
            .context("load rl_state_action_mask cubin")?;
        let rl_state_action_mask_fn = rl_state_action_mask_module
            .load_function("rl_state_action_mask")
            .context("load rl_state_action_mask")?;
        // Phase 3D-C (2026-06-03): PPO surrogate blend operator.
        let rl_pi_grad_blend_module = ctx
            .load_cubin(RL_PI_GRAD_BLEND_CUBIN.to_vec())
            .context("load rl_pi_grad_blend cubin")?;
        let rl_pi_grad_blend_fn = rl_pi_grad_blend_module
            .load_function("rl_pi_grad_blend")
            .context("load rl_pi_grad_blend")?;
        let rl_kl_reference_module = ctx
            .load_cubin(include_bytes!(concat!(env!("OUT_DIR"), "/rl_kl_reference_grad.cubin")).to_vec())
            .context("load rl_kl_reference_grad cubin")?;
        let rl_kl_reference_grad_fn = rl_kl_reference_module
            .load_function("rl_kl_reference_grad")
            .context("load rl_kl_reference_grad")?;
        let rl_ensemble_action_value_module = ctx
            .load_cubin(RL_ENSEMBLE_ACTION_VALUE_CUBIN.to_vec())
            .context("load rl_ensemble_action_value cubin")?;
        let rl_ensemble_action_value_fn = rl_ensemble_action_value_module
            .load_function("rl_ensemble_action_value")
            .context("load rl_ensemble_action_value")?;
        // Phase 4.4 adaptive V blend kernels.
        let rl_v_blend_module = ctx
            .load_cubin(RL_V_BLEND_CUBIN.to_vec())
            .context("load rl_v_blend cubin")?;
        let rl_v_blend_fn = rl_v_blend_module
            .load_function("rl_v_blend")
            .context("load rl_v_blend")?;
        let rl_v_blend_alpha_controller_module = ctx
            .load_cubin(RL_V_BLEND_ALPHA_CONTROLLER_CUBIN.to_vec())
            .context("load rl_v_blend_alpha_controller cubin")?;
        let rl_v_blend_alpha_controller_fn = rl_v_blend_alpha_controller_module
            .load_function("rl_v_blend_alpha_controller")
            .context("load rl_v_blend_alpha_controller")?;
        // Phase 4.5 advantage normalization.
        let rl_advantage_normalize_module = ctx
            .load_cubin(RL_ADVANTAGE_NORMALIZE_CUBIN.to_vec())
            .context("load rl_advantage_normalize cubin")?;
        let rl_advantage_normalize_fn = rl_advantage_normalize_module
            .load_function("rl_advantage_normalize")
            .context("load rl_advantage_normalize")?;
        // Welford online variance updater for adaptive controller-input
        // floors (spec 2026-05-30-adaptive-controller-floor-design).
        let rl_signal_variance_update_module = ctx
            .load_cubin(RL_SIGNAL_VARIANCE_UPDATE_CUBIN.to_vec())
            .context("load rl_signal_variance_update cubin")?;
        let rl_signal_variance_update_fn = rl_signal_variance_update_module
            .load_function("rl_signal_variance_update")
            .context("load rl_signal_variance_update")?;
        // Adaptive risk management — layered stack (spec 2026-05-30-adaptive-risk-management).
        let rl_cmdp_constraints_check_module = ctx
            .load_cubin(RL_CMDP_CONSTRAINTS_CHECK_CUBIN.to_vec())
            .context("load rl_cmdp_constraints_check cubin")?;
        let rl_cmdp_constraints_check_fn = rl_cmdp_constraints_check_module
            .load_function("rl_cmdp_constraints_check")
            .context("load rl_cmdp_constraints_check")?;
        let rl_iqn_action_tau_controller_module = ctx
            .load_cubin(RL_IQN_ACTION_TAU_CONTROLLER_CUBIN.to_vec())
            .context("load rl_iqn_action_tau_controller cubin")?;
        let rl_iqn_action_tau_controller_fn = rl_iqn_action_tau_controller_module
            .load_function("rl_iqn_action_tau_controller")
            .context("load rl_iqn_action_tau_controller")?;
        let rl_inventory_beta_controller_module = ctx
            .load_cubin(RL_INVENTORY_BETA_CONTROLLER_CUBIN.to_vec())
            .context("load rl_inventory_beta_controller cubin")?;
        let rl_inventory_beta_controller_fn = rl_inventory_beta_controller_module
            .load_function("rl_inventory_beta_controller")
            .context("load rl_inventory_beta_controller")?;
        let rl_kelly_fraction_controller_module = ctx
            .load_cubin(RL_KELLY_FRACTION_CONTROLLER_CUBIN.to_vec())
            .context("load rl_kelly_fraction_controller cubin")?;
        let rl_kelly_fraction_controller_fn = rl_kelly_fraction_controller_module
            .load_function("rl_kelly_fraction_controller")
            .context("load rl_kelly_fraction_controller")?;
        let rl_surfer_scaffold_controller_module = ctx
            .load_cubin(RL_SURFER_SCAFFOLD_CONTROLLER_CUBIN.to_vec())
            .context("load rl_surfer_scaffold_controller cubin")?;
        let rl_surfer_scaffold_controller_fn = rl_surfer_scaffold_controller_module
            .load_function("rl_surfer_scaffold_controller")
            .context("load rl_surfer_scaffold_controller")?;
        // Phase 2A-D fix B1.3 — adaptive gate-LR multiplier controller.
        let rl_gate_lr_multiplier_controller_module = ctx
            .load_cubin(RL_GATE_LR_MULTIPLIER_CONTROLLER_CUBIN.to_vec())
            .context("load rl_gate_lr_multiplier_controller cubin")?;
        let rl_gate_lr_multiplier_controller_fn = rl_gate_lr_multiplier_controller_module
            .load_function("rl_gate_lr_multiplier_controller")
            .context("load rl_gate_lr_multiplier_controller")?;
        // v9 — defensive eval-boundary calibration kernel.
        let rl_eval_warmup_decay_module = ctx
            .load_cubin(RL_EVAL_WARMUP_DECAY_CUBIN.to_vec())
            .context("load rl_eval_warmup_decay cubin")?;
        let rl_eval_warmup_decay_fn = rl_eval_warmup_decay_module
            .load_function("rl_eval_warmup_decay")
            .context("load rl_eval_warmup_decay")?;
        // Regime observer (F1.4).
        let rl_regime_flat_count_module = ctx
            .load_cubin(RL_REGIME_FLAT_COUNT_CUBIN.to_vec())
            .context("load rl_regime_flat_count cubin")?;
        let rl_regime_flat_count_fn = rl_regime_flat_count_module
            .load_function("rl_regime_flat_count")
            .context("load rl_regime_flat_count function")?;
        let rl_regime_observer_module = ctx
            .load_cubin(RL_REGIME_OBSERVER_CUBIN.to_vec())
            .context("load rl_regime_observer cubin")?;
        let rl_regime_observer_fn = rl_regime_observer_module
            .load_function("rl_regime_observer")
            .context("load rl_regime_observer function")?;
        let flat_count_d = stream
            .alloc_zeros::<i32>(1)
            .context("alloc flat_count_d")?;
        let rl_win_rate_ema_update_module = ctx
            .load_cubin(RL_WIN_RATE_EMA_UPDATE_CUBIN.to_vec())
            .context("load rl_win_rate_ema_update cubin")?;
        let rl_win_rate_ema_update_fn = rl_win_rate_ema_update_module
            .load_function("rl_win_rate_ema_update")
            .context("load rl_win_rate_ema_update")?;
        let rl_avg_win_loss_ema_update_module = ctx
            .load_cubin(RL_AVG_WIN_LOSS_EMA_UPDATE_CUBIN.to_vec())
            .context("load rl_avg_win_loss_ema_update cubin")?;
        let rl_avg_win_loss_ema_update_fn = rl_avg_win_loss_ema_update_module
            .load_function("rl_avg_win_loss_ema_update")
            .context("load rl_avg_win_loss_ema_update")?;
        let rl_inventory_variance_update_module = ctx
            .load_cubin(RL_INVENTORY_VARIANCE_UPDATE_CUBIN.to_vec())
            .context("load rl_inventory_variance_update cubin")?;
        let rl_inventory_variance_update_fn = rl_inventory_variance_update_module
            .load_function("rl_inventory_variance_update")
            .context("load rl_inventory_variance_update")?;
        let rl_q_distill_lambda_controller_module = ctx
            .load_cubin(RL_Q_DISTILL_LAMBDA_CONTROLLER_CUBIN.to_vec())
            .context("load rl_q_distill_lambda_controller cubin")?;
        let rl_q_distill_lambda_controller_fn = rl_q_distill_lambda_controller_module
            .load_function("rl_q_distill_lambda_controller")
            .context("load rl_q_distill_lambda_controller")?;

        // SP20 P1+P5 cubin loads.
        let rl_unit_state_update_module = ctx
            .load_cubin(RL_UNIT_STATE_UPDATE_CUBIN.to_vec())
            .context("load rl_unit_state_update cubin")?;
        let rl_unit_state_update_fn = rl_unit_state_update_module
            .load_function("rl_unit_state_update")
            .context("load rl_unit_state_update")?;
        let rl_trail_mutate_module = ctx
            .load_cubin(RL_TRAIL_MUTATE_CUBIN.to_vec())
            .context("load rl_trail_mutate cubin")?;
        let rl_trail_mutate_fn = rl_trail_mutate_module
            .load_function("rl_trail_mutate")
            .context("load rl_trail_mutate")?;
        let rl_trail_stop_check_module = ctx
            .load_cubin(RL_TRAIL_STOP_CHECK_CUBIN.to_vec())
            .context("load rl_trail_stop_check cubin")?;
        let rl_trail_stop_check_fn = rl_trail_stop_check_module
            .load_function("rl_trail_stop_check")
            .context("load rl_trail_stop_check")?;
        let rl_position_heat_check_module = ctx
            .load_cubin(RL_POSITION_HEAT_CHECK_CUBIN.to_vec())
            .context("load rl_position_heat_check cubin")?;
        let rl_position_heat_check_fn = rl_position_heat_check_module
            .load_function("rl_position_heat_check")
            .context("load rl_position_heat_check")?;
        let rl_confidence_gate_module = ctx
            .load_cubin(RL_CONFIDENCE_GATE_CUBIN.to_vec())
            .context("load rl_confidence_gate cubin")?;
        let rl_confidence_gate_fn = rl_confidence_gate_module
            .load_function("rl_confidence_gate")
            .context("load rl_confidence_gate")?;
        let rl_frd_gate_module = ctx
            .load_cubin(RL_FRD_GATE_CUBIN.to_vec())
            .context("load rl_frd_gate cubin")?;
        let rl_frd_gate_fn = rl_frd_gate_module
            .load_function("rl_frd_gate")
            .context("load rl_frd_gate")?;
        let rl_fused_reward_pipeline_module = ctx
            .load_cubin(RL_FUSED_REWARD_PIPELINE_CUBIN.to_vec())
            .context("load rl_fused_reward_pipeline cubin")?;
        let rl_fused_reward_pipeline_fn = rl_fused_reward_pipeline_module
            .load_function("rl_fused_reward_pipeline")
            .context("load rl_fused_reward_pipeline")?;
        let rl_gate_threshold_controller_module = ctx
            .load_cubin(RL_GATE_THRESHOLD_CONTROLLER_CUBIN.to_vec())
            .context("load rl_gate_threshold_controller cubin")?;
        let rl_gate_threshold_controller_fn = rl_gate_threshold_controller_module
            .load_function("rl_gate_threshold_controller")
            .context("load rl_gate_threshold_controller")?;
        let rl_fused_controllers_module = ctx
            .load_cubin(RL_FUSED_CONTROLLERS_CUBIN.to_vec())
            .context("load rl_fused_controllers cubin")?;
        let rl_fused_controllers_fn = rl_fused_controllers_module
            .load_function("rl_fused_controllers")
            .context("load rl_fused_controllers")?;
        // Device buffer for the 7 ISV input-slot indices consumed by
        // the fused controller kernel. Allocated once, never mutated.
        let fused_ctrl_input_slots_h: [i32; 7] = [
            crate::rl::isv_slots::RL_MEAN_TRADE_DURATION_EMA_INDEX as i32,
            crate::rl::isv_slots::RL_Q_DIVERGENCE_EMA_INDEX as i32,
            crate::rl::isv_slots::RL_KL_PI_EMA_INDEX as i32,
            crate::rl::isv_slots::RL_ENTROPY_OBSERVED_EMA_INDEX as i32,
            crate::rl::isv_slots::RL_ADV_VAR_PRE_NORM_INDEX as i32,
            crate::rl::isv_slots::RL_TD_KURTOSIS_EMA_INDEX as i32,
            crate::rl::isv_slots::RL_MEAN_ABS_PNL_EMA_INDEX as i32,
        ];
        let mut fused_ctrl_input_slots_d = stream
            .alloc_zeros::<i32>(7)
            .context("alloc fused_ctrl_input_slots_d")?;
        write_slice_i32_d_pub(&stream, &fused_ctrl_input_slots_h, &mut fused_ctrl_input_slots_d)?;
        let rl_asymmetric_trail_decay_module = ctx
            .load_cubin(RL_ASYMMETRIC_TRAIL_DECAY_CUBIN.to_vec())
            .context("load rl_asymmetric_trail_decay cubin")?;
        let rl_asymmetric_trail_decay_fn = rl_asymmetric_trail_decay_module
            .load_function("rl_asymmetric_trail_decay")
            .context("load rl_asymmetric_trail_decay")?;
        let rl_session_risk_check_module = ctx
            .load_cubin(RL_SESSION_RISK_CHECK_CUBIN.to_vec())
            .context("load rl_session_risk_check cubin")?;
        let rl_session_risk_check_fn = rl_session_risk_check_module
            .load_function("rl_session_risk_check")
            .context("load rl_session_risk_check")?;
        let rl_min_hold_check_module = ctx
            .load_cubin(RL_MIN_HOLD_CHECK_CUBIN.to_vec())
            .context("load rl_min_hold_check cubin")?;
        let rl_min_hold_check_fn = rl_min_hold_check_module
            .load_function("rl_min_hold_check")
            .context("load rl_min_hold_check")?;
        let rl_trade_context_update_module = ctx
            .load_cubin(RL_TRADE_CONTEXT_UPDATE_CUBIN.to_vec())
            .context("load rl_trade_context_update cubin")?;
        let rl_trade_context_update_fn = rl_trade_context_update_module
            .load_function("rl_trade_context_update")
            .context("load rl_trade_context_update")?;
        let rl_multires_features_update_module = ctx
            .load_cubin(RL_MULTIRES_FEATURES_UPDATE_CUBIN.to_vec())
            .context("load rl_multires_features_update cubin")?;
        let rl_multires_features_update_fn = rl_multires_features_update_module
            .load_function("rl_multires_features_update")
            .context("load rl_multires_features_update")?;
        let rl_increment_step_module = ctx
            .load_cubin(RL_INCREMENT_STEP_CUBIN.to_vec())
            .context("load rl_increment_step cubin")?;
        let rl_increment_step_fn = rl_increment_step_module
            .load_function("rl_increment_step")
            .context("load rl_increment_step")?;
        let rl_sample_tau_module = ctx
            .load_cubin(RL_SAMPLE_TAU_CUBIN.to_vec())
            .context("load rl_sample_tau cubin")?;
        let rl_sample_tau_fn = rl_sample_tau_module
            .load_function("rl_sample_tau")
            .context("load rl_sample_tau")?;
        let rl_write_u64_module = ctx
            .load_cubin(RL_WRITE_U64_CUBIN.to_vec())
            .context("load rl_write_u64 cubin")?;
        let rl_write_u64_fn = rl_write_u64_module
            .load_function("rl_write_u64")
            .context("load rl_write_u64")?;
        let ts_ns_d = stream
            .alloc_zeros::<u64>(1)
            .context("alloc ts_ns_d")?;
        let actions_to_market_targets_module = ctx
            .load_cubin(ACTIONS_TO_MARKET_TARGETS_CUBIN.to_vec())
            .context("load actions_to_market_targets cubin")?;
        let actions_to_market_targets_fn = actions_to_market_targets_module
            .load_function("actions_to_market_targets")
            .context("load actions_to_market_targets")?;

        // Phase R7a kernel.
        let abs_copy_module = ctx
            .load_cubin(ABS_COPY_CUBIN.to_vec())
            .context("load abs_copy cubin")?;
        let abs_copy_fn = abs_copy_module
            .load_function("abs_copy")
            .context("load abs_copy")?;

        // Streaming kernels for the variance-ratio + kurtosis EMAs.
        // Per the streaming refactor: these now fold ACROSS STEPS (Welford-EMA on
        // per-batch means in ISV state slots) instead of across batch
        // — works correctly at b_size=1 where per-batch variance is
        // identically zero. The kernels write the smoothed estimate
        // directly to the controller-input ISV slot; no separate
        // ema_update_per_step is needed downstream.
        let rl_var_over_abs_mean_streaming_module = ctx
            .load_cubin(RL_VAR_OVER_ABS_MEAN_STREAMING_CUBIN.to_vec())
            .context("load rl_var_over_abs_mean_streaming cubin")?;
        let rl_var_over_abs_mean_streaming_fn = rl_var_over_abs_mean_streaming_module
            .load_function("rl_var_over_abs_mean_streaming")
            .context("load rl_var_over_abs_mean_streaming")?;
        let rl_kurtosis_streaming_module = ctx
            .load_cubin(RL_KURTOSIS_STREAMING_CUBIN.to_vec())
            .context("load rl_kurtosis_streaming cubin")?;
        let rl_kurtosis_streaming_fn = rl_kurtosis_streaming_module
            .load_function("rl_kurtosis_streaming")
            .context("load rl_kurtosis_streaming")?;
        let ema_input_scratch_d = stream
            .alloc_zeros::<f32>(1)
            .context("alloc ema_input_scratch_d")?;

        // Derivation kernels + per-batch state for KL approx, weight
        // L2 divergence, and the trade-duration counter EMAs.
        let rl_kl_approx_b_module = ctx
            .load_cubin(RL_KL_APPROX_B_CUBIN.to_vec())
            .context("load rl_kl_approx_b cubin")?;
        let rl_kl_approx_b_fn = rl_kl_approx_b_module
            .load_function("rl_kl_approx_b")
            .context("load rl_kl_approx_b")?;
        let rl_l2_diff_norm_module = ctx
            .load_cubin(RL_L2_DIFF_NORM_CUBIN.to_vec())
            .context("load rl_l2_diff_norm cubin")?;
        let rl_l2_diff_norm_fn = rl_l2_diff_norm_module
            .load_function("rl_l2_diff_norm")
            .context("load rl_l2_diff_norm")?;
        let rl_l2_norm_module = ctx
            .load_cubin(RL_L2_NORM_CUBIN.to_vec())
            .context("load rl_l2_norm cubin")?;
        let rl_l2_norm_fn = rl_l2_norm_module
            .load_function("rl_l2_norm")
            .context("load rl_l2_norm")?;

        // Determinism Phase 1 (2026-06-02): per-step component checksums.
        let rl_deterministic_checksum_module = ctx
            .load_cubin(RL_DETERMINISTIC_CHECKSUM_CUBIN.to_vec())
            .context("load rl_deterministic_checksum cubin")?;
        let rl_deterministic_checksum_f32_fn = rl_deterministic_checksum_module
            .load_function("rl_deterministic_checksum_f32")
            .context("load rl_deterministic_checksum_f32")?;
        let rl_deterministic_checksum_i32_fn = rl_deterministic_checksum_module
            .load_function("rl_deterministic_checksum_i32")
            .context("load rl_deterministic_checksum_i32")?;
        // 45 f64 slots: 15 final leaves + 12 Adam staging (6 m + 6 v
        // sub-buffers across DQN/π/V w+b) + 1 reserve + 17 encoder
        // intermediate / weight leaves added in Phase 2.2 to localise
        // the `encoder_output` divergence at step 3. Each launch writes
        // a single f64; host-side folds sum the Adam staging into slots
        // 11 and 12. See `compute_diag_checksums` for the slot map.
        let checksum_scratch_d = stream
            .alloc_zeros::<f64>(45)
            .context("alloc checksum_scratch_d")?;
        let checksum_host: Vec<f64> = vec![0.0_f64; 45];

        // audit — PPO ratio clamp controller + per-step log-ratio diag.
        let rl_ppo_ratio_clamp_controller_module = ctx
            .load_cubin(RL_PPO_RATIO_CLAMP_CONTROLLER_CUBIN.to_vec())
            .context("load rl_ppo_ratio_clamp_controller cubin")?;
        let rl_ppo_ratio_clamp_controller_fn = rl_ppo_ratio_clamp_controller_module
            .load_function("rl_ppo_ratio_clamp_controller")
            .context("load rl_ppo_ratio_clamp_controller")?;
        let ppo_log_ratio_abs_max_b_module = ctx
            .load_cubin(PPO_LOG_RATIO_ABS_MAX_B_CUBIN.to_vec())
            .context("load ppo_log_ratio_abs_max_b cubin")?;
        let ppo_log_ratio_abs_max_b_fn = ppo_log_ratio_abs_max_b_module
            .load_function("ppo_log_ratio_abs_max_b")
            .context("load ppo_log_ratio_abs_max_b")?;
        let rl_streaming_clamp_init_module = ctx
            .load_cubin(RL_STREAMING_CLAMP_INIT_CUBIN.to_vec())
            .context("load rl_streaming_clamp_init cubin")?;
        let rl_streaming_clamp_init_fn = rl_streaming_clamp_init_module
            .load_function("rl_streaming_clamp_init")
            .context("load rl_streaming_clamp_init")?;
        let rl_isv_write_module = ctx
            .load_cubin(RL_ISV_WRITE_CUBIN.to_vec())
            .context("load rl_isv_write cubin")?;
        let rl_isv_write_fn = rl_isv_write_module
            .load_function("rl_isv_write")
            .context("load rl_isv_write")?;
        let rl_q_pi_agree_b_module = ctx
            .load_cubin(RL_Q_PI_AGREE_B_CUBIN.to_vec())
            .context("load rl_q_pi_agree_b cubin")?;
        let rl_q_pi_agree_b_fn = rl_q_pi_agree_b_module
            .load_function("rl_q_pi_agree_b")
            .context("load rl_q_pi_agree_b")?;
        let rl_pi_action_kernel_module = ctx
            .load_cubin(RL_PI_ACTION_KERNEL_CUBIN.to_vec())
            .context("load rl_pi_action_kernel cubin")?;
        let rl_pi_action_kernel_fn = rl_pi_action_kernel_module
            .load_function("rl_pi_action_kernel")
            .context("load rl_pi_action_kernel")?;

        // GPU-resident PER kernels (T4 wiring).
        let rl_per_push_ring_module = ctx
            .load_cubin(RL_PER_PUSH_RING_CUBIN.to_vec())
            .context("load rl_per_push_ring cubin")?;
        let rl_per_push_ring_fn = rl_per_push_ring_module
            .load_function("rl_per_push_ring")
            .context("load rl_per_push_ring")?;
        let rl_per_push_flush_module = ctx
            .load_cubin(RL_PER_PUSH_FLUSH_CUBIN.to_vec())
            .context("load rl_per_push_flush cubin")?;
        let rl_per_push_flush_fn = rl_per_push_flush_module
            .load_function("rl_per_push_flush")
            .context("load rl_per_push_flush")?;
        let rl_per_sample_module = ctx
            .load_cubin(RL_PER_SAMPLE_CUBIN.to_vec())
            .context("load rl_per_sample cubin")?;
        let rl_per_sample_fn = rl_per_sample_module
            .load_function("rl_per_sample")
            .context("load rl_per_sample")?;
        let rl_per_update_priority_module = ctx
            .load_cubin(RL_PER_UPDATE_PRIORITY_CUBIN.to_vec())
            .context("load rl_per_update_priority cubin")?;
        let rl_per_update_priority_fn = rl_per_update_priority_module
            .load_function("rl_per_update_priority")
            .context("load rl_per_update_priority")?;
        let rl_per_tree_rebuild_module = ctx
            .load_cubin(RL_PER_TREE_REBUILD_CUBIN.to_vec())
            .context("load rl_per_tree_rebuild cubin")?;
        let rl_per_tree_rebuild_fn = rl_per_tree_rebuild_module
            .load_function("rl_per_tree_rebuild")
            .context("load rl_per_tree_rebuild")?;

        // Bidirectional HER kernels.
        let rl_hindsight_track_module = ctx
            .load_cubin(RL_HINDSIGHT_TRACK_CUBIN.to_vec())
            .context("load rl_hindsight_track cubin")?;
        let rl_hindsight_track_fn = rl_hindsight_track_module
            .load_function("rl_hindsight_track")
            .context("load rl_hindsight_track")?;
        let rl_hindsight_inject_module = ctx
            .load_cubin(RL_HINDSIGHT_INJECT_CUBIN.to_vec())
            .context("load rl_hindsight_inject cubin")?;
        let rl_hindsight_inject_fn = rl_hindsight_inject_module
            .load_function("rl_hindsight_inject")
            .context("load rl_hindsight_inject")?;
        let rl_hindsight_forward_module = ctx
            .load_cubin(RL_HINDSIGHT_FORWARD_CUBIN.to_vec())
            .context("load rl_hindsight_forward cubin")?;
        let rl_hindsight_forward_fn = rl_hindsight_forward_module
            .load_function("rl_hindsight_forward")
            .context("load rl_hindsight_forward")?;

        let hindsight = crate::rl::gpu_hindsight::GpuHindsight::new(&stream, cfg.perception.n_batch)?;

        // Per-batch PRNG state for the Thompson sampler. Seeded
        // deterministically from cfg.dqn_seed via ChaCha8 host RNG so
        // (cfg.dqn_seed, b_size) → identical Thompson draws across
        // re-runs of the same trainer init per
        // `pearl_scoped_init_seed_for_reproducibility`. Zero is the
        // only forbidden xorshift32 state (would freeze) so the
        // `.max(1)` guard ensures every batch's seed is non-zero.
        let b_size = cfg.perception.n_batch;
        let mut prng_host_rng =
            <rand_chacha::ChaCha8Rng as SeedableRng>::seed_from_u64(
                cfg.dqn_seed.wrapping_add(0xC0_C0_C0_C0),
            );
        let prng_seeds: Vec<u32> = (0..b_size)
            .map(|_| prng_host_rng.gen::<u32>().max(1))
            .collect();
        let prng_state_d = stream
            .alloc_zeros::<u32>(b_size)
            .context("alloc prng_state_d")?;
        // Mapped-pinned upload via inline DtoD per
        // `feedback_no_htod_htoh_only_mapped_pinned`. One-shot at
        // construction. Cast through i32 for the typed mapped-pinned
        // buffer (u32 and i32 share width); the kernel reads as u32 —
        // wraparound is well-defined.
        {
            let mut staging = unsafe { MappedI32Buffer::new(b_size) }
                .map_err(|e| anyhow::anyhow!("prng_state staging: {e}"))?;
            let dst = staging.host_slice_mut();
            for (i, &v) in prng_seeds.iter().enumerate() {
                dst[i] = v as i32;
            }
            unsafe {
                let s = stream.cu_stream();
                let dev_dst = prng_state_d.raw_ptr();
                raw_memcpy_dtod_async(
                    dev_dst,
                    staging.dev_ptr,
                    b_size * std::mem::size_of::<u32>(),
                    s,
                )
                .map_err(|e| anyhow::anyhow!("prng_state DtoD upload: {e:?}"))?;
                raw_stream_sync(s)
                    .map_err(|e| anyhow::anyhow!("prng_state sync: {e:?}"))?;
            }
        }

        // Atom supports — structural constant (atom span [-1, +1]).
        // Single allocation + upload at init; read every step by the
        // Thompson + argmax kernels.
        let atom_step = (Q_V_MAX - Q_V_MIN) / ((Q_N_ATOMS - 1) as f32);
        let atom_supports_host: Vec<f32> = (0..Q_N_ATOMS)
            .map(|i| Q_V_MIN + (i as f32) * atom_step)
            .collect();
        let mut atom_supports_d = stream
            .alloc_zeros::<f32>(Q_N_ATOMS)
            .context("alloc atom_supports_d")?;
        // Mapped-pinned upload per
        // `feedback_no_htod_htoh_only_mapped_pinned`. One-shot.
        write_slice_f32_d(&stream, &atom_supports_host, &mut atom_supports_d)
            .context("atom_supports upload")?;

        // Phase R6 per-step buffers. prev_* are sentinel-zero at init
        // so the first call's delta is `current - 0 = current`. For a
        // freshly-constructed LobSimCuda, `pos.realized_pnl == 0` and
        // `pos.position_lots == 0` too, so the first delta is 0 and
        // the first done is false (prev_lots == 0 fails the `prev != 0`
        // condition). Subsequent steps see real prev_* values.
        let prev_realized_pnl_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc prev_realized_pnl_d")?;
        // Phase 5 (2026-06-04) MTM reward: per-batch unrealized snapshot.
        let prev_unrealized_pnl_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc prev_unrealized_pnl_d")?;
        let prev_position_lots_d = stream
            .alloc_zeros::<i32>(b_size)
            .context("alloc prev_position_lots_d")?;
        // SP20 P1+P5 per-unit trade state (slot 0 used; slots 1-3
        // reserved for P7 pyramid).
        let unit_entry_price_d = stream
            .alloc_zeros::<f32>(b_size * 4)
            .context("alloc unit_entry_price_d")?;
        let unit_entry_step_d = stream
            .alloc_zeros::<i32>(b_size * 4)
            .context("alloc unit_entry_step_d")?;
        let unit_lots_d = stream
            .alloc_zeros::<i32>(b_size * 4)
            .context("alloc unit_lots_d")?;
        let unit_initial_r_d = stream
            .alloc_zeros::<f32>(b_size * 4)
            .context("alloc unit_initial_r_d")?;
        let unit_trail_distance_d = stream
            .alloc_zeros::<f32>(b_size * 4)
            .context("alloc unit_trail_distance_d")?;
        let unit_active_d = stream
            .alloc_zeros::<u8>(b_size * 4)
            .context("alloc unit_active_d")?;
        let pyramid_units_count_d = stream
            .alloc_zeros::<i32>(b_size)
            .context("alloc pyramid_units_count_d")?;
        let close_unit_index_d = stream
            .alloc_zeros::<i32>(b_size)
            .context("alloc close_unit_index_d")?;
        let outcome_ema_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc outcome_ema_d")?;
        // CMDP per-batch state (spec 2026-05-30-adaptive-risk-management).
        // Each backtest session in the batch tracks its own running PnL,
        // DD-triggered flag, losing-streak counter, and cooldown remaining.
        let session_pnl_per_batch_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc session_pnl_per_batch_d")?;
        let session_dd_triggered_per_batch_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc session_dd_triggered_per_batch_d")?;
        let consec_loss_per_batch_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc consec_loss_per_batch_d")?;
        let cooldown_remaining_per_batch_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc cooldown_remaining_per_batch_d")?;
        // Edge-decay Phase 1 (2026-06-01): 5 PH per-batch buffers.
        // Zero-init here; also zeroed at every fold/eval boundary via
        // reset_session_state per predictive-reset discipline.
        let ph_mu_per_batch_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc ph_mu_per_batch_d")?;
        let ph_count_per_batch_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc ph_count_per_batch_d")?;
        let ph_m_per_batch_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc ph_m_per_batch_d")?;
        let ph_mmin_per_batch_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc ph_mmin_per_batch_d")?;
        let ph_stat_per_batch_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc ph_stat_per_batch_d")?;
        let trade_context_d = stream
            .alloc_zeros::<f32>(b_size * 4)
            .context("alloc trade_context_d")?;
        let multires_output_d = stream
            .alloc_zeros::<f32>(b_size * 12)
            .context("alloc multires_output_d")?;
        let multires_state_d = stream
            .alloc_zeros::<f32>(b_size * 12)
            .context("alloc multires_state_d")?;
        let multires_prev_mid_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc multires_prev_mid_d")?;
        let multires_prev_ts_ns_d = stream
            .alloc_zeros::<u64>(b_size)
            .context("alloc multires_prev_ts_ns_d")?;
        let unit_prev_pos_lots_d = stream
            .alloc_zeros::<i32>(b_size)
            .context("alloc unit_prev_pos_lots_d")?;
        let rewards_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc rewards_d")?;
        let raw_rewards_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc raw_rewards_d")?;
        let dones_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc dones_d")?;

        // Per-batch trade-duration counter (mutated by
        // `rl_step_counter_update`: incremented each step, reset on
        // done) and its done-gated emit buffer (consumed by
        // `ema_update_on_done` to feed `mean_trade_duration_ema`).
        let steps_since_done_d = stream
            .alloc_zeros::<i32>(b_size)
            .context("alloc steps_since_done_d")?;
        let trade_duration_emit_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc trade_duration_emit_d")?;

        // Phase R7a per-step buffers (persistent so step_with_lobsim
        // doesn't churn allocations every call).
        let reward_abs_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc reward_abs_d")?;
        let actions_d = stream
            .alloc_zeros::<i32>(b_size)
            .context("alloc actions_d")?;
        let next_actions_d = stream
            .alloc_zeros::<i32>(b_size)
            .context("alloc next_actions_d")?;
        let log_pi_old_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc log_pi_old_d")?;
        let advantages_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc advantages_d")?;
        let returns_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc returns_d")?;

        // Phase R7c (data correctness): trainer-owned h_{t+1} buffer.
        // Zero at init — first step_with_lobsim call writes it via the
        // forward_encoder(next_snapshots) + DtoD copy out of
        // perception.h_t_d BEFORE any consumer reads it.
        let h_tp1_d = stream
            .alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc h_tp1_d")?;

        // IQN per-step buffers: tau samples, Q(s,τ,a), E[Q(s,a)].
        let iqn_n_tau = iqn_head.n_tau();
        let iqn_tau_d = stream
            .alloc_zeros::<f32>(b_size * iqn_n_tau)
            .context("alloc iqn_tau_d")?;
        let iqn_q_values_d = stream
            .alloc_zeros::<f32>(b_size * iqn_n_tau * N_ACTIONS)
            .context("alloc iqn_q_values_d")?;
        let iqn_expected_q_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc iqn_expected_q_d")?;
        let ensemble_q_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc ensemble_q_d")?;

        // ── Phase 4 DuelingQHead per-step buffers ────────────────────
        let dueling_v_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc dueling_v_d")?;
        let dueling_v_tp1_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc dueling_v_tp1_d")?;
        // Phase 4.4 adaptive V blend output buffers.
        let v_blended_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc v_blended_d")?;
        let v_blended_tp1_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc v_blended_tp1_d")?;
        let dueling_a_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc dueling_a_d")?;
        let dueling_q_composed_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc dueling_q_composed_d")?;
        let dueling_q_target_composed_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc dueling_q_target_composed_d")?;
        let dueling_v_target_tp1_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc dueling_v_target_tp1_d")?;
        let dueling_a_target_tp1_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc dueling_a_target_tp1_d")?;
        let dueling_v_loss_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc dueling_v_loss_d")?;
        let dueling_a_loss_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc dueling_a_loss_d")?;
        let dueling_target_value_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc dueling_target_value_d")?;
        let dueling_loss_pb_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc dueling_loss_pb_d")?;
        let dueling_grad_composed_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc dueling_grad_composed_d")?;
        let dueling_grad_w_v_pb_d = stream
            .alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc dueling_grad_w_v_pb_d")?;
        let dueling_grad_b_v_pb_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc dueling_grad_b_v_pb_d")?;
        let dueling_grad_w_a_pb_d = stream
            .alloc_zeros::<f32>(b_size * HIDDEN_DIM * N_ACTIONS)
            .context("alloc dueling_grad_w_a_pb_d")?;
        let dueling_grad_b_a_pb_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc dueling_grad_b_a_pb_d")?;
        let dueling_grad_w_v_d = stream
            .alloc_zeros::<f32>(HIDDEN_DIM)
            .context("alloc dueling_grad_w_v_d")?;
        let dueling_grad_b_v_d = stream
            .alloc_zeros::<f32>(1)
            .context("alloc dueling_grad_b_v_d")?;
        let dueling_grad_w_a_d = stream
            .alloc_zeros::<f32>(HIDDEN_DIM * N_ACTIONS)
            .context("alloc dueling_grad_w_a_d")?;
        let dueling_grad_b_a_d = stream
            .alloc_zeros::<f32>(N_ACTIONS)
            .context("alloc dueling_grad_b_a_d")?;

        // Persistent per-step head output buffers (CUDA Graph stable).
        let k_dqn_alloc = N_ACTIONS * Q_N_ATOMS;
        let q_logits_d = stream
            .alloc_zeros::<f32>(b_size * k_dqn_alloc)
            .context("alloc q_logits_d")?;
        let q_logits_target_st_d = stream
            .alloc_zeros::<f32>(b_size * k_dqn_alloc)
            .context("alloc q_logits_target_st_d")?;
        let q_logits_tp1_d = stream
            .alloc_zeros::<f32>(b_size * k_dqn_alloc)
            .context("alloc q_logits_tp1_d")?;
        let v_pred_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc v_pred_d")?;
        let v_pred_tp1_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc v_pred_tp1_d")?;
        let pi_logits_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc pi_logits_d")?;

        // Device-resident PRNG seeds for IQN tau sampling. Starts at
        // zero; the rl_sample_tau kernel self-seeds on first call using
        // batch index (no memcpy needed).
        let iqn_prng_state_d = stream
            .alloc_zeros::<u32>(b_size)
            .context("alloc iqn_prng_state_d")?;

        // NoisyNet exploration layer: h_t → [B, N_ACTIONS] noise added
        // to ensemble_q_d before action selection. Replaces the P_MIN
        // probability floor with learned, state-dependent exploration.
        // Per pearl_scoped_init_seed_for_reproducibility: seed offset
        // +200 from dqn_seed keeps Xavier draws independent of C51/IQN.
        let noisy_exploration = NoisyLinear::new(
            dev,
            NoisyLinearConfig {
                in_dim: HIDDEN_DIM,
                out_dim: N_ACTIONS,
                sigma_init: 0.5, // canonical NoisyNet; ISV slot 547 overrides at step time
                seed: cfg.dqn_seed.wrapping_add(200),
            },
        )
        .context("NoisyLinear::new (exploration)")?;
        let noisy_output_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc noisy_output_d")?;
        // Adam state for the 4 NoisyLinear weight tensors. LR mirrored
        // from ISV[RL_LR_Q_INDEX] (same as the Q head — the noisy layer
        // is part of the Q exploration pipeline).
        let noisy_mu_w_adam =
            AdamW::new(dev, noisy_exploration.mu_w.len(), lr_placeholder)
            .context("noisy_mu_w_adam")?;
        let noisy_sigma_w_adam =
            AdamW::new(dev, noisy_exploration.sigma_w.len(), lr_placeholder)
            .context("noisy_sigma_w_adam")?;
        let noisy_mu_b_adam =
            AdamW::new(dev, noisy_exploration.mu_b.len(), lr_placeholder)
            .context("noisy_mu_b_adam")?;
        let noisy_sigma_b_adam =
            AdamW::new(dev, noisy_exploration.sigma_b.len(), lr_placeholder)
            .context("noisy_sigma_b_adam")?;
        // aux_vec_add_inplace for folding noisy output into ensemble_q_d.
        let aux_vec_add_module = ctx
            .load_cubin(AUX_VEC_ADD_CUBIN.to_vec())
            .context("load aux_vec_add cubin")?;
        let aux_vec_add_fn = aux_vec_add_module
            .load_function("aux_vec_add_inplace")
            .context("load aux_vec_add_inplace fn")?;

        // ── P1.1 PopArt cubin loads ──────────────────────────────────
        let rl_popart_normalize_module = ctx
            .load_cubin(RL_POPART_NORMALIZE_CUBIN.to_vec())
            .context("load rl_popart_normalize cubin")?;
        let rl_popart_normalize_fn = rl_popart_normalize_module
            .load_function("rl_popart_normalize")
            .context("load rl_popart_normalize")?;
        let rl_popart_v_correct_module = ctx
            .load_cubin(RL_POPART_V_CORRECT_CUBIN.to_vec())
            .context("load rl_popart_v_correct cubin")?;
        let rl_popart_v_correct_fn = rl_popart_v_correct_module
            .load_function("rl_popart_v_correct")
            .context("load rl_popart_v_correct")?;

        // ── P1.2 Spectral norm + decouple cubin loads ────────────────
        let rl_spectral_norm_module = ctx
            .load_cubin(RL_SPECTRAL_NORM_CUBIN.to_vec())
            .context("load rl_spectral_norm cubin")?;
        let rl_spectral_norm_fn = rl_spectral_norm_module
            .load_function("rl_spectral_norm")
            .context("load rl_spectral_norm")?;
        let rl_spectral_decouple_module = ctx
            .load_cubin(RL_SPECTRAL_DECOUPLE_CUBIN.to_vec())
            .context("load rl_spectral_decouple cubin")?;
        let rl_spectral_decouple_fn = rl_spectral_decouple_module
            .load_function("rl_spectral_decouple")
            .context("load rl_spectral_decouple")?;

        // Spectral norm v_buffers — persistent across steps for warm-start
        // power iteration. Init to uniform 1/sqrt(n) for stable first iteration.
        let dqn_w_cols = HIDDEN_DIM;
        let iqn_w_out_cols = N_ACTIONS;
        let pi_w_cols = N_ACTIONS;
        let spectral_v_buffer_dqn = {
            let vals: Vec<f32> = vec![1.0 / (dqn_w_cols as f32).sqrt(); dqn_w_cols];
            let mut buf = stream.alloc_zeros::<f32>(dqn_w_cols)
                .context("alloc spectral_v_buffer_dqn")?;
            write_slice_f32_d(&stream, &vals, &mut buf)
                .context("init spectral_v_buffer_dqn")?;
            buf
        };
        let spectral_v_buffer_iqn = {
            let vals: Vec<f32> = vec![1.0 / (iqn_w_out_cols as f32).sqrt(); iqn_w_out_cols];
            let mut buf = stream.alloc_zeros::<f32>(iqn_w_out_cols)
                .context("alloc spectral_v_buffer_iqn")?;
            write_slice_f32_d(&stream, &vals, &mut buf)
                .context("init spectral_v_buffer_iqn")?;
            buf
        };
        let spectral_v_buffer_pi = {
            let vals: Vec<f32> = vec![1.0 / (pi_w_cols as f32).sqrt(); pi_w_cols];
            let mut buf = stream.alloc_zeros::<f32>(pi_w_cols)
                .context("alloc spectral_v_buffer_pi")?;
            write_slice_f32_d(&stream, &vals, &mut buf)
                .context("init spectral_v_buffer_pi")?;
            buf
        };

        // ── P1.3 Outcome head + Adam optimisers ─────────────────────
        // Determinism Phase 2.6 (2026-06-02): pass an explicit seed for
        // the Xavier W draw so same-seed processes produce bit-equal
        // initial outcome weights. Distinct offset (0x0CE0 = "OCE0"
        // /Outcome Classifier Encoder 0/) so init draws are independent
        // of the other heads' streams. Without this, the `near_zero_xavier`
        // fell back to time+thread-id RNG → outcome_w differed at step 0
        // → grad_h_t_outcome drifted at step 2 when the first `done`
        // event activated a non-sentinel label.
        let outcome_head = crate::rl::outcome_head::OutcomeHead::new(
            b_size,
            cfg.dqn_seed.wrapping_add(0x0CE0),
            stream.clone(),
        ).context("OutcomeHead::new")?;
        let outcome_w_adam = AdamW::new(dev, outcome_head.w_d.len(), lr_placeholder)
            .context("outcome_w_adam")?;
        let outcome_b_adam = AdamW::new(dev, outcome_head.b_d.len(), lr_placeholder)
            .context("outcome_b_adam")?;
        // ── P2.1 Q-bias correction cubin load ────────────────────────
        let rl_q_bias_correction_module = ctx
            .load_cubin(RL_Q_BIAS_CORRECTION_CUBIN.to_vec())
            .context("load rl_q_bias_correction cubin")?;
        let rl_q_bias_correction_fn = rl_q_bias_correction_module
            .load_function("rl_q_bias_correction")
            .context("load rl_q_bias_correction")?;

        // ── P2.2 Per-branch LR cubin load ────────────────────────────
        let rl_per_branch_lr_module = ctx
            .load_cubin(RL_PER_BRANCH_LR_CUBIN.to_vec())
            .context("load rl_per_branch_lr cubin")?;
        let rl_per_branch_lr_fn = rl_per_branch_lr_module
            .load_function("rl_per_branch_lr")
            .context("load rl_per_branch_lr")?;

        // Phase R7d: PER sampled-batch device scratch (GPU PER replaces
        // the host-side buffer in the next commits).
        let sampled_h_t_d = stream
            .alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc sampled_h_t_d")?;
        let sampled_h_tp1_d = stream
            .alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc sampled_h_tp1_d")?;
        let sampled_actions_d = stream
            .alloc_zeros::<i32>(b_size)
            .context("alloc sampled_actions_d")?;
        let sampled_rewards_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc sampled_rewards_d")?;
        let sampled_dones_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc sampled_dones_d")?;
        let sampled_n_step_gammas_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc sampled_n_step_gammas_d")?;
        let sampled_log_pi_old_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc sampled_log_pi_old_d")?;
        let sampled_next_actions_d = stream
            .alloc_zeros::<i32>(b_size)
            .context("alloc sampled_next_actions_d")?;
        let td_per_sample_d = stream
            .alloc_zeros::<f32>(b_size)
            .context("alloc td_per_sample_d")?;

        // GPU-resident PER buffer (T2 struct, T4 wiring).
        let gpu_replay = crate::rl::gpu_replay::GpuReplayBuffer::new(
            &stream,
            cfg.per_capacity,
            b_size,
        )
        .context("GpuReplayBuffer::new")?;

        // SP20 P3 FRD head — construct + own per-step buffers. Seed
        // distinct from dqn/ppo to keep Xavier draws independent.
        let frd_head = crate::rl::frd::FrdHead::new(
            dev,
            crate::rl::frd::FrdHeadConfig {
                seed: cfg.dqn_seed.wrapping_add(0xF8D),
            },
        )
        .context("FrdHead::new")?;
        let frd_hidden_d = stream
            .alloc_zeros::<f32>(b_size * crate::rl::common::FRD_HIDDEN_DIM)
            .context("alloc frd_hidden_d")?;
        let frd_logits_d = stream
            .alloc_zeros::<f32>(b_size * crate::rl::frd::FRD_OUT_DIM)
            .context("alloc frd_logits_d")?;
        // SP20 P3 Adam state for the 4 FRD weight tensors. LR mirrored
        // from ISV[RL_FRD_LR_INDEX] (slot 499) on every step before
        // the Adam launches — same pattern as Q/π/V (placeholder=0
        // here, updated per-step).
        let frd_w1_adam = AdamW::new(dev, frd_head.w1_d.len(), lr_placeholder)
            .context("frd_w1_adam")?;
        let frd_b1_adam = AdamW::new(dev, frd_head.b1_d.len(), lr_placeholder)
            .context("frd_b1_adam")?;
        let frd_w2_adam = AdamW::new(dev, frd_head.w2_d.len(), lr_placeholder)
            .context("frd_w2_adam")?;
        let frd_b2_adam = AdamW::new(dev, frd_head.b2_d.len(), lr_placeholder)
            .context("frd_b2_adam")?;
        // FRD labels: persistent device buffer + mapped-pinned staging.
        // Host writes to staging.host_ptr each step, then DtoD async
        // (no sync needed — kernel reads after the DtoD on same stream).
        let frd_n = b_size * crate::rl::common::FRD_N_HORIZONS;
        let frd_labels_d = stream
            .alloc_zeros::<i32>(frd_n)
            .context("alloc frd_labels_d")?;
        let mut frd_labels_staging = unsafe { MappedI32Buffer::new(frd_n) }
            .map_err(|e| anyhow::anyhow!("frd_labels_staging: {e}"))?;
        for v in frd_labels_staging.host_slice_mut().iter_mut() { *v = -1; }

        // ── Persistent replay-step scratch buffers ─────────────────────
        // Pre-allocated at init for CUDA Graph pointer stability. Zeroed
        // via memset_zeros at the start of each step_synthetic /
        // dqn_replay_step call.
        let k_dqn_ss = N_ACTIONS * Q_N_ATOMS;
        let frd_h_dim = crate::rl::common::FRD_HIDDEN_DIM;
        let frd_out_dim = crate::rl::frd::FRD_OUT_DIM;
        let frd_n_h = crate::rl::common::FRD_N_HORIZONS;
        let k_w_out = HIDDEN_DIM * N_ACTIONS;
        let k_w_embed = EMBED_DIM * HIDDEN_DIM;

        // Loss accumulators — mapped-pinned. GPU writes via dev_ptr
        // (atomicAdd in the loss kernels), host reads via host_ptr after
        // one-step delay (previous step's values, guaranteed visible by
        // stream ordering). Zero DtoD copies, zero syncs.
        let ss_q_loss_mapped = unsafe { MappedF32Buffer::new(1) }
            .map_err(|e| anyhow::anyhow!("ss_q_loss_mapped: {e}"))?;
        let ss_q_loss_dev_ptr = ss_q_loss_mapped.dev_ptr;
        let ss_pi_loss_mapped = unsafe { MappedF32Buffer::new(1) }
            .map_err(|e| anyhow::anyhow!("ss_pi_loss_mapped: {e}"))?;
        let ss_pi_loss_dev_ptr = ss_pi_loss_mapped.dev_ptr;
        let ss_pi_loss_entropy_mapped = unsafe { MappedF32Buffer::new(1) }
            .map_err(|e| anyhow::anyhow!("ss_pi_loss_entropy_mapped: {e}"))?;
        let ss_pi_loss_entropy_dev_ptr = ss_pi_loss_entropy_mapped.dev_ptr;
        let ss_v_loss_sum_mapped = unsafe { MappedF32Buffer::new(1) }
            .map_err(|e| anyhow::anyhow!("ss_v_loss_sum_mapped: {e}"))?;
        let ss_v_loss_sum_dev_ptr = ss_v_loss_sum_mapped.dev_ptr;

        // Per-batch diagnostic scratch.
        let ss_v_loss_per_batch_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ss_v_loss_per_batch_d")?;
        let ss_pi_log_prob_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ss_pi_log_prob_d")?;
        let ss_entropy_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ss_entropy_d")?;
        // F4.1 per-batch PPO loss slots (see struct doc).
        let ss_pi_loss_per_b_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ss_pi_loss_per_b_d")?;
        let ss_pi_loss_entropy_per_b_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ss_pi_loss_entropy_per_b_d")?;

        // Q-head gradient buffers. backward_gemm produces grad_w / grad_b
        // directly reduced via cuBLAS SGEMM — no per-batch scratch needed.
        let ss_q_grad_logits_d = stream.alloc_zeros::<f32>(b_size * k_dqn_ss)
            .context("alloc ss_q_grad_logits_d")?;
        let ss_q_grad_h_t_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc ss_q_grad_h_t_d")?;
        let ss_q_grad_w_d = stream.alloc_zeros::<f32>(k_dqn_ss * HIDDEN_DIM)
            .context("alloc ss_q_grad_w_d")?;
        let ss_q_grad_b_d = stream.alloc_zeros::<f32>(k_dqn_ss)
            .context("alloc ss_q_grad_b_d")?;

        // π-head per-batch + reduced gradient buffers.
        let ss_pi_grad_logits_d = stream.alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc ss_pi_grad_logits_d")?;
        let ss_pi_grad_w_per_batch_d = stream
            .alloc_zeros::<f32>(b_size * N_ACTIONS * HIDDEN_DIM)
            .context("alloc ss_pi_grad_w_per_batch_d")?;
        let ss_pi_grad_b_per_batch_d = stream.alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc ss_pi_grad_b_per_batch_d")?;
        let ss_pi_grad_h_t_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc ss_pi_grad_h_t_d")?;
        let ss_pi_grad_w_d = stream.alloc_zeros::<f32>(N_ACTIONS * HIDDEN_DIM)
            .context("alloc ss_pi_grad_w_d")?;
        let ss_pi_grad_b_d = stream.alloc_zeros::<f32>(N_ACTIONS)
            .context("alloc ss_pi_grad_b_d")?;

        // Phase 7a F2-diag (2026-06-04) — per-batch scratch for the
        // kernel-reduced F2 mechanism counters. One block per batch
        // writes its own [batch] index inside `rl_q_pi_distill_grad`
        // (no atomicAdd). Host reduces to mean across batches at diag
        // emit.
        let f2_diag_baseline_per_b_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc f2_diag_baseline_per_b_d")?;
        let f2_diag_adv_max_per_b_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc f2_diag_adv_max_per_b_d")?;
        let f2_diag_hinge_zero_per_b_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc f2_diag_hinge_zero_per_b_d")?;
        let f2_diag_target_argmax_per_b_d = stream.alloc_zeros::<i32>(b_size)
            .context("alloc f2_diag_target_argmax_per_b_d")?;

        // V-head per-batch + reduced gradient buffers.
        let ss_v_grad_w_per_batch_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc ss_v_grad_w_per_batch_d")?;
        let ss_v_grad_b_per_batch_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ss_v_grad_b_per_batch_d")?;
        let ss_v_grad_h_t_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc ss_v_grad_h_t_d")?;
        let ss_v_grad_w_d = stream.alloc_zeros::<f32>(HIDDEN_DIM)
            .context("alloc ss_v_grad_w_d")?;
        let ss_v_grad_b_d = stream.alloc_zeros::<f32>(1)
            .context("alloc ss_v_grad_b_d")?;

        // FRD encoder-upstream gradient.
        let ss_frd_grad_h_t_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc ss_frd_grad_h_t_d")?;

        // Outcome head backward scratch buffers.
        let k_classes = crate::rl::outcome_head::K_CLASSES;
        let ss_outcome_grad_w_pb_d = stream
            .alloc_zeros::<f32>(b_size * HIDDEN_DIM * k_classes)
            .context("alloc ss_outcome_grad_w_pb_d")?;
        let ss_outcome_grad_b_pb_d = stream
            .alloc_zeros::<f32>(b_size * k_classes)
            .context("alloc ss_outcome_grad_b_pb_d")?;
        let ss_outcome_grad_h_t_d = stream
            .alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc ss_outcome_grad_h_t_d")?;
        let ss_outcome_grad_w_d = stream
            .alloc_zeros::<f32>(HIDDEN_DIM * k_classes)
            .context("alloc ss_outcome_grad_w_d")?;
        let ss_outcome_grad_b_d = stream
            .alloc_zeros::<f32>(k_classes)
            .context("alloc ss_outcome_grad_b_d")?;

        // Bellman projection scratch.
        let ss_q_target_full_d = stream.alloc_zeros::<f32>(b_size * k_dqn_ss)
            .context("alloc ss_q_target_full_d")?;
        let ss_target_dist_d = stream.alloc_zeros::<f32>(b_size * Q_N_ATOMS)
            .context("alloc ss_target_dist_d")?;
        // B-9: per-batch saturation tally scratches (4 × [B] f32).
        let bellman_sat_top_per_batch_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc bellman_sat_top_per_batch_d")?;
        let bellman_sat_bot_per_batch_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc bellman_sat_bot_per_batch_d")?;
        let bellman_max_pre_per_batch_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc bellman_max_pre_per_batch_d")?;
        let bellman_min_pre_per_batch_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc bellman_min_pre_per_batch_d")?;
        // B-10: per-batch observability scratches (3 G1 + 4 G3+G4 = 7 × [B] f32).
        let q_dist_entropy_pb_d   = stream.alloc_zeros::<f32>(b_size)
            .context("alloc q_dist_entropy_pb_d")?;
        let q_dist_range_pb_d     = stream.alloc_zeros::<f32>(b_size)
            .context("alloc q_dist_range_pb_d")?;
        let q_dist_abs_max_pb_d   = stream.alloc_zeros::<f32>(b_size)
            .context("alloc q_dist_abs_max_pb_d")?;
        let ppo_a_norm_pb_d        = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ppo_a_norm_pb_d")?;
        let ppo_ratio_dev_pb_d     = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ppo_ratio_dev_pb_d")?;
        let ppo_ratio_clipped_pb_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ppo_ratio_clipped_pb_d")?;
        let ppo_surrogate_pb_d     = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ppo_surrogate_pb_d")?;

        // FRD backward chain scratch.
        let ss_frd_grad_logits_d = stream.alloc_zeros::<f32>(b_size * frd_out_dim)
            .context("alloc ss_frd_grad_logits_d")?;
        let ss_frd_loss_mapped = unsafe { MappedF32Buffer::new(b_size * frd_n_h) }
            .map_err(|e| anyhow::anyhow!("ss_frd_loss_mapped: {e}"))?;
        let ss_frd_loss_dev_ptr = ss_frd_loss_mapped.dev_ptr;
        let ss_frd_grad_w2_pb_d = stream
            .alloc_zeros::<f32>(b_size * frd_h_dim * frd_out_dim)
            .context("alloc ss_frd_grad_w2_pb_d")?;
        let ss_frd_grad_b2_pb_d = stream.alloc_zeros::<f32>(b_size * frd_out_dim)
            .context("alloc ss_frd_grad_b2_pb_d")?;
        let ss_frd_grad_hidden_d = stream.alloc_zeros::<f32>(b_size * frd_h_dim)
            .context("alloc ss_frd_grad_hidden_d")?;
        let ss_frd_grad_w1_pb_d = stream
            .alloc_zeros::<f32>(b_size * HIDDEN_DIM * frd_h_dim)
            .context("alloc ss_frd_grad_w1_pb_d")?;
        let ss_frd_grad_b1_pb_d = stream.alloc_zeros::<f32>(b_size * frd_h_dim)
            .context("alloc ss_frd_grad_b1_pb_d")?;
        let ss_frd_grad_w1_d = stream.alloc_zeros::<f32>(HIDDEN_DIM * frd_h_dim)
            .context("alloc ss_frd_grad_w1_d")?;
        let ss_frd_grad_b1_d = stream.alloc_zeros::<f32>(frd_h_dim)
            .context("alloc ss_frd_grad_b1_d")?;
        let ss_frd_grad_w2_d = stream.alloc_zeros::<f32>(frd_h_dim * frd_out_dim)
            .context("alloc ss_frd_grad_w2_d")?;
        let ss_frd_grad_b2_d = stream.alloc_zeros::<f32>(frd_out_dim)
            .context("alloc ss_frd_grad_b2_d")?;

        // IQN replay-step scratch.
        let ss_iqn_tau_target_d = stream.alloc_zeros::<f32>(b_size * iqn_n_tau)
            .context("alloc ss_iqn_tau_target_d")?;
        let ss_iqn_target_q_d = stream
            .alloc_zeros::<f32>(b_size * iqn_n_tau * N_ACTIONS)
            .context("alloc ss_iqn_target_q_d")?;
        let ss_iqn_loss_pb_d = stream.alloc_zeros::<f32>(b_size)
            .context("alloc ss_iqn_loss_pb_d")?;
        let ss_iqn_grad_q_d = stream
            .alloc_zeros::<f32>(b_size * iqn_n_tau * N_ACTIONS)
            .context("alloc ss_iqn_grad_q_d")?;
        let ss_iqn_grad_w_out_pb_d = stream.alloc_zeros::<f32>(b_size * k_w_out)
            .context("alloc ss_iqn_grad_w_out_pb_d")?;
        let ss_iqn_grad_b_out_pb_d = stream.alloc_zeros::<f32>(b_size * N_ACTIONS)
            .context("alloc ss_iqn_grad_b_out_pb_d")?;
        let ss_iqn_grad_w_embed_pb_d = stream.alloc_zeros::<f32>(b_size * k_w_embed)
            .context("alloc ss_iqn_grad_w_embed_pb_d")?;
        let ss_iqn_grad_b_embed_pb_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
            .context("alloc ss_iqn_grad_b_embed_pb_d")?;
        let ss_iqn_grad_w_out_d = stream.alloc_zeros::<f32>(k_w_out)
            .context("alloc ss_iqn_grad_w_out_d")?;
        let ss_iqn_grad_b_out_d = stream.alloc_zeros::<f32>(N_ACTIONS)
            .context("alloc ss_iqn_grad_b_out_d")?;
        let ss_iqn_grad_w_embed_d = stream.alloc_zeros::<f32>(k_w_embed)
            .context("alloc ss_iqn_grad_w_embed_d")?;
        let ss_iqn_grad_b_embed_d = stream.alloc_zeros::<f32>(HIDDEN_DIM)
            .context("alloc ss_iqn_grad_b_embed_d")?;

        // Cross-stream CUDA events — lightweight inter-stream sync that
        // avoids full cuStreamSynchronize. `None` flag = no timing
        // (pure synchronisation, no profiling overhead).
        let ctx_for_events = dev.cuda_context().context("events ctx")?;
        let push_done_event = ctx_for_events
            .new_event(None)
            .map_err(|e| anyhow::anyhow!("push_done_event: {e}"))?;
        let sample_done_event = ctx_for_events
            .new_event(None)
            .map_err(|e| anyhow::anyhow!("sample_done_event: {e}"))?;
        let replay_done_event = ctx_for_events
            .new_event(None)
            .map_err(|e| anyhow::anyhow!("replay_done_event: {e}"))?;
        let train_done_event = ctx_for_events
            .new_event(None)
            .map_err(|e| anyhow::anyhow!("train_done_event: {e}"))?;

        let hot = HotPathPtrs {
            h_t_encoder: perception.h_t_view().raw_ptr(),
            h_tp1: h_tp1_d.raw_ptr(),
            trade_context: trade_context_d.raw_ptr(),
            multires_output: multires_output_d.raw_ptr(),
        };

        Ok(Self {
            cfg,
            perception,
            dqn_head,
            iqn_head,
            policy_head,
            value_head,
            band_head,
            dueling_q_head,
            dqn_w_adam,
            dqn_b_adam,
            iqn_w_embed_adam,
            iqn_b_embed_adam,
            iqn_w_out_adam,
            iqn_b_out_adam,
            policy_w_adam,
            policy_b_adam,
            value_w_adam,
            value_b_adam,
            // Phase 4-B band-head Adam pair.
            band_w_adam,
            band_b_adam,
            // Phase 2A-C MultiHeadPolicy + Adams (None when flag off).
            use_multi_head_policy,
            multi_head_policy,
            mhp_w_heads_adam,
            mhp_b_heads_adam,
            mhp_w_gate_adam,
            mhp_b_gate_adam,
            dueling_q_w_v_adam,
            dueling_q_b_v_adam,
            dueling_q_w_a_adam,
            dueling_q_b_a_adam,
            isv_mapped,
            isv_dev_ptr,
            raw_stream: stream.cu_stream(),
            raw_train_stream: train_stream.cu_stream(),
            stream,
            train_stream,
            push_done_event,
            sample_done_event,
            replay_done_event,
            train_done_event,
            prefill_graph: None,
            postfill_graph: None,
            reward_graph: None,
            graph_warmup_done: false,
            training_graph: None,
            training_warmup_done: false,
            _grad_h_module: grad_h_module,
            grad_h_accumulate_fn,
            _reduce_axis0_module: reduce_axis0_module,
            reduce_axis0_fn,
            _rl_lr_controller_module: rl_lr_controller_module,
            rl_lr_controller_fn,
            _rl_gamma_controller_module: rl_gamma_controller_module,
            rl_gamma_controller_fn,
            _rl_target_tau_controller_module: rl_target_tau_controller_module,
            rl_target_tau_controller_fn,
            _rl_ppo_clip_controller_module: rl_ppo_clip_controller_module,
            rl_ppo_clip_controller_fn,
            _rl_entropy_coef_controller_module: rl_entropy_coef_controller_module,
            rl_entropy_coef_controller_fn,
            _rl_rollout_steps_controller_module: rl_rollout_steps_controller_module,
            rl_rollout_steps_controller_fn,
            _rl_per_alpha_controller_module: rl_per_alpha_controller_module,
            rl_per_alpha_controller_fn,
            _rl_reward_scale_controller_module: rl_reward_scale_controller_module,
            rl_reward_scale_controller_fn,
            _ema_update_on_done_module: ema_update_on_done_module,
            ema_update_on_done_fn,
            _ema_update_per_step_module: ema_update_per_step_module,
            ema_update_per_step_fn,
            _action_entropy_per_step_module: action_entropy_per_step_module,
            action_entropy_per_step_fn,
            _compute_advantage_return_module: compute_advantage_return_module,
            compute_advantage_return_fn,
            _rl_action_kernel_module: rl_action_kernel_module,
            rl_action_kernel_fn,
            _argmax_expected_q_module: argmax_expected_q_module,
            argmax_expected_q_fn,
            _log_pi_at_action_module: log_pi_at_action_module,
            log_pi_at_action_fn,
            prng_state_d,
            atom_supports_d,
            _apply_reward_scale_module: apply_reward_scale_module,
            apply_reward_scale_fn,
            _rl_reward_clamp_controller_module: rl_reward_clamp_controller_module,
            rl_reward_clamp_controller_fn,
            _rl_atom_support_update_module: rl_atom_support_update_module,
            rl_atom_support_update_fn,
            _rl_q_pi_distill_grad_module: rl_q_pi_distill_grad_module,
            rl_q_pi_distill_grad_fn,
            // Phase 7b F5 (2026-06-05) — state-conditional action mask.
            _rl_state_action_mask_module: rl_state_action_mask_module,
            rl_state_action_mask_fn,
            _rl_pi_grad_blend_module: rl_pi_grad_blend_module,
            rl_pi_grad_blend_fn,
            _rl_kl_reference_module: rl_kl_reference_module,
            _rl_kl_reference_grad_fn: rl_kl_reference_grad_fn,
            _rl_ensemble_action_value_module: rl_ensemble_action_value_module,
            rl_ensemble_action_value_fn,
            _rl_v_blend_module: rl_v_blend_module,
            rl_v_blend_fn,
            _rl_v_blend_alpha_controller_module: rl_v_blend_alpha_controller_module,
            rl_v_blend_alpha_controller_fn,
            _rl_advantage_normalize_module: rl_advantage_normalize_module,
            rl_advantage_normalize_fn,
            _rl_signal_variance_update_module: rl_signal_variance_update_module,
            rl_signal_variance_update_fn,
            // Adaptive risk management — layered stack (spec 2026-05-30-adaptive-risk-management).
            _rl_cmdp_constraints_check_module: rl_cmdp_constraints_check_module,
            rl_cmdp_constraints_check_fn,
            _rl_iqn_action_tau_controller_module: rl_iqn_action_tau_controller_module,
            rl_iqn_action_tau_controller_fn,
            _rl_inventory_beta_controller_module: rl_inventory_beta_controller_module,
            rl_inventory_beta_controller_fn,
            _rl_kelly_fraction_controller_module: rl_kelly_fraction_controller_module,
            rl_kelly_fraction_controller_fn,
            _rl_surfer_scaffold_controller_module: rl_surfer_scaffold_controller_module,
            rl_surfer_scaffold_controller_fn,
            _rl_gate_lr_multiplier_controller_module: rl_gate_lr_multiplier_controller_module,
            rl_gate_lr_multiplier_controller_fn,
            _rl_eval_warmup_decay_module: rl_eval_warmup_decay_module,
            rl_eval_warmup_decay_fn,
            _rl_regime_flat_count_module: rl_regime_flat_count_module,
            rl_regime_flat_count_fn,
            _rl_regime_observer_module: rl_regime_observer_module,
            rl_regime_observer_fn,
            flat_count_d,
            _rl_win_rate_ema_update_module: rl_win_rate_ema_update_module,
            rl_win_rate_ema_update_fn,
            _rl_avg_win_loss_ema_update_module: rl_avg_win_loss_ema_update_module,
            rl_avg_win_loss_ema_update_fn,
            _rl_inventory_variance_update_module: rl_inventory_variance_update_module,
            rl_inventory_variance_update_fn,
            v_blended_d,
            v_blended_tp1_d,
            _rl_q_distill_lambda_controller_module: rl_q_distill_lambda_controller_module,
            rl_q_distill_lambda_controller_fn,
            _rl_unit_state_update_module: rl_unit_state_update_module,
            rl_unit_state_update_fn,
            _rl_trail_mutate_module: rl_trail_mutate_module,
            rl_trail_mutate_fn,
            _rl_trail_stop_check_module: rl_trail_stop_check_module,
            rl_trail_stop_check_fn,
            _rl_position_heat_check_module: rl_position_heat_check_module,
            rl_position_heat_check_fn,
            _rl_confidence_gate_module: rl_confidence_gate_module,
            rl_confidence_gate_fn,
            _rl_frd_gate_module: rl_frd_gate_module,
            rl_frd_gate_fn,
            _rl_fused_reward_pipeline_module: rl_fused_reward_pipeline_module,
            rl_fused_reward_pipeline_fn,
            _rl_gate_threshold_controller_module: rl_gate_threshold_controller_module,
            rl_gate_threshold_controller_fn,
            _rl_fused_controllers_module: rl_fused_controllers_module,
            rl_fused_controllers_fn,
            fused_ctrl_input_slots_d,
            _rl_asymmetric_trail_decay_module: rl_asymmetric_trail_decay_module,
            rl_asymmetric_trail_decay_fn,
            _rl_session_risk_check_module: rl_session_risk_check_module,
            rl_session_risk_check_fn,
            _rl_min_hold_check_module: rl_min_hold_check_module,
            rl_min_hold_check_fn,
            _rl_trade_context_update_module: rl_trade_context_update_module,
            rl_trade_context_update_fn,
            _rl_multires_features_update_module: rl_multires_features_update_module,
            rl_multires_features_update_fn,
            _rl_increment_step_module: rl_increment_step_module,
            rl_increment_step_fn,
            _rl_sample_tau_module: rl_sample_tau_module,
            _rl_sample_tau_fn: rl_sample_tau_fn,
            _rl_write_u64_module: rl_write_u64_module,
            rl_write_u64_fn,
            ts_ns_d,
            outcome_ema_d,
            session_pnl_per_batch_d,
            session_dd_triggered_per_batch_d,
            consec_loss_per_batch_d,
            cooldown_remaining_per_batch_d,
            ph_mu_per_batch_d,
            ph_count_per_batch_d,
            ph_m_per_batch_d,
            ph_mmin_per_batch_d,
            ph_stat_per_batch_d,
            trade_context_d,
            multires_output_d,
            multires_state_d,
            multires_prev_mid_d,
            multires_prev_ts_ns_d,
            unit_entry_price_d,
            unit_entry_step_d,
            unit_lots_d,
            unit_initial_r_d,
            unit_trail_distance_d,
            unit_active_d,
            pyramid_units_count_d,
            close_unit_index_d,
            unit_prev_pos_lots_d,
            _actions_to_market_targets_module: actions_to_market_targets_module,
            actions_to_market_targets_fn,
            _abs_copy_module: abs_copy_module,
            abs_copy_fn,
            _rl_var_over_abs_mean_streaming_module: rl_var_over_abs_mean_streaming_module,
            rl_var_over_abs_mean_streaming_fn,
            _rl_kurtosis_streaming_module: rl_kurtosis_streaming_module,
            rl_kurtosis_streaming_fn,
            ema_input_scratch_d,
            _rl_kl_approx_b_module: rl_kl_approx_b_module,
            rl_kl_approx_b_fn,
            _rl_l2_diff_norm_module: rl_l2_diff_norm_module,
            rl_l2_diff_norm_fn,
            _rl_l2_norm_module: rl_l2_norm_module,
            rl_l2_norm_fn,
            _rl_deterministic_checksum_module: rl_deterministic_checksum_module,
            rl_deterministic_checksum_f32_fn,
            rl_deterministic_checksum_i32_fn,
            checksum_scratch_d,
            checksum_host,
            _rl_ppo_ratio_clamp_controller_module: rl_ppo_ratio_clamp_controller_module,
            rl_ppo_ratio_clamp_controller_fn,
            _ppo_log_ratio_abs_max_b_module: ppo_log_ratio_abs_max_b_module,
            ppo_log_ratio_abs_max_b_fn,
            _rl_streaming_clamp_init_module: rl_streaming_clamp_init_module,
            rl_streaming_clamp_init_fn,
            _rl_isv_write_module: rl_isv_write_module,
            rl_isv_write_fn,
            _rl_q_pi_agree_b_module: rl_q_pi_agree_b_module,
            rl_q_pi_agree_b_fn,
            _rl_pi_action_kernel_module: rl_pi_action_kernel_module,
            rl_pi_action_kernel_fn,
            steps_since_done_d,
            trade_duration_emit_d,
            prev_realized_pnl_d,
            prev_unrealized_pnl_d,
            prev_position_lots_d,
            rewards_d,
            raw_rewards_d,
            dones_d,
            reward_abs_d,
            actions_d,
            next_actions_d,
            log_pi_old_d,
            advantages_d,
            returns_d,
            h_tp1_d,
            iqn_tau_d,
            iqn_q_values_d,
            iqn_expected_q_d,
            ensemble_q_d,
            dueling_v_d,
            dueling_v_tp1_d,
            dueling_a_d,
            dueling_q_composed_d,
            dueling_q_target_composed_d,
            dueling_v_target_tp1_d,
            dueling_a_target_tp1_d,
            dueling_v_loss_d,
            dueling_a_loss_d,
            dueling_target_value_d,
            dueling_loss_pb_d,
            dueling_grad_composed_d,
            dueling_grad_w_v_pb_d,
            dueling_grad_b_v_pb_d,
            dueling_grad_w_a_pb_d,
            dueling_grad_b_a_pb_d,
            dueling_grad_w_v_d,
            dueling_grad_b_v_d,
            dueling_grad_w_a_d,
            dueling_grad_b_a_d,
            q_logits_d,
            q_logits_target_st_d,
            q_logits_tp1_d,
            v_pred_d,
            v_pred_tp1_d,
            pi_logits_d,
            iqn_prng_state_d,
            noisy_exploration,
            noisy_output_d,
            noisy_mu_w_adam,
            noisy_sigma_w_adam,
            noisy_mu_b_adam,
            noisy_sigma_b_adam,
            _aux_vec_add_module: aux_vec_add_module,
            aux_vec_add_fn,
            // P1.1 PopArt
            _rl_popart_normalize_module: rl_popart_normalize_module,
            rl_popart_normalize_fn,
            _rl_popart_v_correct_module: rl_popart_v_correct_module,
            rl_popart_v_correct_fn,
            // P1.2 Spectral
            _rl_spectral_norm_module: rl_spectral_norm_module,
            rl_spectral_norm_fn,
            _rl_spectral_decouple_module: rl_spectral_decouple_module,
            rl_spectral_decouple_fn,
            spectral_v_buffer_dqn,
            spectral_v_buffer_iqn,
            spectral_v_buffer_pi,
            // P1.3 Outcome
            outcome_head,
            outcome_w_adam,
            outcome_b_adam,
            // P2.1 Q-bias
            _rl_q_bias_correction_module: rl_q_bias_correction_module,
            rl_q_bias_correction_fn,
            // P2.2 Per-branch LR
            _rl_per_branch_lr_module: rl_per_branch_lr_module,
            rl_per_branch_lr_fn,
            sampled_h_t_d,
            sampled_h_tp1_d,
            sampled_actions_d,
            sampled_rewards_d,
            sampled_dones_d,
            sampled_n_step_gammas_d,
            sampled_log_pi_old_d,
            gpu_replay,
            _rl_per_push_ring_module: rl_per_push_ring_module,
            rl_per_push_ring_fn,
            _rl_per_push_flush_module: rl_per_push_flush_module,
            rl_per_push_flush_fn,
            _rl_per_sample_module: rl_per_sample_module,
            rl_per_sample_fn,
            _rl_per_update_priority_module: rl_per_update_priority_module,
            rl_per_update_priority_fn,
            _rl_per_tree_rebuild_module: rl_per_tree_rebuild_module,
            rl_per_tree_rebuild_fn,
            hindsight,
            _rl_hindsight_track_module: rl_hindsight_track_module,
            rl_hindsight_track_fn,
            _rl_hindsight_inject_module: rl_hindsight_inject_module,
            rl_hindsight_inject_fn,
            _rl_hindsight_forward_module: rl_hindsight_forward_module,
            rl_hindsight_forward_fn,
            sampled_next_actions_d,
            td_per_sample_d,
            grad_h_t_combined_d,
            last_pi_loss: 0.0,
            last_q_loss: 0.0,
            last_bce_loss: 0.0,
            last_aux_loss: 0.0,
            last_v_loss: 0.0,
            last_step_stats: IntegratedStepStats::default(),
            last_k_updates: 0,
            step_counter: 0,
            gpu_encoder_active: false,
            pos_fraction_staging: {
                let n = 2 * crate::heads::N_HORIZONS;
                Some(unsafe { MappedF32Buffer::new(n) }
                    .map_err(|e| anyhow::anyhow!("pos_fraction_staging: {e}"))?)
            },
            spectral_norm_counter: 0,
            frd_head,
            frd_hidden_d,
            frd_logits_d,
            frd_labels_d,
            frd_labels_staging,
            frd_w1_adam,
            frd_b1_adam,
            frd_w2_adam,
            frd_b2_adam,
            ss_q_loss_mapped,
            ss_q_loss_dev_ptr,
            ss_pi_loss_mapped,
            ss_pi_loss_dev_ptr,
            ss_pi_loss_entropy_mapped,
            ss_pi_loss_entropy_dev_ptr,
            ss_v_loss_sum_mapped,
            ss_v_loss_sum_dev_ptr,
            ss_v_loss_per_batch_d,
            ss_pi_log_prob_d,
            ss_entropy_d,
            ss_pi_loss_per_b_d,
            ss_pi_loss_entropy_per_b_d,
            ss_q_grad_logits_d,
            ss_q_grad_h_t_d,
            ss_q_grad_w_d,
            ss_q_grad_b_d,
            ss_pi_grad_logits_d,
            ss_pi_grad_w_per_batch_d,
            ss_pi_grad_b_per_batch_d,
            ss_pi_grad_h_t_d,
            ss_pi_grad_w_d,
            ss_pi_grad_b_d,
            f2_diag_baseline_per_b_d,
            f2_diag_adv_max_per_b_d,
            f2_diag_hinge_zero_per_b_d,
            f2_diag_target_argmax_per_b_d,
            ss_v_grad_w_per_batch_d,
            ss_v_grad_b_per_batch_d,
            ss_v_grad_h_t_d,
            ss_v_grad_w_d,
            ss_v_grad_b_d,
            ss_frd_grad_h_t_d,
            ss_outcome_grad_w_pb_d,
            ss_outcome_grad_b_pb_d,
            ss_outcome_grad_h_t_d,
            ss_outcome_grad_w_d,
            ss_outcome_grad_b_d,
            ss_q_target_full_d,
            ss_target_dist_d,
            bellman_sat_top_per_batch_d,
            bellman_sat_bot_per_batch_d,
            bellman_max_pre_per_batch_d,
            bellman_min_pre_per_batch_d,
            q_dist_entropy_pb_d,
            q_dist_range_pb_d,
            q_dist_abs_max_pb_d,
            ppo_a_norm_pb_d,
            ppo_ratio_dev_pb_d,
            ppo_ratio_clipped_pb_d,
            ppo_surrogate_pb_d,
            ss_frd_grad_logits_d,
            ss_frd_loss_mapped,
            ss_frd_loss_dev_ptr,
            ss_frd_grad_w2_pb_d,
            ss_frd_grad_b2_pb_d,
            ss_frd_grad_hidden_d,
            ss_frd_grad_w1_pb_d,
            ss_frd_grad_b1_pb_d,
            ss_frd_grad_w1_d,
            ss_frd_grad_b1_d,
            ss_frd_grad_w2_d,
            ss_frd_grad_b2_d,
            ss_iqn_tau_target_d,
            ss_iqn_target_q_d,
            ss_iqn_loss_pb_d,
            ss_iqn_grad_q_d,
            ss_iqn_grad_w_out_pb_d,
            ss_iqn_grad_b_out_pb_d,
            ss_iqn_grad_w_embed_pb_d,
            ss_iqn_grad_b_embed_pb_d,
            ss_iqn_grad_w_out_d,
            ss_iqn_grad_b_out_d,
            ss_iqn_grad_w_embed_d,
            ss_iqn_grad_b_embed_d,
            hot,
        }
        .with_controllers_bootstrapped()?)
    }

    /// Adaptive risk management — session boundary reset (spec 2026-05-30).
    /// Called by the caller at train→eval (or fold) transition. Clears
    /// the Layer 1 session-level state so the eval phase starts with
    /// fresh DD tracking + cooldown counters. Kelly + inventory EMAs
    /// are intentionally NOT reset: training-acquired statistics should
    /// carry into eval (matches "policy learned during train, applied
    /// in eval" intent).
    ///
    /// Fix D (2026-05-30): also zero the per-batch state buffers added
    /// by the CMDP per-batch refactor (39efacf77). Without this, the
    /// summary slots are reset to 0 but per-batch arrays carry over,
    /// so on the next launch the CMDP kernel re-overwrites the summary
    /// slots with the stale aggregates. Net effect was eval phases
    /// inheriting all the dead/cooldown accounts from training.
    pub fn reset_session_state(&self) -> Result<()> {
        // Summary slots — direct ISV writes.
        //
        // v9 (2026-05-31, [[pearl_adaptive_carryover_discipline]]): reset
        // EVERY adaptive EMA driving downstream behavior, including the
        // ones Fix D previously preserved. The rationale for the prior
        // selective preservation ("training-acquired statistics should
        // carry into eval") proved wrong at the v8 fold-1 boundary:
        // Kelly entered eval with train wr=0.34 vs eval wr=0.25, took
        // overconfident positions, and lost $500k before EMAs could
        // re-converge. Pure adaptive principle now: every signal that
        // drives behavior must be re-bootstrapped from new-regime data.
        // Defensive warmup duration. Matches RL_REGIME_TRANSITION_STEPS_CONFIG_INDEX
        // bootstrap (see ISV bootstrap block in `with_controllers_bootstrapped`).
        // Hardcoded here because reset_session_state runs before any ISV read
        // would observe a host-visible value; the kernel reads its own config.
        let warmup_steps = 500.0_f32;
        for (slot, value) in [
            // Layer 1 CMDP summary slots (kept from Fix D).
            (crate::rl::isv_slots::RL_SESSION_PNL_USD_INDEX,         0.0_f32),
            (crate::rl::isv_slots::RL_SESSION_PNL_WORST_INDEX,       0.0_f32),
            (crate::rl::isv_slots::RL_SESSION_DD_TRIGGERED_INDEX,    0.0_f32),
            (crate::rl::isv_slots::RL_CONSEC_LOSS_COUNT_INDEX,       0.0_f32),
            (crate::rl::isv_slots::RL_COOLDOWN_REMAINING_STEPS_INDEX, 0.0_f32),
            // Layer 4 (Kelly) EMAs — Predictive but resets to NEUTRAL CONSERVATIVE
            // values, NOT to 0 sentinels (B-2/B-3: same pattern as pos_max_ema).
            // The "bootstrap to first observation" anti-pattern bit Kelly hard at
            // eval boundary: 4xmxm eval[1] showed b̂=10:1 win/loss ratio from a
            // single trade pair → Kelly recommended 53% sizing → -$100M eval pnl.
            // Cold-start values: avg_win=avg_loss=1.0 → b̂=1, p̂=0.5 → f_kelly=0.
            // Trust schedule (B-3) gates with f_floor=0.05 during warmup.
            (crate::rl::isv_slots::RL_WIN_RATE_EMA_INDEX,            0.5_f32),  // neutral
            (crate::rl::isv_slots::RL_AVG_WIN_USD_EMA_INDEX,         1.0_f32),  // cold-start (was 0)
            (crate::rl::isv_slots::RL_AVG_LOSS_USD_EMA_INDEX,        1.0_f32),  // cold-start (was 0)
            (crate::rl::isv_slots::RL_CUMULATIVE_DONES_INDEX,        0.0_f32),  // re-enter trust schedule
            (crate::rl::isv_slots::RL_KELLY_FRACTION_INDEX,          1.0_f32),  // bootstrap (overridden by controller)
            // Layer 3 (Inventory) EMAs — NEW in v9.
            (crate::rl::isv_slots::RL_INVENTORY_PENALTY_BETA_INDEX,  0.0_f32),  // sentinel
            (crate::rl::isv_slots::RL_INVENTORY_VARIANCE_EMA_INDEX,  0.0_f32),  // sentinel
            // NORMALIZATION EMAs intentionally NOT reset (refines
            // [[pearl_adaptive_carryover_discipline]] via
            // [[pearl_popart_reset_at_eval_boundary_shocks_normalization]]):
            //   RL_POS_SCALED_REWARD_MAX_EMA_INDEX
            //   RL_NEG_SCALED_REWARD_MAX_EMA_INDEX
            //   RL_REWARD_CLAMP_CLIP_RATE_EMA_INDEX
            //   RL_POPART_MAX_ABS_REWARD_EMA_INDEX (F4 envelope)
            // These calibrate signal scale, not predict behavior. The model
            // was trained against rewards at this scale; preserving the
            // scale keeps the reward clamp armed (cap = pos_max_ema × ratio)
            // and popart's envelope at train-final magnitude. Resetting
            // them disabled the clamp (cap = 0 × ratio = 0) and let outliers
            // explode the envelope σ 1500× over ~14 steps → -$185M eval
            // loss (alpha-rl-6kghr fold 1, 2026-05-31).
            // v9 — arm the defensive warmup window.
            (crate::rl::isv_slots::RL_REGIME_TRANSITION_REMAINING_INDEX,   warmup_steps),
            // Regime observer boundary policy (F1.7 — spec 2026-05-31 v3,
            // [[pearl_adaptive_carryover_discipline]]):
            //   - Transient state (FLAG/DURATION/TIMEOUT) → 0
            //   - RECOVERY_FACTOR → 1.0 (no recent tail; ε = ε_max)
            //   - TAIL_EVENT_RECENCY → 1e6 sentinel ("no tails seen yet")
            //   - KELLY_EPS_RECOVERY_LIVE → 0.50 (= ε_max default)
            //   - PREV_WORST_PNL → 0 (CRITICAL — must align with the just-reset
            //     session_pnl_worst above to prevent spurious 3σ tail event)
            //   - POPART_MAX_ABS_REWARD_EMA intentionally NOT reset — see
            //     normalization-EMA preservation block above.
            //   - Welford state (M2/MEAN/COUNT) intentionally NOT reset — the
            //     variance of session_pnl_change is regime-invariant.
            (crate::rl::isv_slots::RL_REGIME_DEAD_ZONE_FLAG_INDEX,           0.0_f32),
            (crate::rl::isv_slots::RL_REGIME_DEAD_ZONE_DURATION_INDEX,       0.0_f32),
            (crate::rl::isv_slots::RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX,   0.0_f32),
            (crate::rl::isv_slots::RL_REGIME_TAIL_EVENT_RECENCY_INDEX,       1.0e6_f32),
            (crate::rl::isv_slots::RL_REGIME_RECOVERY_FACTOR_INDEX,          1.0_f32),
            (crate::rl::isv_slots::RL_KELLY_EPS_RECOVERY_LIVE_INDEX,         0.50_f32),
            (crate::rl::isv_slots::RL_REGIME_PREV_WORST_PNL_INDEX,           0.0_f32),
        ] {
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot as i32);
            args.push_f32(value);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_isv_write_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_isv_write (reset_session_state slot {slot}): {:?}", e))?;
            }
        }
        // Per-batch buffers — bulk memset to zero. Each is [b_size] f32.
        for (buf_ptr, buf_bytes, name) in [
            (self.session_pnl_per_batch_d.raw_ptr(),
             self.session_pnl_per_batch_d.num_bytes(),
             "session_pnl_per_batch_d"),
            (self.session_dd_triggered_per_batch_d.raw_ptr(),
             self.session_dd_triggered_per_batch_d.num_bytes(),
             "session_dd_triggered_per_batch_d"),
            (self.consec_loss_per_batch_d.raw_ptr(),
             self.consec_loss_per_batch_d.num_bytes(),
             "consec_loss_per_batch_d"),
            (self.cooldown_remaining_per_batch_d.raw_ptr(),
             self.cooldown_remaining_per_batch_d.num_bytes(),
             "cooldown_remaining_per_batch_d"),
            // Edge-decay Phase 1 (2026-06-01): PH state IS predictive
            // (predicts future edge degradation from recent trajectory).
            // Per pearl_popart_reset_at_eval_boundary_shocks_normalization
            // "RESET PREDICTIVE, PRESERVE NORMALIZATION" — these 5 buffers
            // are reset at every fold/eval boundary along with the existing
            // CMDP per-batch state, so eval-side PH alerts come from
            // eval-side trades only.
            (self.ph_mu_per_batch_d.raw_ptr(),
             self.ph_mu_per_batch_d.num_bytes(),
             "ph_mu_per_batch_d"),
            (self.ph_count_per_batch_d.raw_ptr(),
             self.ph_count_per_batch_d.num_bytes(),
             "ph_count_per_batch_d"),
            (self.ph_m_per_batch_d.raw_ptr(),
             self.ph_m_per_batch_d.num_bytes(),
             "ph_m_per_batch_d"),
            (self.ph_mmin_per_batch_d.raw_ptr(),
             self.ph_mmin_per_batch_d.num_bytes(),
             "ph_mmin_per_batch_d"),
            (self.ph_stat_per_batch_d.raw_ptr(),
             self.ph_stat_per_batch_d.num_bytes(),
             "ph_stat_per_batch_d"),
        ] {
            unsafe {
                raw_memset_d8_zero(buf_ptr, buf_bytes, self.raw_stream)
                    .map_err(|e| anyhow::anyhow!("zero {name}: {:?}", e))?;
            }
        }
        Ok(())
    }

    /// Read a single ISV slot from mapped-pinned host memory. Volatile
    /// read prevents the compiler from caching stale values. The GPU
    /// writes via `dev_ptr`; by the time the host reads (after stream
    /// ordering guarantees from the previous step), the value reflects
    /// the previous step's controller output (one-step delay, acceptable
    /// for EMA-based controllers and JSONL diagnostics).
    #[inline(always)]
    pub fn read_isv_host(&self, slot: usize) -> f32 {
        debug_assert!(slot < self.isv_mapped.len);
        unsafe { std::ptr::read_volatile(self.isv_mapped.host_ptr.add(slot)) }
    }

    /// Construct a host-side slice view over the ISV mapped-pinned
    /// buffer. Uses volatile reads internally via the `MappedRecordBuffer`
    /// API. Caller must ensure the producing stream has completed
    /// (one-step delay pattern).
    pub fn isv_host_slice(&self) -> &[f32] {
        unsafe { std::slice::from_raw_parts(self.isv_mapped.host_ptr, self.isv_mapped.len) }
    }

    /// Phase R1: fire each of the 7 RL controllers ONCE against the
    /// freshly-zeroed `isv_d`. Each kernel detects the sentinel (its
    /// dedicated ISV slot == 0.0), executes its first-observation-
    /// bootstrap path per `pearl_first_observation_bootstrap`, and
    /// writes its canonical default into the slot:
    ///
    ///   ISV[400] γ              = target(trade_duration_ema) — 0.90 at
    ///                              sentinel input (after the bootstrap audit; was
    ///                              hardcoded 0.99 which coincided with
    ///                              target(d≈69) creating a Wiener-blend
    ///                              dead-zone at canonical hold time)
    ///   ISV[401] τ              = 0.005  (TAU_BOOTSTRAP)
    ///   ISV[402] ε              = 0.2    (EPS_BOOTSTRAP)
    ///   ISV[403] entropy_coef   = target(entropy_observed_ema) — 0.035
    ///                              at sentinel input (after the bootstrap audit;
    ///                              was hardcoded 0.01 which coincided
    ///                              with target(h≈1.099) creating a
    ///                              Wiener-blend dead-zone at canonical
    ///                              mid-range entropy)
    ///   ISV[404] n_rollout_steps= 2048   (ROLLOUT_BOOTSTRAP)
    ///   ISV[405] per_α          = target(td_kurtosis_ema) — 0.4 at
    ///                              sentinel input (after the bootstrap audit; was
    ///                              hardcoded 0.6 which coincided with
    ///                              target(kurt=10) creating a Wiener-
    ///                              blend dead-zone at canonical market
    ///                              kurtosis)
    ///   ISV[406] reward_scale   = 1.0    (REWARD_SCALE_BOOTSTRAP)
    ///
    /// The scalar `input` argument is IGNORED on the bootstrap path
    /// (each kernel `return`s immediately after the write). Phase R5
    /// reuses [`Self::launch_isv_controller_3arg`] with real EMA
    /// inputs sourced from ISV[417..424] — at that point the Wiener-α
    /// blend kicks in and the slots adapt from the bootstrap defaults.
    ///
    /// Replaces the flawed Phase F approach of host-side `memcpy_htod`
    /// of canonical constants, which violated
    /// `feedback_no_htod_htoh_only_mapped_pinned` (tests not exempt)
    /// and short-circuited `pearl_first_observation_bootstrap`.
    ///
    /// Phase R5 update: each controller now reads its EMA input from
    /// the corresponding ISV[417..423] slot via `input_slot`. At
    /// bootstrap time those slots are also at `alloc_zeros` sentinel
    /// zero, but the kernel returns before the input read (the
    /// controller's own output slot is at sentinel zero, triggering
    /// the `pearl_first_observation_bootstrap` return path). The same
    /// helper, `launch_isv_controller_3arg`, serves bootstrap and
    /// per-step launches.
    fn with_controllers_bootstrapped(self) -> Result<Self> {
        // audit — seed ISV-resident design constants FIRST, before any
        // controller bootstrap fires. The controllers' bootstrap paths
        // read these slots (e.g. rl_entropy_coef_controller reads
        // RL_ENTROPY_TARGET_FRAC_INDEX at bootstrap to derive its
        // target); if seeded after, they'd see sentinel 0.0 and
        // bootstrap to wrong values. Generic rl_isv_write per
        // (slot, value) pair — pure device write, no HtoD per
        // `feedback_no_htod_htoh_only_mapped_pinned`.
        {
            let isv_constants: [(usize, f32); 276] = [
                // Static seeds for the adaptive reward-clamp controller —
                // these are the initial values that
                // `rl_reward_clamp_controller` will replace once it
                // observes the first positive reward. Until then, kernels
                // reading slots 452/453 see the documented [-3, +1]
                // defaults. MARGIN (480) starts at 1.5 then adapts via
                // the controller's Schulman bounded-step from clip-rate
                // EMA (482) vs target (483). CLIP_RATE_TARGET seeded to
                // 0.05 = "let 5% of wins ride the clamp as tail
                // outliers." V_MAX/V_MIN seeded to the original C51
                // [-1, +1] span as the canonical floor — the ratchet
                // in `rl_reward_clamp_controller` only grows magnitude.
                (crate::rl::isv_slots::RL_REWARD_CLAMP_WIN_INDEX,       1.0),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_LOSS_INDEX,      3.0),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_MARGIN_INDEX,    1.5),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_RATIO_INDEX,     3.0),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_CLIP_RATE_TARGET_INDEX, 0.05),
                (crate::rl::isv_slots::RL_C51_V_MAX_INDEX,              1.0),
                (crate::rl::isv_slots::RL_C51_V_MIN_INDEX,             -1.0),
                // Q→π distillation (vj5f6 followup) — λ now controller-
                // driven by `rl_q_distill_lambda_controller` from
                // KL_EMA vs KL_TARGET. Seed 0.05 is the starting point
                // before the controller picks up. τ=1.0 canonical.
                (crate::rl::isv_slots::RL_Q_DISTILL_LAMBDA_INDEX,         0.01),
                (crate::rl::isv_slots::RL_Q_DISTILL_TEMPERATURE_INDEX,    5.0),
                (crate::rl::isv_slots::RL_Q_DISTILL_KL_TARGET_INDEX,      0.1),
                // Reward-scale MIN floor (was hardcoded 1e-3) — now
                // ISV-driven, lowered to 1e-4 per wwcsz audit to give
                // one more order of magnitude before pegging when
                // mean_abs_pnl spikes.
                (crate::rl::isv_slots::RL_REWARD_SCALE_MIN_INDEX,         1e-4),
                // Q→π distill KL_EMA blend α (was hardcoded 0.05f in
                // rl_q_pi_distill_grad.cu, caught by audit-isv.sh dogfood).
                (crate::rl::isv_slots::RL_Q_DISTILL_KL_EMA_ALPHA_INDEX,   0.05),
                // SP20 P1+P5 trail-stop bounds (audit-wiring fix for a7/a8 dead).
                (crate::rl::isv_slots::RL_TRAIL_MIN_INDEX,                0.001),
                (crate::rl::isv_slots::RL_TRAIL_MAX_INDEX,                100.0),
                (crate::rl::isv_slots::RL_TRAIL_K_INIT_INDEX,             2.0),
                // RL_TRAIL_ADJUST_RATE_INDEX (497) deprecated 2026-05-30 —
                // superseded by independent slots 682/683 (trail tighten/loosen
                // factors) bootstrapped below. Slot 497 stays allocated for
                // numbering compatibility but is no longer written.
                // SP20 P3 FRD head seeds — λ, LR, h1-3 tick offsets, ±σ atom range.
                // Per feedback_isv_for_adaptive_bounds + single-source-of-truth:
                // horizon ticks + bucket-range σ derive from the canonical
                // `rl::common` consts (same source the loader's
                // `compute_frd_labels` reads via `MultiHorizonLoaderConfig`),
                // so the two sides can't drift across literal-vs-const edits.
                (crate::rl::isv_slots::RL_FRD_LAMBDA_INDEX,               0.5),
                (crate::rl::isv_slots::RL_FRD_LR_INDEX,                   1e-3),
                (crate::rl::isv_slots::RL_FRD_HORIZON_1_TICKS_INDEX,
                    crate::rl::common::FRD_HORIZON_TICKS[0] as f32),
                (crate::rl::isv_slots::RL_FRD_HORIZON_2_TICKS_INDEX,
                    crate::rl::common::FRD_HORIZON_TICKS[1] as f32),
                (crate::rl::isv_slots::RL_FRD_HORIZON_3_TICKS_INDEX,
                    crate::rl::common::FRD_HORIZON_TICKS[2] as f32),
                (crate::rl::isv_slots::RL_FRD_BUCKET_RANGE_SIGMA_INDEX,
                    crate::rl::common::FRD_BUCKET_RANGE_SIGMA),
                (crate::rl::isv_slots::RL_HEAT_CAP_MAX_LOTS_INDEX,  8.0),
                (crate::rl::isv_slots::RL_PYRAMID_THRESHOLD_INDEX,  0.50),
                (crate::rl::isv_slots::RL_ANTIMARTINGALE_KAPPA_INDEX, 1.0),
                (crate::rl::isv_slots::RL_ANTIMARTINGALE_MIN_INDEX,   0.5),
                (crate::rl::isv_slots::RL_ANTIMARTINGALE_MAX_INDEX,   2.0),
                (crate::rl::isv_slots::RL_CONF_GATE_THRESHOLD_INDEX,  0.3),
                (crate::rl::isv_slots::RL_CONF_GATE_LAMBDA_INDEX,     1.0),
                (crate::rl::isv_slots::RL_CONF_GATE_SIGMA_NORM_INDEX, 1.0),
                (crate::rl::isv_slots::RL_FRD_GATE_THR_LONG_INDEX,  0.05),
                (crate::rl::isv_slots::RL_FRD_GATE_THR_SHORT_INDEX, 0.05),
                // P_MIN set to 0.0: NoisyNet state-dependent exploration
                // replaces the fixed probability floor. Per
                // `pearl_pi_actor_collapses_without_entropy_floor`:
                // NoisyLinear noise on ensemble_q_d provides learned
                // exploration, so the P_MIN anti-collapse floor in
                // rl_pi_action_kernel is no longer needed.
                (crate::rl::isv_slots::RL_PI_SAMPLE_P_MIN_INDEX,    0.0),
                (crate::rl::isv_slots::RL_OUTCOME_ALPHA_INDEX,      0.1),
                (crate::rl::isv_slots::RL_GATE_WARMUP_STEPS_INDEX, 100.0),
                (crate::rl::isv_slots::RL_GATE_DONES_TARGET_INDEX, 0.10),
                (crate::rl::isv_slots::RL_GATE_CONF_MIN_INDEX,     0.0),
                (crate::rl::isv_slots::RL_GATE_CONF_MAX_INDEX,     0.50),
                (crate::rl::isv_slots::RL_GATE_FRD_MIN_INDEX,      0.0),
                (crate::rl::isv_slots::RL_GATE_FRD_MAX_INDEX,      0.50),
                (crate::rl::isv_slots::RL_GATE_ADJUST_RATE_INDEX,  0.95),
                (crate::rl::isv_slots::RL_ENTRY_COST_INDEX,        15.0),
                (crate::rl::isv_slots::RL_SHORT_HOLD_MIN_STEPS_INDEX, 100.0),
                (crate::rl::isv_slots::RL_SHORT_HOLD_PENALTY_INDEX,   0.5),
                (crate::rl::isv_slots::RL_HOLD_BONUS_INDEX,          2.0),
                (crate::rl::isv_slots::RL_MIN_HOLD_STEPS_INDEX,    100.0),
                (crate::rl::isv_slots::RL_ASYM_LOSS_DECAY_RATE_INDEX, 0.995),
                (crate::rl::isv_slots::RL_ASYM_WIN_TRAIL_FACTOR_INDEX, 0.5),
                (crate::rl::isv_slots::RL_SESSION_LOSS_LIMIT_INDEX, -50.0),
                (crate::rl::isv_slots::RL_SESSION_EMA_ALPHA_INDEX,  0.02),
                (crate::rl::isv_slots::RL_ASYM_WIN_THRESHOLD_INDEX, 0.25),
                (crate::rl::isv_slots::RL_N_STEP_INDEX,            10.0),
                (crate::rl::isv_slots::RL_MULTIRES_HORIZON_1_INDEX, 1.0),
                (crate::rl::isv_slots::RL_MULTIRES_HORIZON_2_INDEX, 10.0),
                (crate::rl::isv_slots::RL_MULTIRES_HORIZON_3_INDEX, 600.0),
                (crate::rl::isv_slots::RL_KL_TARGET_INDEX,              0.01),
                (crate::rl::isv_slots::RL_IMPROVEMENT_THRESHOLD_INDEX,  0.99),
                (crate::rl::isv_slots::RL_PLATEAU_PATIENCE_INDEX,    1000.0),
                (crate::rl::isv_slots::RL_DIV_TARGET_INDEX,             0.01),
                (crate::rl::isv_slots::RL_ENTROPY_TARGET_FRAC_INDEX,    0.7),
                (crate::rl::isv_slots::RL_KURT_LIFT_SCALE_INDEX,        7.0),
                (crate::rl::isv_slots::RL_PPO_CLAMP_MARGIN_INDEX,      10.0),
                (crate::rl::isv_slots::RL_LR_WARMUP_STEPS_INDEX,     2000.0),
                (crate::rl::isv_slots::RL_LR_BOOTSTRAP_INDEX,           1e-3),
                (crate::rl::isv_slots::RL_LR_MIN_INDEX,                 1e-4),
                (crate::rl::isv_slots::RL_LR_MAX_INDEX,                 1e-2),
                (crate::rl::isv_slots::RL_LR_LOSS_EMA_ALPHA_INDEX,      0.05),
                (crate::rl::isv_slots::RL_LR_DECAY_FACTOR_INDEX,        0.5),
                (crate::rl::isv_slots::RL_LOSS_LAMBDA_AUX_INDEX,        1.0),
                // Batch 3 — Schulman pattern + streaming α + kurt refs + bootstraps.
                (crate::rl::isv_slots::RL_SCHULMAN_TOLERANCE_INDEX,     1.5),
                (crate::rl::isv_slots::RL_SCHULMAN_ADJUST_RATE_INDEX,   1.5),
                (crate::rl::isv_slots::RL_STREAM_ALPHA_INDEX,           0.05),
                (crate::rl::isv_slots::RL_KURT_GAUSSIAN_INDEX,          3.0),
                (crate::rl::isv_slots::RL_KURT_NOISE_FLOOR_INDEX,       1.0),
                (crate::rl::isv_slots::RL_TAU_BOOTSTRAP_INDEX,          0.005),
                (crate::rl::isv_slots::RL_TARGET_TAU_MAX_INDEX,        0.005),
                (crate::rl::isv_slots::RL_HOLD_PRIOR_INDEX,           0.1),
                (crate::rl::isv_slots::RL_HOLD_TARGET_FRAC_INDEX,     0.5),
                (crate::rl::isv_slots::RL_KL_REF_BETA_INDEX,         0.0005),
                (crate::rl::isv_slots::RL_REWARD_KL_BETA_INDEX,      0.001),
                (crate::rl::isv_slots::RL_KL_REF_TARGET_INDEX,       0.5),
                // Adaptive controller floors (spec 2026-05-30): bootstrap ε at
                // EPS_MIN = 0.05 (the lowest valid clip per the EPS_MIN clamp).
                // An earlier draft used 0.01 (= KL target) but that's BELOW the
                // EPS_MIN clamp range, so the post-bootstrap step always snapped
                // ε to MIN regardless of signal direction — the bootstrap-clamp
                // inconsistency Task 16 surfaced. Matching bootstrap to MIN
                // keeps the first post-bootstrap step honest: asymmetric Schulman
                // widens to track observed signal, tightens get clamped at MIN
                // (no-op, controller stays at the floor). Conservative + consistent.
                (crate::rl::isv_slots::RL_EPS_BOOTSTRAP_INDEX,          0.05),
                (crate::rl::isv_slots::RL_ROLLOUT_BOOTSTRAP_INDEX,      2048.0),
                (crate::rl::isv_slots::RL_REWARD_SCALE_BOOTSTRAP_INDEX, 1.0),
                (crate::rl::isv_slots::RL_PPO_RATIO_CLAMP_BOOTSTRAP_INDEX, 10.0),
                // Adaptive controller floors (spec 2026-05-30 Special cases G + Q):
                // safe defaults before the gamma controller / q_distill controller
                // overwrite these on their first per-step fire. γ_min 0.95
                // straddles short-trade (0.968) and surfer-regime (0.995) defaults;
                // λ_distill MIN 0.01 matches the q_distill λ bootstrap so the
                // hardcoded MIN_LAMBDA = 0.05 floor is replaced without a sudden
                // jump in λ at controller bootstrap.
                (crate::rl::isv_slots::RL_GAMMA_MIN_ADAPTIVE_INDEX,        0.95),
                (crate::rl::isv_slots::RL_Q_DISTILL_LAMBDA_MIN_ADAPTIVE_INDEX, 0.01),
                // Reward-floor fix (2026-05-30 follow-up): track real cumulative
                // closed trades (sum of dones per step) instead of closed-trade-STEPS.
                // Threshold = 5000 trades → ~715 steps of floor protection at typical
                // b=1024 dones rate. Both slots ISV-driven per
                // `feedback_adaptive_not_tuned`.
                (crate::rl::isv_slots::RL_CUMULATIVE_DONES_INDEX,          0.0),
                (crate::rl::isv_slots::RL_MIN_TRADES_FOR_RELEASE_INDEX, 5000.0),
                // IQN ensemble seeds — alpha=0.5 (equal C51/IQN blend),
                // N_TAU=32, LR=1e-3 (canonical).
                (crate::rl::isv_slots::RL_IQN_N_TAU_INDEX,                32.0),
                (crate::rl::isv_slots::RL_IQN_ENSEMBLE_ALPHA_INDEX,        0.5),
                (crate::rl::isv_slots::RL_IQN_LR_INDEX,                    1e-3),
                // NoisyNet σ_init — canonical Fortunato et al. 2017 value.
                // Read by NoisyLinear at construction (fallback if ISV is
                // sentinel-zero) and available for runtime re-tuning via
                // diagnostic JSONL visibility.
                (crate::rl::isv_slots::RL_NOISY_SIGMA_INIT_INDEX,          0.5),
                // Bidirectional HER ISV bootstrap — threshold, priority boost, lookahead.
                (crate::rl::isv_slots::RL_HINDSIGHT_THRESHOLD_INDEX,       1.5),
                (crate::rl::isv_slots::RL_HINDSIGHT_PRIORITY_BOOST_INDEX,  3.0),
                (crate::rl::isv_slots::RL_HINDSIGHT_FORWARD_LOOKAHEAD_INDEX, 50.0),
                // P1.1 PopArt ISV bootstrap — mean=0, var=1, sigma=1 (identity normalization).
                (crate::rl::isv_slots::RL_POPART_MEAN_INDEX,               0.0),
                (crate::rl::isv_slots::RL_POPART_VAR_INDEX,                1.0),
                (crate::rl::isv_slots::RL_POPART_SIGMA_INDEX,              1.0),
                (crate::rl::isv_slots::RL_POPART_SIGMA_OLD_INDEX,          1.0),
                (crate::rl::isv_slots::RL_POPART_MEAN_OLD_INDEX,           0.0),
                (crate::rl::isv_slots::RL_POPART_ALPHA_INDEX,              0.001),
                // P1.2 Spectral norm + decouple ISV bootstrap.
                (crate::rl::isv_slots::RL_SPECTRAL_NORM_MAX_INDEX,         3.0),
                (crate::rl::isv_slots::RL_SPECTRAL_DECOUPLE_LAMBDA_INDEX,  0.01),
                // P2.1 Q-bias correction ISV bootstrap.
                (crate::rl::isv_slots::RL_Q_BIAS_EMA_INDEX,               0.0),
                (crate::rl::isv_slots::RL_Q_BIAS_CORRECTION_INDEX,         0.0),
                (crate::rl::isv_slots::RL_Q_BIAS_ALPHA_INDEX,              0.01),
                // P2.2 Per-branch LR scale factors — start at unity.
                (crate::rl::isv_slots::RL_LR_SCALE_Q_INDEX,               1.0),
                (crate::rl::isv_slots::RL_LR_SCALE_PI_INDEX,              1.0),
                (crate::rl::isv_slots::RL_LR_SCALE_V_INDEX,               1.0),
                (crate::rl::isv_slots::RL_LR_SCALE_IQN_INDEX,             1.0),
                // P1.3 Outcome aux lambda.
                (crate::rl::isv_slots::RL_OUTCOME_AUX_LAMBDA_INDEX,        0.1),
                // SAC entropy regularization for π distillation.
                // α = 0.01 initial; auto-tuned by rl_q_pi_distill_grad to
                // maintain target entropy. H_target = 0.7 × ln(11) ≈ 1.68.
                (crate::rl::isv_slots::RL_SAC_ALPHA_INDEX,                  0.01),
                (crate::rl::isv_slots::RL_SAC_ENTROPY_TARGET_INDEX,         1.68),
                (crate::rl::isv_slots::RL_CONF_GATE_MAX_HOLD_FRAC_INDEX,   0.85),
                // ─── Adaptive controller floors — Group B + clamp-bound expansion
                // (spec 2026-05-30-adaptive-controller-floor-design). Every value
                // preserves the prior hardcoded constant exactly so the runtime
                // behaviour at bootstrap matches pre-spec behaviour bit-for-bit;
                // the win is that all bounds are now ISV-resident (visible in
                // diag JSONL, re-tunable via re-seed instead of recompile) and
                // can be replaced by Welford-driven adaptive logic in follow-up
                // controllers without further code changes.
                //
                // V-blend (Phase 4.4) — 5 design constants previously #define'd
                // in rl_v_blend_alpha_controller.cu.
                (crate::rl::isv_slots::RL_V_BLEND_DEAD_SIGNAL_FLOOR_INDEX,      1e-4),
                (crate::rl::isv_slots::RL_V_BLEND_TARGET_TRACK_RATIO_INDEX,     0.10),
                (crate::rl::isv_slots::RL_V_BLEND_SCHULMAN_STEP_INDEX,          0.01),
                (crate::rl::isv_slots::RL_V_BLEND_EMA_ALPHA_INDEX,              0.01),
                (crate::rl::isv_slots::RL_V_BLEND_BOOTSTRAP_ALPHA_INDEX,        1.0),
                // PPO ratio clamp — adaptive MAX ceiling (was hardcoded 1000.0f).
                (crate::rl::isv_slots::RL_PPO_RATIO_CLAMP_MAX_ADAPTIVE_INDEX,   1000.0),
                // Reward clamp — design constants previously #define'd in
                // rl_reward_clamp_controller.cu. Defaults match the historic
                // V_BOUND_FLOOR=1.0, V_BOUND_EWMA_ALPHA=0.001, MIN_WIN=1.0,
                // MIN_RATIO=1.0, MAX_RATIO=3.0, MIN_MARGIN=1.0, MAX_MARGIN=5.0,
                // MARGIN_TOLERANCE=1.5, MARGIN_ADJUST_RATE=1.2,
                // CLIP_RATE_EMA_ALPHA=0.05.
                (crate::rl::isv_slots::RL_REWARD_CLAMP_V_BOUND_FLOOR_INDEX,       1.0),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_V_BOUND_EWMA_ALPHA_INDEX,  0.001),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_MIN_WIN_INDEX,             1.0),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_MIN_RATIO_INDEX,           1.0),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_MAX_RATIO_INDEX,           3.0),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_MIN_MARGIN_INDEX,          1.0),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_MAX_MARGIN_INDEX,          5.0),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_MARGIN_TOLERANCE_INDEX,    1.5),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_MARGIN_ADJUST_RATE_INDEX,  1.2),
                (crate::rl::isv_slots::RL_REWARD_CLAMP_CLIP_RATE_EMA_ALPHA_INDEX, 0.05),
                // Gate threshold EMA α — was hardcoded 0.01f in
                // rl_gate_threshold_controller.cu.
                (crate::rl::isv_slots::RL_GATE_EMA_ALPHA_INDEX,                   0.01),
                // Clamp bounds — Batch 2 of the 2026-05-30 expansion.
                // Each value mirrors the prior #define so behaviour is preserved.
                (crate::rl::isv_slots::RL_PPO_CLIP_EPS_MIN_INDEX,                 0.05),
                (crate::rl::isv_slots::RL_PPO_CLIP_EPS_MAX_INDEX,                 0.5),
                (crate::rl::isv_slots::RL_TARGET_TAU_MIN_INDEX,                   0.001),
                (crate::rl::isv_slots::RL_ROLLOUT_MIN_INDEX,                      256.0),
                (crate::rl::isv_slots::RL_ROLLOUT_MAX_INDEX,                      8192.0),
                (crate::rl::isv_slots::RL_ENTROPY_COEF_MIN_INDEX,                 0.0),
                (crate::rl::isv_slots::RL_ENTROPY_COEF_MAX_INDEX,                 0.5),
                (crate::rl::isv_slots::RL_PER_ALPHA_MIN_INDEX,                    0.3),
                (crate::rl::isv_slots::RL_PER_ALPHA_MAX_INDEX,                    1.0),
                (crate::rl::isv_slots::RL_GAMMA_MAX_INDEX,                        0.999),
                (crate::rl::isv_slots::RL_Q_DISTILL_LAMBDA_MAX_INDEX,             1.0),
                (crate::rl::isv_slots::RL_Q_DISTILL_KL_TOLERANCE_INDEX,           3.0),
                (crate::rl::isv_slots::RL_Q_DISTILL_LAMBDA_RAMP_RATE_INDEX,       1.2),
                (crate::rl::isv_slots::RL_Q_DISTILL_LAMBDA_DECAY_RATE_INDEX,      0.998),
                // Shared Wiener-α floor — read by 9 controllers (slot 659).
                (crate::rl::isv_slots::RL_WIENER_ALPHA_FLOOR_INDEX,               0.4),
                // ─── Adaptive risk management — 22 new slots (spec 2026-05-30-adaptive-risk-management) ───
                // Layer 1 (CMDP hard constraints): session DD + cooldown + caps.
                (crate::rl::isv_slots::RL_SESSION_PNL_USD_INDEX,            0.0),       // session boundary reset
                (crate::rl::isv_slots::RL_SESSION_DD_LIMIT_USD_INDEX,    -3500.0),       // 10% of $35k starting capital
                (crate::rl::isv_slots::RL_SESSION_DD_TRIGGERED_INDEX,        0.0),
                (crate::rl::isv_slots::RL_MAX_OPEN_UNITS_INDEX,              4.0),       // = current MAX_UNITS
                (crate::rl::isv_slots::RL_CONSEC_LOSS_LIMIT_INDEX,          10.0),
                (crate::rl::isv_slots::RL_CONSEC_LOSS_COUNT_INDEX,           0.0),
                (crate::rl::isv_slots::RL_COOLDOWN_REMAINING_STEPS_INDEX,    0.0),
                (crate::rl::isv_slots::RL_COOLDOWN_DURATION_INDEX,         500.0),
                (crate::rl::isv_slots::RL_NET_INVENTORY_LIMIT_USD_INDEX, 10000.0),
                // Layer 2 (IQN risk-averse τ): median bootstrap.
                (crate::rl::isv_slots::RL_IQN_ACTION_TAU_INDEX,              0.5),       // median ≈ current behavior
                (crate::rl::isv_slots::RL_IQN_ACTION_TAU_MIN_INDEX,          0.1),
                (crate::rl::isv_slots::RL_IQN_ACTION_TAU_DD_SENSITIVITY_INDEX, 5.0),     // hits MIN at 10% DD
                // Layer 3 (inventory penalty): sentinel-zero until variance EMA accumulates.
                (crate::rl::isv_slots::RL_INVENTORY_PENALTY_BETA_INDEX,      0.0),       // sentinel
                (crate::rl::isv_slots::RL_INVENTORY_VARIANCE_EMA_INDEX,      0.0),       // sentinel
                // Layer 4 (Kelly fraction sizing): full size pre-warmup, half-Kelly post.
                (crate::rl::isv_slots::RL_KELLY_FRACTION_INDEX,              1.0),       // full size pre-warmup
                (crate::rl::isv_slots::RL_WIN_RATE_EMA_INDEX,                0.5),       // neutral sentinel
                (crate::rl::isv_slots::RL_AVG_WIN_USD_EMA_INDEX,             0.0),       // sentinel
                (crate::rl::isv_slots::RL_AVG_LOSS_USD_EMA_INDEX,            0.0),       // sentinel
                (crate::rl::isv_slots::RL_KELLY_SAFETY_FRAC_INDEX,           0.5),       // half-Kelly (Thorp)
                (crate::rl::isv_slots::RL_KELLY_MIN_TRADES_FOR_RELEASE_INDEX, 1000.0),
                // Layer D (TrailTighten/Loosen factors).
                (crate::rl::isv_slots::RL_TRAIL_TIGHTEN_FACTOR_INDEX,        0.9),
                (crate::rl::isv_slots::RL_TRAIL_LOOSEN_FACTOR_INDEX,         1.1),
                // v9 (defensive eval-boundary calibration, [[pearl_adaptive_carryover_discipline]]):
                // 500-step warmup with 200-step linear decay phase. During warmup,
                // risk-sizing floors are temporarily overridden with defensive
                // values; reset_session_state arms the counter at each boundary.
                (crate::rl::isv_slots::RL_REGIME_TRANSITION_REMAINING_INDEX,       -1.0),     // sentinel: no active warmup at boot
                (crate::rl::isv_slots::RL_REGIME_TRANSITION_STEPS_CONFIG_INDEX,   500.0),     // total warmup duration
                (crate::rl::isv_slots::RL_REGIME_TRANSITION_DECAY_STEPS_CONFIG_INDEX, 200.0), // linear decay window
                // Defensive overrides (active for ~first 300 steps post-boundary).
                (crate::rl::isv_slots::RL_EVAL_KELLY_SAFETY_DEFENSIVE_INDEX,     0.25), // quarter-Kelly
                (crate::rl::isv_slots::RL_EVAL_IQN_TAU_MIN_DEFENSIVE_INDEX,      0.30), // raise pessimism floor
                (crate::rl::isv_slots::RL_EVAL_ENTROPY_COEF_MIN_DEFENSIVE_INDEX, 0.05), // more exploration
                (crate::rl::isv_slots::RL_EVAL_PPO_CLIP_EPS_MIN_DEFENSIVE_INDEX, 0.10), // wider trust region
                // Normal target values (restored post-decay; mirror Phase-3/4 bootstrap floors).
                (crate::rl::isv_slots::RL_EVAL_KELLY_SAFETY_NORMAL_INDEX,        0.50), // half-Kelly (Thorp)
                (crate::rl::isv_slots::RL_EVAL_IQN_TAU_MIN_NORMAL_INDEX,         0.10), // existing IQN floor
                (crate::rl::isv_slots::RL_EVAL_ENTROPY_COEF_MIN_NORMAL_INDEX,    0.01), // existing entropy floor
                (crate::rl::isv_slots::RL_EVAL_PPO_CLIP_EPS_MIN_NORMAL_INDEX,    0.05), // existing PPO ε floor
                // Regime observer (F1.6) — spec 2026-05-31-regime-observer-design.md v3.
                // Transient state defaults; configs are tunable via ISV at runtime.
                (crate::rl::isv_slots::RL_REGIME_DEAD_ZONE_FLAG_INDEX,           0.0_f32),
                (crate::rl::isv_slots::RL_REGIME_DEAD_ZONE_DURATION_INDEX,       0.0_f32),
                (crate::rl::isv_slots::RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX,   0.0_f32),
                (crate::rl::isv_slots::RL_REGIME_RECOVERY_FACTOR_INDEX,          1.0_f32),     // start at max recovery
                (crate::rl::isv_slots::RL_REGIME_SESSION_PNL_VARIANCE_EMA_INDEX, 0.0_f32),
                (crate::rl::isv_slots::RL_REGIME_TAIL_EVENT_RECENCY_INDEX,       1.0e6_f32),   // sentinel "no tails seen"
                (crate::rl::isv_slots::RL_REGIME_SESSION_PNL_VAR_M2_INDEX,       0.0_f32),     // Welford
                (crate::rl::isv_slots::RL_REGIME_SESSION_PNL_VAR_MEAN_INDEX,     0.0_f32),     // Welford
                (crate::rl::isv_slots::RL_REGIME_SESSION_PNL_VAR_COUNT_INDEX,    0.0_f32),     // Welford
                (crate::rl::isv_slots::RL_REGIME_PREV_WORST_PNL_INDEX,           0.0_f32),     // sentinel
                // Kelly resurrection config
                (crate::rl::isv_slots::RL_KELLY_EPS_RECOVERY_LIVE_INDEX,         0.50_f32),    // = ε_max (no override needed)
                (crate::rl::isv_slots::RL_KELLY_EPS_RECOVERY_MIN_INDEX,          0.05_f32),
                (crate::rl::isv_slots::RL_KELLY_EPS_RECOVERY_MAX_INDEX,          0.50_f32),
                (crate::rl::isv_slots::RL_KELLY_EPS_RECOVERY_N_RECOVERY_INDEX,   100.0_f32),
                // Safety net
                (crate::rl::isv_slots::RL_REGIME_DEAD_ZONE_MAX_DURATION_INDEX,   1000.0_f32),
                // IQN τ tail-boost config
                (crate::rl::isv_slots::RL_IQN_TAU_TAIL_BOOST_FACTOR_INDEX,       1.5_f32),
                (crate::rl::isv_slots::RL_IQN_TAU_TAIL_BOOST_N_WINDOW_INDEX,     100.0_f32),
                // regime_observer config
                (crate::rl::isv_slots::RL_REGIME_TAIL_SIGMA_THRESHOLD_INDEX,     3.0_f32),
                // Popart envelope (F4 — bootstrap only; kernel mod lands later)
                (crate::rl::isv_slots::RL_POPART_MAX_ABS_REWARD_EMA_INDEX,       0.0_f32),     // sentinel
                (crate::rl::isv_slots::RL_POPART_MAX_DECAY_ALPHA_INDEX,          0.01_f32),    // ~69-step half-life
                // Reward-scale controller "warmed" flag (2026-05-31 addendum).
                // 0 = cold start (boot_floor=0.10 active); 1 = warmed up (boot_floor=scale_min).
                // Set once by the fused reward-scale block when cumulative_dones first
                // crosses min_trades; persists thereafter (never reset across boundaries).
                (crate::rl::isv_slots::RL_REWARD_SCALE_CONTROLLER_WARMED_FLAG_INDEX, 0.0_f32),
                // pos_max_ema cold-start redesign (2026-05-31 B-2 spec).
                // Replaces "bootstrap to first observation" anti-pattern. Conservative
                // initial value (= MIN_WIN = 1.0) → clamp_win starts at MARGIN × 1.0 = 1.5
                // → rewards heavily clipped until EMA adapts to true magnitude over
                // ~30 steps via geometric 1.5×/step growth.
                (crate::rl::isv_slots::RL_POS_MAX_EMA_COLD_START_INDEX,           1.0_f32),
                (crate::rl::isv_slots::RL_POS_MAX_EMA_GROWTH_CAP_BASE_INDEX,      1.225_f32),
                (crate::rl::isv_slots::RL_POS_MAX_EMA_GROWTH_CAP_CV_GAIN_INDEX,   0.0_f32),
                // Bootstrap the EMA slots themselves to the cold-start value.
                // Required because removing the controller's `if (ema_prev == 0.0f)`
                // branch (B-2 spec §3.3) would otherwise leave ema stuck at 0:
                // Wiener step (1-α)×0 + α×pos = α×pos; rate-cap max_grow = 0×1.225 = 0;
                // → ema_new = min(α×pos, 0) = 0 forever.
                (crate::rl::isv_slots::RL_POS_SCALED_REWARD_MAX_EMA_INDEX,        1.0_f32),
                (crate::rl::isv_slots::RL_NEG_SCALED_REWARD_MAX_EMA_INDEX,        1.0_f32),
                // B-3: Kelly fractional-trust schedule + avg_win/loss cold-start
                // bootstrap (math-derived from Hoeffding concentration — see
                // 2026-05-31-pos-max-ema-cold-start-redesign.md §B-3 follow-up).
                // f_floor=0.05 prevents trade-death; cold-start avg_win=avg_loss=1.0
                // gives b̂=1, p̂=0.5 → f_kelly=0 → trust schedule provides the position
                // size during warmup.
                (crate::rl::isv_slots::RL_KELLY_BOOTSTRAP_FLOOR_INDEX,            0.05_f32),
                (crate::rl::isv_slots::RL_AVG_WIN_USD_EMA_INDEX,                  1.0_f32),
                (crate::rl::isv_slots::RL_AVG_LOSS_USD_EMA_INDEX,                 1.0_f32),
                // B-6 ISV-driven adaptive asymmetric Wiener-α (Bayesian shrinkage).
                // α_slow_min = floor admission rate (full skepticism at cold-start).
                // n_full_threshold = cumulative dones for full data trust (asymmetry
                // fades fully). Reset_session_state zeroes cum_dones → asymmetry
                // resumes at every fold boundary. Phase 2: cv_gain modulates trust
                // by signal volatility (Welford CV); 0 disables Phase 2.
                (crate::rl::isv_slots::RL_EMA_ALPHA_SLOW_MIN_INDEX,               0.001_f32),
                (crate::rl::isv_slots::RL_EMA_TRUST_FULL_THRESHOLD_INDEX,         30000.0_f32),
                (crate::rl::isv_slots::RL_EMA_CV_GAIN_INDEX,                      1.0_f32),
                // B-7 reward-clamp toggle. 0 = DISABLED (popart handles tails).
                // This is the *experimental* default — set to 1.0 to restore legacy.
                (crate::rl::isv_slots::RL_REWARD_CLAMP_ENABLED_INDEX,             0.0_f32),
                // B-8 (2026-06-01): popart σ_welford disaggregation sentinel.
                // `rl_popart_normalize` overwrites this every step (line 141.5
                // post-insertion). At step 0 — IF popart hasn't yet fired —
                // diag reads 0.0 for one row; the identity invariant test
                // starts validation at row 1 to skip the bootstrap row.
                (crate::rl::isv_slots::RL_POPART_SIGMA_WELFORD_INDEX,             0.0_f32),
                // B-9 (2026-06-01): C51 Bellman-target saturation observability.
                // Sentinels overwritten every step by the dedicated reducer
                // kernel `rl_bellman_target_saturation_reduce` after whichever
                // bellman variant ran. Pure observability — see
                // `pearl_c51_v_max_freeze_required_for_surfer.md` for why
                // adaptation needs cluster evidence, not a precautionary fix.
                (crate::rl::isv_slots::RL_Q_TARGET_TOP_SATURATION_RATE_INDEX,     0.0_f32),
                (crate::rl::isv_slots::RL_Q_TARGET_BOT_SATURATION_RATE_INDEX,     0.0_f32),
                (crate::rl::isv_slots::RL_Q_TARGET_MAX_PRE_PROJ_INDEX,            0.0_f32),
                (crate::rl::isv_slots::RL_Q_TARGET_MIN_PRE_PROJ_INDEX,            0.0_f32),
                // B-10 (2026-06-01): policy-quality cascade diagnostic (13 slots).
                // Sentinels overwritten every step by the dedicated reducer kernels
                // `rl_q_distribution_stats` (G1) + `rl_q_pi_distill_grad` (G2 inline
                // emit) + `rl_ppo_diagnostic_stats_reduce` (G3+G4). Pure observability
                // — the falsification criteria in the B-10 spec §2.G5 select which
                // B-11 path (α Q-collapse, β Q-distill, γ heavy-tailed advantage)
                // future cluster evidence motivates.
                (crate::rl::isv_slots::RL_Q_DIST_ENTROPY_MEAN_INDEX,              0.0_f32),
                (crate::rl::isv_slots::RL_Q_VALUE_RANGE_MEAN_INDEX,               0.0_f32),
                (crate::rl::isv_slots::RL_Q_VALUE_ABS_MAX_INDEX,                  0.0_f32),
                (crate::rl::isv_slots::RL_Q_DISTILL_TARGET_ENTROPY_MEAN_INDEX,    0.0_f32),
                (crate::rl::isv_slots::RL_Q_DISTILL_PI_ENTROPY_DIFF_INDEX,        0.0_f32),
                (crate::rl::isv_slots::RL_PPO_A_NORM_ABS_MAX_INDEX,               0.0_f32),
                (crate::rl::isv_slots::RL_PPO_A_NORM_ABS_MEAN_INDEX,              0.0_f32),
                (crate::rl::isv_slots::RL_PPO_A_UNNORM_ABS_MAX_INDEX,             0.0_f32),
                (crate::rl::isv_slots::RL_PPO_A_UNNORM_ABS_MEAN_INDEX,            0.0_f32),
                (crate::rl::isv_slots::RL_PPO_A_SIGMA_USED_INDEX,                 0.0_f32),
                (crate::rl::isv_slots::RL_PPO_RATIO_DEV_ABS_MEAN_INDEX,           0.0_f32),
                (crate::rl::isv_slots::RL_PPO_RATIO_CLIP_RATE_INDEX,              0.0_f32),
                (crate::rl::isv_slots::RL_PPO_L_SURROGATE_INDEX,                  0.0_f32),
                // CMDP fleet-fraction diagnostic (2026-06-01). Pure observability;
                // bootstrap=0.0 sentinel, kernel overwrites every step from the
                // existing rl_cmdp_constraints_check per-batch loop.
                (crate::rl::isv_slots::RL_CMDP_FRAC_IN_COOLDOWN_INDEX,            0.0_f32),
                (crate::rl::isv_slots::RL_CMDP_FRAC_CONSEC_NEAR_LIMIT_INDEX,      0.0_f32),
                (crate::rl::isv_slots::RL_CMDP_FRAC_DD_TRIGGERED_INDEX,           0.0_f32),
                (crate::rl::isv_slots::RL_CMDP_FRAC_SESSION_NEG_INDEX,            0.0_f32),
                // Edge-decay detector Phase 1 (2026-06-01). δ, λ, warmup_min
                // in σ_welford-relative unit-less terms (kernel divides
                // rewards[b] by isv[RL_POPART_SIGMA_WELFORD_INDEX = 725]).
                // δ=0.1 ignores drifts smaller than 0.1σ; λ=5.0 alerts at
                // cumulative deviation 5σ above lowest recent point; warmup_min=10
                // ensures Welford μ̄ has converged before PH cumulative starts.
                // These are calibration constants; Phase 2 may make them ISV-driven.
                (crate::rl::isv_slots::RL_EDGE_PH_TOLERANCE_INDEX,                0.1_f32),
                (crate::rl::isv_slots::RL_EDGE_PH_THRESHOLD_INDEX,                5.0_f32),
                (crate::rl::isv_slots::RL_EDGE_PH_WARMUP_MIN_INDEX,              10.0_f32),
                // Aggregates: 0.0 sentinel; kernel overwrites every step.
                (crate::rl::isv_slots::RL_EDGE_PH_MEAN_INDEX,                     0.0_f32),
                (crate::rl::isv_slots::RL_EDGE_PH_FRAC_ALERTED_INDEX,             0.0_f32),
                (crate::rl::isv_slots::RL_EDGE_PH_FRAC_WARMUP_INDEX,              0.0_f32),
                // Adaptive surfer-scaffold shaping (spec v5 2026-06-01).
                // w = 1.0: full Phase 5 shaping (surfer-baseline path, like dd049d9a4)
                // w = 0.0: pure pnl reward (no shaping)
                // 0<w<1:   smooth interpolation
                // Bootstrap = 1.0 (random init needs the inductive bias scaffold);
                // controller `rl_surfer_scaffold_controller` fades w as the agent
                // crosses break-even with statistical confidence, and re-engages
                // it if edge-decay PH flags policy degradation.
                (crate::rl::isv_slots::RL_SURFER_SCAFFOLD_WEIGHT_INDEX,           0.0_f32), // pure-pnl mode (w=0) — Phase 3B test
                // Controller config:
                (crate::rl::isv_slots::RL_SURFER_BREAK_EVEN_WR_INDEX,             0.30_f32),
                (crate::rl::isv_slots::RL_SURFER_K_SHARPNESS_INDEX,               30.0_f32),
                (crate::rl::isv_slots::RL_SURFER_WARMUP_TRADES_INDEX,             200.0_f32),
                // Phase 3B-Y (2026-06-03): PopArt normalization OFF by default.
                // `rl_popart_normalize.cu` skips the final whitening loop when
                // slot 793 ≤ 0.5; running mean/var/sigma stats still update so
                // the gate can be re-enabled mid-session without restart.
                // Targets the 40-50% of `Pearson(reward, Δpnl)` gap attributed
                // to popart sign-flips by the Phase 3B-followup audit.
                (crate::rl::isv_slots::RL_POPART_NORMALIZE_ENABLED_INDEX,         0.0_f32),
                // Phase 3D-B (2026-06-03): quadratic-in-trade-size impact-aware
                // cost (Cao et al. 2026 arXiv:2603.29086). Penalises overtrading
                // even when reward is pnl-aligned. Cost = α·|Δlots| + β·(Δlots)²
                // applied per env step inside `rl_fused_reward_pipeline.cu`
                // Phase 1.5. Bootstraps target ~$2.5 (1-lot) → ~$132 (8-lot flip)
                // reduction in reward units (pts × lots).
                (crate::rl::isv_slots::RL_QUADRATIC_COST_ALPHA_INDEX,             0.5_f32),
                (crate::rl::isv_slots::RL_QUADRATIC_COST_BETA_INDEX,              2.0_f32),
                (crate::rl::isv_slots::RL_QUADRATIC_COST_ENABLED_INDEX,           1.0_f32),
                // Phase 3D-C (2026-06-03): PPO surrogate gradient restoration.
                // Currently π is trained ONLY by Q-distillation (see header at
                // line 5850). The four-stage Q→π attenuation chain (Goodhart-
                // Skalse 2024) blinds π to small persistent fees. Restoring
                // the PPO clipped-surrogate gradient at weight 0.005 gives π
                // a direct policy-gradient channel for the quadratic-cost
                // signal, while Q-distill remains the dominant term.
                (crate::rl::isv_slots::RL_PPO_SURROGATE_WEIGHT_INDEX,             0.005_f32),
                (crate::rl::isv_slots::RL_PPO_SURROGATE_ENABLED_INDEX,            1.0_f32),
                // Phase 4-A (2026-06-03) No-transaction-band foundation
                // bootstraps. Master gate at slot 799 starts at 0.0 (OFF)
                // by default so the foundation preserves Phase 3D
                // `bd811a774` bit-equality until the operator flips the
                // gate. The `FOXHUNT_BAND_ENABLED=1` env var overrides the
                // bootstrap to 1.0 for local A/B smokes (one-shot — no
                // trainer-construction-time branch like
                // `FOXHUNT_USE_MULTI_HEAD_POLICY`, just bootstrap-value
                // adjustment).
                // Spec: docs/superpowers/specs/2026-06-03-no-transaction-band-architecture.md §4.
                (
                    crate::rl::isv_slots::RL_BAND_ENABLED_INDEX,
                    if std::env::var("FOXHUNT_BAND_ENABLED")
                        .ok()
                        .and_then(|v| v.parse::<u8>().ok())
                        .map(|n| n > 0)
                        .unwrap_or(false)
                    {
                        1.0_f32
                    } else {
                        0.0_f32
                    },
                ),
                (crate::rl::isv_slots::RL_BAND_LOWER_INIT_INDEX,                 -0.5_f32),
                (crate::rl::isv_slots::RL_BAND_UPPER_INIT_INDEX,                  0.5_f32),
                (crate::rl::isv_slots::RL_BAND_LOSS_WEIGHT_INDEX,                 0.01_f32),
                (crate::rl::isv_slots::RL_BAND_TURNOVER_TARGET_INDEX,             0.05_f32),
                (crate::rl::isv_slots::RL_BAND_GRAD_SHARPNESS_INDEX,              4.0_f32),
                (crate::rl::isv_slots::RL_BAND_FLAT_RECENTER_RATE_INDEX,          0.001_f32),
                (crate::rl::isv_slots::RL_BAND_WIDTH_MIN_INDEX,                   0.1_f32),
                (crate::rl::isv_slots::RL_BAND_WIDTH_MAX_INDEX,                   1.8_f32),
                (crate::rl::isv_slots::RL_BAND_DIAG_LAUNCH_EVERY_INDEX,           1.0_f32),
                // Phase 4-B (2026-06-04) adaptive turnover controller seeds.
                // EMA + bootstrap-done sentinel at 0.0 (the controller
                // bootstraps on first call). Adaptive target initialised to
                // 0.05 — matches the Phase 4-A static target so the first
                // step's turnover loss is identical to 4-A. The frac slot
                // is sentinel-zeroed; the GPU reducer overwrites every step
                // the band is enabled.
                (crate::rl::isv_slots::RL_BAND_TURNOVER_EMA_INDEX,                0.0_f32),
                (crate::rl::isv_slots::RL_BAND_CONTROLLER_BOOT_DONE_INDEX,        0.0_f32),
                (crate::rl::isv_slots::RL_BAND_TURNOVER_TARGET_ADAPTIVE_INDEX,    0.05_f32),
                (crate::rl::isv_slots::RL_BAND_FRAC_NOT_MASKED_OBSERVED_INDEX,    0.0_f32),
                // Phase 4-A2 (2026-06-04) band mask exploration bypass.
                // 0.85 = 15% of batches always escape masking → positions
                // vary → sigmoid surrogate gradient flows → band can learn.
                // Closes the dead-signal trap from Phase 4-B smoke at
                // 552d91bf4. Spec §2.2 + §9.1 Mitigation 2.
                (crate::rl::isv_slots::RL_BAND_MAX_MASK_FRAC_INDEX,               0.85_f32),
                // Phase 5 (2026-06-04) trail-max ceiling + MTM reward.
                // Three coupled fixes for the bimodal Hold+TrailLoosen
                // pathology surfaced by the Phase 4-A3 ultrathink
                // investigation. ALL three slots are gated default-ON —
                // the trail ceiling is bounded behavior (4× initial_r,
                // higher than the smoke fleet's typical trail), MTM has
                // its own gate (slot 815) for A/B isolation if needed.
                (crate::rl::isv_slots::RL_TRAIL_MAX_INITIAL_R_RATIO_INDEX,        4.0_f32),
                (crate::rl::isv_slots::RL_MTM_REWARD_ENABLED_INDEX,               1.0_f32),
                (crate::rl::isv_slots::RL_MTM_REWARD_WEIGHT_INDEX,                1.0_f32),
                // Phase 7a F2 (2026-06-04) — Q-centered distill target.
                // ENABLED bootstraps to 1.0 (ON) to deploy the hinge in
                // every training run by default; the master gate is the
                // ISV slot so flipping to 0.0 reproduces the legacy
                // `π_target = softmax(E_Q / τ)` path bit-equal.
                // NOOP_SET_MODE bootstraps to 0.0 (mode 0 = F2a static
                // set Hold/TrailTighten/TrailLoosen).
                (crate::rl::isv_slots::RL_F2_DISTILL_CENTERED_ENABLED_INDEX, 1.0),
                (crate::rl::isv_slots::RL_F2_DISTILL_NOOP_SET_MODE_INDEX,    0.0),
                // Phase 7a F2-diag (2026-06-04) — kernel-side mechanism
                // counters. Sentinel for target_argmax = -1.0 ("no
                // batch reduced yet"); the kernel overwrites at step 1.
                (crate::rl::isv_slots::RL_F2_DIAG_BASELINE_MEAN_INDEX,    0.0),
                (crate::rl::isv_slots::RL_F2_DIAG_ADV_MAX_MEAN_INDEX,     0.0),
                (crate::rl::isv_slots::RL_F2_DIAG_HINGE_ZERO_RATE_INDEX,  0.0),
                (crate::rl::isv_slots::RL_F2_DIAG_TARGET_ARGMAX_INDEX,   -1.0),
                // Phase 7b F5 (2026-06-05) — state-conditional action mask
                // master gate. Bootstrap 0.0 (OFF) preserves Phase 7a
                // bit-equality. Operator flips to 1.0 to engage the mask;
                // verify mechanism gates F5-G1..G6 at Tier 1.5 before any
                // cluster submit per spec §5.2.
                //
                // KNOWN INTERACTION (2026-06-05 smoke verification): when
                // engaged, the downstream gates rl_confidence_gate,
                // rl_frd_gate, rl_session_risk_check and rl_min_hold_check
                // can override the F5-cleared action back to Hold from
                // flat states. F5-G1 (`hold_frac_flat == 0`) cannot be
                // achieved without a follow-up wiring task that either
                // (a) suppresses those gates' Hold override when
                // `isv[RL_F5_STATE_MASK_ENABLED_INDEX] > 0.5 AND
                // position_lots == 0`, or (b) re-runs the F5 mask after
                // the gate stack. Spec §3.5 expected-failure-mode #5
                // (confidence-gate interaction) covers this; orthogonal
                // to F5 kernel correctness.
                (crate::rl::isv_slots::RL_F5_STATE_MASK_ENABLED_INDEX,    1.0),
                // Phase A reward-pnl alignment (2026-06-05) — surfer-scaffold
                // force-pin. Env-gated per the FOXHUNT_USE_ROLLOUT precedent
                // (commit 779c03b9d): unset/0 → 0.0 (controller writes slot
                // 753 as before, behaviour unchanged); =1 → 1.0 (controller
                // skips its write, slot 753 holds its 0.0 pure-pnl bootstrap).
                (crate::rl::isv_slots::RL_SURFER_SCAFFOLD_FORCE_PIN_INDEX, {
                    let pin: bool = std::env::var("FOXHUNT_PIN_SURFER_SCAFFOLD")
                        .ok()
                        .and_then(|v| v.parse::<u8>().ok())
                        .map(|n| n > 0)
                        .unwrap_or(false);
                    if pin { 1.0_f32 } else { 0.0_f32 }
                }),
            ];
            for (slot, value) in isv_constants.iter() {
                let slot_i32 = *slot as i32;
                let value_f32 = *value;
                let mut args = RawArgs::new();
                args.push_ptr(self.isv_dev_ptr);
                args.push_i32(slot_i32);
                args.push_f32(value_f32);
                let mut ptrs = args.build_arg_ptrs();
                unsafe {
                    raw_launch(
                        self.rl_isv_write_fn.cu_function(),
                        (1, 1, 1), (1, 1, 1), 0,
                        self.raw_stream,
                        &mut ptrs[..args.len()],
                    ).map_err(|e| anyhow::anyhow!("rl_isv_write (design constant): {:?}", e))?;
                }
            }

            // ── Phase 2A-C (2026-06-03) MultiHeadPolicy ISV bootstrap ────
            //
            // Slots 761-764 (allocated in Phase 2A-A). Bootstrap is gated
            // on `FOXHUNT_USE_MULTI_HEAD_POLICY` for bit-equality with
            // commit `e22da61cf`: writing these slots when the flag is off
            // would diverge `isv_state` checksum (the slots are 0.0
            // sentinel at HEAD; writing 3.0 / 0.549 / 0.959 / 0.05 shifts
            // the checksum), failing verification gate C.7.4.
            //
            // When flag is on, this writes match the dispatch's required
            // bootstrap values per spec ADDENDUM 2026-06-03 §R.1:
            //   * K=3:                 spec §1.1 (production default)
            //   * gating_entropy_floor = log(K)·0.5 ≈ 0.549
            //   * head_entropy_floor   = log(N_ACTIONS)·0.4 ≈ 0.959
            //   * aux_prior_beta       = 0.05 (5% of policy-loss magnitude)
            if self.use_multi_head_policy {
                // Phase 2A-D fix B1 (2026-06-03): slot 790 gate-LR multiplier
                // bootstrap 5.0 — addresses gate-not-learning observed in
                // 3-seed mid-smoke at HEAD 5f10bcde3 (gate_entropy stayed
                // pinned at MAX, argmax_mass = 1.0). See slot doc-comment.
                //
                // Phase 2A-D fix B1.3 (2026-06-03): slot 791 (entropy EMA)
                // and slot 792 (bootstrap-done flag) bootstrap to 0.0 — the
                // controller's first-observation pattern flips the flag to
                // 1.0 on its first launch and replaces the EMA with the
                // current `gate_entropy_mean` measurement, so the 0.0
                // sentinel is never blended into the EMA. Per
                // `pearl_bootstrap_must_respect_clamp_range`, slot 790's
                // bootstrap 5.0 lies strictly inside the controller's
                // [1.0, 50.0] clamp range.
                let mhp_isv_constants: [(usize, f32); 7] = [
                    (crate::rl::isv_slots::RL_POLICY_NUM_HEADS_INDEX,                            3.0_f32),
                    (crate::rl::isv_slots::RL_POLICY_GATING_ENTROPY_FLOOR_INDEX,                 0.549_f32),
                    (crate::rl::isv_slots::RL_POLICY_HEAD_ENTROPY_FLOOR_INDEX,                   0.959_f32),
                    (crate::rl::isv_slots::RL_POLICY_AUX_PRIOR_BETA_INDEX,                       0.05_f32),
                    (crate::rl::isv_slots::RL_POLICY_GATE_LR_MULTIPLIER_INDEX,                   5.0_f32),
                    (crate::rl::isv_slots::RL_POLICY_GATE_ENTROPY_EMA_INDEX,                     0.0_f32),
                    (crate::rl::isv_slots::RL_POLICY_GATE_CONTROLLER_BOOTSTRAP_DONE_INDEX,       0.0_f32),
                ];
                for (slot, value) in mhp_isv_constants.iter() {
                    let slot_i32 = *slot as i32;
                    let value_f32 = *value;
                    let mut args = RawArgs::new();
                    args.push_ptr(self.isv_dev_ptr);
                    args.push_i32(slot_i32);
                    args.push_f32(value_f32);
                    let mut ptrs = args.build_arg_ptrs();
                    unsafe {
                        raw_launch(
                            self.rl_isv_write_fn.cu_function(),
                            (1, 1, 1), (1, 1, 1), 0,
                            self.raw_stream,
                            &mut ptrs[..args.len()],
                        ).map_err(|e| anyhow::anyhow!("rl_isv_write (multi-head bootstrap): {:?}", e))?;
                    }
                }
            }
        }

        let alpha = RL_LR_CONTROLLER_ALPHA;
        self.launch_isv_controller_3arg(
            &self.rl_gamma_controller_fn,
            alpha,
            crate::rl::isv_slots::RL_MEAN_TRADE_DURATION_EMA_INDEX as i32,
        )
        .context("R1 bootstrap rl_gamma_controller")?;
        self.launch_isv_controller_3arg(
            &self.rl_target_tau_controller_fn,
            alpha,
            crate::rl::isv_slots::RL_Q_DIVERGENCE_EMA_INDEX as i32,
        )
        .context("R1 bootstrap rl_target_tau_controller")?;
        self.launch_isv_controller_3arg(
            &self.rl_ppo_clip_controller_fn,
            alpha,
            crate::rl::isv_slots::RL_KL_PI_EMA_INDEX as i32,
        )
        .context("R1 bootstrap rl_ppo_clip_controller")?;
        self.launch_isv_controller_3arg(
            &self.rl_entropy_coef_controller_fn,
            alpha,
            crate::rl::isv_slots::RL_ENTROPY_OBSERVED_EMA_INDEX as i32,
        )
        .context("R1 bootstrap rl_entropy_coef_controller")?;
        // Adaptive controller floors (spec 2026-05-30): rollout_steps now
        // consumes pre-normalization advantage variance (RL_ADV_VAR_PRE_NORM_INDEX).
        // Post-norm advantage variance is definitionally ≈ 0 under Phase 4.5
        // (per-batch normalize), which left this controller blind.
        self.launch_isv_controller_3arg(
            &self.rl_rollout_steps_controller_fn,
            alpha,
            crate::rl::isv_slots::RL_ADV_VAR_PRE_NORM_INDEX as i32,
        )
        .context("R1 bootstrap rl_rollout_steps_controller")?;
        self.launch_isv_controller_3arg(
            &self.rl_per_alpha_controller_fn,
            alpha,
            crate::rl::isv_slots::RL_TD_KURTOSIS_EMA_INDEX as i32,
        )
        .context("R1 bootstrap rl_per_alpha_controller")?;
        self.launch_isv_controller_3arg(
            &self.rl_reward_scale_controller_fn,
            alpha,
            crate::rl::isv_slots::RL_MEAN_ABS_PNL_EMA_INDEX as i32,
        )
        .context("R1 bootstrap rl_reward_scale_controller")?;
        // audit — PPO ratio clamp ceiling. At bootstrap RL_PPO_CLIP_INDEX
        // is still at sentinel zero; the kernel writes
        // PPO_RATIO_CLAMP_BOOTSTRAP=10.0 and returns before reading.
        self.launch_isv_controller_3arg(
            &self.rl_ppo_ratio_clamp_controller_fn,
            alpha,
            crate::rl::isv_slots::RL_PPO_CLIP_INDEX as i32,
        )
        .context("bootstrap rl_ppo_ratio_clamp_controller")?;
        // audit — seed streaming-kernel output clamp ceilings device-side
        // (no HtoD per feedback_no_htod_htoh_only_mapped_pinned).
        // Single-thread kernel writes design constants to ISV[447]
        // and ISV[448] which the streaming variance / kurtosis kernels
        // read each step to clamp their outputs.
        {
            let adv_var_slot: i32 =
                crate::rl::isv_slots::RL_ADV_VAR_RATIO_CLAMP_INDEX as i32;
            let adv_var_val: f32 = 100.0;
            let td_kurt_slot: i32 =
                crate::rl::isv_slots::RL_TD_KURTOSIS_CLAMP_INDEX as i32;
            let td_kurt_val: f32 = 30.0;
            // ISV-driven regression target for rl_rollout_steps_controller.
            // 5.0 matches the b_size=1 streaming regime where
            // var/|mean| naturally lives in [1, 10]. The prior
            // hardcoded #define 0.1 was a b_size>1 design choice and
            // caused 99.99% WIDEN events in alpha-rl-cvf86.
            let adv_var_target_slot: i32 =
                crate::rl::isv_slots::RL_ADV_VAR_RATIO_TARGET_INDEX as i32;
            let adv_var_target_val: f32 = 5.0;
            // K-loop config — also ISV-driven per
            // `feedback_isv_for_adaptive_bounds`. Divisor 2048 matches
            // ROLLOUT_BOOTSTRAP (so K=1 at controller bootstrap, before
            // any adaptation). K_MAX=4 prevents gradient overtraining
            // at b_size=1; raised from default 8 after alpha-rl-f2ggr
            // confirmed K=8 firing on 22% of steps over-trained
            // (KL excursions to 12.44, reward/trade -$0.585 → -$0.723).
            let k_loop_divisor_slot: i32 =
                crate::rl::isv_slots::RL_K_LOOP_DIVISOR_INDEX as i32;
            let k_loop_divisor_val: f32 = 2048.0;
            let k_loop_max_slot: i32 =
                crate::rl::isv_slots::RL_K_LOOP_MAX_INDEX as i32;
            let k_loop_max_val: f32 = 4.0;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(adv_var_slot);
            args.push_f32(adv_var_val);
            args.push_i32(td_kurt_slot);
            args.push_f32(td_kurt_val);
            args.push_i32(adv_var_target_slot);
            args.push_f32(adv_var_target_val);
            args.push_i32(k_loop_divisor_slot);
            args.push_f32(k_loop_divisor_val);
            args.push_i32(k_loop_max_slot);
            args.push_f32(k_loop_max_val);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_streaming_clamp_init_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_streaming_clamp_init: {:?}", e))?;
            }
        }
        unsafe {
            raw_stream_sync(self.raw_stream)
                .map_err(|e| anyhow::anyhow!("R1 controller bootstrap sync: {:?}", e))?;
        }
        Ok(self)
    }

    /// Phase R1 / R5: single-thread launch of any of the 7 RL adaptive
    /// controllers. All 7 share the `(isv*, alpha, input_slot)`
    /// signature after R5; the kernel reads the EMA input from
    /// `isv[input_slot]` directly, eliminating the 7 DtoH-per-step host
    /// roundtrips a scalar-arg signature would have required (per
    /// `feedback_cpu_is_read_only`).
    ///
    /// At bootstrap (R1), the controller's output slot is at sentinel
    /// zero; the kernel writes its `*_BOOTSTRAP` value and returns
    /// before the input read, so `input_slot` can point at the (also
    /// sentinel-zero) EMA-input slot safely. At per-step launches
    /// (R5+), the EMA producer (Phase R3 `ema_update_*` kernels) has
    /// populated `isv[input_slot]` with a real observation and the
    /// Wiener-α-with-floor blend per
    /// `pearl_wiener_alpha_floor_for_nonstationary` runs against it.
    fn launch_isv_controller_3arg(
        &self,
        func: &CudaFunction,
        alpha: f32,
        input_slot: i32,
    ) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_f32(alpha);
        args.push_i32(input_slot);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                func.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("isv controller 3-arg: {:?}", e))?;
        }
        Ok(())
    }

    /// Fire all 10 RL ISV controllers in a single fused kernel launch.
    ///
    /// Replaces 10 individual kernel launches (gamma, tau, ppo_clip,
    /// entropy_coef, rollout_steps, per_alpha, reward_scale,
    /// ppo_ratio_clamp, gate_threshold, q_distill_lambda) with one
    /// launch of `rl_fused_controllers`, saving ~40-80us/step of
    /// kernel-launch overhead.
    ///
    /// Slot pairing (output <- input):
    ///   ISV[400] gamma           <- ISV[417] MEAN_TRADE_DURATION_EMA
    ///   ISV[401] tau             <- ISV[418] Q_DIVERGENCE_EMA
    ///   ISV[402] eps             <- ISV[419] KL_PI_EMA
    ///   ISV[403] entropy_coef    <- ISV[420] ENTROPY_OBSERVED_EMA
    ///   ISV[404] n_rollout_steps <- ISV[421] ADVANTAGE_VAR_RATIO_EMA
    ///   ISV[405] per_alpha       <- ISV[422] TD_KURTOSIS_EMA
    ///   ISV[406] reward_scale    <- ISV[423] MEAN_ABS_PNL_EMA
    ///   ISV[440] ratio_clamp     <- ISV[402] (reads eps just written)
    ///   ISV[512,516,517] gates   <- dones[B]
    ///   ISV[486] lambda_distill  <- ISV[488] KL_EMA
    pub fn launch_rl_fused_controllers(
        &self,
        b_size: usize,
    ) -> Result<()> {
        let alpha = RL_LR_CONTROLLER_ALPHA;
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(self.dones_d.raw_ptr());
        args.push_f32(alpha);
        args.push_i32(b_size_i);
        args.push_ptr(self.fused_ctrl_input_slots_d.raw_ptr());
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_fused_controllers_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_fused_controllers: {:?}", e))?;
        }
        Ok(())
    }

    /// Launch a Welford-variance update for ONE controller-input EMA
    /// slot (spec `2026-05-30-adaptive-controller-floor-design`).
    ///
    /// Single-thread, single-block. The kernel reads `isv[input_slot]`,
    /// skips on sentinel zero (per `pearl_first_observation_bootstrap`),
    /// and updates the (count, mean, M²) Welford triple in place. Sample
    /// variance = `M² / (count - 1)` when `count > 1`; controllers consume
    /// it as `sqrt(M²/(count-1))` to scale their noise floors.
    ///
    /// Caller's responsibility: launch ONCE per controller-input EMA per
    /// step, sequenced AFTER the EMA producer that writes `input_slot`
    /// and BEFORE the controller that reads the Welford triple.
    pub fn launch_rl_signal_variance_update(
        &self,
        input_slot: usize,
        count_slot: usize,
        mean_slot: usize,
        m2_slot: usize,
    ) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_i32(input_slot as i32);
        args.push_i32(count_slot as i32);
        args.push_i32(mean_slot as i32);
        args.push_i32(m2_slot as i32);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_signal_variance_update_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_signal_variance_update: {:?}", e))?;
        }
        Ok(())
    }

    /// Adaptive risk management — Layer 1 (spec 2026-05-30-adaptive-risk-management).
    /// Maintains session pnl + DD-triggered flag + cooldown counter +
    /// consecutive-loss counter. Single-thread single-block; reads
    /// `rewards_d` and `dones_d` (`[b_size]` f32 device buffers populated
    /// by `rl_fused_reward_pipeline`). Outcome is derived inline (done &
    /// reward sign). Must run AFTER the reward pipeline and BEFORE
    /// `actions_to_market_targets` reads the override flags.
    pub fn launch_rl_cmdp_constraints_check(
        &self,
        rewards_d: &CudaSlice<f32>,
        dones_d: &CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(rewards_d.raw_ptr());
        args.push_ptr(dones_d.raw_ptr());
        args.push_ptr(self.session_pnl_per_batch_d.raw_ptr());
        args.push_ptr(self.consec_loss_per_batch_d.raw_ptr());
        args.push_ptr(self.session_dd_triggered_per_batch_d.raw_ptr());
        args.push_ptr(self.cooldown_remaining_per_batch_d.raw_ptr());
        // Edge-decay Phase 1: 5 PH per-batch buffers. Read+written ONLY by
        // this kernel; reset_session_state zero-memsets all 5 at fold/eval
        // boundaries per predictive-reset discipline.
        args.push_ptr(self.ph_mu_per_batch_d.raw_ptr());
        args.push_ptr(self.ph_count_per_batch_d.raw_ptr());
        args.push_ptr(self.ph_m_per_batch_d.raw_ptr());
        args.push_ptr(self.ph_mmin_per_batch_d.raw_ptr());
        args.push_ptr(self.ph_stat_per_batch_d.raw_ptr());
        args.push_i32(b_size as i32);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_cmdp_constraints_check_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_cmdp_constraints_check: {:?}", e))?;
        }
        Ok(())
    }

    /// Adaptive risk management — Layer 2 IQN τ controller (spec 2026-05-30).
    /// Standalone launcher. Production calls go through `rl_fused_controllers`;
    /// this helper is retained for unit-test isolation.
    pub fn launch_rl_iqn_action_tau_controller(&self) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_iqn_action_tau_controller_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_iqn_action_tau_controller: {:?}", e))?;
        }
        Ok(())
    }

    /// Adaptive risk management — Layer 3 inventory β controller (spec 2026-05-30).
    /// Standalone launcher; production calls go through `rl_fused_controllers`.
    pub fn launch_rl_inventory_beta_controller(&self) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_inventory_beta_controller_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_inventory_beta_controller: {:?}", e))?;
        }
        Ok(())
    }

    /// Adaptive surfer-scaffold weight controller (spec v5 2026-06-01).
    /// Single-thread kernel; reads wr_ema/cumulative_dones/edge_decay_frac;
    /// writes `RL_SURFER_SCAFFOLD_WEIGHT_INDEX`.
    pub fn launch_rl_surfer_scaffold_controller(&self) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_surfer_scaffold_controller_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_surfer_scaffold_controller: {:?}", e))?;
        }
        Ok(())
    }

    /// Adaptive gate-LR multiplier controller (Phase 2A-D fix B1.3,
    /// 2026-06-03). Single-thread kernel; reads `gate_entropy_mean`
    /// (slot 781) via an EMA → escalates `RL_POLICY_GATE_LR_MULTIPLIER_INDEX`
    /// (slot 790) when the gate is stuck near-uniform (under-learning),
    /// decays it when the gate is at risk of collapsing to a single head.
    /// Must run AFTER `MultiHeadPolicy::emit_diag_stats` (which writes
    /// slot 781) and BEFORE the next step's `lr_pi × gate_lr_mult` read at
    /// the top of `step_*_body` (which uses the result for the gate Adams).
    pub fn launch_rl_gate_lr_multiplier_controller(&self) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_gate_lr_multiplier_controller_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_gate_lr_multiplier_controller: {:?}", e))?;
        }
        Ok(())
    }

    /// Adaptive risk management — Layer 4 Kelly fraction controller (spec 2026-05-30).
    /// Standalone launcher; production calls go through `rl_fused_controllers`.
    pub fn launch_rl_kelly_fraction_controller(&self) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_kelly_fraction_controller_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_kelly_fraction_controller: {:?}", e))?;
        }
        Ok(())
    }

    /// v9 — defensive eval-boundary calibration. Launch once per step
    /// AFTER all risk-stack controllers run (so this kernel's overrides
    /// of Kelly_safety_frac / IQN τ_min / entropy_coef_min / PPO ε_min
    /// take effect for the next step's consumers). Returns early if
    /// `RL_REGIME_TRANSITION_REMAINING_INDEX` ≤ 0, so the runtime cost when
    /// warmup is inactive is just a single mapped-pinned ISV read.
    pub fn launch_rl_eval_warmup_decay(&self) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_eval_warmup_decay_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_eval_warmup_decay: {:?}", e))?;
        }
        Ok(())
    }

    /// Regime observer helper — block-reduce per-account flat count.
    /// Output: `flat_count_d[0]` = number of accounts with lots==0.
    /// Launch: Grid=(1,1,1), Block=(256,1,1), smem=256*sizeof(int).
    pub fn launch_rl_regime_flat_count(&self, lots_d: &CudaSlice<i32>, b_size: usize) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(lots_d.raw_ptr());
        args.push_ptr(self.flat_count_d.raw_ptr());
        args.push_i32(b_size as i32);
        let mut ptrs = args.build_arg_ptrs();
        let smem = (256 * std::mem::size_of::<i32>()) as u32;
        unsafe {
            raw_launch(
                self.rl_regime_flat_count_fn.cu_function(),
                (1, 1, 1), (256, 1, 1), smem,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_regime_flat_count: {:?}", e))?;
        }
        Ok(())
    }

    /// Regime observer — emits 6 surface signals + Welford state + ε_recovery_live.
    /// Reads: kelly_f (prior step), cooldown, flat_count, worst_pnl, Welford state.
    /// Launch: Grid=(1,1,1), Block=(1,1,1), smem=0.
    pub fn launch_rl_regime_observer(&self, b_size: usize) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(self.flat_count_d.raw_ptr());
        args.push_i32(b_size as i32);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_regime_observer_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_regime_observer: {:?}", e))?;
        }
        Ok(())
    }

    /// Adaptive risk management — Layer 4 input EMA producer (win_rate).
    /// Reads `rewards_d`, `dones_d` [b_size] f32; outcome derived inline.
    /// Writes ISV[RL_WIN_RATE_EMA_INDEX = 677].
    pub fn launch_rl_win_rate_ema_update(
        &self,
        rewards_d: &CudaSlice<f32>,
        dones_d: &CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(rewards_d.raw_ptr());
        args.push_ptr(dones_d.raw_ptr());
        args.push_i32(b_size as i32);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_win_rate_ema_update_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_win_rate_ema_update: {:?}", e))?;
        }
        Ok(())
    }

    /// Adaptive risk management — Layer 4 input EMA producer (avg_win/loss USD).
    /// Reads `rewards_d`, `dones_d` [b_size] f32; outcome derived inline.
    /// Writes ISV[RL_AVG_WIN_USD_EMA_INDEX = 678, RL_AVG_LOSS_USD_EMA_INDEX = 679].
    pub fn launch_rl_avg_win_loss_ema_update(
        &self,
        rewards_d: &CudaSlice<f32>,
        dones_d: &CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(rewards_d.raw_ptr());
        args.push_ptr(dones_d.raw_ptr());
        args.push_i32(b_size as i32);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_avg_win_loss_ema_update_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_avg_win_loss_ema_update: {:?}", e))?;
        }
        Ok(())
    }

    /// Adaptive risk management — Layer 3 input EMA producer (inventory variance).
    /// Reads `net_position_per_batch_d` [b_size] i32 of signed contracts
    /// (typically the trainer's `prev_position_lots_d`); writes
    /// ISV[RL_INVENTORY_VARIANCE_EMA_INDEX = 675].
    pub fn launch_rl_inventory_variance_update(
        &self,
        net_position_per_batch_d: &CudaSlice<i32>,
        b_size: usize,
    ) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(net_position_per_batch_d.raw_ptr());
        args.push_i32(b_size as i32);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_inventory_variance_update_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_inventory_variance_update: {:?}", e))?;
        }
        Ok(())
    }

    /// Phase R3: launch `ema_update_on_done` — done-gated EMA producer
    /// that writes the per-batch closed-trade mean into `isv[slot_index]`.
    /// `alpha` is the Wiener-α (caller must pre-floor at 0.4 per
    /// `pearl_wiener_alpha_floor_for_nonstationary`). `obs_d` and
    /// `dones_d` are `[b_size]` device buffers; only entries where
    /// `dones[b] >= 0.5` contribute to the EMA observation. If no
    /// batch closed a trade this step, the kernel is a no-op (ISV slot
    /// retains its prior value).
    ///
    /// Shared-memory tree reduce; single block, `b_size` threads.
    /// Caller's responsibility: `b_size <= 1024` (one block max).
    pub fn launch_ema_update_on_done(
        &self,
        slot_index: usize,
        alpha: f32,
        obs_d: &CudaSlice<f32>,
        dones_d: &CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(obs_d.len(), b_size);
        debug_assert_eq!(dones_d.len(), b_size);
        debug_assert!(b_size <= 1024, "ema_update_on_done: b_size > 1024 needs multi-block reduce");
        let smem = (2 * b_size * std::mem::size_of::<f32>()) as u32;
        let slot_i = slot_index as i32;
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_i32(slot_i);
        args.push_f32(alpha);
        args.push_ptr(obs_d.raw_ptr());
        args.push_ptr(dones_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.ema_update_on_done_fn.cu_function(),
                (1, 1, 1), (b_size as u32, 1, 1), smem,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("ema_update_on_done: {:?}", e))?;
        }
        Ok(())
    }

    /// Phase R3: launch `ema_update_per_step` — per-step EMA producer
    /// that writes the per-batch mean into `isv[slot_index]` on every
    /// call (no done-gate). Used for continuous EMAs (KL, entropy,
    /// advantage variance). Same shared-mem tree-reduce as
    /// `ema_update_on_done`.
    pub fn launch_ema_update_per_step(
        &self,
        slot_index: usize,
        alpha: f32,
        obs_d: &CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(obs_d.len(), b_size);
        debug_assert!(b_size <= 1024, "ema_update_per_step: b_size > 1024 needs multi-block reduce");
        let smem = (b_size * std::mem::size_of::<f32>()) as u32;
        let slot_i = slot_index as i32;
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_i32(slot_i);
        args.push_f32(alpha);
        args.push_ptr(obs_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.ema_update_per_step_fn.cu_function(),
                (1, 1, 1), (b_size as u32, 1, 1), smem,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("ema_update_per_step: {:?}", e))?;
        }
        Ok(())
    }

    /// Phase R4: launch `rl_action_kernel` — Thompson sampler over
    /// the C51 Q distribution. Writes per-batch action indices to
    /// `actions_d`. Advances per-batch xorshift32 PRNG state in place.
    /// One block per batch, `N_ACTIONS` threads. `q_logits_d` is
    /// `[b_size, N_ACTIONS, Q_N_ATOMS]` row-major.
    pub fn launch_rl_action_kernel(
        &mut self,
        q_logits_d: &CudaSlice<f32>,
        actions_d: &mut CudaSlice<i32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(q_logits_d.len(), b_size * N_ACTIONS * Q_N_ATOMS);
        debug_assert_eq!(actions_d.len(), b_size);
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(q_logits_d.raw_ptr());
        args.push_ptr(self.atom_supports_d.raw_ptr());
        args.push_ptr(self.prng_state_d.raw_ptr());
        args.push_ptr(actions_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_action_kernel_fn.cu_function(),
                (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_action_kernel: {:?}", e))?;
        }
        Ok(())
    }

    /// Phase R4: launch `argmax_expected_q` — Bellman-target argmax
    /// over expected Q per action. Deterministic (no PRNG). Pairs with
    /// `launch_rl_action_kernel` per
    /// `pearl_thompson_for_distributional_action_selection` (Thompson
    /// for rollout, argmax for Bellman target).
    pub fn launch_argmax_expected_q(
        &self,
        q_logits_d: &CudaSlice<f32>,
        next_actions_d: &mut CudaSlice<i32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(q_logits_d.len(), b_size * N_ACTIONS * Q_N_ATOMS);
        debug_assert_eq!(next_actions_d.len(), b_size);
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(q_logits_d.raw_ptr());
        args.push_ptr(self.atom_supports_d.raw_ptr());
        args.push_ptr(next_actions_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.argmax_expected_q_fn.cu_function(),
                (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("argmax_expected_q: {:?}", e))?;
        }
        Ok(())
    }

    /// Phase R4: launch `log_pi_at_action` — per-batch
    /// `log π(actions[b] | s_b)` via log-softmax + lookup. Feeds the
    /// PPO importance ratio. One thread per batch entry.
    pub fn launch_log_pi_at_action(
        &self,
        pi_logits_d: &CudaSlice<f32>,
        actions_d: &CudaSlice<i32>,
        log_pi_out_d: &mut CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(pi_logits_d.len(), b_size * N_ACTIONS);
        debug_assert_eq!(actions_d.len(), b_size);
        debug_assert_eq!(log_pi_out_d.len(), b_size);
        let grid_x = ((b_size as u32) + 31) / 32;
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(pi_logits_d.raw_ptr());
        args.push_ptr(actions_d.raw_ptr());
        args.push_ptr(log_pi_out_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.log_pi_at_action_fn.cu_function(),
                (grid_x.max(1), 1, 1), (32, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("log_pi_at_action: {:?}", e))?;
        }
        Ok(())
    }

    /// SP20 P4: launch `actions_to_market_targets` — convert per-batch
    /// action index → (side, size) pair written to `market_targets_d`
    /// as `[b_size * 2]` row-major. Reads `pos_state_d` for actions
    /// that depend on current position (a3/a4 flat, a9/a10 half-flat).
    /// Public for `sp20_trade_management_kernels` GPU-oracle tests.
    #[allow(clippy::too_many_arguments)]
    pub fn launch_actions_to_market_targets(
        &self,
        actions_d: &CudaSlice<i32>,
        pos_state_d: &CudaSlice<u8>,
        market_targets_d: &mut CudaSlice<i32>,
        bid_px_d: &CudaSlice<f32>,
        ask_px_d: &CudaSlice<f32>,
        unit_entry_price_d: &CudaSlice<f32>,
        unit_active_d: &CudaSlice<u8>,
        unit_lots_d: &CudaSlice<i32>,
        pyramid_units_count_d: &CudaSlice<i32>,
        close_unit_index_d: &CudaSlice<i32>,
        outcome_ema_d: &CudaSlice<f32>,
        b_size: usize,
        pos_bytes: usize,
    ) -> Result<()> {
        debug_assert_eq!(actions_d.len(), b_size);
        debug_assert_eq!(market_targets_d.len(), b_size * 2);
        debug_assert_eq!(pos_state_d.len(), b_size * pos_bytes);
        debug_assert_eq!(unit_entry_price_d.len(), b_size * 4);
        debug_assert_eq!(unit_active_d.len(), b_size * 4);
        debug_assert_eq!(unit_lots_d.len(), b_size * 4);
        debug_assert_eq!(pyramid_units_count_d.len(), b_size);
        debug_assert_eq!(close_unit_index_d.len(), b_size);
        debug_assert_eq!(outcome_ema_d.len(), b_size);
        let b_size_i = b_size as i32;
        let pos_bytes_i = pos_bytes as i32;
        let grid_x = ((b_size as u32) + 31) / 32;
        let mut args = RawArgs::new();
        args.push_ptr(actions_d.raw_ptr());
        args.push_ptr(pos_state_d.raw_ptr());
        args.push_ptr(market_targets_d.raw_ptr());
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(bid_px_d.raw_ptr());
        args.push_ptr(ask_px_d.raw_ptr());
        args.push_ptr(unit_entry_price_d.raw_ptr());
        args.push_ptr(unit_active_d.raw_ptr());
        args.push_ptr(unit_lots_d.raw_ptr());
        args.push_ptr(pyramid_units_count_d.raw_ptr());
        args.push_ptr(close_unit_index_d.raw_ptr());
        args.push_ptr(outcome_ema_d.raw_ptr());
        // CMDP per-batch state — one DD-trigger / cooldown per session.
        args.push_ptr(self.session_dd_triggered_per_batch_d.raw_ptr());
        args.push_ptr(self.cooldown_remaining_per_batch_d.raw_ptr());
        args.push_i32(b_size_i);
        args.push_i32(pos_bytes_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.actions_to_market_targets_fn.cu_function(),
                (grid_x, 1, 1), (32, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("actions_to_market_targets: {:?}", e))?;
        }
        Ok(())
    }

    /// SP20 P5: launch `rl_trail_mutate` — mutates per-(batch, unit)
    /// Launch `rl_q_pi_distill_grad` directly — public surface for the
    /// Phase 5 `entropy_gradient_matches_analytical` invariants test and
    /// Phase 7a F2-diag GPU-oracle tests. In the trainer hot path this
    /// kernel is invoked through the integrated step pipeline; the test
    /// path bypasses that orchestration to verify the kernel's
    /// entropy-gradient formula + per-batch F2 mechanism counters in
    /// isolation.
    ///
    /// Phase 7a F2-diag extension (2026-06-04): the 4 mechanism counters
    /// (`f2_baseline_per_b_d`, `f2_adv_max_per_b_d`,
    /// `f2_hinge_zero_per_b_d`, `f2_target_argmax_per_b_d`) are per-batch
    /// `[B]` scratch buffers that the kernel populates via a single-
    /// thread-per-block reduction in the `a == 0` block (no atomicAdd —
    /// each block writes its own batch index only). Callers reduce
    /// host-side at diag emit.
    pub fn launch_rl_q_pi_distill_grad(
        &self,
        q_logits_target_d: &CudaSlice<f32>,
        pi_logits_d: &CudaSlice<f32>,
        atom_supports_d: &CudaSlice<f32>,
        pi_grad_logits_d: &mut CudaSlice<f32>,
        f2_baseline_per_b_d: &mut CudaSlice<f32>,
        f2_adv_max_per_b_d: &mut CudaSlice<f32>,
        f2_hinge_zero_per_b_d: &mut CudaSlice<f32>,
        f2_target_argmax_per_b_d: &mut CudaSlice<i32>,
        b_size: usize,
    ) -> Result<()> {
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(q_logits_target_d.raw_ptr());
        args.push_ptr(pi_logits_d.raw_ptr());
        args.push_ptr(atom_supports_d.raw_ptr());
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(pi_grad_logits_d.raw_ptr());
        args.push_ptr(f2_baseline_per_b_d.raw_ptr());
        args.push_ptr(f2_adv_max_per_b_d.raw_ptr());
        args.push_ptr(f2_hinge_zero_per_b_d.raw_ptr());
        args.push_ptr(f2_target_argmax_per_b_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_q_pi_distill_grad_fn.cu_function(),
                (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_q_pi_distill_grad: {:?}", e))?;
        }
        Ok(())
    }

    /// Phase 7b F5 (2026-06-05) — launch `rl_state_action_mask`. Sets
    /// `pi_logits[b][a] = -INFINITY` for state-illegal actions BEFORE
    /// `rl_pi_action_kernel` samples. Master-gated at slot 823
    /// (`RL_F5_STATE_MASK_ENABLED_INDEX`, bootstrap 0.0 = OFF) — when
    /// disabled the kernel returns immediately, preserving bit-equality.
    ///
    /// MUST be launched OUTSIDE graph capture (matches the existing
    /// `rl_band_mask` pattern at `BandHead::launch_mask`) since the
    /// kernel branches on per-batch position state and trail buffers.
    ///
    /// Spec: docs/superpowers/specs/2026-06-04-bellman-target-foundation-reshape.md
    /// §3.5 + §5.2.
    pub fn launch_rl_state_action_mask(
        &self,
        pi_logits_d: &mut CudaSlice<f32>,
        pos_state_d: &CudaSlice<u8>,
        unit_trail_distance_d: &CudaSlice<f32>,
        unit_initial_r_d: &CudaSlice<f32>,
        unit_active_d: &CudaSlice<u8>,
        b_size: usize,
        pos_bytes: usize,
    ) -> Result<()> {
        debug_assert_eq!(pi_logits_d.len(), b_size * N_ACTIONS);
        debug_assert_eq!(unit_trail_distance_d.len(), b_size * 4); // MAX_UNITS
        debug_assert_eq!(unit_initial_r_d.len(), b_size * 4);
        debug_assert_eq!(unit_active_d.len(), b_size * 4);
        let b_size_i = b_size as i32;
        let pos_bytes_i = pos_bytes as i32;
        let mut args = RawArgs::new();
        args.push_ptr(pi_logits_d.raw_ptr());
        args.push_ptr(pos_state_d.raw_ptr());
        args.push_ptr(unit_trail_distance_d.raw_ptr());
        args.push_ptr(unit_initial_r_d.raw_ptr());
        args.push_ptr(unit_active_d.raw_ptr());
        args.push_ptr(self.isv_dev_ptr);
        args.push_i32(b_size_i);
        args.push_i32(pos_bytes_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_state_action_mask_fn.cu_function(),
                (b_size as u32, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_state_action_mask: {:?}", e))?;
        }
        Ok(())
    }

    /// `unit_trail_distance_d` for active units when the chosen
    /// action is a7 (TrailTighten) or a8 (TrailLoosen). Uses ISV
    /// slots 494/495/497 (MIN/MAX/ADJUST_RATE) from ISV (mapped-pinned).
    /// Phase 5 (2026-06-04): also reads slot 814 for the
    /// per-unit-initial_r-relative absolute ceiling on TrailLoosen.
    /// Public for SP20 P14 GPU-oracle tests.
    pub fn launch_rl_trail_mutate(
        &self,
        actions_d: &CudaSlice<i32>,
        unit_active_d: &CudaSlice<u8>,
        unit_initial_r_d: &CudaSlice<f32>,
        unit_trail_distance_d: &mut CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(actions_d.len(), b_size);
        debug_assert_eq!(unit_active_d.len(), b_size * 4); // MAX_UNITS
        debug_assert_eq!(unit_initial_r_d.len(), b_size * 4);
        debug_assert_eq!(unit_trail_distance_d.len(), b_size * 4);
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(actions_d.raw_ptr());
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(unit_active_d.raw_ptr());
        args.push_ptr(unit_initial_r_d.raw_ptr());
        args.push_ptr(unit_trail_distance_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_trail_mutate_fn.cu_function(),
                (b_size as u32, 1, 1), (4, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_trail_mutate: {:?}", e))?;
        }
        Ok(())
    }

    /// Launch `rl_trail_stop_check` — for each active unit, check if
    /// mid breached the unit's stop (entry ± trail_distance). On breach
    /// with multiple units: overrides to HalfFlat + writes
    /// close_unit_index. With single unit: overrides to full flat.
    /// Resets close_unit_index_d to -1 device-side each invocation.
    /// Public for GPU-oracle tests.
    #[allow(clippy::too_many_arguments)]
    pub fn launch_rl_trail_stop_check(
        &self,
        actions_d: &mut CudaSlice<i32>,
        bid_px_d: &CudaSlice<f32>,
        ask_px_d: &CudaSlice<f32>,
        unit_active_d: &CudaSlice<u8>,
        unit_entry_price_d: &CudaSlice<f32>,
        unit_lots_d: &CudaSlice<i32>,
        unit_trail_distance_d: &CudaSlice<f32>,
        pyramid_units_count_d: &CudaSlice<i32>,
        close_unit_index_d: &mut CudaSlice<i32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(actions_d.len(), b_size);
        debug_assert_eq!(unit_active_d.len(), b_size * 4);
        debug_assert_eq!(unit_entry_price_d.len(), b_size * 4);
        debug_assert_eq!(unit_lots_d.len(), b_size * 4);
        debug_assert_eq!(unit_trail_distance_d.len(), b_size * 4);
        debug_assert_eq!(pyramid_units_count_d.len(), b_size);
        debug_assert_eq!(close_unit_index_d.len(), b_size);
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(actions_d.raw_ptr());
        args.push_ptr(bid_px_d.raw_ptr());
        args.push_ptr(ask_px_d.raw_ptr());
        args.push_ptr(unit_active_d.raw_ptr());
        args.push_ptr(unit_entry_price_d.raw_ptr());
        args.push_ptr(unit_lots_d.raw_ptr());
        args.push_ptr(unit_trail_distance_d.raw_ptr());
        args.push_ptr(pyramid_units_count_d.raw_ptr());
        args.push_ptr(close_unit_index_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_trail_stop_check_fn.cu_function(),
                (b_size as u32, 1, 1), (4, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_trail_stop_check: {:?}", e))?;
        }
        Ok(())
    }

    /// SP20 P6: launch `rl_position_heat_check` — if `|position_lots|`
    /// exceeds the ISV-driven cap (`RL_HEAT_CAP_MAX_LOTS_INDEX`, slot
    /// 504, default 8), OVERRIDE `actions[b]` to FlatFromLong (a3) or
    /// FlatFromShort (a4). Last-defense guard — full flat, no partial.
    /// Also writes `RL_HEAT_CAP_FIRED_COUNT_INDEX` (slot 505) with the
    /// per-step fired count for diag.
    pub fn launch_rl_position_heat_check(
        &self,
        actions_d: &mut CudaSlice<i32>,
        pos_state_d: &CudaSlice<u8>,
        b_size: usize,
        pos_bytes: usize,
    ) -> Result<()> {
        debug_assert_eq!(actions_d.len(), b_size);
        debug_assert_eq!(pos_state_d.len(), b_size * pos_bytes);
        let block = (b_size as u32).min(256);
        let grid = ((b_size as u32) + block - 1) / block;
        let smem = 256 * std::mem::size_of::<i32>() as u32;
        let b_size_i = b_size as i32;
        let pos_bytes_i = pos_bytes as i32;
        let mut args = RawArgs::new();
        args.push_ptr(actions_d.raw_ptr());
        args.push_ptr(pos_state_d.raw_ptr());
        args.push_ptr(self.isv_dev_ptr);
        args.push_i32(b_size_i);
        args.push_i32(pos_bytes_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_position_heat_check_fn.cu_function(),
                (grid.max(1), 1, 1), (block.max(1), 1, 1), smem,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_position_heat_check: {:?}", e))?;
        }
        Ok(())
    }

    /// Launch `rl_confidence_gate` — for opening actions on flat
    /// positions, compute C51 distributional LCB and override to Hold
    /// if below threshold. Public for GPU-oracle tests.
    #[allow(clippy::too_many_arguments)]
    pub fn launch_rl_confidence_gate(
        &self,
        actions_d: &mut CudaSlice<i32>,
        q_logits_d: &CudaSlice<f32>,
        pos_state_d: &CudaSlice<u8>,
        b_size: usize,
        pos_bytes: usize,
    ) -> Result<()> {
        debug_assert_eq!(actions_d.len(), b_size);
        debug_assert_eq!(q_logits_d.len(), b_size * N_ACTIONS * Q_N_ATOMS);
        debug_assert_eq!(pos_state_d.len(), b_size * pos_bytes);
        let b_size_i = b_size as i32;
        let pos_bytes_i = pos_bytes as i32;
        let mut args = RawArgs::new();
        args.push_ptr(actions_d.raw_ptr());
        args.push_ptr(q_logits_d.raw_ptr());
        args.push_ptr(self.atom_supports_d.raw_ptr());
        args.push_ptr(pos_state_d.raw_ptr());
        args.push_ptr(self.isv_dev_ptr);
        args.push_i32(b_size_i);
        args.push_i32(pos_bytes_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_confidence_gate_fn.cu_function(),
                (b_size as u32, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_confidence_gate: {:?}", e))?;
        }
        Ok(())
    }

    /// Launch `rl_frd_gate` — for opening actions on flat positions,
    /// check FRD head's horizon-2 favorable probability mass. Override
    /// to Hold if below threshold. Public for GPU-oracle tests.
    pub fn launch_rl_frd_gate(
        &self,
        actions_d: &mut CudaSlice<i32>,
        frd_logits_d: &CudaSlice<f32>,
        pos_state_d: &CudaSlice<u8>,
        b_size: usize,
        pos_bytes: usize,
    ) -> Result<()> {
        debug_assert_eq!(actions_d.len(), b_size);
        let frd_out_dim = crate::rl::frd::FRD_OUT_DIM;
        debug_assert_eq!(frd_logits_d.len(), b_size * frd_out_dim);
        debug_assert_eq!(pos_state_d.len(), b_size * pos_bytes);
        let b_size_i = b_size as i32;
        let pos_bytes_i = pos_bytes as i32;
        let mut args = RawArgs::new();
        args.push_ptr(actions_d.raw_ptr());
        args.push_ptr(frd_logits_d.raw_ptr());
        args.push_ptr(pos_state_d.raw_ptr());
        args.push_ptr(self.isv_dev_ptr);
        args.push_i32(b_size_i);
        args.push_i32(pos_bytes_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_frd_gate_fn.cu_function(),
                (b_size as u32, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_frd_gate: {:?}", e))?;
        }
        Ok(())
    }

    /// Launch `rl_unit_state_update` — detects per-batch position
    /// transitions vs `prev_pos_lots_d` and updates per-unit state
    /// (entry_price, entry_step, lots, initial_r, trail_distance,
    /// active, pyramid_units_count). Handles pyramid add (allocate next
    /// slot) and partial flat (deactivate oldest slot). Uses ISV slot
    /// 496 (K_INIT) from ISV (mapped-pinned) to scale the bootstrap trail.
    /// Public for GPU-oracle tests.
    #[allow(clippy::too_many_arguments)]
    pub fn launch_rl_unit_state_update(
        &self,
        pos_state_d: &CudaSlice<u8>,
        prev_pos_lots_d: &mut CudaSlice<i32>,
        unit_entry_price_d: &mut CudaSlice<f32>,
        unit_entry_step_d: &mut CudaSlice<i32>,
        unit_lots_d: &mut CudaSlice<i32>,
        unit_initial_r_d: &mut CudaSlice<f32>,
        unit_trail_distance_d: &mut CudaSlice<f32>,
        unit_active_d: &mut CudaSlice<u8>,
        pyramid_units_count_d: &mut CudaSlice<i32>,
        b_size: usize,
        pos_bytes: usize,
    ) -> Result<()> {
        debug_assert_eq!(pos_state_d.len(), b_size * pos_bytes);
        debug_assert_eq!(prev_pos_lots_d.len(), b_size);
        debug_assert_eq!(unit_entry_price_d.len(), b_size * 4);
        debug_assert_eq!(unit_entry_step_d.len(), b_size * 4);
        debug_assert_eq!(unit_lots_d.len(), b_size * 4);
        debug_assert_eq!(unit_initial_r_d.len(), b_size * 4);
        debug_assert_eq!(unit_trail_distance_d.len(), b_size * 4);
        debug_assert_eq!(unit_active_d.len(), b_size * 4);
        debug_assert_eq!(pyramid_units_count_d.len(), b_size);
        let b_size_i = b_size as i32;
        let pos_bytes_i = pos_bytes as i32;
        let grid_x = ((b_size as u32) + 31) / 32;
        let mut args = RawArgs::new();
        args.push_ptr(pos_state_d.raw_ptr());
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(prev_pos_lots_d.raw_ptr());
        args.push_ptr(unit_entry_price_d.raw_ptr());
        args.push_ptr(unit_entry_step_d.raw_ptr());
        args.push_ptr(unit_lots_d.raw_ptr());
        args.push_ptr(unit_initial_r_d.raw_ptr());
        args.push_ptr(unit_trail_distance_d.raw_ptr());
        args.push_ptr(unit_active_d.raw_ptr());
        args.push_ptr(pyramid_units_count_d.raw_ptr());
        args.push_i32(b_size_i);
        args.push_i32(pos_bytes_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_unit_state_update_fn.cu_function(),
                (grid_x, 1, 1), (32, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_unit_state_update: {:?}", e))?;
        }
        Ok(())
    }

    /// Phase R7a: launch `abs_copy` — element-wise `dst[b] = fabsf(src[b])`.
    /// Feeds the `|reward|` signal into `ema_update_on_done` for the
    /// `MEAN_ABS_PNL_EMA` slot without mutating the signed `rewards_d`.
    pub fn launch_abs_copy(
        &self,
        src_d: &CudaSlice<f32>,
        dst_d: &mut CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(src_d.len(), b_size);
        debug_assert_eq!(dst_d.len(), b_size);
        let grid_x = ((b_size as u32) + 31) / 32;
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(src_d.raw_ptr());
        args.push_ptr(dst_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.abs_copy_fn.cu_function(),
                (grid_x.max(1), 1, 1), (32, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("abs_copy: {:?}", e))?;
        }
        Ok(())
    }

    /// Phase R6: launch `apply_reward_scale` — element-wise
    /// `scaled = rewards[b] * isv[RL_REWARD_SCALE_INDEX = 406]`,
    /// then asymmetric clamp to `[-REWARD_CLAMP_LOSS, +REWARD_CLAMP_WIN]`
    /// = `[-3, +1]` (per
    /// `pearl_audit_unboundedness_for_implicit_asymmetry`).
    /// Also writes per-step pre-clamp `max(|scaled|)` to
    /// `ISV[RL_MAX_ABS_SCALED_REWARD_PRE_CLAMP_INDEX = 439]` for diag.
    ///
    /// Single-block layout (no atomics per `pearl_no_atomicadd`).
    /// Block dim = min(b_size, 256); shared memory = block_dim · 4 B
    /// for the tree reduction.
    pub fn launch_apply_reward_scale(
        &self,
        rewards_d: &mut CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(rewards_d.len(), b_size);
        let block_x: u32 = (b_size as u32).min(256).max(1);
        // Shared mem: 3 × block × f32 — kernel runs two parallel
        // reductions (max|scaled| + max(positive scaled)) per audit
        // 2026-05-24, both stored in `extern __shared__ float smem[]`.
        let smem = 3 * block_x * (std::mem::size_of::<f32>() as u32);
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(rewards_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.apply_reward_scale_fn.cu_function(),
                (1, 1, 1), (block_x, 1, 1), smem,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("apply_reward_scale: {:?}", e))?;
        }

        // Adaptive reward-clamp controller — reads the per-step
        // positive-tail max just published into slot 478, maintains
        // an EMA in slot 479, writes adaptive WIN/LOSS bounds into
        // slots 452/453 for the NEXT step's apply_reward_scale to
        // consume. Single-thread kernel; α driven by the same
        // controller-cohort floor as the other R5 EMAs.
        {
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_f32(alpha);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_reward_clamp_controller_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_reward_clamp_controller: {:?}", e))?;
            }
        }

        // C51 atom support refresh — the reward clamp controller just
        // ratcheted V_MIN/V_MAX; rewrite atom_supports_d so downstream
        // C51 kernels (argmax_expected_q, rl_action_kernel,
        // dqn_distributional_q) see the updated span. 21-thread
        // single-block kernel; trivially cheap.
        {
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.atom_supports_d.raw_ptr());
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_atom_support_update_fn.cu_function(),
                    (1, 1, 1), (21, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_atom_support_update: {:?}", e))?;
            }
        }
        Ok(())
    }

    /// Phase R3: launch `compute_advantage_return` — Q-advantage variant.
    /// `returns[b] = r + γ(1-done)·V(s_{t+1})` (unchanged for V target),
    /// `advantages[b] = E_Q(s_t, a_taken) − V(s_t)` (Q-advantage).
    ///
    /// Uses Q's distributional expected value for the taken action instead
    /// of TD return, coupling Q's C51 calibration directly into the PPO
    /// advantage signal. Fixes the q_pi_agree anti-correlation where
    /// V-advantage pushed π away from Q's preferred actions.
    #[allow(clippy::too_many_arguments)]
    pub fn launch_compute_advantage_return(
        &self,
        rewards_d: &CudaSlice<f32>,
        dones_d: &CudaSlice<f32>,
        v_t_d: &CudaSlice<f32>,
        v_tp1_d: &CudaSlice<f32>,
        returns_d: &mut CudaSlice<f32>,
        advantages_d: &mut CudaSlice<f32>,
        b_size: usize,
    ) -> Result<()> {
        debug_assert_eq!(rewards_d.len(), b_size);
        debug_assert_eq!(dones_d.len(), b_size);
        debug_assert_eq!(v_t_d.len(), b_size);
        debug_assert_eq!(v_tp1_d.len(), b_size);
        debug_assert_eq!(returns_d.len(), b_size);
        debug_assert_eq!(advantages_d.len(), b_size);
        let b_size_i = b_size as i32;
        let grid_x = ((b_size as u32) + 31) / 32;
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(rewards_d.raw_ptr());
        args.push_ptr(dones_d.raw_ptr());
        args.push_ptr(v_t_d.raw_ptr());
        args.push_ptr(v_tp1_d.raw_ptr());
        args.push_ptr(returns_d.raw_ptr());
        args.push_ptr(advantages_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.compute_advantage_return_fn.cu_function(),
                (grid_x.max(1), 1, 1), (32, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("compute_advantage_return: {:?}", e))?;
        }
        Ok(())
    }

    /// Phase E.2 + E.2-DEFER: synthetic-batch end-to-end step. Runs real
    /// GPU forward + backward + per-head Adam on Q, π, V heads on a
    /// SHARED encoder; combined per-head `grad_h_t` flows back into the
    /// encoder via `PerceptionTrainer::backward_encoder_with_grad_h_t`
    /// so the encoder is no longer BCE+aux-only.
    ///
    /// All learning rates are sourced from `ISV[RL_LR_*_INDEX]` per
    /// `pearl_controller_anchors_isv_driven`; the `lr` parameter is
    /// removed (greenfield — no caller override). Bellman target
    /// distribution is built on-device by `bellman_target_projection`
    /// reading γ from `ISV[RL_GAMMA_INDEX=400]`.
    ///
    /// Returns the per-head kernel-emitted losses and the combined-loss
    /// total weighted by the ISV-driven λs.
    /// Phase R7a refactor: step_synthetic reads the per-step inputs from
    /// trainer-owned device buffers (`self.actions_d`, `self.rewards_d`,
    /// `self.dones_d`, `self.next_actions_d`, `self.advantages_d`,
    /// `self.returns_d`, `self.log_pi_old_d`) instead of taking host
    /// slices. Caller's responsibility (step_with_lobsim) is to
    /// populate those buffers via GPU kernels before calling. This
    /// eliminates the 7 host-slice uploads (+4 DtoH copies in
    /// step_with_lobsim's old handoff) that violated
    /// `feedback_cpu_is_read_only` in the flawed Phase F+G branch.
    pub fn step_synthetic(
        &mut self,
        snapshots: &[Mbp10RawInput],
    ) -> Result<IntegratedStepStats> {
        self.step_synthetic_read_deferred_stats()?;
        if self.gpu_encoder_active {
            // GPU data path: SoA buffers pre-filled by gather kernels.
            let _ = self
                .perception
                .forward_encoder_from_device()
                .context("forward_encoder_from_device")?;
        } else {
            let _ = self
                .perception
                .forward_encoder(snapshots)
                .context("forward_encoder")?;
        }
        self.step_synthetic_body()
    }

    /// Read previous step's losses from mapped-pinned memory and
    /// compose `self.last_step_stats`. Shared preamble for both
    /// `step_synthetic` (host data) and `step_synthetic_gpu` (device
    /// data). Must be called before the encoder forward each step.
    fn step_synthetic_read_deferred_stats(&mut self) -> Result<()> {
        let b_size = self.cfg.perception.n_batch;
        if b_size == 0 {
            anyhow::bail!("step_synthetic: empty batch (b_size = 0)");
        }

        // Validate per-batch input buffer lengths.
        debug_assert_eq!(self.actions_d.len(), b_size);
        debug_assert_eq!(self.rewards_d.len(), b_size);
        debug_assert_eq!(self.dones_d.len(), b_size);
        debug_assert_eq!(self.next_actions_d.len(), b_size);
        debug_assert_eq!(self.advantages_d.len(), b_size);
        debug_assert_eq!(self.returns_d.len(), b_size);
        debug_assert_eq!(self.log_pi_old_d.len(), b_size);

        // ── Step 0a: sync perception training event ────────────────────
        // The perception trainer's `step_batched_from_device` records a
        // CUDA event after its training graph launch instead of
        // host-blocking via `cuStreamSynchronize`. By the time we
        // reach this point, the GPU has executed the entire reward /
        // PER / controller pipeline since the event was recorded, so
        // the sync completes instantly (~0 us). This guarantees the
        // mapped-pinned loss values below are host-visible.
        self.perception.sync_training_event()
            .context("sync perception training event")?;

        // ── Step 0b: read PREVIOUS step's losses from mapped-pinned ────
        // Mapped-pinned buffers are GPU+host visible. The GPU wrote loss
        // values during the previous step's kernel execution on
        // self.stream. By the time we enter the current step, the
        // previous step's stream work has completed (train_done_event
        // ensures cross-stream coherence in step_with_lobsim;
        // training_done_event ensures the perception training graph
        // completed). Volatile reads prevent the compiler from caching
        // stale values. At construction the mapped pages are zero-
        // initialized, producing sentinel values the LR controller's
        // cold-start gate expects.
        {
            let l_pi_host = unsafe { std::ptr::read_volatile(self.ss_pi_loss_mapped.host_ptr) };
            self.last_pi_loss = l_pi_host;

            let l_q_host = unsafe { std::ptr::read_volatile(self.ss_q_loss_mapped.host_ptr) };
            self.last_q_loss = l_q_host / (b_size as f32);

            let l_v_sum_host = unsafe { std::ptr::read_volatile(self.ss_v_loss_sum_mapped.host_ptr) };
            let l_v_host = l_v_sum_host / (b_size as f32);
            self.last_v_loss = l_v_host;

            let frd_n_h = crate::rl::common::FRD_N_HORIZONS;
            let frd_n = b_size * frd_n_h;
            let l_frd_host = {
                let mut sum = 0.0_f32;
                for i in 0..frd_n {
                    sum += unsafe { std::ptr::read_volatile(self.ss_frd_loss_mapped.host_ptr.add(i)) };
                }
                sum / (frd_n as f32)
            };

            // Compose stats from deferred values. ISV lambdas read
            // directly from mapped-pinned host_ptr — one-step deferred,
            // consistent lag with the loss reads.
            let isv_host_slice = unsafe {
                std::slice::from_raw_parts(self.isv_mapped.host_ptr, self.isv_mapped.len)
            };
            let prev_lambdas = read_loss_lambdas_from_isv(isv_host_slice);
            let l_bce = self.last_bce_loss;
            let l_aux = self.last_aux_loss;
            let l_q = self.last_q_loss;
            let l_pi = self.last_pi_loss;
            let l_total = (prev_lambdas.bce * l_bce
                + prev_lambdas.q * l_q
                + prev_lambdas.pi * l_pi
                + prev_lambdas.v * l_v_host
                + prev_lambdas.aux * l_aux
                + prev_lambdas.frd * l_frd_host)
                / 6.0;

            self.last_step_stats = IntegratedStepStats {
                l_bce,
                l_q,
                l_pi,
                l_v: l_v_host,
                l_aux,
                l_frd: l_frd_host,
                l_total,
                lambdas: prev_lambdas,
            };
        }
        Ok(())
    }

    /// Post-encoder body shared by `step_synthetic` and
    /// `step_synthetic_gpu`. Runs the LR controller, all head
    /// forwards/backwards, Adam steps, and loss composition. The
    /// encoder must have been forwarded before this call (either via
    /// `forward_encoder` or `forward_encoder_from_device`).
    fn step_synthetic_body(&mut self) -> Result<IntegratedStepStats> {
        let b_size = self.cfg.perception.n_batch;

        // ── Step 2: per-head LR controller emit ──────────────────────
        // The controller emits the bootstrap target on first call
        // (sentinel-zero → 1e-3); subsequent calls Wiener-α blend.
        // Uses last_*_loss populated by the deferred readback above
        // (one-step delayed). At construction these are 0.0 (sentinel)
        // and the controller's cold-start gate (early-return on
        // observed_loss == 0) holds LR at LR_BOOTSTRAP for the first
        // step.
        self.launch_rl_lr_controller(
            self.last_q_loss,
            self.last_pi_loss,
            self.last_v_loss,
        )
        .context("rl_lr_controller launch")?;

        let lambdas = read_loss_lambdas_from_isv(self.isv_host_slice());
        // Mutate each per-head Adam's lr field from the ISV mapped-pinned
        // host_ptr. The controller has just emitted into ISV[412..417],
        // so this read reflects this step's learning rate. Reading ALL
        // five before touching ANY AdamW keeps the field-by-field
        // mutations borrow-checker safe.
        let lr_bce = self.read_isv_host(RL_LR_BCE_INDEX);
        let lr_q   = self.read_isv_host(RL_LR_Q_INDEX);
        let lr_pi  = self.read_isv_host(RL_LR_PI_INDEX);
        let lr_v   = self.read_isv_host(RL_LR_V_INDEX);
        let lr_aux = self.read_isv_host(RL_LR_AUX_INDEX);
        // The aux head LR is consumed by the PerceptionTrainer's aux
        // optimisers; the perception trainer reads it through its own
        // mutator (`set_lr_aux`). The integrated trainer only needs to
        // propagate the value — its own Adam instances are Q / π / V.
        // BCE LR currently rides the existing `lr_cfc` on the perception
        // side (the BCE head's grad path is interlocked with the CfC
        // backward through the K-loop). Phase E.3+ separates the BCE
        // optimiser; until then we keep the BCE slot in ISV updated for
        // forward-looking diagnostics + the controller's first-obs
        // bootstrap, and we propagate `lr_aux` via the perception
        // trainer's existing mutator hook.
        let _ = lr_bce;
        self.perception.set_lr_aux(lr_aux);
        self.dqn_w_adam.lr   = lr_q;
        self.dqn_b_adam.lr   = lr_q;
        self.policy_w_adam.lr = lr_pi;
        self.policy_b_adam.lr = lr_pi;
        // Phase 2A-C: mirror lr_pi onto the multi-head Adam state when
        // the gate is on. ISV[RL_LR_PI_INDEX] remains the single source
        // of truth — no separate slot for multi-head LR.
        //
        // Phase 2A-D fix B1 (2026-06-03): gate Adams use lr_pi × ISV[790]
        // (RL_POLICY_GATE_LR_MULTIPLIER_INDEX, bootstrap 5.0) so the
        // routing gradient — structurally weak near uniform g_k ≈ 1/K —
        // can escape the uniform basin without amplifying head training.
        // Heads keep raw lr_pi.
        if self.use_multi_head_policy {
            let gate_lr_mult = self.read_isv_host(
                crate::rl::isv_slots::RL_POLICY_GATE_LR_MULTIPLIER_INDEX,
            );
            let lr_gate = lr_pi * gate_lr_mult;
            if let Some(a) = self.mhp_w_heads_adam.as_mut() { a.lr = lr_pi; }
            if let Some(a) = self.mhp_b_heads_adam.as_mut() { a.lr = lr_pi; }
            if let Some(a) = self.mhp_w_gate_adam.as_mut() { a.lr = lr_gate; }
            if let Some(a) = self.mhp_b_gate_adam.as_mut() { a.lr = lr_gate; }
        }
        self.value_w_adam.lr  = lr_v;
        self.value_b_adam.lr  = lr_v;
        // SP20 P3 FRD head — LR from dedicated ISV slot (498-503 block).
        let lr_frd = self.read_isv_host(crate::rl::isv_slots::RL_FRD_LR_INDEX);
        self.frd_w1_adam.lr = lr_frd;
        self.frd_b1_adam.lr = lr_frd;
        self.frd_w2_adam.lr = lr_frd;
        self.frd_b2_adam.lr = lr_frd;
        // Outcome head shares the FRD LR slot (both are lightweight aux heads).
        self.outcome_w_adam.lr = lr_frd;
        self.outcome_b_adam.lr = lr_frd;

        // Borrow encoder hidden state for forward kernels.
        let h_t_borrow: &CudaSlice<f32> = self.perception.h_t_view();
        debug_assert_eq!(h_t_borrow.len(), b_size * HIDDEN_DIM);

        // ── CUDA Graph C: replay training step ───────────────────────
        // Three-state machine: first step = warmup (eager), second =
        // capture, third+ = replay. All device pointers are stable
        // (pre-allocated ss_* fields). Loss DtoDs (async, no sync)
        // stay OUTSIDE the captured region.
        if self.training_graph.is_some() {
            unsafe {
                raw_graph_launch(
                    self.training_graph.as_ref().unwrap().cu_graph_exec,
                    self.raw_stream,
                ).map_err(|e| anyhow::anyhow!("training graph launch: {:?}", e))?;
            }
        } else {
            let capturing_training = self.training_warmup_done;
            if !self.training_warmup_done {
                self.training_warmup_done = true;
            }
            if capturing_training {
                self.stream
                    .begin_capture(CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_RELAXED)
                    .map_err(|e| anyhow::anyhow!("training begin_capture: {e}"))?;
            }

        // ── Step 3: REMOVED — 36 memset_zeros that previously zeroed
        // every per-step scratch buffer inside this graph capture region.
        // Wasteful: backward kernels + reduce_axis0 OVERWRITE every
        // element, and buffers were alloc_zeros'd at construction. The
        // memsets replayed on every graph launch, burning ~36 × memset
        // bandwidth per step for zero correctness benefit.

        // ── Step 4 (Phase R7a): inputs are trainer-owned device buffers,
        // populated by step_with_lobsim's GPU pipeline before this call.
        // No host uploads here.

        // ── Step 5: forward kernels (Q, π, V) ────────────────────────
        // R7d: Q forward consumes PER-SAMPLED h_t (off-policy DQN);
        // π and V forwards stay on current-step h_t (on-policy PPO + V).
        // The shared encoder gets gradient signal ONLY from π + V (and
        // BCE/aux via perception's separate step_batched path); Q's
        // encoder-direction gradient is stop-grad'd (discarded in
        // Step 10 by NOT accumulating q_grad_h_t into the encoder
        // grad slot).
        self.dqn_head
            .forward(&self.sampled_h_t_d, b_size, &mut self.q_logits_d)
            .context("dqn_head.forward(sampled_h_t) [R7d off-policy]")?;
        // Phase 2A-C: gate π forward between legacy single-head and
        // MultiHeadPolicy K-head mixture. Host branch runs during graph
        // CAPTURE only; the captured kernel sequence bakes the chosen
        // path so replay is single-path. `regime_h` is read DIRECTLY
        // from perception's `step_regime_d` (parallel channel — bypasses
        // VSN).
        if self.use_multi_head_policy {
            let regime_h = self.perception.step_regime_d_view();
            let mhp = self.multi_head_policy
                .as_ref()
                .expect("use_multi_head_policy=true but multi_head_policy=None (construction bug)");
            mhp.forward(h_t_borrow, regime_h, &mut self.pi_logits_d)
                .context("multi_head_policy.forward [step_synthetic_body]")?;
            // Phase 2A-D (2026-06-03) — device-aggregated gate/head diagnostics.
            // Reads forward outputs (gate_probs_d, pi_probs_k_d) before any
            // downstream consumer; writes 25 ISV slots (765..790) for the
            // diag emit at step N+1. Single-stream — no extra sync needed.
            mhp.emit_diag_stats(self.isv_dev_ptr)
                .context("multi_head_policy.emit_diag_stats [step_synthetic_body]")?;
        } else {
            self.policy_head
                .forward_logits(h_t_borrow, b_size, &mut self.pi_logits_d)
                .context("policy_head.forward_logits")?;
        }
        self.value_head
            .forward(h_t_borrow, &self.isv_dev_ptr, b_size, &mut self.v_pred_d)
            .context("value_head.forward")?;

        // R7d: Online Q at sampled_h_tp1 for the Double-DQN argmax that
        // feeds the Bellman target. The argmax is recomputed every step
        // because online net weights drift faster than transitions
        // recycle through the replay buffer; storing it at push time
        // would feed stale-action data into the projection.
        self.dqn_head
            .forward(&self.sampled_h_tp1_d, b_size, &mut self.q_logits_tp1_d)
            .context("dqn_head.forward(sampled_h_tp1) [R7d Double-DQN argmax src]")?;
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_tp1_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(self.sampled_next_actions_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.argmax_expected_q_fn.cu_function(),
                    (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("argmax_expected_q (sampled_h_tp1 -> sampled_next_actions): {:?}", e))?;
            }
        }

        // PPO surrogate forward — policy loss + entropy bonus only.
        // V loss flows through the V-head kernels (item 4).
        {
            self.policy_head
                .surrogate_forward(
                    &self.pi_logits_d,
                    &self.log_pi_old_d,
                    &self.actions_d,
                    &self.advantages_d,
                    &self.isv_dev_ptr,
                    b_size,
                    &mut self.ss_pi_log_prob_d,
                    &mut self.ss_entropy_d,
                    &mut self.ss_pi_loss_per_b_d,
                    &mut self.ss_pi_loss_entropy_per_b_d,
                    &self.ss_pi_loss_dev_ptr,
                    &self.ss_pi_loss_entropy_dev_ptr,
                    // B-10 G3+G4: 4 per-batch observability scratches.
                    &mut self.ppo_a_norm_pb_d,
                    &mut self.ppo_ratio_dev_pb_d,
                    &mut self.ppo_ratio_clipped_pb_d,
                    &mut self.ppo_surrogate_pb_d,
                )
                .context("policy_head.surrogate_forward")?;
            // Host reads deferred to after graph capture region.
            // B-10 G3+G4: cross-batch reducer over the per-batch scratches.
            // Reads slot 612 (var_pre_norm) for σ_used; writes ISV 735-742.
            self.policy_head
                .launch_ppo_diagnostic_stats_reduce(
                    &self.isv_dev_ptr,
                    b_size,
                    &self.ppo_a_norm_pb_d,
                    &self.ppo_ratio_dev_pb_d,
                    &self.ppo_ratio_clipped_pb_d,
                    &self.ppo_surrogate_pb_d,
                )
                .context("policy_head.launch_ppo_diagnostic_stats_reduce")?;
        }

        // ── Step 6a: Bellman target build (item 2) ───────────────────
        // R7d off-policy: target Q on PER-SAMPLED h_{t+1}, action
        // selector = sampled_next_actions (Double-DQN argmax on
        // online_Q(sampled_h_tp1) computed just above), rewards/dones
        // from the SAMPLED transitions' metadata. The projection
        // itself reads γ from ISV[400] on-device.
        //
        // R7c data correctness lift (this commit's predecessor) put
        // the trainer on h_{t+1} via self.h_tp1_d; R7d re-routes the
        // entire Bellman target build through PER-sampled buffers per
        // `feedback_always_per`.
        self.dqn_head
            .forward_target(&self.sampled_h_tp1_d, b_size, &mut self.ss_q_target_full_d)
            .context("dqn_head.forward_target(sampled_h_tp1) [R7d off-policy]")?;
        self.dqn_head
            .fused_select_and_project_bellman(
                &self.ss_q_target_full_d,
                &self.sampled_next_actions_d,
                &self.sampled_rewards_d,
                &self.sampled_dones_d,
                &self.sampled_n_step_gammas_d,
                &self.isv_dev_ptr,
                b_size,
                &mut self.ss_target_dist_d,
                &mut self.bellman_sat_top_per_batch_d,
                &mut self.bellman_sat_bot_per_batch_d,
                &mut self.bellman_max_pre_per_batch_d,
                &mut self.bellman_min_pre_per_batch_d,
            )
            .context("dqn_head.fused_select_and_project_bellman (sampled rewards/dones)")?;
        // B-9 (2026-06-01): cross-batch saturation reduce → ISV 726-729.
        self.dqn_head
            .launch_saturation_reduce(
                &self.isv_dev_ptr,
                b_size,
                &self.bellman_sat_top_per_batch_d,
                &self.bellman_sat_bot_per_batch_d,
                &self.bellman_max_pre_per_batch_d,
                &self.bellman_min_pre_per_batch_d,
            )
            .context("dqn_head.launch_saturation_reduce (after sampled bellman)")?;

        // ── Step 6b: DQN backward (logits → grad_w/b/h_t) ────────────
        // F4.1: kernel writes per-batch CE to td_per_sample_d only;
        // the scalar q-loss diagnostic is produced by the mean_reduce
        // launch below (replaces the prior in-kernel atomicAdd).
        self.dqn_head
            .backward_logits(
                &self.q_logits_d,
                &self.ss_target_dist_d,
                &self.sampled_actions_d,
                b_size,
                &mut self.td_per_sample_d,
                &mut self.ss_q_grad_logits_d,
            )
            .context("dqn_head.backward_logits (sampled_actions) [R7d off-policy]")?;
        // F4.1 (2026-05-31): block tree-reduce [B] → [1] of per-batch CE
        // into the mapped-pinned scalar the trainer reads for the LR
        // controller. Uses the `mean_reduce_b_f32` kernel that
        // `policy_head` loaded with its ppo_loss_reduce_b cubin.
        self.dqn_head
            .reduce_loss_to_scalar(
                &self.td_per_sample_d,
                b_size,
                &self.ss_q_loss_dev_ptr,
            )
            .context("dqn_head.reduce_loss_to_scalar (sampled_actions)")?;
        // Host read deferred to after graph capture region.

        self.dqn_head
            .backward_gemm(
                &self.sampled_h_t_d,
                &self.ss_q_grad_logits_d,
                b_size,
                &mut self.ss_q_grad_w_d,
                &mut self.ss_q_grad_b_d,
                &mut self.ss_q_grad_h_t_d,
            )
            .context("dqn_head.backward_gemm(sampled_h_t) [R7d off-policy]")?;
        // R7d stop-grad: ss_q_grad_h_t_d is the gradient wrt SAMPLED h_t
        // (a past-step encoder output). Accumulating it into the
        // shared encoder via grad_h_t_combined_d would poison the
        // encoder with stale-state gradient signal. Standard pattern
        // for off-policy + shared encoder (SAC / R2D2 / IMPALA do the
        // same). The buffer is pre-allocated for backward_gemm
        // to write into; we just don't FOLD it into the encoder grad
        // combine below. Encoder learns from π + V (on-policy) +
        // BCE/aux (supervised via step_batched) only.
        let _ = &self.ss_q_grad_h_t_d;

        // ── Step 7: π ← w·PPO_surrogate_grad + Q distillation + SAC entropy ─
        // Phase 3D-C (2026-06-03): π gradient now blends two sources:
        //
        //   (a) PPO clipped-surrogate gradient (per-batch, advantage-aware,
        //       fee-sensitive). Written to `ss_pi_grad_logits_d` by
        //       `policy_head.surrogate_backward_logits`, then scaled in place
        //       by `RL_PPO_SURROGATE_WEIGHT_INDEX` via the blend kernel
        //       (or zeroed if the enable gate ≤ 0.5, preserving the legacy
        //       zero-then-distill path for ablation).
        //
        //   (b) Q→π distillation + SAC entropy (existing). The
        //       `rl_q_pi_distill_grad` kernel uses `+=` so it ADDS on top
        //       of whatever the blend kernel produced. Result:
        //           pi_grad = w_ppo · grad_PPO + grad_Q_distill_and_entropy
        //
        // Why both: per Goodhart-Skalse 2024 (Causes of Misalignment), the
        // four-stage Q→π attenuation chain (V → Bellman target → softmax(Q/τ)
        // → KL distill) cannot transmit small persistent fees back to π.
        // Cao et al. 2026 (arXiv:2603.29086) showed restoring a direct
        // policy-gradient channel (weight 1e-3 to 1e-2) cuts turnover 96%
        // across TD3/PPO/SAC/DDPG/A2C when paired with quadratic impact
        // costs. This commit pairs (a)+(b)+Phase 3D-A Hold-bias + Phase 3D-B
        // quadratic cost (one atomic intervention package).
        {
            // (a.1) PPO surrogate backward → ss_pi_grad_logits_d (OVERWRITE).
            // Reads pi_logits, log_pi_old, actions, advantages from the
            // same buffers the forward pass used; emits per-logit gradient
            // including the entropy bonus contribution via the kernel's
            // `entropy_coef` slot read.
            self.policy_head.surrogate_backward_logits(
                &self.pi_logits_d,
                &self.log_pi_old_d,
                &self.actions_d,
                &self.advantages_d,
                &self.isv_dev_ptr,
                b_size,
                &mut self.ss_pi_grad_logits_d,
            )
            .context("policy_head.surrogate_backward_logits (Phase 3D-C blend)")?;

            // (a.2) Scale ss_pi_grad_logits_d in place by ppo_surrogate_weight
            //       — OR — zero it if ENABLED ≤ 0.5 (legacy zero-then-distill
            //       path for ablation). One element-wise kernel.
            let total = (b_size * N_ACTIONS) as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.ss_pi_grad_logits_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size as i32);
            args.push_i32(N_ACTIONS as i32);
            let block: u32 = 128;
            let grid: u32 = ((total as u32) + block - 1) / block;
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_pi_grad_blend_fn.cu_function(),
                    (grid, 1, 1), (block, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_pi_grad_blend: {:?}", e))?;
            }
        }
        {
            // (b) Q→π distillation + SAC entropy gradient (ADDS via `+=`).
            // grad = λ × (π_θ(a|s) − softmax(E_Q/τ)(a)) − SAC_α × ...
            //
            // Phase 7a F2-diag (2026-06-04): the kernel additionally
            // populates 4 per-batch scratch buffers (baseline / adv_max /
            // hinge_zero / target_argmax) via a single-thread-per-block
            // reduction at end-of-kernel. The trainer reduces these to
            // means / mode at diag emit (build_diag_value).
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_target_st_d.raw_ptr());
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.ss_pi_grad_logits_d.raw_ptr());
            args.push_ptr(self.f2_diag_baseline_per_b_d.raw_ptr());
            args.push_ptr(self.f2_diag_adv_max_per_b_d.raw_ptr());
            args.push_ptr(self.f2_diag_hinge_zero_per_b_d.raw_ptr());
            args.push_ptr(self.f2_diag_target_argmax_per_b_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_q_pi_distill_grad_fn.cu_function(),
                    (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_q_pi_distill_grad: {:?}", e))?;
            }
        }

        // π gradient = w_ppo · grad_PPO + grad_Q_distill + grad_SAC_entropy
        // (Phase 3D-C blend; ablation: set RL_PPO_SURROGATE_ENABLED_INDEX ≤ 0.5).

        // Phase 2A-C: gate π backward between legacy and MultiHeadPolicy.
        // Both paths consume `ss_pi_grad_logits_d` (Q-distill output)
        // and produce reduced grads ready for Adam below. The
        // MultiHeadPolicy path internally:
        //   * distributes grad through mixture + per-head softmax,
        //   * adds β-weighted aux KL prior gradient,
        //   * distributes through gating softmax,
        //   * reduces per-batch grads to [K × N_ACTIONS × HIDDEN_DIM],
        //     [K × N_ACTIONS], [K × REGIME_DIM], [K] (ready for Adam).
        // Legacy path uses the existing single-head GEMM-style backward.
        if self.use_multi_head_policy {
            // Split disjoint borrows: `perception`, `ss_pi_grad_logits_d`,
            // and `multi_head_policy` are separate fields, so the borrow
            // checker can prove non-aliasing. `isv_dev_ptr` is `u64` (Copy).
            let isv_dev_ptr = self.isv_dev_ptr;
            let regime_h: &CudaSlice<f32> = self.perception.step_regime_d_view();
            let grad_pi: &CudaSlice<f32> = &self.ss_pi_grad_logits_d;
            let mhp = self
                .multi_head_policy
                .as_mut()
                .expect("use_multi_head_policy=true but multi_head_policy=None (construction bug)");
            mhp.backward(grad_pi, h_t_borrow, regime_h, isv_dev_ptr)
                .context("multi_head_policy.backward [step_synthetic_body]")?;
            // NOTE: per Phase 2A-B comment in `MultiHeadPolicy::backward`,
            // `grad_h_t_scratch_d` is NOT folded into the encoder grad
            // here — Phase 2A-C wires the encoder path conservatively
            // (encoder still receives V-head + auxiliaries; π gradient
            // into encoder via multi-head route would change the
            // encoder learning signal materially and is deferred to a
            // future phase per the dispatch's "no incidental changes"
            // constraint).
        } else {
            self.policy_head
                .backward_to_w_b_h(
                    h_t_borrow,
                    &self.ss_pi_grad_logits_d,
                    b_size,
                    &mut self.ss_pi_grad_w_per_batch_d,
                    &mut self.ss_pi_grad_b_per_batch_d,
                    &mut self.ss_pi_grad_h_t_d,
                )
                .context("policy_head.backward_to_w_b_h")?;

            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_pi_grad_w_per_batch_d, b_size, N_ACTIONS * HIDDEN_DIM, &mut self.ss_pi_grad_w_d)?;
            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_pi_grad_b_per_batch_d, b_size, N_ACTIONS, &mut self.ss_pi_grad_b_d)?;
        }

        // ── Step 8: V backward ───────────────────────────────────────
        self.value_head
            .backward(
                h_t_borrow,
                &self.v_pred_d,
                &self.returns_d,
                b_size,
                &mut self.ss_v_loss_per_batch_d,
                &mut self.ss_v_grad_w_per_batch_d,
                &mut self.ss_v_grad_b_per_batch_d,
                &mut self.ss_v_grad_h_t_d,
            )
            .context("value_head.backward")?;

        reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_v_grad_w_per_batch_d, b_size, HIDDEN_DIM, &mut self.ss_v_grad_w_d)?;
        reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_v_grad_b_per_batch_d, b_size, 1, &mut self.ss_v_grad_b_d)?;
        reduce_axis0_mapped(&self.stream, &self.reduce_axis0_fn, &self.ss_v_loss_per_batch_d, b_size, 1, self.ss_v_loss_sum_dev_ptr)?;
        // Host read deferred to after graph capture region.

        // ── Step 9: Adam updates on each head's w and b ──────────────
        self.dqn_w_adam
            .step(&mut self.dqn_head.w_d, &self.ss_q_grad_w_d)
            .context("dqn_w_adam.step")?;
        self.dqn_b_adam
            .step(&mut self.dqn_head.b_d, &self.ss_q_grad_b_d)
            .context("dqn_b_adam.step")?;
        // Phase 2A-C: gate the policy Adam step between legacy single-head
        // and MultiHeadPolicy four-tensor update. Both branches preserve
        // the existing LR sourcing (slot 412 `RL_LR_PI_INDEX`) — see the
        // `lr_pi` assignment near line 5345.
        if self.use_multi_head_policy {
            let mhp = self
                .multi_head_policy
                .as_mut()
                .expect("use_multi_head_policy=true but multi_head_policy=None (construction bug)");
            // Same LR for all four tensors (mirrors legacy w/b sharing
            // a single `lr_pi`). Future controller could split.
            self.mhp_w_heads_adam
                .as_mut()
                .expect("mhp_w_heads_adam None with flag on")
                .step(&mut mhp.w_heads_d, &mhp.grad_w_heads_d)
                .context("mhp_w_heads_adam.step")?;
            self.mhp_b_heads_adam
                .as_mut()
                .expect("mhp_b_heads_adam None with flag on")
                .step(&mut mhp.b_heads_d, &mhp.grad_b_heads_d)
                .context("mhp_b_heads_adam.step")?;
            self.mhp_w_gate_adam
                .as_mut()
                .expect("mhp_w_gate_adam None with flag on")
                .step(&mut mhp.w_gate_d, &mhp.grad_w_gate_d)
                .context("mhp_w_gate_adam.step")?;
            self.mhp_b_gate_adam
                .as_mut()
                .expect("mhp_b_gate_adam None with flag on")
                .step(&mut mhp.b_gate_d, &mhp.grad_b_gate_d)
                .context("mhp_b_gate_adam.step")?;
        } else {
            self.policy_w_adam
                .step(&mut self.policy_head.w_d, &self.ss_pi_grad_w_d)
                .context("policy_w_adam.step")?;
            self.policy_b_adam
                .step(&mut self.policy_head.b_d, &self.ss_pi_grad_b_d)
                .context("policy_b_adam.step")?;
        }
        self.value_w_adam
            .step(&mut self.value_head.w_d, &self.ss_v_grad_w_d)
            .context("value_w_adam.step")?;
        self.value_b_adam
            .step(&mut self.value_head.b_d, &self.ss_v_grad_b_d)
            .context("value_b_adam.step")?;

        // ── Step 9b: Spectral decouple on Q logits + π logits ────────
        // Adds lambda * mean(logits^2) penalty to the Q and π loss accumulators
        // and accumulates the corresponding gradient to the grad_logits buffers.
        // Runs AFTER Adam (affects the loss diagnostic, not the param update —
        // the penalty gradient was already folded into the Q/π grad_logits
        // before backward_to_w_b_h in a future tightening pass; for now the
        // penalty is diagnostic + next-step grad accumulation).
        {
            let q_n = (b_size * N_ACTIONS * Q_N_ATOMS) as i32;
            let block_x = (q_n as u32).min(256).max(1);
            let smem = block_x * (std::mem::size_of::<f32>() as u32);
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_d.raw_ptr());
            args.push_ptr(self.ss_q_loss_dev_ptr);
            args.push_ptr(self.ss_q_grad_logits_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(q_n);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_spectral_decouple_fn.cu_function(),
                    (1, 1, 1), (block_x, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_spectral_decouple(q_logits): {:?}", e))?;
            }
        }
        {
            let pi_n = (b_size * N_ACTIONS) as i32;
            let block_x = (pi_n as u32).min(256).max(1);
            let smem = block_x * (std::mem::size_of::<f32>() as u32);
            let mut args = RawArgs::new();
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(self.ss_pi_loss_dev_ptr);
            args.push_ptr(self.ss_pi_grad_logits_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(pi_n);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_spectral_decouple_fn.cu_function(),
                    (1, 1, 1), (block_x, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_spectral_decouple(pi_logits): {:?}", e))?;
            }
        }

        // ── Step 9c: Outcome head fused forward + CE + backward ────────
        // K=3 outcome classifier trained from reward/done labels assigned
        // in step_with_lobsim. Single fused kernel: fwd → CE → bwd with
        // logits and grad_logits kept in shared memory (eliminates two
        // global round-trips). reduce_axis0 → batch-summed grad_W/b.
        // Adam on outcome W/b.
        // Lambda from ISV[RL_OUTCOME_AUX_LAMBDA_INDEX] scales the
        // encoder-upstream grad_h_t contribution in Step 10.
        {
            let h_t_borrow_for_outcome: &CudaSlice<f32> = &self.sampled_h_t_d;
            self.outcome_head.forward_loss_backward(
                h_t_borrow_for_outcome,
                &mut self.ss_outcome_grad_w_pb_d,
                &mut self.ss_outcome_grad_b_pb_d,
                &mut self.ss_outcome_grad_h_t_d,
                b_size,
            ).context("step_synthetic: outcome_head.forward_loss_backward")?;
            // Reduce-axis-0: batch-summed weight and bias gradients.
            let k_classes = crate::rl::outcome_head::K_CLASSES;
            reduce_axis0_free(
                &self.stream, &self.reduce_axis0_fn,
                &self.ss_outcome_grad_w_pb_d, b_size,
                HIDDEN_DIM * k_classes,
                &mut self.ss_outcome_grad_w_d,
            )?;
            reduce_axis0_free(
                &self.stream, &self.reduce_axis0_fn,
                &self.ss_outcome_grad_b_pb_d, b_size,
                k_classes,
                &mut self.ss_outcome_grad_b_d,
            )?;
            // Adam step on outcome W and b.
            self.outcome_w_adam
                .step(&mut self.outcome_head.w_d, &self.ss_outcome_grad_w_d)
                .context("outcome_w_adam.step")?;
            self.outcome_b_adam
                .step(&mut self.outcome_head.b_d, &self.ss_outcome_grad_b_d)
                .context("outcome_b_adam.step")?;
        }

        // ── SP20 P3 FRD backward chain (F.4) ─────────────────────────
        // Reads frd_hidden_d + frd_logits_d (populated by
        // step_with_lobsim's FRD fwd) + self.frd_labels_d (sentinel -1
        // until F.5 loader integration populates from forward-snapshot
        // lookahead). On all-sentinel labels, softmax_ce_grad emits
        // zero gradient + zero loss → entire chain produces no signal,
        // Adam steps are no-ops modulo β decay. Once F.5 lands real
        // labels, this path activates the supervised learning signal
        // without any further trainer-side changes.
        {
            let frd_h_dim = crate::rl::common::FRD_HIDDEN_DIM;
            let frd_out_dim = crate::rl::frd::FRD_OUT_DIM;
            // FRD scratch zeroing REMOVED — backward kernels overwrite
            // every element; alloc_zeros'd at construction suffices.
            self.frd_head.softmax_ce_grad(
                &self.frd_logits_d,
                &self.frd_labels_d,
                &mut self.ss_frd_grad_logits_d,
                &self.ss_frd_loss_dev_ptr,
                b_size,
            )?;
            self.frd_head.layer2_bwd(
                &self.frd_hidden_d,
                &self.ss_frd_grad_logits_d,
                &mut self.ss_frd_grad_w2_pb_d,
                &mut self.ss_frd_grad_b2_pb_d,
                &mut self.ss_frd_grad_hidden_d,
                b_size,
            )?;
            self.frd_head.layer1_bwd(
                h_t_borrow,
                &self.frd_hidden_d,
                &self.ss_frd_grad_hidden_d,
                &mut self.ss_frd_grad_w1_pb_d,
                &mut self.ss_frd_grad_b1_pb_d,
                &mut self.ss_frd_grad_h_t_d,
                b_size,
            )?;
            // Reduce-axis-0: batch-summed weight grads.
            // FRD reduced-grad zeroing REMOVED — reduce_axis0 overwrites.
            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_frd_grad_w1_pb_d, b_size, HIDDEN_DIM * frd_h_dim, &mut self.ss_frd_grad_w1_d)?;
            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_frd_grad_b1_pb_d, b_size, frd_h_dim, &mut self.ss_frd_grad_b1_d)?;
            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_frd_grad_w2_pb_d, b_size, frd_h_dim * frd_out_dim, &mut self.ss_frd_grad_w2_d)?;
            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_frd_grad_b2_pb_d, b_size, frd_out_dim, &mut self.ss_frd_grad_b2_d)?;
            // Adam steps — sentinel labels yield zero grads → no
            // weight movement (only momentum decay).
            self.frd_w1_adam
                .step(&mut self.frd_head.w1_d, &self.ss_frd_grad_w1_d)
                .context("frd_w1_adam.step")?;
            self.frd_b1_adam
                .step(&mut self.frd_head.b1_d, &self.ss_frd_grad_b1_d)
                .context("frd_b1_adam.step")?;
            self.frd_w2_adam
                .step(&mut self.frd_head.w2_d, &self.ss_frd_grad_w2_d)
                .context("frd_w2_adam.step")?;
            self.frd_b2_adam
                .step(&mut self.frd_head.b2_d, &self.ss_frd_grad_b2_d)
                .context("frd_b2_adam.step")?;
            // Host read deferred to after graph capture region.
        }

        // ── Step 10: encoder grad combine (Phase E.2-DEFER item 1) ───
        // Zero the combined slot, fold π+V grad_h_t with λ-weights.
        //
        // R7d stop-grad: Q's grad_h_t is OMITTED here per the off-policy
        // shared-encoder pattern (Q is trained on PER-sampled past h_t
        // values, whose gradient should NOT flow into the encoder
        // — accumulating stale-state grad would poison the encoder).
        // Encoder learns from π + V (on-policy current-step) + FRD +
        // outcome (aux supervised) + BCE + aux (supervised via
        // perception.step_batched). Standard off-policy AC pattern
        // (SAC / R2D2 / IMPALA do the same for the Q stop-grad).
        //
        // The combined grad slot feeds `backward_encoder_with_grad_h_t`
        // below, which runs the shared encoder backward chain that
        // `dispatch_train_step` also uses (single source of truth per
        // feedback_single_source_of_truth_no_duplicates).
        // Zero via raw driver call — this accumulator genuinely needs
        // zeroing (accumulate_grad_h ADDs, doesn't overwrite), but we
        // bypass cudarc's event tracking overhead.
        unsafe {
            raw_memset_d8_zero(
                self.grad_h_t_combined_d.raw_ptr(),
                self.grad_h_t_combined_d.num_bytes(),
                self.raw_stream,
            ).map_err(|e| anyhow::anyhow!("raw zero grad_h_t_combined: {:?}", e))?;
        }
        // All encoder-upstream gradients are /B normalized for
        // batch-size invariance. Without this, doubling B doubles the
        // encoder gradient from each RL head.
        let b_inv = 1.0 / b_size as f32;
        accumulate_grad_h(
            &self.stream,
            &self.grad_h_accumulate_fn,
            &self.ss_pi_grad_h_t_d,
            lambdas.pi * b_inv,
            &mut self.grad_h_t_combined_d,
        )?;
        accumulate_grad_h(
            &self.stream,
            &self.grad_h_accumulate_fn,
            &self.ss_v_grad_h_t_d,
            lambdas.v * b_inv,
            &mut self.grad_h_t_combined_d,
        )?;
        accumulate_grad_h(
            &self.stream,
            &self.grad_h_accumulate_fn,
            &self.ss_frd_grad_h_t_d,
            lambdas.frd * b_inv,
            &mut self.grad_h_t_combined_d,
        )?;
        {
            let outcome_lambda =
                self.read_isv_host(crate::rl::isv_slots::RL_OUTCOME_AUX_LAMBDA_INDEX);
            accumulate_grad_h(
                &self.stream,
                &self.grad_h_accumulate_fn,
                &self.ss_outcome_grad_h_t_d,
                outcome_lambda * b_inv,
                &mut self.grad_h_t_combined_d,
            )?;
        }

        // ── Phase 4-B (2026-06-04) — fold band-head grad_h_t into encoder ─
        // The band's backward kernel was launched in `step_with_lobsim_gpu_body`
        // OUTSIDE graph capture (host gated on `RL_BAND_ENABLED_INDEX > 0.5`);
        // here we reduce the per-batch weight/bias scratches via reduce_axis0
        // and fold the OVERWRITE `grad_h_t_d` into the encoder grad combiner.
        // When band is disabled the device-side master-gate guard wrote
        // zeros into `grad_h_t_d`, so the accumulate is a no-op and Phase 3D
        // bit-equality is preserved.
        let band_enabled_host = self
            .read_isv_host(crate::rl::isv_slots::RL_BAND_ENABLED_INDEX)
            > 0.5;
        if band_enabled_host {
            // Reduce per-batch weight + bias grads.
            reduce_axis0_free(
                &self.stream,
                &self.reduce_axis0_fn,
                &self.band_head.grad_w_per_b_d,
                b_size,
                crate::rl::band_head::BAND_OUT * HIDDEN_DIM,
                &mut self.band_head.grad_w_d,
            )?;
            reduce_axis0_free(
                &self.stream,
                &self.reduce_axis0_fn,
                &self.band_head.grad_b_per_b_d,
                b_size,
                crate::rl::band_head::BAND_OUT,
                &mut self.band_head.grad_b_d,
            )?;
            // Fold band's grad_h_t into encoder grad combiner. λ_band is
            // sourced from `RL_BAND_LOSS_WEIGHT_INDEX` (slot 802) so an
            // operator can dial down the band's encoder-grad contribution
            // via the same slot that gates the loss magnitude.
            let lambda_band = self
                .read_isv_host(crate::rl::isv_slots::RL_BAND_LOSS_WEIGHT_INDEX);
            accumulate_grad_h(
                &self.stream,
                &self.grad_h_accumulate_fn,
                &self.band_head.grad_h_t_d,
                lambda_band * b_inv,
                &mut self.grad_h_t_combined_d,
            )?;
            // Adam steps on band W and b. LR sourced from RL_LR_PI_INDEX —
            // the band is a structural action-selection prior and tracks
            // π's plateau dynamics.
            let lr_pi_band = self.read_isv_host(crate::rl::isv_slots::RL_LR_PI_INDEX);
            self.band_w_adam.lr = lr_pi_band;
            self.band_b_adam.lr = lr_pi_band;
            self.band_w_adam
                .step(&mut self.band_head.w_band_d, &self.band_head.grad_w_d)
                .context("band_w_adam.step")?;
            self.band_b_adam
                .step(&mut self.band_head.b_band_d, &self.band_head.grad_b_d)
                .context("band_b_adam.step")?;
        }

        // ── Step 11: encoder backward (Phase E.3a) ───────────────────
        // Seed the perception trainer's per-K hidden-state grad buffer
        // from `grad_h_t_combined_d` at slot K-1 (the only slot the
        // RL heads consumed `h_t` from) and run the shared encoder
        // backward kernel chain. This updates ALL encoder params:
        // CfC ×4, LN ×2, VSN, attn_q, Mamba2 L1+L2 — closes the last
        // deferred item from Phase E.2.
        self.perception
            .backward_encoder_with_grad_h_t(
                &self.grad_h_t_combined_d,
                self.cfg.perception.n_batch,
                self.cfg.perception.seq_len,
            )
            .context("perception.backward_encoder_with_grad_h_t")?;

        // ── Step 11b: feed step_synthetic's EMA inputs (entropy,
        // td_kurtosis, KL) into their controller ISV slots. Deferred
        // to AFTER the encoder backward so the `&self.perception`
        // borrow (held by h_t_borrow earlier in this function) is
        // released before the `&mut self` launches here.
        //
        //   1. `entropy_observed_ema` (ISV[420]) ← per-batch entropy
        //      from PPO surrogate forward (entropy_d still live here).
        //      ema_update_per_step does the mean reduce internally,
        //      so entropy_d goes in directly.
        //   2. `td_kurtosis_ema` (ISV[422]) ← kurtosis of per-sample
        //      CE loss (td_per_sample_d). rl_kurtosis_b reduces to
        //      a scalar, then ema_update_per_step with b_size=1.
        //
        // Both updates take effect on the NEXT step's controller
        // fire (controllers fire in step_with_lobsim BEFORE
        // step_synthetic). One-step lag is acceptable — controllers
        // adapt over many steps.
        self.launch_ema_update_per_step(
            crate::rl::isv_slots::RL_ENTROPY_OBSERVED_EMA_INDEX,
            RL_LR_CONTROLLER_ALPHA,
            &self.ss_entropy_d,
            b_size,
        )
        .context("ema_update_per_step(entropy_observed)")?;
        // Streaming kurtosis kernel writes directly to
        // RL_TD_KURTOSIS_EMA_INDEX; no separate ema_update_per_step
        // needed (the streaming kernel IS the EMA — folds across STEPS).
        {
            let b_size_i = b_size as i32;
            let out_slot = crate::rl::isv_slots::RL_TD_KURTOSIS_EMA_INDEX as i32;
            let mean_slot = crate::rl::isv_slots::RL_TD_KURT_STREAM_MEAN_INDEX as i32;
            let m2_slot = crate::rl::isv_slots::RL_TD_KURT_STREAM_M2_INDEX as i32;
            let m4_slot = crate::rl::isv_slots::RL_TD_KURT_STREAM_M4_INDEX as i32;
            let clamp_slot = crate::rl::isv_slots::RL_TD_KURTOSIS_CLAMP_INDEX as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.td_per_sample_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(out_slot);
            args.push_i32(mean_slot);
            args.push_i32(m2_slot);
            args.push_i32(m4_slot);
            args.push_i32(clamp_slot);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_kurtosis_streaming_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_kurtosis_streaming (td): {:?}", e))?;
            }
        }

        // kl_pi EMA — Schulman-style approximation
        // `mean(log π_old(a) − log π_new(a))` over the sampled action.
        // log_pi_old_d was recorded at action-sample time (per-batch
        // log π under the previous policy at the action taken);
        // pi_log_prob_d (= log π_new at the same action) is the PPO
        // surrogate forward's per-batch output. Mean-reduce to scalar
        // → ema_update_per_step with b_size=1.
        {
            let block_dim = (b_size.next_power_of_two() as u32).max(1);
            let smem = (block_dim as usize * std::mem::size_of::<f32>()) as u32;
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.log_pi_old_d.raw_ptr());
            args.push_ptr(self.ss_pi_log_prob_d.raw_ptr());
            args.push_ptr(self.ema_input_scratch_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_kl_approx_b_fn.cu_function(),
                    (1, 1, 1), (block_dim, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_kl_approx_b: {:?}", e))?;
            }
        }

        // R9 diag — per-batch max |log π_new − log π_old| → ISV[441].
        // Same input buffers as rl_kl_approx_b but max-reduce |diff|
        // instead of mean signed-diff. Surfaces the largest single
        // policy excursion in this step's batch for the diag JSONL.
        // Cross-check: log(isv[RL_PPO_RATIO_CLAMP_MAX_INDEX]) is the
        // ceiling — when this slot exceeds that, the ratio clamp fired
        // this step.
        {
            let block_dim = (b_size.next_power_of_two() as u32).max(1);
            let smem = (block_dim as usize * std::mem::size_of::<f32>()) as u32;
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.log_pi_old_d.raw_ptr());
            args.push_ptr(self.ss_pi_log_prob_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.ppo_log_ratio_abs_max_b_fn.cu_function(),
                    (1, 1, 1), (block_dim, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("ppo_log_ratio_abs_max_b: {:?}", e))?;
            }
        }
        // Inline ema_update_per_step(kl_pi) — avoids &mut self borrow
        // conflict that forced .clone() on ema_input_scratch_d.
        {
            let smem_ema = std::mem::size_of::<f32>() as u32;
            let slot_i = crate::rl::isv_slots::RL_KL_PI_EMA_INDEX as i32;
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let b_size_i = 1i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot_i);
            args.push_f32(alpha);
            args.push_ptr(self.ema_input_scratch_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.ema_update_per_step_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), smem_ema,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("ema_update_per_step (kl_pi): {:?}", e))?;
            }
        }

        // Per-head gradient-norm EMAs (Q, π, V) — feed the
        // signal-modulated LR controller (rl_lr_controller's target
        // formula: `lr_prev × TARGET_GRAD_NORM / observed_grad_norm`).
        // ss_*_grad_w_d buffers are persistent struct fields; the
        // l2_norm launches here read them after the Adam step has
        // already consumed them.
        //
        // Inlined launch_l2_norm + launch_ema_update_per_step to avoid
        // CudaSlice::clone() inside graph capture (allocations forbidden).
        let k_dqn_hidden = (N_ACTIONS * Q_N_ATOMS * HIDDEN_DIM) as usize;
        let smem_l2: u32 = 256 * (std::mem::size_of::<f32>() as u32);
        let smem_ema: u32 = std::mem::size_of::<f32>() as u32;
        {
            let n_i = k_dqn_hidden as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.ss_q_grad_w_d.raw_ptr());
            args.push_ptr(self.ema_input_scratch_d.raw_ptr());
            args.push_i32(n_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_l2_norm_fn.cu_function(),
                    (1, 1, 1), (256, 1, 1), smem_l2,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_l2_norm (ss_q_grad_w_d): {:?}", e))?;
            }
        }
        {
            let slot_i = crate::rl::isv_slots::RL_Q_GRAD_NORM_EMA_INDEX as i32;
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let b_size_i = 1i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot_i);
            args.push_f32(alpha);
            args.push_ptr(self.ema_input_scratch_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.ema_update_per_step_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), smem_ema,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("ema_update_per_step (q_grad_norm): {:?}", e))?;
            }
        }

        let n_actions_hidden = (N_ACTIONS * HIDDEN_DIM) as usize;
        {
            let n_i = n_actions_hidden as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.ss_pi_grad_w_d.raw_ptr());
            args.push_ptr(self.ema_input_scratch_d.raw_ptr());
            args.push_i32(n_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_l2_norm_fn.cu_function(),
                    (1, 1, 1), (256, 1, 1), smem_l2,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_l2_norm (ss_pi_grad_w_d): {:?}", e))?;
            }
        }
        {
            let slot_i = crate::rl::isv_slots::RL_PI_GRAD_NORM_EMA_INDEX as i32;
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let b_size_i = 1i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot_i);
            args.push_f32(alpha);
            args.push_ptr(self.ema_input_scratch_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.ema_update_per_step_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), smem_ema,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("ema_update_per_step (pi_grad_norm): {:?}", e))?;
            }
        }

        {
            let n_i = HIDDEN_DIM as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.ss_v_grad_w_d.raw_ptr());
            args.push_ptr(self.ema_input_scratch_d.raw_ptr());
            args.push_i32(n_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_l2_norm_fn.cu_function(),
                    (1, 1, 1), (256, 1, 1), smem_l2,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_l2_norm (ss_v_grad_w_d): {:?}", e))?;
            }
        }
        {
            let slot_i = crate::rl::isv_slots::RL_V_GRAD_NORM_EMA_INDEX as i32;
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let b_size_i = 1i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot_i);
            args.push_f32(alpha);
            args.push_ptr(self.ema_input_scratch_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.ema_update_per_step_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), smem_ema,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("ema_update_per_step (v_grad_norm): {:?}", e))?;
            }
        }

            if capturing_training {
                let graph = self.stream
                    .end_capture(
                        CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH,
                    )
                    .context("training end_capture")?
                    .ok_or_else(|| anyhow::anyhow!("training end_capture returned None"))?;
                self.training_graph = Some(graph);
            }
        } // end else (training warmup / capture)

        // Loss values are now read directly from mapped-pinned host_ptr
        // at the start of the NEXT step_synthetic call (one-step delay).
        // No DtoD copies, no events — GPU writes via dev_ptr are visible
        // to the host after stream ordering guarantees completion.
        Ok(self.last_step_stats.clone())
    }

    /// DQN-only replay step — used by step_with_lobsim's K-loop for
    /// iterations after the first. Skips PPO surrogate, V regression,
    /// encoder backward, LR controller emit, ISV mirror refresh — all
    /// of those run once per env step in the first iter via
    /// step_synthetic. This helper just does:
    ///
    ///   1. Forward Q on `sampled_h_t` and `sampled_h_tp1`
    ///   2. Double-DQN argmax on online Q at h_tp1 →
    ///      `sampled_next_actions_d`
    ///   3. Bellman target via `forward_target` (target net) +
    ///      `fused_select_and_project_bellman`
    ///   4. Q backward: logits → grad_w/b/h_t. Q's grad_h_t is
    ///      discarded (R7d stop-grad: SAMPLED h_t is a past-step
    ///      encoder output; folding its gradient into the encoder
    ///      would poison live training).
    ///   5. Per-batch grad reduce → grad_w/grad_b
    ///   6. Q Adam (uses LR already set in step_synthetic via LR
    ///      controller emit)
    ///   7. Writes `td_per_sample_d` for PER priority update by the
    ///      caller.
    ///
    /// Assumes GPU PER sample has populated `sampled_h_t_d /
    /// sampled_h_tp1_d / sampled_actions_d / sampled_rewards_d /
    /// sampled_dones_d` externally and writes `sampled_next_actions_d`.
    ///
    /// Returns mean Q loss (scalar). Caller uses this for the
    /// LR-controller's plateau detection (but DOES NOT mirror it into
    /// `last_q_loss` — that field is the env-step-paired iter's Q
    /// loss, the canonical reference for plateau detection).
    pub fn dqn_replay_step(&mut self, b_size: usize) -> Result<f32> {
        if b_size == 0 {
            anyhow::bail!("dqn_replay_step: empty batch (b_size = 0)");
        }
        debug_assert_eq!(self.sampled_h_t_d.len(), b_size * HIDDEN_DIM);
        debug_assert_eq!(self.sampled_h_tp1_d.len(), b_size * HIDDEN_DIM);

        // Zero persistent per-iter scratch — raw memset bypasses cudarc's
        // bind_to_thread + event tracking (~200us overhead per call).
        {
            let s = self.raw_stream;
            unsafe {
                raw_memset_d8_zero(self.ss_q_loss_dev_ptr, std::mem::size_of::<f32>(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_q_loss: {:?}", e))?;
                raw_memset_d8_zero(self.ss_q_target_full_d.raw_ptr(), self.ss_q_target_full_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_q_target_full_d: {:?}", e))?;
                raw_memset_d8_zero(self.ss_target_dist_d.raw_ptr(), self.ss_target_dist_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_target_dist_d: {:?}", e))?;
                raw_memset_d8_zero(self.ss_q_grad_logits_d.raw_ptr(), self.ss_q_grad_logits_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_q_grad_logits_d: {:?}", e))?;
                raw_memset_d8_zero(self.ss_q_grad_h_t_d.raw_ptr(), self.ss_q_grad_h_t_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_q_grad_h_t_d: {:?}", e))?;
                raw_memset_d8_zero(self.ss_q_grad_w_d.raw_ptr(), self.ss_q_grad_w_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_q_grad_w_d: {:?}", e))?;
                raw_memset_d8_zero(self.ss_q_grad_b_d.raw_ptr(), self.ss_q_grad_b_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_q_grad_b_d: {:?}", e))?;
                // Outcome head backward scratch (replay steps also train the head).
                raw_memset_d8_zero(self.ss_outcome_grad_w_pb_d.raw_ptr(), self.ss_outcome_grad_w_pb_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_outcome_grad_w_pb_d (replay): {:?}", e))?;
                raw_memset_d8_zero(self.ss_outcome_grad_b_pb_d.raw_ptr(), self.ss_outcome_grad_b_pb_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_outcome_grad_b_pb_d (replay): {:?}", e))?;
                raw_memset_d8_zero(self.ss_outcome_grad_h_t_d.raw_ptr(), self.ss_outcome_grad_h_t_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_outcome_grad_h_t_d (replay): {:?}", e))?;
                raw_memset_d8_zero(self.ss_outcome_grad_w_d.raw_ptr(), self.ss_outcome_grad_w_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_outcome_grad_w_d (replay): {:?}", e))?;
                raw_memset_d8_zero(self.ss_outcome_grad_b_d.raw_ptr(), self.ss_outcome_grad_b_d.num_bytes(), s)
                    .map_err(|e| anyhow::anyhow!("zero ss_outcome_grad_b_d (replay): {:?}", e))?;
            }
        }

        let b_size_i = b_size as i32;

        // ── 1. Forward Q on sampled_h_t and sampled_h_tp1 ───────────
        self.dqn_head
            .forward(&self.sampled_h_t_d, b_size, &mut self.q_logits_d)
            .context("dqn_replay_step: dqn_head.forward(sampled_h_t)")?;
        self.dqn_head
            .forward(&self.sampled_h_tp1_d, b_size, &mut self.q_logits_tp1_d)
            .context("dqn_replay_step: dqn_head.forward(sampled_h_tp1)")?;

        // ── 1b. IQN forward + loss + backward on replay transitions.
        // Online forward on sampled_h_t, target forward on sampled_h_tp1,
        // quantile Huber loss, backward through forward to weight grads,
        // reduce_axis0 + Adam step.
        {
            let n_tau = self.iqn_head.n_tau();

            // Online forward on sampled_h_t (fused tau sampling + IQN forward).
            self.iqn_head
                .forward(
                    &mut self.iqn_prng_state_d,
                    &self.sampled_h_t_d,
                    &mut self.iqn_tau_d,
                    b_size,
                    n_tau,
                    &mut self.iqn_q_values_d,
                )
                .context("dqn_replay_step: iqn_head.forward(sampled_h_t)")?;
            self.iqn_head
                .expected_q(
                    &self.iqn_q_values_d,
                    b_size,
                    n_tau,
                    &mut self.iqn_expected_q_d,
                )
                .context("dqn_replay_step: iqn_head.expected_q")?;

            // Zero IQN per-iter scratch — raw memset, same rationale as above.
            {
                let s = self.raw_stream;
                unsafe {
                    raw_memset_d8_zero(self.ss_iqn_tau_target_d.raw_ptr(), self.ss_iqn_tau_target_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_tau_target_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_target_q_d.raw_ptr(), self.ss_iqn_target_q_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_target_q_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_loss_pb_d.raw_ptr(), self.ss_iqn_loss_pb_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_loss_pb_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_grad_q_d.raw_ptr(), self.ss_iqn_grad_q_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_grad_q_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_grad_w_out_pb_d.raw_ptr(), self.ss_iqn_grad_w_out_pb_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_grad_w_out_pb_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_grad_b_out_pb_d.raw_ptr(), self.ss_iqn_grad_b_out_pb_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_grad_b_out_pb_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_grad_w_embed_pb_d.raw_ptr(), self.ss_iqn_grad_w_embed_pb_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_grad_w_embed_pb_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_grad_b_embed_pb_d.raw_ptr(), self.ss_iqn_grad_b_embed_pb_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_grad_b_embed_pb_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_grad_w_out_d.raw_ptr(), self.ss_iqn_grad_w_out_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_grad_w_out_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_grad_b_out_d.raw_ptr(), self.ss_iqn_grad_b_out_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_grad_b_out_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_grad_w_embed_d.raw_ptr(), self.ss_iqn_grad_w_embed_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_grad_w_embed_d: {:?}", e))?;
                    raw_memset_d8_zero(self.ss_iqn_grad_b_embed_d.raw_ptr(), self.ss_iqn_grad_b_embed_d.num_bytes(), s)
                        .map_err(|e| anyhow::anyhow!("zero ss_iqn_grad_b_embed_d: {:?}", e))?;
                }
            }

            // Target forward on sampled_h_tp1 (fused tau sampling + IQN forward).
            self.iqn_head
                .forward_target(
                    &mut self.iqn_prng_state_d,
                    &self.sampled_h_tp1_d,
                    &mut self.ss_iqn_tau_target_d,
                    b_size,
                    n_tau,
                    &mut self.ss_iqn_target_q_d,
                )
                .context("dqn_replay_step: iqn_head.forward_target(sampled_h_tp1)")?;

            // Quantile Huber loss + gradient w.r.t. online Q values.
            self.iqn_head
                .compute_loss(
                    &self.iqn_q_values_d,
                    &self.ss_iqn_target_q_d,
                    &self.iqn_tau_d,
                    &self.sampled_actions_d,
                    b_size,
                    n_tau,
                    n_tau,
                    &mut self.ss_iqn_loss_pb_d,
                    &mut self.ss_iqn_grad_q_d,
                )
                .context("dqn_replay_step: iqn_head.compute_loss")?;

            // Backward through IQN forward: grad_q → grad_w_out/b_out/w_embed/b_embed.
            let k_w_out = HIDDEN_DIM * N_ACTIONS;
            let k_w_embed = EMBED_DIM * HIDDEN_DIM;

            self.iqn_head
                .backward(
                    &self.sampled_h_t_d,
                    &self.iqn_tau_d,
                    &self.ss_iqn_grad_q_d,
                    b_size,
                    n_tau,
                    &mut self.ss_iqn_grad_w_out_pb_d,
                    &mut self.ss_iqn_grad_b_out_pb_d,
                    &mut self.ss_iqn_grad_w_embed_pb_d,
                    &mut self.ss_iqn_grad_b_embed_pb_d,
                )
                .context("dqn_replay_step: iqn_head.backward")?;

            // Reduce across batches.

            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_iqn_grad_w_out_pb_d, b_size, k_w_out, &mut self.ss_iqn_grad_w_out_d)?;
            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_iqn_grad_b_out_pb_d, b_size, N_ACTIONS, &mut self.ss_iqn_grad_b_out_d)?;
            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_iqn_grad_w_embed_pb_d, b_size, k_w_embed, &mut self.ss_iqn_grad_w_embed_d)?;
            reduce_axis0_free(&self.stream, &self.reduce_axis0_fn, &self.ss_iqn_grad_b_embed_pb_d, b_size, HIDDEN_DIM, &mut self.ss_iqn_grad_b_embed_d)?;

            // IQN Adam steps (LR from same ISV[RL_IQN_LR_INDEX] as the Q head).
            self.iqn_w_out_adam
                .step(&mut self.iqn_head.w_out_d, &self.ss_iqn_grad_w_out_d)
                .context("dqn_replay_step: iqn_w_out_adam.step")?;
            self.iqn_b_out_adam
                .step(&mut self.iqn_head.b_out_d, &self.ss_iqn_grad_b_out_d)
                .context("dqn_replay_step: iqn_b_out_adam.step")?;
            self.iqn_w_embed_adam
                .step(&mut self.iqn_head.w_embed_d, &self.ss_iqn_grad_w_embed_d)
                .context("dqn_replay_step: iqn_w_embed_adam.step")?;
            self.iqn_b_embed_adam
                .step(&mut self.iqn_head.b_embed_d, &self.ss_iqn_grad_b_embed_d)
                .context("dqn_replay_step: iqn_b_embed_adam.step")?;
        }

        // ── 2. Double-DQN argmax on online Q at h_tp1 ───────────────
        {
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_tp1_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(self.sampled_next_actions_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.argmax_expected_q_fn.cu_function(),
                    (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("dqn_replay_step: argmax_expected_q: {:?}", e))?;
            }
        }

        // ── 3. Bellman target build via TARGET net at h_tp1 ─────────
        self.dqn_head
            .forward_target(&self.sampled_h_tp1_d, b_size, &mut self.ss_q_target_full_d)
            .context("dqn_replay_step: dqn_head.forward_target(sampled_h_tp1)")?;
        self.dqn_head
            .fused_select_and_project_bellman(
                &self.ss_q_target_full_d,
                &self.sampled_next_actions_d,
                &self.sampled_rewards_d,
                &self.sampled_dones_d,
                &self.sampled_n_step_gammas_d,
                &self.isv_dev_ptr,
                b_size,
                &mut self.ss_target_dist_d,
                &mut self.bellman_sat_top_per_batch_d,
                &mut self.bellman_sat_bot_per_batch_d,
                &mut self.bellman_max_pre_per_batch_d,
                &mut self.bellman_min_pre_per_batch_d,
            )
            .context("dqn_replay_step: dqn_head.fused_select_and_project_bellman")?;
        // B-9 (2026-06-01): cross-batch saturation reduce → ISV 726-729.
        self.dqn_head
            .launch_saturation_reduce(
                &self.isv_dev_ptr,
                b_size,
                &self.bellman_sat_top_per_batch_d,
                &self.bellman_sat_bot_per_batch_d,
                &self.bellman_max_pre_per_batch_d,
                &self.bellman_min_pre_per_batch_d,
            )
            .context("dqn_replay_step: dqn_head.launch_saturation_reduce")?;

        // ── 4. Q backward (logits → grad_w/b/h_t) ───────────────────
        // F4.1: kernel writes per-batch CE only; scalar q-loss comes
        // from the separate mean-reduce kernel call below.
        self.dqn_head
            .backward_logits(
                &self.q_logits_d,
                &self.ss_target_dist_d,
                &self.sampled_actions_d,
                b_size,
                &mut self.td_per_sample_d,
                &mut self.ss_q_grad_logits_d,
            )
            .context("dqn_replay_step: dqn_head.backward_logits")?;
        self.dqn_head
            .reduce_loss_to_scalar(
                &self.td_per_sample_d,
                b_size,
                &self.ss_q_loss_dev_ptr,
            )
            .context("dqn_replay_step: dqn_head.reduce_loss_to_scalar")?;
        // Read PREVIOUS step's Q loss directly from mapped-pinned host
        // ptr. One-step delay: the GPU wrote this value during the prior
        // dqn_replay_step or step_synthetic (same stream, guaranteed
        // complete by stream ordering). At construction the mapped page
        // is zero = sentinel, and the per-branch LR controller's cold-
        // start gate handles that.
        let l_q_host = unsafe { std::ptr::read_volatile(self.ss_q_loss_mapped.host_ptr) };
        let l_q = l_q_host / (b_size as f32).max(1.0);

        self.dqn_head
            .backward_gemm(
                &self.sampled_h_t_d,
                &self.ss_q_grad_logits_d,
                b_size,
                &mut self.ss_q_grad_w_d,
                &mut self.ss_q_grad_b_d,
                &mut self.ss_q_grad_h_t_d,
            )
            .context("dqn_replay_step: dqn_head.backward_gemm")?;
        // R7d stop-grad: discard ss_q_grad_h_t_d (sampled h_t is past-step
        // encoder output; folding its gradient into the encoder would
        // poison live training). Same semantics as step_synthetic.
        let _ = &self.ss_q_grad_h_t_d;

        // ── 5. Q Adam (uses LR set by step_synthetic; we don't re-fire
        // the LR controller here — that runs once per env step).
        self.dqn_w_adam
            .step(&mut self.dqn_head.w_d, &self.ss_q_grad_w_d)
            .context("dqn_replay_step: dqn_w_adam.step")?;
        self.dqn_b_adam
            .step(&mut self.dqn_head.b_d, &self.ss_q_grad_b_d)
            .context("dqn_replay_step: dqn_b_adam.step")?;

        // ── 6. Spectral norm on DQN, IQN, policy weights ────────────
        // Amortized to every SPECTRAL_NORM_PERIOD replay steps (Weyl's
        // inequality: sigma_max shifts by at most ||Delta_W||_2 per Adam
        // step, so the guard remains valid between full power iterations).
        // 175us/head x 3 heads = 843us/step → 8.4us amortized at
        // SPECTRAL_NORM_PERIOD=100. The v_buffer is persistent for
        // warm-start across amortized calls.
        self.spectral_norm_counter += 1;
        if self.spectral_norm_counter % SPECTRAL_NORM_PERIOD == 0 {
        {
            let dqn_rows = (N_ACTIONS * Q_N_ATOMS) as i32;
            let dqn_cols = HIDDEN_DIM as i32;
            let block_x = (dqn_rows as u32).min(256);
            let smem = ((HIDDEN_DIM as u32) + block_x) * (std::mem::size_of::<f32>() as u32);
            let mut args = RawArgs::new();
            args.push_ptr(self.dqn_head.w_d.raw_ptr());
            args.push_ptr(self.spectral_v_buffer_dqn.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(dqn_rows);
            args.push_i32(dqn_cols);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_spectral_norm_fn.cu_function(),
                    (1, 1, 1), (block_x, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_spectral_norm(dqn_w): {:?}", e))?;
            }
        }
        {
            let iqn_rows = HIDDEN_DIM as i32;
            let iqn_cols = N_ACTIONS as i32;
            let block_x = (iqn_rows as u32).min(256);
            let smem = ((N_ACTIONS as u32) + block_x) * (std::mem::size_of::<f32>() as u32);
            let mut args = RawArgs::new();
            args.push_ptr(self.iqn_head.w_out_d.raw_ptr());
            args.push_ptr(self.spectral_v_buffer_iqn.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(iqn_rows);
            args.push_i32(iqn_cols);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_spectral_norm_fn.cu_function(),
                    (1, 1, 1), (block_x, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_spectral_norm(iqn_w_out): {:?}", e))?;
            }
        }
        {
            let pi_rows = N_ACTIONS as i32;
            let pi_cols = HIDDEN_DIM as i32;
            let block_x = (pi_rows as u32).min(256);
            let smem = ((HIDDEN_DIM as u32) + block_x) * (std::mem::size_of::<f32>() as u32);
            let mut args = RawArgs::new();
            args.push_ptr(self.policy_head.w_d.raw_ptr());
            args.push_ptr(self.spectral_v_buffer_pi.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(pi_rows);
            args.push_i32(pi_cols);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_spectral_norm_fn.cu_function(),
                    (1, 1, 1), (block_x, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_spectral_norm(policy_w): {:?}", e))?;
            }
        }
        } // end spectral norm amortization guard

        // ── 7. Q-bias correction ─────────────────────────────────────
        // Track mean(Q_predicted - actual_return), emit correction to ISV.
        {
            let block_x = (b_size as u32).min(256).max(1);
            let smem = block_x * (std::mem::size_of::<f32>() as u32);
            let mut args = RawArgs::new();
            args.push_ptr(self.ensemble_q_d.raw_ptr());
            args.push_ptr(self.sampled_rewards_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_q_bias_correction_fn.cu_function(),
                    (1, 1, 1), (block_x, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_q_bias_correction: {:?}", e))?;
            }
        }

        // ── 8. Outcome head fused forward + CE + backward ─────────────
        // Single fused kernel: fwd → CE → bwd with logits and grad_logits
        // in shared memory (eliminates two global round-trips).
        // grad_h_t is computed but discarded (no encoder backward in
        // replay steps — same stop-grad rationale as Q's grad_h_t).
        self.outcome_head.forward_loss_backward(
            &self.sampled_h_t_d,
            &mut self.ss_outcome_grad_w_pb_d,
            &mut self.ss_outcome_grad_b_pb_d,
            &mut self.ss_outcome_grad_h_t_d,
            b_size,
        ).context("dqn_replay_step: outcome_head.forward_loss_backward")?;
        {
            let k_classes = crate::rl::outcome_head::K_CLASSES;
            reduce_axis0_free(
                &self.stream, &self.reduce_axis0_fn,
                &self.ss_outcome_grad_w_pb_d, b_size,
                HIDDEN_DIM * k_classes,
                &mut self.ss_outcome_grad_w_d,
            )?;
            reduce_axis0_free(
                &self.stream, &self.reduce_axis0_fn,
                &self.ss_outcome_grad_b_pb_d, b_size,
                k_classes,
                &mut self.ss_outcome_grad_b_d,
            )?;
            self.outcome_w_adam
                .step(&mut self.outcome_head.w_d, &self.ss_outcome_grad_w_d)
                .context("dqn_replay_step: outcome_w_adam.step")?;
            self.outcome_b_adam
                .step(&mut self.outcome_head.b_d, &self.ss_outcome_grad_b_d)
                .context("dqn_replay_step: outcome_b_adam.step")?;
        }

        // ── 9. Per-branch LR controller ──────────────────────────────
        // Single-thread kernel adjusts LR scale factors from loss deltas.
        {
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_f32(self.last_q_loss);
            args.push_f32(self.last_pi_loss);
            args.push_f32(self.last_v_loss);
            args.push_f32(l_q); // IQN loss proxied by this step's Q loss
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_per_branch_lr_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_per_branch_lr: {:?}", e))?;
            }
        }

        Ok(l_q)
    }

    /// Phase R6: run one integrated training step driven by a real
    /// `LobSimCuda` (via the narrow `RlLobBackend` trait, dep-cycle
    /// break only). GPU-pure for the env interaction: Thompson-
    /// sampled actions land in `lobsim.market_targets_d` via the
    /// `actions_to_market_targets` kernel; the fill runs via
    /// `lobsim.step_fill_from_market_targets`; rewards + done flags
    /// extract from `lobsim.pos_d` via the
    /// `extract_realized_pnl_delta` kernel. No per-batch host loop.
    ///
    /// Replaces the synthetic args of [`step_synthetic`] with values
    /// derived from a real action sampling + reward signal:
    ///
    ///   1. `forward_encoder(snapshots)` → `h_t [B × HIDDEN_DIM]`.
    ///   2. Forward Q-head + V-head; read `q_logits`, `v_pred` to host.
    ///   3. Thompson-sample one action per batch from the per-action
    ///      atom distribution (per
    ///      `pearl_thompson_for_distributional_action_selection`).
    ///   4. For each batch:
    ///        a. `lobsim.apply_snapshot(snapshot)`,
    ///        b. `lobsim.submit_action(batch_idx, action, ts_ns)`,
    ///        c. `lobsim.step_event(batch_idx, ts_ns, trade_signed_vol)`
    ///           → `(reward, done)`.
    ///   5. Build the synthetic-style arg vectors from the real action /
    ///      reward / done values:
    ///        * `actions[b]      = sampled action`,
    ///        * `rewards[b]      = lobsim reward`,
    ///        * `dones[b]        = 1.0 if lobsim done else 0.0`,
    ///        * `next_actions[b] = argmax_a Q(s_t, a)`  (host-side
    ///                              expected-value argmax — Phase F may
    ///                              move this to a kernel),
    ///        * `advantages[b]   = Q(s_t, a_t) - V(s_t)`  (one-step
    ///                              advantage from the same-state V),
    ///        * `returns[b]      = reward + γ × (1 - done) × V(s_t)`
    ///                              (one-step bootstrap target; γ is
    ///                              read from the host-mirrored
    ///                              `ISV[RL_GAMMA_INDEX]`),
    ///        * `log_pi_old[b]   = log π_sampled(a_t | s_t)` recorded at
    ///                              sample time (note: `step_synthetic`
    ///                              re-runs the policy forward on the
    ///                              same `h_t`, so log π_new == log
    ///                              π_old on the first PPO pass — the
    ///                              importance ratio is 1 and the
    ///                              clipped surrogate's gradient flows
    ///                              from the entropy + advantage path,
    ///                              not the ratio. Phase F adds a
    ///                              proper rollout buffer so multiple
    ///                              PPO epochs see a non-trivial ratio).
    ///   6. Delegate to [`Self::step_synthetic`] for the actual backward
    ///      + per-head Adam update + encoder backward — keeping the
    ///      training kernel chain a single source of truth per
    ///      `feedback_single_source_of_truth_no_duplicates`. The
    ///      encoder forward runs TWICE per step (once here for action
    ///      sampling, once inside `step_synthetic`); the encoder is
    ///      deterministic given the same input, so this is a pure
    ///      compute redundancy that Phase F can fuse.
    ///
    /// Returns the same stats struct as `step_synthetic`. Tests
    /// (`dqn_toy`, `ppo_toy`, `integrated_trainer_smoke`) drive this in
    /// a loop with `MockLobEnv` to gate that the trainer converges on
    /// the toy bandit optimal action.
    pub fn step_with_lobsim(
        &mut self,
        snapshots: &[Mbp10RawInput],
        next_snapshots: &[Mbp10RawInput],
        lobsim: &mut dyn RlLobBackend,
    ) -> Result<IntegratedStepStats> {
        let b_size = self.cfg.perception.n_batch;
        if b_size == 0 {
            anyhow::bail!("step_with_lobsim: empty batch (n_batch = 0)");
        }
        if snapshots.is_empty() {
            anyhow::bail!("step_with_lobsim: snapshots empty (need ≥ 1 for apply_snapshot)");
        }
        if next_snapshots.len() != snapshots.len() {
            anyhow::bail!(
                "step_with_lobsim: next_snapshots.len() ({}) must match snapshots.len() ({}) — \
                 R8's CLI binary builds them via MultiHorizonLoader::next_sequence_pair (R2) which \
                 guarantees this invariant for adjacent (s_t, s_{{t+1}}) windows",
                next_snapshots.len(),
                snapshots.len()
            );
        }

        // ── Event-based inter-stream sync: wait for previous step's PER
        // priority update + tree rebuild to complete before this step
        // touches the replay buffer (push_ring/push_flush write the same
        // tree). Uses train_done_event (GPU-side wait) instead of
        // train_stream.synchronize() (host-blocking) — the host thread
        // stays free to queue subsequent kernels while the GPU serialises
        // the dependency. Saves ~2ms/step host idle time.
        unsafe {
            raw_stream_wait_event(self.raw_stream, self.train_done_event.cu_event())
                .map_err(|e| anyhow::anyhow!("stream wait train_done: {:?}", e))?;
        }

        // ── Step 0: bump device-resident step counter (ISV[548]).
        // Must run BEFORE any kernel that reads current_step from ISV.
        // Single thread, single block — graph-safe (no scalar args change).
        {
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_increment_step_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_increment_step: {:?}", e))?;
            }
        }

        // ── Step 1a (R7c): encoder forward on NEXT snapshots to land
        // h_{t+1} at slot K-1. Done FIRST so the subsequent
        // forward_encoder(snapshots) call leaves perception's internal
        // forward state (h_new_per_k_d / CfC h_state_d) populated for
        // the CURRENT-step encoder backward in step_synthetic — the
        // most recent forward_encoder call wins. We DtoD-copy
        // perception.h_t_d into the trainer-owned self.h_tp1_d
        // immediately so the second forward_encoder doesn't clobber it.
        let _ = self
            .perception
            .forward_encoder(next_snapshots)
            .context("step_with_lobsim: forward_encoder(next_snapshots)")?;
        {
            let nbytes = b_size * HIDDEN_DIM * std::mem::size_of::<f32>();
            unsafe {
                raw_memcpy_dtod_async(
                    self.hot.h_tp1, self.hot.h_t_encoder, nbytes, self.raw_stream,
                )
                .map_err(|e| anyhow::anyhow!("step_with_lobsim: DtoD h_t → h_tp1: {:?}", e))?;
            }
        }

        // ── Step 1b: encoder forward on CURRENT snapshots — leaves
        // perception's internal forward state primed for the encoder
        // backward in step_synthetic, AND lands h_t at slot K-1 for
        // the action-sampling forwards below.
        {
            self.perception.encoder_ctx_trade_ptr = self.hot.trade_context;
            self.perception.encoder_ctx_multires_ptr = self.hot.multires_output;
        }
        let _ = self
            .perception
            .forward_encoder(snapshots)
            .context("step_with_lobsim: forward_encoder(snapshots)")?;

        // Host-side step counter — must stay outside graph capture region
        // (host ops aren't captured, would silently stop incrementing).
        self.step_counter = self.step_counter.wrapping_add(1);

        // ── Graph A: pre-snapshot kernel pipeline ──────────────────────
        // 20 kernels from FRD/DQN/IQN/V/π forwards + action selection +
        // gates. Three-state machine: warmup (first) → capture (second)
        // → replay (third+). All device pointers are stable (pre-allocated
        // at init), so the captured graph replays with fresh data.
        if self.prefill_graph.is_some() {
            unsafe {
                raw_graph_launch(
                    self.prefill_graph.as_ref().unwrap().cu_graph_exec,
                    self.raw_stream,
                ).map_err(|e| anyhow::anyhow!("prefill graph launch: {:?}", e))?;
            }
        } else {
            let capturing_prefill = self.graph_warmup_done;
            if !self.graph_warmup_done {
                self.graph_warmup_done = true;
            }
            if capturing_prefill {
                self.stream
                    .begin_capture(CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_RELAXED)
                    .map_err(|e| anyhow::anyhow!("prefill begin_capture: {e}"))?;
            }

        // ── Step 2: Q + V forwards on h_t for action sampling. ────────
        let h_t_borrow: &CudaSlice<f32> = self.perception.h_t_view();
        debug_assert_eq!(h_t_borrow.len(), b_size * HIDDEN_DIM);
        debug_assert_eq!(self.h_tp1_d.len(), b_size * HIDDEN_DIM);

        // ── SP20 P3: FRD head forward — reads h_t, writes 3 horizons ×
        // 21 atom logits per batch into self.frd_logits_d (consumed by
        // the FRD gate in P9 and the supervised CE loss in F.3). The
        // post-ReLU hidden activations land in self.frd_hidden_d for
        // the bwd kernel to consume without recomputing the W1 product.
        self.frd_head
            .forward(
                h_t_borrow,
                &mut self.frd_hidden_d,
                &mut self.frd_logits_d,
                b_size,
            )
            .context("frd_head.forward in step_with_lobsim")?;

        self.dqn_head
            .forward(h_t_borrow, b_size, &mut self.q_logits_d)
            .context("step_with_lobsim: dqn_head.forward(h_t)")?;
        // B-10 G1 (2026-06-01): Q-distribution informativeness diagnostic
        // on online Q logits — emits ISV slots 730/731/732. Pure
        // observability; reads q_logits_d immediately after the forward,
        // before any downstream consumer (distill / Thompson / PPO).
        // Slot 612 (var_pre_norm) is independent — not consumed here.
        self.dqn_head
            .launch_q_distribution_stats(
                &self.isv_dev_ptr,
                &self.q_logits_d,
                &self.atom_supports_d,
                b_size,
                &mut self.q_dist_entropy_pb_d,
                &mut self.q_dist_range_pb_d,
                &mut self.q_dist_abs_max_pb_d,
            )
            .context("step_with_lobsim: dqn_head.launch_q_distribution_stats")?;
        self.dqn_head
            .forward_target(h_t_borrow, b_size, &mut self.q_logits_target_st_d)
            .context("step_with_lobsim: dqn_head.forward_target(h_t) for distillation")?;
        self.dqn_head
            .forward(&self.h_tp1_d, b_size, &mut self.q_logits_tp1_d)
            .context("step_with_lobsim: dqn_head.forward(h_tp1) for Double-DQN argmax")?;
        self.value_head
            .forward(h_t_borrow, &self.isv_dev_ptr, b_size, &mut self.v_pred_d)
            .context("step_with_lobsim: value_head.forward(h_t)")?;
        self.value_head
            .forward(&self.h_tp1_d, &self.isv_dev_ptr, b_size, &mut self.v_pred_tp1_d)
            .context("step_with_lobsim: value_head.forward(h_tp1) for true V(s_{t+1})")?;

        // Phase 4-A (2026-06-03) — No-transaction-band head forward.
        // Produces `band_out_d [B × 2]` (b_l ≤ 0 ≤ b_u, in lots units)
        // consumed OUTSIDE graph capture by `rl_band_mask.cu`. The forward
        // is fully capturable (pure linear projection + ±|tanh| activation,
        // no control flow); the master gate at slot 799 is NOT checked here
        // — when disabled the forward is still launched (cost ~2 weight
        // rows × B reads, negligible) but the mask kernel returns early so
        // actions are untouched and the band output is unused. This
        // preserves capture-stability across off↔on flips of the gate.
        self.band_head
            .forward(h_t_borrow, &self.isv_dev_ptr, b_size)
            .context("step_with_lobsim: band_head.forward(h_t) (Phase 4-A)")?;

        // ── IQN forward alongside C51 ────────────────────────────────
        // Fused tau sampling + IQN forward + expected_q. The tau sampling
        // is done inline in the rl_iqn_forward kernel (graph-safe, no
        // host RNG), eliminating the separate rl_sample_tau launch.
        {
            let n_tau = self.iqn_head.n_tau();
            self.iqn_head
                .forward(
                    &mut self.iqn_prng_state_d,
                    h_t_borrow,
                    &mut self.iqn_tau_d,
                    b_size,
                    n_tau,
                    &mut self.iqn_q_values_d,
                )
                .context("step_with_lobsim: iqn_head.forward(h_t)")?;
            self.iqn_head
                .expected_q(
                    &self.iqn_q_values_d,
                    b_size,
                    n_tau,
                    &mut self.iqn_expected_q_d,
                )
                .context("step_with_lobsim: iqn_head.expected_q")?;
        }

        // ── Ensemble action-value: α × E_C51 + (1-α) × E_IQN ────────
        // Launched after both C51 and IQN forwards complete. The ensemble
        // Q feeds the Q→π agreement diagnostic below.
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(self.iqn_expected_q_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.ensemble_q_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_ensemble_action_value_fn.cu_function(),
                    (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_ensemble_action_value: {:?}", e))?;
            }
        }

        // ── NoisyNet state-dependent exploration on ensemble Q ─────────
        // Resample factored noise, forward h_t through the NoisyLinear
        // layer → noisy_output_d [B, N_ACTIONS], then fold into
        // ensemble_q_d in-place via aux_vec_add_inplace. The perturbed
        // ensemble Q feeds the Q→π agreement diagnostic and distillation
        // gradient, so the policy indirectly sees noisy Q preferences
        // and learns to explore without a fixed P_MIN floor.
        //
        // Target network path (Bellman argmax / bellman_target_projection)
        // does NOT see noise — it reads the un-perturbed C51/IQN target
        // weights. This matches the NoisyNet paper's prescription:
        // noise on the online network only during rollout.
        self.noisy_exploration
            .resample_noise()
            .context("step_with_lobsim: noisy_exploration.resample_noise")?;
        self.noisy_exploration
            .forward(h_t_borrow, b_size, &mut self.noisy_output_d)
            .context("step_with_lobsim: noisy_exploration.forward")?;
        {
            let n_elems = (b_size * N_ACTIONS) as i32;
            let grid_x = ((n_elems as u32) + 255) / 256;
            let mut args = RawArgs::new();
            args.push_ptr(self.ensemble_q_d.raw_ptr());
            args.push_ptr(self.noisy_output_d.raw_ptr());
            args.push_i32(n_elems);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.aux_vec_add_fn.cu_function(),
                    (grid_x, 1, 1), (256, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("aux_vec_add_inplace (noisy -> ensemble_q): {:?}", e))?;
            }
        }

        // ── Step 2b: Forward π logits for log_pi_old. ─────────────────
        // Phase 2A-C: same gate as step_synthetic_body. Inside prefill
        // graph capture; host branch runs once and bakes the chosen
        // forward kernel sequence into the replayed graph.
        if self.use_multi_head_policy {
            let regime_h = self.perception.step_regime_d_view();
            let mhp = self.multi_head_policy
                .as_ref()
                .expect("use_multi_head_policy=true but multi_head_policy=None (construction bug)");
            mhp.forward(h_t_borrow, regime_h, &mut self.pi_logits_d)
                .context("multi_head_policy.forward [step_with_lobsim]")?;
            // Phase 2A-D — see comment in step_synthetic_body for rationale.
            mhp.emit_diag_stats(self.isv_dev_ptr)
                .context("multi_head_policy.emit_diag_stats [step_with_lobsim]")?;
        } else {
            self.policy_head
                .forward_logits(h_t_borrow, b_size, &mut self.pi_logits_d)
                .context("step_with_lobsim: policy_head.forward_logits")?;
        }

        // R9 audit — Q-vs-π agreement diag fires into ISV[407] EMA.
        // Needs both q_logits_d (from step 2) and pi_logits_d (just
        // computed). Single-block tree-reduce; block_dim must be
        // power of 2 ≥ b_size for the in-block sum.
        {
            let block_dim = (b_size.next_power_of_two() as u32).max(1);
            let smem = (block_dim as usize * std::mem::size_of::<f32>()) as u32;
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_d.raw_ptr());
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_q_pi_agree_b_fn.cu_function(),
                    (1, 1, 1), (block_dim, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_q_pi_agree_b: {:?}", e))?;
            }
        }

        // ── Phase 7b F5 (2026-06-05) — state-conditional action mask ──
        // Sets `pi_logits[b][a] = -INFINITY` for state-illegal actions
        // BEFORE `rl_pi_action_kernel` samples. Device-side master gate
        // at slot 823 (RL_F5_STATE_MASK_ENABLED_INDEX) makes this a no-op
        // when ≤ 0.5 — preserves bit-equality with Phase 7a HEAD.
        //
        // Captured inside the prefill graph: pure device kernel with
        // master gate read device-side; no host branching. Composes with
        // F2 Q-distill — softmax(pi_logits) has exactly zero mass on
        // masked actions; distill gradient `(π_θ − π_target) = (0 −
        // π_target_masked)` naturally drives π_target off masked actions.
        //
        // Spec: docs/superpowers/specs/2026-06-04-bellman-target-foundation-reshape.md §3.5.
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let pos_bytes_u = lobsim.pos_bytes();
            let b_size_i = b_size as i32;
            let pos_bytes_i = pos_bytes_u as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(self.unit_trail_distance_d.raw_ptr());
            args.push_ptr(self.unit_initial_r_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_state_action_mask_fn.cu_function(),
                    (b_size as u32, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_state_action_mask (Phase 7b F5): {:?}", e))?;
            }
        }

        // ── Step 3 (Phase R7b): GPU-pure action sampling. ─────────────
        // π-driven action selection (Option B fix per
        // `pearl_q_thompson_actor_makes_pi_dead_weight`). Replaces the
        // prior Q-Thompson selector. π is now the actor: actions come
        // from multinomial sample of softmax(pi_logits). Q remains the
        // critic (Bellman target via argmax_expected_q on h_{t+1}).
        // argmax_expected_q computes the Bellman-target argmax
        // (deterministic, expected value rather than sampled) per
        // `pearl_thompson_for_distributional_action_selection`.
        //
        // log_pi_at_action runs AFTER confidence gate + FRD gate (below)
        // so that log π_old matches the post-gate action. PPO
        // importance-ratio surrogate now has canonical semantics —
        // π_new / π_old at the action that actually executed.
        //
        // The rl_action_kernel (Q-Thompson) field is kept loaded for
        // backward-compat tests + diagnostic comparison; it's no
        // longer in the hot path.
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(self.prng_state_d.raw_ptr());
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_pi_action_kernel_fn.cu_function(),
                    (b_size as u32, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_pi_action_kernel (multinomial from softmax(pi)): {:?}", e))?;
            }
        }

        // argmax over expected Q for next_actions — Double-DQN: argmax
        // of ONLINE Q at h_{t+1} (per
        // `pearl_thompson_for_distributional_action_selection`'s
        // distinction between Thompson selector (on h_t for rollout)
        // and argmax Bellman selector (on h_{t+1} for target)).
        //
        // R7c fix: was reading q_logits_d (online Q at h_t) which
        // produced an off-by-one-time-index Bellman argmax since R4.
        // Now reads q_logits_tp1_d (online Q at h_{t+1}) which is the
        // canonical Double-DQN target-action selector.
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_tp1_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(self.next_actions_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.argmax_expected_q_fn.cu_function(),
                    (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("argmax_expected_q (Bellman target on h_tp1): {:?}", e))?;
            }
        }

            if capturing_prefill {
                let graph = self
                    .stream
                    .end_capture(
                        CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH,
                    )
                    .context("prefill end_capture")?
                    .ok_or_else(|| anyhow::anyhow!("prefill end_capture returned None"))?;
                self.prefill_graph = Some(graph);
            }
        } // end else (warmup / capture dispatch)

        // ── Gates + log_pi: OUTSIDE graph capture so they fire every
        // step (not captured as no-ops during warmup). Device-side kernels.
        //
        // Phase 4-A (2026-06-03): band mask runs FIRST in the gate stack
        // — the architectural no-trade default per Davis-Norman (1990).
        // Confidence gate + FRD gate run AFTER and are no-ops on already-
        // masked Hold actions (their `if action == HOLD: return` guards).
        // Master gate at `RL_BAND_ENABLED_INDEX` (slot 799) makes the
        // kernel a no-op at the device level when bootstrap 0.0 is in
        // place → bit-equality with Phase 3D baseline `bd811a774`. Spec
        // §2.1 / §2.3 / §9.5.
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            self.band_head
                .launch_mask(
                    &self.actions_d,
                    pos_d_ref,
                    &self.isv_dev_ptr,
                    b_size,
                    lobsim.pos_bytes(),
                )
                .context("step_with_lobsim: band_head.launch_mask (Phase 4-A)")?;
        }
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let b_size_i = b_size as i32;
            let pos_bytes_i = lobsim.pos_bytes() as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.q_logits_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_confidence_gate_fn.cu_function(),
                    (b_size as u32, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_confidence_gate: {:?}", e))?;
            }
        }
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let b_size_i = b_size as i32;
            let pos_bytes_i = lobsim.pos_bytes() as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.frd_logits_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_frd_gate_fn.cu_function(),
                    (b_size as u32, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_frd_gate: {:?}", e))?;
            }
        }
        // Action entropy EMA — POST-gate actions. SAC α/τ co-tuning
        // responds to this (not policy entropy) because the confidence
        // gate decouples policy from actions.
        {
            let slot_i = crate::rl::isv_slots::RL_ACTION_ENTROPY_EMA_INDEX as i32;
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot_i);
            args.push_f32(0.4_f32); // Wiener-α floor per pearl
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.action_entropy_per_step_fn.cu_function(),
                    (1, 1, 1),
                    (N_ACTIONS as u32, 1, 1),
                    0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("action_entropy_per_step: {:?}", e))?;
            }
        }
        {
            let grid_x = ((b_size as u32) + 31) / 32;
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.log_pi_old_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.log_pi_at_action_fn.cu_function(),
                    (grid_x.max(1), 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("log_pi_at_action (post-gate): {:?}", e))?;
            }
        }

        // ── Step 5 (Phase R6): GPU-pure env step. ─────────────────────
        // No per-batch host loop — actions land in lobsim's
        // market_targets_d via the `actions_to_market_targets` kernel
        // (which reads current position_lots from lobsim.pos_d to
        // resolve conditional Flat-from-Long/Short sizes), the fill
        // runs via `step_fill_from_market_targets`, and rewards +
        // done flags extract from the post-fill Pos array via
        // `extract_realized_pnl_delta` (reading pos.realized_pnl
        // delta against the trainer's prev_realized_pnl_d snapshot
        // taken at end of the previous step).
        //
        // Phase E.3b broadcasts the LAST snapshot in the window to
        // the book; Phase F may stream every snapshot through.
        let last_snap = snapshots
            .last()
            .expect("snapshots non-empty: guarded above");
        lobsim
            .apply_snapshot(last_snap)
            .context("step_with_lobsim: lobsim.apply_snapshot")?;

        // GPU translate (action index → market_targets {side, size}).
        // Reads current position_lots from lobsim.pos_d for Flat-from-*
        // actions; reads actions from self.actions_d (just written by
        // rl_action_kernel above).
        let pos_bytes_i = lobsim.pos_bytes() as i32;
        let b_size_i = b_size as i32;

        // Stage ts_ns to device buffer for graph-captured multires kernel.
        // Launched OUTSIDE graph so the scalar write doesn't break capture.
        {
            let ts_ns_val: u64 = last_snap.ts_ns;
            let mut args = RawArgs::new();
            args.push_ptr(self.ts_ns_d.raw_ptr());
            args.push_u64(ts_ns_val);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_write_u64_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_write_u64 (ts_ns): {:?}", e))?;
            }
        }

        // ── Graph A2: post-snapshot / pre-fill kernel pipeline ─────────
        // 7 kernels from session_risk through actions_to_market_targets.
        // Same three-state machine as prefill — captures after warmup,
        // replays on third+ step. Shares the warmup counter with Graph A.
        if self.postfill_graph.is_some() {
            unsafe {
                raw_graph_launch(
                    self.postfill_graph.as_ref().unwrap().cu_graph_exec,
                    self.raw_stream,
                ).map_err(|e| anyhow::anyhow!("postfill graph launch: {:?}", e))?;
            }
        } else {
            let capturing_postfill = self.prefill_graph.is_some();
            if capturing_postfill {
                self.stream
                    .begin_capture(CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_RELAXED)
                    .map_err(|e| anyhow::anyhow!("postfill begin_capture: {e}"))?;
            }

        // Session risk circuit breaker — block opening actions when
        // cumulative PnL EMA drops below threshold.
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.rewards_d.raw_ptr());
            args.push_ptr(self.dones_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_session_risk_check_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_session_risk_check: {:?}", e))?;
            }
        }

        // Min hold check — override closing actions to Hold when
        // hold time < ISV-driven minimum. Trail stops still fire
        // (they run AFTER this, safety overrides patience).
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.steps_since_done_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_min_hold_check_fn.cu_function(),
                    (grid_x, 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_min_hold_check: {:?}", e))?;
            }
        }

        // Asymmetric trail decay — auto-tighten losers, widen winners.
        // Runs BEFORE trail_mutate so the structural decay is applied
        // first, then agent's a7/a8 fine-tuning on top.
        {
            let bid_px_d = lobsim.bid_px_d();
            let ask_px_d = lobsim.ask_px_d();
            let mut args = RawArgs::new();
            args.push_ptr(self.unit_trail_distance_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.unit_entry_price_d.raw_ptr());
            args.push_ptr(self.unit_initial_r_d.raw_ptr());
            args.push_ptr(self.unit_lots_d.raw_ptr());
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_asymmetric_trail_decay_fn.cu_function(),
                    (b_size as u32, 1, 1), (4, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_asymmetric_trail_decay: {:?}", e))?;
            }
        }

        // Trail mutate (a7/a8) — BEFORE actions_to_market_targets.
        // Mutates unit_trail_distance for active units if the chosen action
        // is TrailTighten/Loosen. Non-trail actions pass through.
        // Phase 5 (2026-06-04): reads unit_initial_r_d for the per-unit
        // absolute ceiling on TrailLoosen (slot 814 ratio).
        {
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.unit_initial_r_d.raw_ptr());
            args.push_ptr(self.unit_trail_distance_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_trail_mutate_fn.cu_function(),
                    (b_size as u32, 1, 1), (4, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_trail_mutate: {:?}", e))?;
            }
        }

        // ── SP20 P1+P5 trail stop check — AFTER trail_mutate, BEFORE
        // actions_to_market_targets. May OVERRIDE actions_d[b] to
        // FlatFromLong/Short on per-unit breach. Reads shared lobsim
        // best book for current mid.
        {
            let bid_px_d = lobsim.bid_px_d();
            let ask_px_d = lobsim.ask_px_d();
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.unit_entry_price_d.raw_ptr());
            args.push_ptr(self.unit_lots_d.raw_ptr());
            args.push_ptr(self.unit_trail_distance_d.raw_ptr());
            args.push_ptr(self.pyramid_units_count_d.raw_ptr());
            args.push_ptr(self.close_unit_index_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_trail_stop_check_fn.cu_function(),
                    (b_size as u32, 1, 1), (4, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_trail_stop_check: {:?}", e))?;
            }
        }

        // ── SP20 P6 position heat cap — AFTER trail_stop_check, BEFORE
        // actions_to_market_targets. If |position_lots| exceeds the ISV-
        // driven max (slot 504, default 8), OVERRIDE to FlatFromLong/Short.
        // Reads pos_state for aggregate position; writes diag fired-count
        // to ISV slot 505.
        {
            let pos_d_ref_heat: &CudaSlice<u8> = lobsim.pos_d();
            let block = (b_size as u32).min(256);
            let grid = ((b_size as u32) + block - 1) / block;
            let smem = 256 * std::mem::size_of::<i32>() as u32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(pos_d_ref_heat.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_position_heat_check_fn.cu_function(),
                    (grid.max(1), 1, 1), (block.max(1), 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_position_heat_check: {:?}", e))?;
            }
        }

        {
            let (pos_d_ref, bid_px_d, ask_px_d, market_targets_d) =
                lobsim.pos_book_and_market_targets_mut();
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(market_targets_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.unit_entry_price_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.unit_lots_d.raw_ptr());
            args.push_ptr(self.pyramid_units_count_d.raw_ptr());
            args.push_ptr(self.close_unit_index_d.raw_ptr());
            args.push_ptr(self.outcome_ema_d.raw_ptr());
            // CMDP per-batch state — one DD-trigger / cooldown per session.
            args.push_ptr(self.session_dd_triggered_per_batch_d.raw_ptr());
            args.push_ptr(self.cooldown_remaining_per_batch_d.raw_ptr());
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.actions_to_market_targets_fn.cu_function(),
                    (grid_x, 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("actions_to_market_targets: {:?}", e))?;
            }
        }

            if capturing_postfill {
                let graph = self
                    .stream
                    .end_capture(
                        CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH,
                    )
                    .context("postfill end_capture")?
                    .ok_or_else(|| anyhow::anyhow!("postfill end_capture returned None"))?;
                self.postfill_graph = Some(graph);
            }
        } // end else (postfill warmup / capture dispatch)

        // Run the fill + pnl_track on whatever's now in
        // market_targets_d.
        lobsim
            .step_fill_from_market_targets(last_snap.ts_ns)
            .context("step_with_lobsim: lobsim.step_fill_from_market_targets")?;

        self.step_with_lobsim_reward_and_train(lobsim, snapshots, b_size)
    }

    /// Post-fill reward pipeline, controllers, advantage computation,
    /// PER push/sample, K-loop (step_synthetic + dqn_replay), and
    /// target-net soft update. Shared by both `step_with_lobsim` (host
    /// data) and `step_with_lobsim_gpu` (GPU data). The `snapshots`
    /// parameter is passed through to `step_synthetic` in the K-loop
    /// for the host path; when `self.gpu_encoder_active` is true,
    /// `step_synthetic` ignores snapshots and uses
    /// `forward_encoder_from_device`.
    fn step_with_lobsim_reward_and_train(
        &mut self,
        lobsim: &mut dyn RlLobBackend,
        snapshots: &[Mbp10RawInput],
        b_size: usize,
    ) -> Result<IntegratedStepStats> {
        let b_size_i = b_size as i32;
        let pos_bytes_i = lobsim.pos_bytes() as i32;

        // ── Graph B: post-fill reward/EMA/controller pipeline ─────────
        // ~20 kernels from extract_realized_pnl_delta through
        // var_over_abs_mean. Same three-state machine as Graph A/A2 —
        // captures after A2 is done, replays on subsequent steps.
        if self.reward_graph.is_some() {
            unsafe {
                raw_graph_launch(
                    self.reward_graph.as_ref().unwrap().cu_graph_exec,
                    self.raw_stream,
                ).map_err(|e| anyhow::anyhow!("reward graph launch: {:?}", e))?;
            }
        } else {
            let capturing_reward = self.postfill_graph.is_some();
            if capturing_reward {
                self.stream
                    .begin_capture(CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_RELAXED)
                    .map_err(|e| anyhow::anyhow!("reward begin_capture: {e}"))?;
            }

        // Fused reward pipeline: extract_realized_pnl_delta +
        // unit_state_update + step_counter_update + abs_copy +
        // reward_shaping + raw_rewards snapshot + recent_outcome_update
        // in a single per-batch kernel (7→1 launch).
        // Phase 5 (2026-06-04): also takes bid_px/ask_px + prev_unrealized_pnl
        // for the mark-to-market reward component (Phase 1.6).
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let bid_px_d = lobsim.bid_px_d();
            let ask_px_d = lobsim.ask_px_d();
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(self.prev_realized_pnl_d.raw_ptr());
            args.push_ptr(self.prev_position_lots_d.raw_ptr());
            args.push_ptr(self.rewards_d.raw_ptr());
            args.push_ptr(self.dones_d.raw_ptr());
            args.push_ptr(self.unit_prev_pos_lots_d.raw_ptr());
            args.push_ptr(self.unit_entry_price_d.raw_ptr());
            args.push_ptr(self.unit_entry_step_d.raw_ptr());
            args.push_ptr(self.unit_lots_d.raw_ptr());
            args.push_ptr(self.unit_initial_r_d.raw_ptr());
            args.push_ptr(self.unit_trail_distance_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.pyramid_units_count_d.raw_ptr());
            args.push_ptr(self.steps_since_done_d.raw_ptr());
            args.push_ptr(self.trade_duration_emit_d.raw_ptr());
            args.push_ptr(self.reward_abs_d.raw_ptr());
            args.push_ptr(self.raw_rewards_d.raw_ptr());
            args.push_ptr(self.outcome_ema_d.raw_ptr());
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.prev_unrealized_pnl_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_fused_reward_pipeline_fn.cu_function(),
                    (grid_x, 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_fused_reward_pipeline: {:?}", e))?;
            }
        }

        // ────────────────────────────────────────────────────────────
        // Regime observer — F1.5 spec 2026-05-31-regime-observer-design.md v3.
        //
        // Runs BEFORE the risk-stack controllers. Reads:
        //   - PRIOR-step kelly_f (slot 676) — one-step lag accepted; the
        //     absorbing-state condition persists across step boundaries.
        //   - PRIOR-step cooldown + worst_pnl (CMDP outputs from last step).
        //   - CURRENT-step flat_count (from this step's position lots,
        //     just-updated by LobSim).
        // Writes 6 surface signals + Welford state + drift-free
        // ε_recovery_live for Kelly resurrection (F2) to consume.
        //
        // Per Theorem 1: one-step lag is benign because the dead-zone state
        // persists; detection at T+1 still fires before any harm escalates.
        // ────────────────────────────────────────────────────────────
        self.launch_rl_regime_flat_count(&self.prev_position_lots_d, b_size)
            .context("launch_rl_regime_flat_count")?;
        self.launch_rl_regime_observer(b_size)
            .context("launch_rl_regime_observer")?;

        // ────────────────────────────────────────────────────────────
        // Adaptive risk management — spec 2026-05-30-adaptive-risk-management.
        //
        // Layer 1 (CMDP gates): track session pnl + DD-triggered flag +
        // cooldown counter + consecutive-loss counter from this step's
        // rewards/dones. Must precede the Layer 2 IQN-τ controller because
        // τ derives from `session_pnl` written here.
        //
        // EMA producers: feed Layer 4 Kelly (win_rate, avg_win/loss) and
        // Layer 3 (inventory variance). All three are single-thread
        // single-block kernels.
        //
        // The Layer 2/3/4 controllers themselves are folded into
        // `rl_fused_controllers` below, which fires after the existing
        // Welford updates — so this is the producer side only.
        // ────────────────────────────────────────────────────────────
        self.launch_rl_cmdp_constraints_check(
            &self.rewards_d,
            &self.dones_d,
            b_size,
        )
        .context("launch_rl_cmdp_constraints_check")?;
        self.launch_rl_win_rate_ema_update(
            &self.rewards_d,
            &self.dones_d,
            b_size,
        )
        .context("launch_rl_win_rate_ema_update")?;
        self.launch_rl_avg_win_loss_ema_update(
            &self.rewards_d,
            &self.dones_d,
            b_size,
        )
        .context("launch_rl_avg_win_loss_ema_update")?;
        self.launch_rl_inventory_variance_update(
            &self.prev_position_lots_d,
            b_size,
        )
        .context("launch_rl_inventory_variance_update")?;

        // Scale + clamp rewards, write per-step max to ISV for the
        // adaptive clamp controller. Without this, raw PnL ($100s)
        // goes into C51 with atom span ±0.5 → binary Q resolution.
        {
            let block_x: u32 = (b_size as u32).min(256).max(1);
            let smem = 3 * block_x * (std::mem::size_of::<f32>() as u32);
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.rewards_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.apply_reward_scale_fn.cu_function(),
                    (1, 1, 1), (block_x, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("apply_reward_scale: {:?}", e))?;
            }
        }

        // Adaptive reward-clamp controller — reads per-step max from
        // apply_reward_scale, maintains EMAs, adapts C51 atom span.
        {
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_f32(alpha);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_reward_clamp_controller_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_reward_clamp_controller: {:?}", e))?;
            }
        }

        // C51 atom support refresh from updated V_MIN/V_MAX.
        {
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.atom_supports_d.raw_ptr());
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_atom_support_update_fn.cu_function(),
                    (1, 1, 1), (21, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_atom_support_update: {:?}", e))?;
            }
        }

        // Trade context features — derived from unit state + current mid.
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let bid_px_d = lobsim.bid_px_d();
            let ask_px_d = lobsim.ask_px_d();
            let b_size_i = b_size as i32;
            let pos_bytes_i = lobsim.pos_bytes() as i32;
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(self.trade_context_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.unit_entry_price_d.raw_ptr());
            args.push_ptr(self.unit_entry_step_d.raw_ptr());
            args.push_ptr(self.unit_initial_r_d.raw_ptr());
            args.push_ptr(self.unit_lots_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_trade_context_update_fn.cu_function(),
                    (grid_x, 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_trade_context_update: {:?}", e))?;
            }
        }

        // Multi-resolution streaming features — time-weighted EMA at 3 horizons.
        {
            let bid_px_d = lobsim.bid_px_d();
            let ask_px_d = lobsim.ask_px_d();
            let b_size_i = b_size as i32;
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(self.multires_state_d.raw_ptr());
            args.push_ptr(self.multires_output_d.raw_ptr());
            args.push_ptr(self.multires_prev_mid_d.raw_ptr());
            args.push_ptr(self.multires_prev_ts_ns_d.raw_ptr());
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(bid_px_d.raw_ptr());  // bid_sz uses bid_px
            args.push_ptr(ask_px_d.raw_ptr());  // ask_sz uses ask_px
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.ts_ns_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_multires_features_update_fn.cu_function(),
                    (grid_x, 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_multires_features_update: {:?}", e))?;
            }
        }

        // step_counter_update — handled by rl_fused_reward_pipeline above.
        {
            let smem = (2 * b_size * std::mem::size_of::<f32>()) as u32;
            let slot_i = crate::rl::isv_slots::RL_MEAN_TRADE_DURATION_EMA_INDEX as i32;
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot_i);
            args.push_f32(alpha);
            args.push_ptr(self.trade_duration_emit_d.raw_ptr());
            args.push_ptr(self.dones_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.ema_update_on_done_fn.cu_function(),
                    (1, 1, 1), (b_size as u32, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("ema_update_on_done(mean_trade_duration): {:?}", e))?;
            }
        }

        // ── Step 6 (Phase R7c): GPU-pure post-fill pipeline. ──────────
        // All actions / next_actions / log_pi_old are already in
        // trainer-owned device buffers (R4 kernels wrote them in Step 3).
        // No host uploads here. V(s_t) and V(s_{t+1}) BOTH live on
        // device — v_pred_d from value_head.forward(h_t),
        // v_pred_tp1_d from value_head.forward(h_tp1) (R7c data
        // correctness lift: was an alias of v_pred_d in R7a/R7b).

        // abs_copy — handled by rl_fused_reward_pipeline above.

        // Update ISV[423] MEAN_ABS_PNL_EMA from |reward| over ALL
        // non-zero reward events (NOT just trade closes).
        //
        // Originally gated on dones_d so only closed-trade realized
        // PnL contributed to the EMA. Cluster smoke `alpha-rl-9cbpj`
        // diag revealed that 170 of 266 non-zero reward events occur
        // on non-done steps (mid-trade PnL deltas from trail-stop
        // adjustments / mark-to-market / partial fills). These
        // mid-trade swings can be 2-3× larger than realized close
        // PnL — invisible to a done-gated EMA, they end up scaled
        // by `reward_scale = 1/mean_abs_pnl_close` which is calibrated
        // for the smaller magnitude. Result: V regression target
        // spikes ×50-100 on volatile mid-trade steps (l_v=3,456
        // observed at step 590 with raw $2,390 PnL × scale=0.0011).
        //
        // The kernel's "done" parameter is really a generic gate
        // tested as `d >= 0.5`. Passing reward_abs_d as the gate
        // gives "gate on |reward| ≥ 0.5" (= "non-zero reward" for
        // any practical magnitude in dollars) — exactly the
        // semantics we want. Zero-reward steps stay excluded so the
        // EMA isn't biased toward zero on idle steps.
        {
            let smem = (2 * b_size * std::mem::size_of::<f32>()) as u32;
            let slot_i = crate::rl::isv_slots::RL_MEAN_ABS_PNL_EMA_INDEX as i32;
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot_i);
            args.push_f32(alpha);
            args.push_ptr(self.reward_abs_d.raw_ptr());
            args.push_ptr(self.reward_abs_d.raw_ptr());  // gate on |reward|>0
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.ema_update_on_done_fn.cu_function(),
                    (1, 1, 1), (b_size as u32, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("ema_update_on_done(MEAN_ABS_PNL): {:?}", e))?;
            }
        }

        // Signed outcome EMA (ISV[508]) — updated on trade close only.
        // Feeds anti-martingale sizing in actions_to_market_targets.
        {
            let smem = (2 * b_size * std::mem::size_of::<f32>()) as u32;
            let slot_i = crate::rl::isv_slots::RL_OUTCOME_EMA_INDEX as i32;
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot_i);
            args.push_f32(alpha);
            args.push_ptr(self.rewards_d.raw_ptr());
            args.push_ptr(self.dones_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.ema_update_on_done_fn.cu_function(),
                    (1, 1, 1), (b_size as u32, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("ema_update_on_done(OUTCOME_EMA): {:?}", e))?;
            }
        }

        // recent_outcome_update — handled by rl_fused_reward_pipeline above.

        // Adaptive controller floors (spec
        // `2026-05-30-adaptive-controller-floor-design`): launch a
        // Welford-variance update for each controller-input EMA so the
        // 12 controllers + fused kernel can read `sqrt(M²/(n-1))` from
        // their dedicated triple instead of falling back to
        // `target × 0.5`. Sequenced AFTER all EMA producers above and
        // BEFORE the fused controller launch / standalone v_blend_alpha
        // controller below. Sentinel-zero skip is handled inside the
        // kernel — no per-step gating needed here.
        //
        // Slots written *this* step (kl_pi, entropy_observed, td_kurtosis,
        // mean_trade_duration) feed Welford with the current step's
        // observation. Slots written *later* in the step
        // (q_divergence at step end, q_distill_kl by distill grad,
        // reward_magnitude by reward_scale controller, v_scalar_mag by
        // v_blend_alpha controller, adv_var_pre_norm by advantage
        // normalize) feed Welford with the previous step's value — a
        // one-step lag, acceptable per the spec since controllers adapt
        // over many steps and the Welford counter still reflects real
        // observations.
        for (input_slot, count_slot, mean_slot, m2_slot) in [
            (crate::rl::isv_slots::RL_KL_PI_EMA_INDEX,
             crate::rl::isv_slots::RL_KL_PI_VAR_COUNT_INDEX,
             crate::rl::isv_slots::RL_KL_PI_VAR_MEAN_INDEX,
             crate::rl::isv_slots::RL_KL_PI_VAR_M2_INDEX),
            (crate::rl::isv_slots::RL_Q_DIVERGENCE_EMA_INDEX,
             crate::rl::isv_slots::RL_Q_DIV_VAR_COUNT_INDEX,
             crate::rl::isv_slots::RL_Q_DIV_VAR_MEAN_INDEX,
             crate::rl::isv_slots::RL_Q_DIV_VAR_M2_INDEX),
            (crate::rl::isv_slots::RL_ADV_VAR_PRE_NORM_INDEX,
             crate::rl::isv_slots::RL_ADV_VAR_VAR_COUNT_INDEX,
             crate::rl::isv_slots::RL_ADV_VAR_VAR_MEAN_INDEX,
             crate::rl::isv_slots::RL_ADV_VAR_VAR_M2_INDEX),
            (crate::rl::isv_slots::RL_ENTROPY_OBSERVED_EMA_INDEX,
             crate::rl::isv_slots::RL_ENTROPY_OBS_VAR_COUNT_INDEX,
             crate::rl::isv_slots::RL_ENTROPY_OBS_VAR_MEAN_INDEX,
             crate::rl::isv_slots::RL_ENTROPY_OBS_VAR_M2_INDEX),
            (crate::rl::isv_slots::RL_TD_KURTOSIS_EMA_INDEX,
             crate::rl::isv_slots::RL_TD_KURT_VAR_COUNT_INDEX,
             crate::rl::isv_slots::RL_TD_KURT_VAR_MEAN_INDEX,
             crate::rl::isv_slots::RL_TD_KURT_VAR_M2_INDEX),
            (crate::rl::isv_slots::RL_MEAN_TRADE_DURATION_EMA_INDEX,
             crate::rl::isv_slots::RL_TRADE_DUR_VAR_COUNT_INDEX,
             crate::rl::isv_slots::RL_TRADE_DUR_VAR_MEAN_INDEX,
             crate::rl::isv_slots::RL_TRADE_DUR_VAR_M2_INDEX),
            (crate::rl::isv_slots::RL_REWARD_MAGNITUDE_EMA_INDEX,
             crate::rl::isv_slots::RL_REWARD_MAG_VAR_COUNT_INDEX,
             crate::rl::isv_slots::RL_REWARD_MAG_VAR_MEAN_INDEX,
             crate::rl::isv_slots::RL_REWARD_MAG_VAR_M2_INDEX),
            (crate::rl::isv_slots::RL_Q_DISTILL_KL_EMA_INDEX,
             crate::rl::isv_slots::RL_Q_DISTILL_KL_VAR_COUNT_INDEX,
             crate::rl::isv_slots::RL_Q_DISTILL_KL_VAR_MEAN_INDEX,
             crate::rl::isv_slots::RL_Q_DISTILL_KL_VAR_M2_INDEX),
            (crate::rl::isv_slots::RL_V_SCALAR_MAG_EMA_INDEX,
             crate::rl::isv_slots::RL_V_BLEND_SCALAR_VAR_COUNT_INDEX,
             crate::rl::isv_slots::RL_V_BLEND_SCALAR_VAR_MEAN_INDEX,
             crate::rl::isv_slots::RL_V_BLEND_SCALAR_VAR_M2_INDEX),
        ] {
            self.launch_rl_signal_variance_update(
                input_slot, count_slot, mean_slot, m2_slot,
            )
            .context("launch_rl_signal_variance_update")?;
        }

        // Fire all 10 RL ISV controllers in a single fused kernel
        // launch (gamma, tau, ppo_clip, entropy_coef, rollout_steps,
        // per_alpha, reward_scale, ppo_ratio_clamp, gate_threshold,
        // q_distill_lambda). Critical: this happens BEFORE
        // apply_reward_scale so the scale ISV[406] reflects this
        // step's mean_abs_pnl EMA update.
        self.launch_rl_fused_controllers(b_size)
            .context("launch_rl_fused_controllers")?;

        // v9 defensive eval-boundary calibration ([[pearl_adaptive_carryover_discipline]]).
        // MUST run AFTER fused controllers: last-writer-wins on the four risk-sizing
        // floors (Kelly safety_frac, IQN τ_min, entropy_coef_min, PPO clip ε_min).
        // Steady-state (counter < 0): kernel returns early — zero behavioral effect.
        // After `reset_session_state` arms the counter, kernel overrides floors with
        // defensive defaults for warmup_steps, with a linear decay over decay_steps.
        self.launch_rl_eval_warmup_decay()
            .context("launch_rl_eval_warmup_decay")?;

        // Adaptive surfer-scaffold weight controller (spec v5 §6, 2026-06-01).
        // Reads wr_ema + cumulative_dones + edge_decay_frac_alerted; writes
        // `RL_SURFER_SCAFFOLD_WEIGHT_INDEX` ∈ [0,1] consumed by
        // `rl_fused_reward_pipeline.cu` Phase 5 NEXT step. MUST run after
        // any controller that updates wr_ema/cumulative_dones so the
        // weight reflects this-step state.
        self.launch_rl_surfer_scaffold_controller()
            .context("launch_rl_surfer_scaffold_controller")?;

        // Phase 2A-D fix B1.3 (2026-06-03) — adaptive gate-LR multiplier
        // controller. Flag-gated on `use_multi_head_policy`: the captured
        // graph baked at construction time mirrors the flag state, so
        // flag-off runs do NOT include this launch and bit-equality vs
        // pre-B1.3 HEAD 6f3639bfb is preserved. Must run AFTER the
        // forward-path aggregator (`emit_diag_stats`) wrote
        // `gate_entropy_mean` to slot 781 for this step, and BEFORE the
        // next step's `lr_gate = lr_pi × ISV[790]` host read at the top
        // of `step_*_body`.
        if self.use_multi_head_policy {
            self.launch_rl_gate_lr_multiplier_controller()
                .context("launch_rl_gate_lr_multiplier_controller")?;
        }

        // PopArt: normalize rewards in-place (replaces apply_reward_scale).
        // Welford-EMA on batch mean+variance → whitened rewards → ISV
        // stats for V-correct. Grid=(1), Block=(min(B, 256)). Shared mem
        // for tree-reduce: block_dim*3 + 2 floats
        // (s_sum + s_sum_sq + s_max_abs + broadcast); F4 adds 3rd bank.
        {
            let block_x: u32 = (b_size as u32).min(256).max(1);
            let smem_bytes = (block_x * 3 + 3) * (std::mem::size_of::<f32>() as u32);
            let mut args = RawArgs::new();
            args.push_ptr(self.rewards_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_popart_normalize_fn.cu_function(),
                    (1, 1, 1), (block_x, 1, 1), smem_bytes,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_popart_normalize: {:?}", e))?;
            }
        }

        // PopArt V-correction on v_pred_d — correct the value head's
        // output for the normalization stats shift (sigma_old/sigma_new
        // affine transform per van Hasselt et al. 2016).
        {
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(self.v_pred_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_popart_v_correct_fn.cu_function(),
                    (grid_x.max(1), 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_popart_v_correct(v_pred_d): {:?}", e))?;
            }
        }
        // PopArt V-correction on v_pred_tp1_d (same kernel, separate launch).
        {
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(self.v_pred_tp1_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_popart_v_correct_fn.cu_function(),
                    (grid_x.max(1), 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_popart_v_correct(v_pred_tp1_d): {:?}", e))?;
            }
        }

        // Outcome label assignment — assign Profit/Timeout/Loss labels
        // on done steps from the reward signal.
        self.outcome_head.assign_labels(
            &self.rewards_d,
            &self.dones_d,
            b_size,
        ).context("outcome_head.assign_labels")?;

        // Adaptive reward-clamp controller — refresh slots 452/453 from
        // the just-published positive-tail max in slot 478. The new
        // bounds take effect on the NEXT step's apply_reward_scale.
        {
            let alpha = RL_LR_CONTROLLER_ALPHA;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_f32(alpha);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_reward_clamp_controller_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_reward_clamp_controller (step_with_lobsim): {:?}", e))?;
            }

            // C51 atom support refresh from updated V_MIN/V_MAX.
            let mut args2 = RawArgs::new();
            args2.push_ptr(self.isv_dev_ptr);
            args2.push_ptr(self.atom_supports_d.raw_ptr());
            let mut ptrs2 = args2.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_atom_support_update_fn.cu_function(),
                    (1, 1, 1), (21, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs2[..args2.len()],
                ).map_err(|e| anyhow::anyhow!("rl_atom_support_update (step_with_lobsim): {:?}", e))?;
            }
        }

        // GPU compute_advantage_return: returns_d, advantages_d
        // populated for step_synthetic to consume.
        // Q-advantage: A(s,a) = E_Q(s,a_taken) - V(s) instead of
        // the original returns - V(s). This couples Q's distributional
        // calibration directly into the PPO advantage signal, fixing
        // the q_pi_agree anti-correlation where PPO with V-advantage
        // pushed π away from Q's preferred actions.
        //
        // Phase 4.4 (2026-05-30): adaptive V blend.
        //
        // V_used[b] = α × V_scalar[b] + (1 − α) × V_dq[b]
        //
        // α driven on-device by rl_v_blend_alpha_controller which adapts
        // from observed EMA(|V_dq − V_scalar|) / EMA(|V_scalar|) tracking
        // ratio. Sentinel 0 → α = 1.0 bootstrap (Plan A v2 behavior on
        // first step). Schulman bounded ±0.01 per step. Dead-signal
        // guard at |V_scalar|_ema < 1e-4 holds α.
        //
        // Per pearl_controller_anchors_isv_driven, feedback_adaptive_not_tuned:
        // no host-side scheduling; controller is fully device-resident.
        //
        // The blended V replaces v_pred_d/dueling_v_d in compute_advantage_return.
        // value_head and DuelingQHead BOTH still train (MSE on returns +
        // dueling Bellman respectively); the blend just selects which
        // baseline feeds PPO advantage per step.
        let alpha_slot_i = crate::rl::isv_slots::RL_V_BLEND_ALPHA_INDEX as i32;
        let trackerr_slot_i = crate::rl::isv_slots::RL_V_TRACK_ERR_EMA_INDEX as i32;
        let mag_slot_i = crate::rl::isv_slots::RL_V_SCALAR_MAG_EMA_INDEX as i32;
        // Adaptive α controller — single block parallel reduction over batch.
        // Reads V_scalar at h_t (v_pred_d) and V_dq at h_t (dueling_v_d).
        {
            let mut args = RawArgs::new();
            args.push_ptr(self.v_pred_d.raw_ptr());
            args.push_ptr(self.dueling_v_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(alpha_slot_i);
            args.push_i32(trackerr_slot_i);
            args.push_i32(mag_slot_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_v_blend_alpha_controller_fn.cu_function(),
                    (1, 1, 1), (1024, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_v_blend_alpha_controller: {:?}", e))?;
            }
        }
        // Blend kernel: v_blended_d at s_t.
        {
            let grid_x = ((b_size as u32) + 255) / 256;
            let mut args = RawArgs::new();
            args.push_ptr(self.v_pred_d.raw_ptr());
            args.push_ptr(self.dueling_v_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(alpha_slot_i);
            args.push_ptr(self.v_blended_d.raw_ptr());
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_v_blend_fn.cu_function(),
                    (grid_x.max(1), 1, 1), (256, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_v_blend (s_t): {:?}", e))?;
            }
        }
        // Blend kernel: v_blended_tp1_d at s_{t+1} (same α — controller emits ONCE per step).
        {
            let grid_x = ((b_size as u32) + 255) / 256;
            let mut args = RawArgs::new();
            args.push_ptr(self.v_pred_tp1_d.raw_ptr());
            args.push_ptr(self.dueling_v_tp1_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(alpha_slot_i);
            args.push_ptr(self.v_blended_tp1_d.raw_ptr());
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_v_blend_fn.cu_function(),
                    (grid_x.max(1), 1, 1), (256, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_v_blend (s_tp1): {:?}", e))?;
            }
        }
        {
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.rewards_d.raw_ptr());
            args.push_ptr(self.dones_d.raw_ptr());
            args.push_ptr(self.v_blended_d.raw_ptr());
            args.push_ptr(self.v_blended_tp1_d.raw_ptr());
            args.push_ptr(self.returns_d.raw_ptr());
            args.push_ptr(self.advantages_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.compute_advantage_return_fn.cu_function(),
                    (grid_x, 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("compute_advantage_return: {:?}", e))?;
            }
        }

        // Phase 4.5 (2026-05-30): per-batch advantage normalization.
        //
        //   advantage[b] ← (advantage[b] − mean_b advantage) / sqrt(var_b + ε²)
        //
        // Standard PPO practice (Schulman et al. 2017). Self-adaptive
        // from per-batch statistics — no tuned hyperparams. Stabilizes
        // l_pi magnitude across batches when V baseline scale shifts
        // (Phase 4.3 saw l_pi=1.6e9 because V_dq's baseline produced
        // higher-variance advantages than V_scalar's; advantage
        // normalization fixes this regardless of V source).
        //
        // Single block parallel reduction over batch, in-place.
        {
            let block_x = (b_size as u32).min(1024).max(1);
            let mut args = RawArgs::new();
            args.push_ptr(self.advantages_d.raw_ptr());
            // Adaptive controller floors (spec 2026-05-30): emit raw pre-norm
            // variance to ISV[RL_ADV_VAR_PRE_NORM_INDEX=612] for
            // rl_rollout_steps_controller's input signal.
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_advantage_normalize_fn.cu_function(),
                    (1, 1, 1), (block_x, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_advantage_normalize: {:?}", e))?;
            }
        }

        // γ is read on-device by compute_advantage_return from
        // ISV[400] — no host gamma scalar needed in this hot path
        // post-R7b (the bootstrap-fallback host read of γ that the
        // flawed branch did is gone with the host advantage loop).

        // Feed `advantage_var_ratio_ema` (ISV[421] → rl_rollout_steps
        // controller) from this step's advantages_d. Streaming kernel
        // writes directly to ISV[421] (folds across STEPS — works at
        // b_size=1 where per-batch variance is undefined). No
        // ema_update_per_step needed downstream.
        {
            let b_size_i = b_size as i32;
            let out_slot = crate::rl::isv_slots::RL_ADVANTAGE_VAR_RATIO_EMA_INDEX as i32;
            let mean_slot = crate::rl::isv_slots::RL_ADV_VAR_STREAM_MEAN_INDEX as i32;
            let m2_slot = crate::rl::isv_slots::RL_ADV_VAR_STREAM_M2_INDEX as i32;
            let clamp_slot = crate::rl::isv_slots::RL_ADV_VAR_RATIO_CLAMP_INDEX as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.advantages_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(out_slot);
            args.push_i32(mean_slot);
            args.push_i32(m2_slot);
            args.push_i32(clamp_slot);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_var_over_abs_mean_streaming_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_var_over_abs_mean_streaming (advantages): {:?}", e))?;
            }
        }

            if capturing_reward {
                let graph = self
                    .stream
                    .end_capture(
                        CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH,
                    )
                    .context("reward end_capture")?
                    .ok_or_else(|| anyhow::anyhow!("reward end_capture returned None"))?;
                self.reward_graph = Some(graph);
            }
        } // end else (reward warmup / capture)

        // HER: track mid price for backward hindsight — accumulate
        // mid-price into ring buffer and update peak (max for long,
        // min for short) while a position is open. Flat resets ring.
        {
            let pos_d_ref = lobsim.pos_d();
            let bid_px_d = lobsim.bid_px_d();
            let ask_px_d = lobsim.ask_px_d();
            let grid_x = ((b_size as u32) + 31) / 32;
            let b_size_i = b_size as i32;
            let pos_bytes_i = lobsim.pos_bytes() as i32;
            let mut args = RawArgs::new();
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.hindsight.mid_ring_d.raw_ptr());
            args.push_ptr(self.hindsight.ring_write_idx_d.raw_ptr());
            args.push_ptr(self.hindsight.peak_mid_d.raw_ptr());
            args.push_ptr(self.hindsight.entry_mid_d.raw_ptr());
            args.push_ptr(self.hindsight.position_dir_d.raw_ptr());
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_hindsight_track_fn.cu_function(),
                    (grid_x, 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_hindsight_track: {:?}", e))?;
            }
        }

        // ── Step 7a (R7d): PER push + sample + gather. ────────────────
        // Push CURRENT step's b_size transitions (one per batch) to
        // GPU PER buffer; sample b_size indices (priority^α from
        // ISV[405]) and gather into sampled_*_d buffers that
        // step_synthetic's Q forward + Bellman + backward consume.
        //
        // ORDER MATTERS: push BEFORE sample so the first
        // step_with_lobsim call has ≥ b_size transitions to sample from
        // (sample_indices on an empty buffer returns Vec::new(), which
        // would force a Q-skip warmup path; pushing first eliminates
        // that path and keeps Q always-on per `feedback_always_per`).
        //
        // ISV host reads are now zero-copy via mapped-pinned host_ptr.
        // No staging buffer, no DtoD copy — the GPU writes via dev_ptr
        // and the host reads via host_ptr (same physical memory).
        // One-step delay on PER alpha (ISV[405]) is acceptable: alpha
        // changes via slow EMA. On the first call the mapped pages are
        // zero-filled; the PER sample kernel interprets alpha=0 as
        // uniform sampling (correct cold-start).

        // GPU PER push — split into two coalesced kernels:
        //   Phase 1 (ring): n-step accumulation + flush decision per batch.
        //   Phase 2 (flush): prefix-sum slot allocation + coalesced replay write.
        // Both use Grid=(b_size,1,1), Block=(128,1,1) for coalesced h_t copies.

        // Phase 1: ring write + flush decision
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.perception.h_t_view().raw_ptr());
            args.push_ptr(self.rewards_d.raw_ptr());
            args.push_ptr(self.raw_rewards_d.raw_ptr());
            args.push_ptr(self.dones_d.raw_ptr());
            args.push_ptr(self.log_pi_old_d.raw_ptr());
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.gpu_replay.nstep_scalars_d.raw_ptr());
            args.push_ptr(self.gpu_replay.nstep_h_t_d.raw_ptr());
            args.push_ptr(self.gpu_replay.nstep_write_idx_d.raw_ptr());
            args.push_ptr(self.gpu_replay.nstep_count_d.raw_ptr());
            args.push_ptr(self.gpu_replay.flush_flags_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_per_push_ring_fn.cu_function(),
                    (b_size as u32, 1, 1), (128, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_per_push_ring: {:?}", e))?;
            }
        }
        // Phase 2: coordinated flush (coalesced replay write).
        // Reset the ready flag (write_offsets[b_size+1] = 0) before launch so
        // non-block-0 blocks start spinning correctly.
        unsafe {
            raw_memset_d8_zero(
                self.gpu_replay.write_offsets_d.raw_ptr(),
                self.gpu_replay.write_offsets_d.num_bytes(),
                self.raw_stream,
            ).map_err(|e| anyhow::anyhow!("step_with_lobsim: zero write_offsets: {:?}", e))?;
        }
        {
            let b_size_i = b_size as i32;
            let cap_i = self.gpu_replay.capacity as i32;
            // nstep_count_d appears twice in the kernel signature: once as
            // const read (arg 5) and once as mutable reset (arg 17). Same
            // device pointer — extract raw to satisfy the borrow checker.
            let nstep_count_ptr = self.gpu_replay.nstep_count_d.raw_ptr();
            let mut args = RawArgs::new();
            args.push_ptr(self.gpu_replay.flush_flags_d.raw_ptr());
            args.push_ptr(self.gpu_replay.write_offsets_d.raw_ptr());
            args.push_ptr(self.gpu_replay.nstep_scalars_d.raw_ptr());
            args.push_ptr(self.gpu_replay.nstep_h_t_d.raw_ptr());
            args.push_ptr(self.gpu_replay.nstep_write_idx_d.raw_ptr());
            args.push_ptr(nstep_count_ptr);
            args.push_ptr(self.h_tp1_d.raw_ptr());
            args.push_ptr(self.log_pi_old_d.raw_ptr());
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.gpu_replay.h_t_d.raw_ptr());
            args.push_ptr(self.gpu_replay.h_tp1_d.raw_ptr());
            args.push_ptr(self.gpu_replay.scalars_d.raw_ptr());
            args.push_ptr(self.gpu_replay.priority_tree_d.raw_ptr());
            args.push_ptr(self.gpu_replay.write_head_d.raw_ptr());
            args.push_ptr(self.gpu_replay.replay_len_d.raw_ptr());
            args.push_ptr(self.gpu_replay.max_priority_d.raw_ptr());
            args.push_ptr(nstep_count_ptr);
            args.push_i32(b_size_i);
            args.push_i32(cap_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_per_push_flush_fn.cu_function(),
                    (b_size as u32, 1, 1), (128, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_per_push_flush: {:?}", e))?;
            }
        }

        // HER: backward inject — on trade close, if peak PnL >> actual
        // PnL (threshold from ISV[549]), inject a synthetic "held to
        // peak" transition at boosted priority (ISV[550]).
        {
            let b_size_i = b_size as i32;
            let cap_i = self.gpu_replay.capacity as i32;
            let smem = (2 * b_size * std::mem::size_of::<i32>()) as u32;
            // peak_mid_d appears twice: const read (arg 2) and mutable
            // reset (arg 15). Same device pointer — extract raw to
            // satisfy the borrow checker (same pattern as nstep_count_d
            // in rl_per_push_flush).
            let peak_mid_ptr = self.hindsight.peak_mid_d.raw_ptr();
            let mut args = RawArgs::new();
            args.push_ptr(self.dones_d.raw_ptr());
            args.push_ptr(self.raw_rewards_d.raw_ptr());
            args.push_ptr(peak_mid_ptr);
            args.push_ptr(self.hindsight.entry_mid_d.raw_ptr());
            args.push_ptr(self.hindsight.position_dir_d.raw_ptr());
            args.push_ptr(self.perception.h_t_view().raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.gpu_replay.h_t_d.raw_ptr());
            args.push_ptr(self.gpu_replay.h_tp1_d.raw_ptr());
            args.push_ptr(self.gpu_replay.scalars_d.raw_ptr());
            args.push_ptr(self.gpu_replay.priority_tree_d.raw_ptr());
            args.push_ptr(self.gpu_replay.write_head_d.raw_ptr());
            args.push_ptr(self.gpu_replay.replay_len_d.raw_ptr());
            args.push_ptr(self.gpu_replay.max_priority_d.raw_ptr());
            args.push_ptr(self.hindsight.ring_write_idx_d.raw_ptr());
            args.push_ptr(peak_mid_ptr);
            args.push_i32(b_size_i);
            args.push_i32(cap_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_hindsight_inject_fn.cu_function(),
                    (1, 1, 1), (b_size as u32, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_hindsight_inject: {:?}", e))?;
            }
        }

        // HER: forward — evaluate closed trades after lookahead window
        // (ISV[552] steps). If holding would have been better, inject
        // a synthetic "should-have-held-longer" transition.
        {
            let bid_px_d = lobsim.bid_px_d();
            let ask_px_d = lobsim.ask_px_d();
            let smem = (2 * 256 * std::mem::size_of::<i32>()) as u32;
            let cap_i = self.gpu_replay.capacity as i32;
            let mut args = RawArgs::new();
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.hindsight.closed_ring_d.raw_ptr());
            args.push_ptr(self.hindsight.closed_h_t_d.raw_ptr());
            args.push_ptr(self.hindsight.closed_step_d.raw_ptr());
            args.push_ptr(self.gpu_replay.h_t_d.raw_ptr());
            args.push_ptr(self.gpu_replay.h_tp1_d.raw_ptr());
            args.push_ptr(self.gpu_replay.scalars_d.raw_ptr());
            args.push_ptr(self.gpu_replay.priority_tree_d.raw_ptr());
            args.push_ptr(self.gpu_replay.write_head_d.raw_ptr());
            args.push_ptr(self.gpu_replay.replay_len_d.raw_ptr());
            args.push_ptr(self.gpu_replay.max_priority_d.raw_ptr());
            args.push_i32(cap_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_hindsight_forward_fn.cu_function(),
                    (1, 1, 1), (256, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_hindsight_forward: {:?}", e))?;
            }
        }

        // ── Step 7b: K-loop training intensification driven by
        // `n_rollout_steps` (ISV[404]). Wires the previously-write-only
        // controller to actual training behavior.
        //
        // The controller widens its emitted value when advantages are
        // noisy (var/|mean| high → "need more samples to learn"). We
        // map that to the number of training iterations performed per
        // env step: K = clamp(isv[404] / K_DIVISOR, 1, K_MAX). Each
        // iteration is a fresh PER sample + full step_synthetic call
        // (Q + π + V forward, backward, Adam) + PER priority update.
        //
        //   * isv[404] = 256 (MIN)         → K = 1 (single update per env step, current behavior)
        //   * isv[404] = 2048 (BOOTSTRAP)  → K = 2
        //   * isv[404] = 8192 (MAX)        → K = 8
        //
        // Adapts the training:env ratio so noisy regimes get more
        // gradient samples without slowing down env stepping. b_size=1
        // means each iteration is cheap; the K-multiplier directly
        // addresses the per-step gradient signal starvation that left
        // l_q stuck at 2.82 (uniform=3.04, only 7% information gain)
        // in alpha-rl-frt7s.
        // K-loop config — both divisor and max are ISV-driven per
        // `feedback_isv_for_adaptive_bounds` so they're discoverable
        // in diag and tunable at runtime by re-launching
        // rl_streaming_clamp_init. Defaults seeded at trainer init:
        // divisor=2048 (matches ROLLOUT_BOOTSTRAP so K=1 at controller
        // bootstrap), max=4 (prevents gradient overtraining at
        // b_size=1).
        let n_rollout_steps =
            self.read_isv_host(crate::rl::isv_slots::RL_N_ROLLOUT_STEPS_INDEX);
        let k_divisor = self
            .read_isv_host(crate::rl::isv_slots::RL_K_LOOP_DIVISOR_INDEX)
            .max(1.0);
        let k_max = self
            .read_isv_host(crate::rl::isv_slots::RL_K_LOOP_MAX_INDEX)
            .max(1.0) as usize;
        let k_updates = ((n_rollout_steps / k_divisor).round() as usize)
            .clamp(1, k_max);
        self.last_k_updates = k_updates;

        // K-loop split (Path B): first iter does the full env-step-
        // paired Q+π+V+encoder update via step_synthetic. Subsequent
        // iters do DQN-ONLY replay via dqn_replay_step — fresh PER
        // sample + Q forward + Bellman + Q backward + Q Adam. π+V+
        // encoder updates stay at 1× per env step regardless of K, so
        // the K-loop intensifies Q learning without overshooting PPO
        // (avoids the f2ggr KL=12.44 pathology where K=8 ran 8× PPO
        // updates per env-step). At b_size=16 the K-loop typically
        // settles at K=1 (advantage_var_ratio drops with batch size),
        // but the split protects against pathological regimes and
        // makes the actor/critic separation explicit.
        // Event-based cross-stream sync before K-loop: record
        // push_done_event on self.stream after push_ring + push_flush.
        // train_stream waits on this event before reading the replay
        // buffer — no host-blocking cuStreamSynchronize needed.
        unsafe {
            raw_event_record(self.push_done_event.cu_event(), self.raw_stream)
                .map_err(|e| anyhow::anyhow!("push_done_event record: {:?}", e))?;
        }

        let mut stats = IntegratedStepStats::default();
        for k_iter in 0..k_updates {
            // First iter: train_stream waits for env stream's PER push
            // (push_done_event). Subsequent iters: the PER sample on
            // train_stream is ordered after the tree rebuild from the
            // previous iteration (same stream), so no cross-stream wait
            // is needed — train_stream already serialises its own work.
            if k_iter == 0 {
                unsafe {
                    raw_stream_wait_event(self.raw_train_stream, self.push_done_event.cu_event())
                        .map_err(|e| anyhow::anyhow!("train_stream wait push_done: {:?}", e))?;
                }
            }

            // Fresh PER sample per iteration — different transitions
            // each gradient step. Grid=(b_size), Block=(1) — one thread
            // per sample does stratified tree walk + gather.
            {
                let b_size_i = b_size as i32;
                let cap_i = self.gpu_replay.capacity as i32;
                let mut args = RawArgs::new();
                args.push_ptr(self.gpu_replay.priority_tree_d.raw_ptr());
                args.push_ptr(self.gpu_replay.h_t_d.raw_ptr());
                args.push_ptr(self.gpu_replay.h_tp1_d.raw_ptr());
                args.push_ptr(self.gpu_replay.scalars_d.raw_ptr());
                args.push_ptr(self.gpu_replay.replay_len_d.raw_ptr());
                args.push_ptr(self.isv_dev_ptr);
                args.push_ptr(self.gpu_replay.sample_prng_d.raw_ptr());
                args.push_ptr(self.sampled_h_t_d.raw_ptr());
                args.push_ptr(self.sampled_h_tp1_d.raw_ptr());
                args.push_ptr(self.sampled_rewards_d.raw_ptr());
                args.push_ptr(self.sampled_dones_d.raw_ptr());
                args.push_ptr(self.sampled_log_pi_old_d.raw_ptr());
                args.push_ptr(self.sampled_n_step_gammas_d.raw_ptr());
                args.push_ptr(self.sampled_actions_d.raw_ptr());
                args.push_ptr(self.gpu_replay.sample_indices_d.raw_ptr());
                args.push_i32(b_size_i);
                args.push_i32(cap_i);
                let mut ptrs = args.build_arg_ptrs();
                unsafe {
                    raw_launch(
                        self.rl_per_sample_fn.cu_function(),
                        (b_size as u32, 1, 1), (1, 1, 1), 0,
                        self.raw_train_stream,
                        &mut ptrs[..args.len()],
                    ).map_err(|e| anyhow::anyhow!("rl_per_sample: {:?}", e))?;
                }
            }

            // Event-based sync: record sample_done_event on train_stream
            // so self.stream can wait on it before reading sampled buffers.
            unsafe {
                raw_event_record(self.sample_done_event.cu_event(), self.raw_train_stream)
                    .map_err(|e| anyhow::anyhow!("sample_done_event record: {:?}", e))?;
                raw_stream_wait_event(self.raw_stream, self.sample_done_event.cu_event())
                    .map_err(|e| anyhow::anyhow!("stream wait sample_done: {:?}", e))?;
            }

            if k_iter == 0 {
                // First iter: full step_synthetic (env-step-paired).
                stats = self.step_synthetic(snapshots)?;
            } else {
                // Subsequent iters: Q-only replay (no π, no V, no
                // encoder backward, no LR controller refresh).
                let _l_q_extra = self
                    .dqn_replay_step(b_size)
                    .context("step_with_lobsim: dqn_replay_step (k-loop iter > 0)")?;
            }
            // Record replay_done_event on self.stream after BOTH
            // step_synthetic (k_iter==0) and dqn_replay_step (k_iter>0).
            // train_stream waits on this before the PER priority update
            // reads td_per_sample_d. Previously k_iter==0 relied on
            // step_synthetic's host-blocking stream.synchronize() to make
            // self.stream's work globally visible; now that the sync is
            // removed (fully async loss readback), the event-based wait
            // provides the cross-stream ordering for both paths.
            unsafe {
                raw_event_record(self.replay_done_event.cu_event(), self.raw_stream)
                    .map_err(|e| anyhow::anyhow!("replay_done_event record: {:?}", e))?;
                raw_stream_wait_event(self.raw_train_stream, self.replay_done_event.cu_event())
                    .map_err(|e| anyhow::anyhow!("train_stream wait replay_done (pre-priority): {:?}", e))?;
            }

            // PER priority update — writes new priorities from per-sample
            // TD-errors (td_per_sample_d, populated by Q backward) into
            // sum-tree leaf positions. Block-wide tree-reduce for
            // max_priority (no atomicAdd per feedback_no_atomicadd).
            {
                let b_size_i = b_size as i32;
                let cap_i = self.gpu_replay.capacity as i32;
                let smem = (b_size * std::mem::size_of::<f32>()) as u32;
                let mut args = RawArgs::new();
                args.push_ptr(self.gpu_replay.sample_indices_d.raw_ptr());
                args.push_ptr(self.td_per_sample_d.raw_ptr());
                args.push_ptr(self.gpu_replay.priority_tree_d.raw_ptr());
                args.push_ptr(self.gpu_replay.max_priority_d.raw_ptr());
                args.push_ptr(self.isv_dev_ptr);
                args.push_i32(b_size_i);
                args.push_i32(cap_i);
                let mut ptrs = args.build_arg_ptrs();
                unsafe {
                    raw_launch(
                        self.rl_per_update_priority_fn.cu_function(),
                        (1, 1, 1), (b_size as u32, 1, 1), smem,
                        self.raw_train_stream,
                        &mut ptrs[..args.len()],
                    ).map_err(|e| anyhow::anyhow!("rl_per_update_priority: {:?}", e))?;
                }
            }

            // Sum-tree rebuild — bottom-up deterministic single-block
            // reduction.  Grid=(1), Block=(1024).  Determinism Phase 2
            // §2.F fix (Option C): the previous Grid=(128) launch used
            // __threadfence() between levels, which is NOT a grid-wide
            // barrier — block N could read level-L nodes while block
            // M's level-L writes were still in flight, producing
            // run-dependent addition orderings.  Single-block geometry
            // makes __syncthreads() a proper barrier and the tree
            // build deterministic by construction.  See the kernel
            // header comment for the speed analysis.
            // No atomicAdd; each internal node written by exactly one thread.
            {
                let cap_i = self.gpu_replay.capacity as i32;
                let mut args = RawArgs::new();
                args.push_ptr(self.gpu_replay.priority_tree_d.raw_ptr());
                args.push_i32(cap_i);
                let mut ptrs = args.build_arg_ptrs();
                unsafe {
                    raw_launch(
                        self.rl_per_tree_rebuild_fn.cu_function(),
                        (1, 1, 1), (1024, 1, 1), 0,
                        self.raw_train_stream,
                        &mut ptrs[..args.len()],
                    ).map_err(|e| anyhow::anyhow!("rl_per_tree_rebuild: {:?}", e))?;
                }
            }
        }

        // Record train_done_event on train_stream after the last K-loop
        // iteration's tree rebuild. The NEXT step_with_lobsim's
        // stream.wait(&train_done_event) ensures the replay buffer is
        // coherent before push_ring/push_flush write new transitions.
        // GPU-side event wait replaces the prior host-blocking
        // train_stream.synchronize() — saves ~2ms/step.
        unsafe {
            raw_event_record(self.train_done_event.cu_event(), self.raw_train_stream)
                .map_err(|e| anyhow::anyhow!("train_done_event record: {:?}", e))?;
        }

        // Target-net soft update (Phase R5 + R6) — runs once per step
        // after the Q-head Adam update inside step_synthetic. Reads τ
        // from ISV[401].
        self.dqn_head
            .soft_update_target(&self.isv_dev_ptr)
            .context("dqn_head.soft_update_target")?;

        // q_divergence EMA — `‖W_online − W_target‖₂` after the soft
        // update. Single-block grid-stride reduce over the full
        // weight tensor (24,192 floats), result feeds the
        // rl_target_tau controller via ISV[418].
        {
            let n_total = self.dqn_head.w_d.len() as i32;
            let block_dim: u32 = 256;
            let smem = (block_dim as usize * std::mem::size_of::<f32>()) as u32;
            let mut args = RawArgs::new();
            args.push_ptr(self.dqn_head.w_d.raw_ptr());
            args.push_ptr(self.dqn_head.w_target_d.raw_ptr());
            args.push_ptr(self.ema_input_scratch_d.raw_ptr());
            args.push_i32(n_total);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_l2_diff_norm_fn.cu_function(),
                    (1, 1, 1), (block_dim, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_l2_diff_norm: {:?}", e))?;
            }
        }
        self.launch_ema_update_per_step(
            crate::rl::isv_slots::RL_Q_DIVERGENCE_EMA_INDEX,
            RL_LR_CONTROLLER_ALPHA,
            &self.ema_input_scratch_d.clone(),
            1,
        )
        .context("ema_update_per_step(q_divergence)")?;

        Ok(stats)
    }

    /// GPU-resident data path variant of `step_with_lobsim`. Runs one
    /// full RL training step driven by `LobSimCuda` (via `RlLobBackend`)
    /// using GPU-resident `GpuDataset` + `GpuDataLoader` for data
    /// loading. Zero CPU data work per step — sampling, window gathering,
    /// FRD label gathering, and AoS->SoA scatter all run as GPU kernels.
    ///
    /// Key differences from `step_with_lobsim`:
    ///   * Encoder forwards read from SoA buffers pre-filled by the GPU
    ///     gather kernels (`forward_encoder_from_device` replaces
    ///     `forward_encoder(snapshots)`).
    ///   * `lobsim.apply_snapshot_from_device` replaces the HtoD
    ///     `apply_snapshot`: batch 0's last-snapshot book data is DtoD'd
    ///     from the SoA buffers into the lobsim's staging arrays, then the
    ///     `book_update_apply_snapshot` kernel broadcasts to all backtests.
    ///   * FRD labels are gathered by `gpu_gather_frd_labels` directly
    ///     into `self.frd_labels_d` (no mapped-pinned staging).
    ///   * `ts_ns` for `step_fill_from_market_targets` is DtoD'd from
    ///     the SoA's last-snapshot position; the multires kernel reads
    ///     `self.ts_ns_d` which is filled by the same DtoD.
    ///
    /// Call sequence per step:
    ///   1. `sample_and_gather`   — samples (file, anchor), gathers s_t
    ///   2. `gather_next_window`  — gathers s_{t+1} using saved anchor
    ///   3. `forward_encoder_from_device` on s_{t+1} -> h_{t+1}
    ///   4. DtoD h_t -> h_tp1
    ///   5. `gather_current_window` — re-gathers s_t (saved anchor, no PRNG)
    ///   6. `forward_encoder_from_device` on s_t -> h_t
    ///   7. Pre-snapshot graph pipeline (Q/V/IQN/Pi/gates)
    ///   8. Post-snapshot pipeline (lobsim fill, reward, controllers)
    ///   9. PER push/sample, K-loop training, target-net update
    pub fn step_with_lobsim_gpu(
        &mut self,
        gpu_loader: &mut crate::data::gpu_dataset::GpuDataLoader,
        gpu_dataset: &crate::data::gpu_dataset::GpuDataset,
        lobsim: &mut dyn RlLobBackend,
    ) -> Result<IntegratedStepStats> {
        let b_size = self.cfg.perception.n_batch;
        let seq_len = self.cfg.perception.seq_len;
        if b_size == 0 {
            anyhow::bail!("step_with_lobsim_gpu: empty batch (n_batch = 0)");
        }

        // ── Event-based inter-stream sync (same as step_with_lobsim) ──
        unsafe {
            raw_stream_wait_event(self.raw_stream, self.train_done_event.cu_event())
                .map_err(|e| anyhow::anyhow!("stream wait train_done: {:?}", e))?;
        }

        // ── Step 0: bump device-resident step counter (ISV[548]). ──
        {
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_increment_step_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_increment_step: {:?}", e))?;
            }
        }

        // ── Step 1a: GPU data loading + encoder forward on s_{t+1}. ──
        //
        // 1. sample_and_gather: samples (file, anchor), gathers s_t to
        //    SoA, saves anchor in device scratch.
        // 2. gather_next_window: gathers s_{t+1} to SoA (overwrites s_t)
        //    using the saved anchor.
        // 3. forward_encoder_from_device: runs encoder on SoA (= s_{t+1}).
        // 4. DtoD h_t -> h_tp1: saves h_{t+1}.
        let soa = self.perception.soa_buffer_ptrs();
        gpu_loader
            .sample_and_gather(gpu_dataset, &soa, seq_len, b_size)
            .context("step_with_lobsim_gpu: sample_and_gather")?;
        gpu_loader
            .gather_next_window(gpu_dataset, &soa, seq_len, b_size)
            .context("step_with_lobsim_gpu: gather_next_window")?;
        let _ = self
            .perception
            .forward_encoder_from_device()
            .context("step_with_lobsim_gpu: forward_encoder_from_device(s_tp1)")?;
        {
            let nbytes = b_size * HIDDEN_DIM * std::mem::size_of::<f32>();
            unsafe {
                raw_memcpy_dtod_async(
                    self.hot.h_tp1, self.hot.h_t_encoder, nbytes, self.raw_stream,
                )
                .map_err(|e| anyhow::anyhow!("step_with_lobsim_gpu: DtoD h_t -> h_tp1: {:?}", e))?;
            }
        }

        // ── Step 1b: re-gather s_t + encoder forward on current snap. ──
        //
        // gather_current_window re-populates the SoA with s_t using the
        // saved (file_offset, anchor) from step 1a — no PRNG re-sampling.
        gpu_loader
            .gather_current_window(gpu_dataset, &soa, seq_len, b_size)
            .context("step_with_lobsim_gpu: gather_current_window")?;

        // FRD labels gathered by GPU kernel into trainer's frd_labels_d.
        gpu_loader
            .gather_frd_labels(gpu_dataset, seq_len, b_size, self.frd_labels_d.raw_ptr())
            .context("step_with_lobsim_gpu: gather_frd_labels")?;

        // ── Supervised BCE + aux training (closes the encoder-quality
        // gap where the GPU data path was BCE-blind). ─────────────────
        //
        // Gather per-horizon BCE labels, aux outcome labels, and per-file
        // pos_fraction from the GPU dataset into the perception trainer's
        // mapped-pinned staging buffers. Then run `step_batched_from_device`
        // which dispatches the full supervised forward + backward + Adam
        // cycle (same kernels as `step_batched`, minus the AoS scatter).
        //
        // The SoA buffers still contain s_t (from gather_current_window).
        // After this call the encoder weights are updated with BCE+aux
        // gradient signal, so the subsequent RL forward on h_t sees the
        // jointly-supervised encoder.
        {
            use crate::heads::N_HORIZONS as PERC_N_HORIZONS;
            let stg = self.perception.label_staging_ptrs();
            let total_snaps = gpu_dataset.total_snapshots;

            // BCE labels → stg_labels
            gpu_loader
                .gather_bce_labels(
                    gpu_dataset.labels_d.raw_ptr(), total_snaps,
                    PERC_N_HORIZONS, seq_len, b_size, stg.labels,
                )
                .context("step_with_lobsim_gpu: gather_bce_labels")?;
            // Aux prof-long → stg_aux_y_prof_long
            gpu_loader
                .gather_bce_labels(
                    gpu_dataset.outcome_prof_long_d.raw_ptr(), total_snaps,
                    PERC_N_HORIZONS, seq_len, b_size, stg.aux_y_prof_long,
                )
                .context("step_with_lobsim_gpu: gather outcome_prof_long")?;
            // Aux prof-short → stg_aux_y_prof_short
            gpu_loader
                .gather_bce_labels(
                    gpu_dataset.outcome_prof_short_d.raw_ptr(), total_snaps,
                    PERC_N_HORIZONS, seq_len, b_size, stg.aux_y_prof_short,
                )
                .context("step_with_lobsim_gpu: gather outcome_prof_short")?;
            // Aux size-long → stg_aux_y_size_long
            gpu_loader
                .gather_bce_labels(
                    gpu_dataset.outcome_size_long_d.raw_ptr(), total_snaps,
                    PERC_N_HORIZONS, seq_len, b_size, stg.aux_y_size_long,
                )
                .context("step_with_lobsim_gpu: gather outcome_size_long")?;
            // Aux size-short → stg_aux_y_size_short
            gpu_loader
                .gather_bce_labels(
                    gpu_dataset.outcome_size_short_d.raw_ptr(), total_snaps,
                    PERC_N_HORIZONS, seq_len, b_size, stg.aux_y_size_short,
                )
                .context("step_with_lobsim_gpu: gather outcome_size_short")?;

            // pos_fraction: gathered into a small device scratch, then
            // read back on the host to compute clamped pos_weight. The
            // gather writes to a mapped-pinned buffer so we can read it
            // after a stream sync.
            let pf_staging = self.pos_fraction_staging.as_ref()
                .expect("pos_fraction_staging allocated in new()");
            gpu_loader
                .gather_pos_fraction(
                    gpu_dataset, PERC_N_HORIZONS, b_size,
                    pf_staging.dev_ptr,
                )
                .context("step_with_lobsim_gpu: gather_pos_fraction")?;

            // One-step deferred read: the gather kernel wrote pos_fraction
            // on the stream THIS step, but we read PREVIOUS step's data
            // (already host-visible from prior stream work). One-step lag
            // is acceptable — pos_fraction is a dataset-level class balance
            // statistic that barely changes step to step. Eliminates a
            // 7ms host-blocking stream sync.
            let pf_slice = pf_staging.read_all();

            // Run supervised training step.
            let (bce, aux) = self.perception
                .step_batched_from_device(&pf_slice)
                .context("step_with_lobsim_gpu: step_batched_from_device")?;
            self.last_bce_loss = bce;
            self.last_aux_loss = aux;
        }

        {
            self.perception.encoder_ctx_trade_ptr = self.hot.trade_context;
            self.perception.encoder_ctx_multires_ptr = self.hot.multires_output;
        }
        let _ = self
            .perception
            .forward_encoder_from_device()
            .context("step_with_lobsim_gpu: forward_encoder_from_device(s_t)")?;

        self.step_counter = self.step_counter.wrapping_add(1);

        // ── From here, the kernel pipeline is identical to
        // step_with_lobsim. The remaining differences are:
        //
        //   * apply_snapshot_from_device DtoD's batch 0's last-snapshot
        //     book data from the SoA, replacing the HtoD apply_snapshot.
        //   * ts_ns for step_fill_from_market_targets is DtoD'd from the
        //     SoA's last-snapshot position into self.ts_ns_d.
        //
        // We call step_with_lobsim_gpu_body to run the shared pipeline.
        self.step_with_lobsim_gpu_body(lobsim, &soa, b_size, seq_len)
    }

    /// Shared body for `step_with_lobsim_gpu`. Runs the graph pipelines,
    /// lobsim fill, reward/controller chain, PER, K-loop, and target-net
    /// update. Called after both encoder forwards have completed and h_t /
    /// h_tp1 are populated.
    ///
    /// `soa` is passed for the ts_ns DtoD (avoids reconstructing the
    /// pointer struct). `b_size` and `seq_len` are passed to avoid
    /// re-reading from cfg.
    fn step_with_lobsim_gpu_body(
        &mut self,
        lobsim: &mut dyn RlLobBackend,
        soa: &crate::trainer::perception::SoaBufferPtrs,
        b_size: usize,
        seq_len: usize,
    ) -> Result<IntegratedStepStats> {
        // ── Graph A: pre-snapshot kernel pipeline ──────────────────────
        if self.prefill_graph.is_some() {
            unsafe {
                raw_graph_launch(
                    self.prefill_graph.as_ref().unwrap().cu_graph_exec,
                    self.raw_stream,
                ).map_err(|e| anyhow::anyhow!("prefill graph launch: {:?}", e))?;
            }
        } else {
            let capturing_prefill = self.graph_warmup_done;
            if !self.graph_warmup_done {
                self.graph_warmup_done = true;
            }
            if capturing_prefill {
                self.stream
                    .begin_capture(CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_RELAXED)
                    .map_err(|e| anyhow::anyhow!("prefill begin_capture: {e}"))?;
            }

        // ── Step 2: Q + V forwards on h_t for action sampling. ────────
        let h_t_borrow: &CudaSlice<f32> = self.perception.h_t_view();
        debug_assert_eq!(h_t_borrow.len(), b_size * HIDDEN_DIM);
        debug_assert_eq!(self.h_tp1_d.len(), b_size * HIDDEN_DIM);

        self.frd_head
            .forward(
                h_t_borrow,
                &mut self.frd_hidden_d,
                &mut self.frd_logits_d,
                b_size,
            )
            .context("frd_head.forward in step_with_lobsim_gpu")?;

        self.dqn_head
            .forward(h_t_borrow, b_size, &mut self.q_logits_d)
            .context("step_with_lobsim_gpu: dqn_head.forward(h_t)")?;
        // B-10 G1 (2026-06-01): Q-distribution informativeness diagnostic
        // on online Q logits — emits ISV slots 730/731/732. Pure
        // observability; reads q_logits_d immediately after the forward,
        // before any downstream consumer (distill / Thompson / PPO).
        // (Sister-launch added at line 6622 in `step_with_lobsim` — the
        // GPU path here is the canonical one that the alpha_rl_train
        // example actually exercises.)
        self.dqn_head
            .launch_q_distribution_stats(
                &self.isv_dev_ptr,
                &self.q_logits_d,
                &self.atom_supports_d,
                b_size,
                &mut self.q_dist_entropy_pb_d,
                &mut self.q_dist_range_pb_d,
                &mut self.q_dist_abs_max_pb_d,
            )
            .context("step_with_lobsim_gpu: dqn_head.launch_q_distribution_stats")?;
        self.dqn_head
            .forward_target(h_t_borrow, b_size, &mut self.q_logits_target_st_d)
            .context("step_with_lobsim_gpu: dqn_head.forward_target(h_t) for distillation")?;
        self.dqn_head
            .forward(&self.h_tp1_d, b_size, &mut self.q_logits_tp1_d)
            .context("step_with_lobsim_gpu: dqn_head.forward(h_tp1)")?;
        self.value_head
            .forward(h_t_borrow, &self.isv_dev_ptr, b_size, &mut self.v_pred_d)
            .context("step_with_lobsim_gpu: value_head.forward(h_t)")?;
        self.value_head
            .forward(&self.h_tp1_d, &self.isv_dev_ptr, b_size, &mut self.v_pred_tp1_d)
            .context("step_with_lobsim_gpu: value_head.forward(h_tp1)")?;

        // Phase 4-A (2026-06-03) — No-transaction-band head forward.
        // Identical wiring to step_with_lobsim path; capturable since the
        // master gate at slot 799 is read by the downstream mask (outside
        // capture), not by this forward. See spec §1.1 / §9.5.
        self.band_head
            .forward(h_t_borrow, &self.isv_dev_ptr, b_size)
            .context("step_with_lobsim_gpu: band_head.forward(h_t) (Phase 4-A)")?;

        // IQN forward
        {
            let n_tau = self.iqn_head.n_tau();
            self.iqn_head
                .forward(
                    &mut self.iqn_prng_state_d,
                    h_t_borrow,
                    &mut self.iqn_tau_d,
                    b_size,
                    n_tau,
                    &mut self.iqn_q_values_d,
                )
                .context("step_with_lobsim_gpu: iqn_head.forward(h_t)")?;
            self.iqn_head
                .expected_q(
                    &self.iqn_q_values_d,
                    b_size,
                    n_tau,
                    &mut self.iqn_expected_q_d,
                )
                .context("step_with_lobsim_gpu: iqn_head.expected_q")?;
        }

        // ── Phase 4 DuelingQHead forward passes ─────────────────────
        // Per spec docs/superpowers/specs/2026-05-30-phase4-independent-dueling-head-design.md:
        //   • forward(h_t_borrow)   → V_dq for PPO baseline (Phase 4.3)
        //   • forward(h_tp1_d)      → V_dq_tp1 for PPO baseline
        //   • forward(sampled_h_t)  → online composed_Q for loss
        //   • forward_target(sampled_h_tp1) → target composed_Q for Bellman
        //
        // composed_Q NEVER feeds ensemble / distill / action selection.
        // Only V_dq is consumed by the rest of the trainer (in Phase 4.3
        // swap; currently diagnostic-only).
        self.dueling_q_head
            .forward(
                h_t_borrow,
                b_size,
                &mut self.dueling_v_d,
                &mut self.dueling_a_d,
                &mut self.dueling_q_composed_d, // discarded — diag only at h_t
            )
            .context("step_with_lobsim_gpu: dueling_q_head.forward(h_t)")?;
        self.dueling_q_head
            .forward(
                &self.h_tp1_d,
                b_size,
                &mut self.dueling_v_tp1_d,
                &mut self.dueling_a_target_tp1_d, // reusing as scratch — discarded
                &mut self.dueling_q_target_composed_d, // reusing as scratch — discarded
            )
            .context("step_with_lobsim_gpu: dueling_q_head.forward(h_tp1)")?;
        self.dueling_q_head
            .forward(
                &self.sampled_h_t_d,
                b_size,
                &mut self.dueling_v_loss_d,
                &mut self.dueling_a_loss_d,
                &mut self.dueling_q_composed_d,
            )
            .context("step_with_lobsim_gpu: dueling_q_head.forward(sampled_h_t)")?;
        self.dueling_q_head
            .forward_target(
                &self.sampled_h_tp1_d,
                b_size,
                &mut self.dueling_v_target_tp1_d,
                &mut self.dueling_a_target_tp1_d,
                &mut self.dueling_q_target_composed_d,
            )
            .context("step_with_lobsim_gpu: dueling_q_head.forward_target(sampled_h_tp1)")?;

        // Build Bellman target from target composed Q + rewards + dones.
        self.dueling_q_head
            .build_bellman_target(
                &self.dueling_q_target_composed_d,
                &self.sampled_rewards_d,
                &self.sampled_dones_d,
                &self.sampled_n_step_gammas_d,
                b_size,
                &mut self.dueling_target_value_d,
            )
            .context("step_with_lobsim_gpu: dueling_q_head.build_bellman_target")?;

        // Compute Huber loss + grad_composed (only taken-action cell nonzero).
        self.dueling_q_head
            .compute_loss_and_grad(
                &self.dueling_q_composed_d,
                &self.dueling_target_value_d,
                &self.sampled_actions_d,
                b_size,
                &mut self.dueling_loss_pb_d,
                &mut self.dueling_grad_composed_d,
            )
            .context("step_with_lobsim_gpu: dueling_q_head.compute_loss_and_grad")?;

        // Decompose grad_composed → grad_V/grad_A + per-batch weight grads
        // (outer product with sampled_h_t).
        self.dueling_q_head
            .decompose_and_backward_to_weights(
                &self.sampled_h_t_d,
                &self.dueling_grad_composed_d,
                &self.sampled_actions_d,
                b_size,
                &mut self.dueling_grad_w_v_pb_d,
                &mut self.dueling_grad_b_v_pb_d,
                &mut self.dueling_grad_w_a_pb_d,
                &mut self.dueling_grad_b_a_pb_d,
            )
            .context("step_with_lobsim_gpu: dueling_q_head.decompose_and_backward_to_weights")?;

        // Reduce per-batch grads → final weight grads via reduce_axis0.
        reduce_axis0_free(
            &self.stream, &self.reduce_axis0_fn,
            &self.dueling_grad_w_v_pb_d, b_size, HIDDEN_DIM,
            &mut self.dueling_grad_w_v_d,
        )?;
        reduce_axis0_free(
            &self.stream, &self.reduce_axis0_fn,
            &self.dueling_grad_b_v_pb_d, b_size, 1,
            &mut self.dueling_grad_b_v_d,
        )?;
        reduce_axis0_free(
            &self.stream, &self.reduce_axis0_fn,
            &self.dueling_grad_w_a_pb_d, b_size, HIDDEN_DIM * N_ACTIONS,
            &mut self.dueling_grad_w_a_d,
        )?;
        reduce_axis0_free(
            &self.stream, &self.reduce_axis0_fn,
            &self.dueling_grad_b_a_pb_d, b_size, N_ACTIONS,
            &mut self.dueling_grad_b_a_d,
        )?;

        // Adam step on each weight. LR sourced from RL_LR_Q_INDEX since
        // DuelingQHead is a Q learner; tracks Q's plateau dynamics.
        let lr_q = self.read_isv_host(crate::rl::isv_slots::RL_LR_Q_INDEX);
        self.dueling_q_w_v_adam.lr = lr_q;
        self.dueling_q_b_v_adam.lr = lr_q;
        self.dueling_q_w_a_adam.lr = lr_q;
        self.dueling_q_b_a_adam.lr = lr_q;
        self.dueling_q_w_v_adam
            .step(&mut self.dueling_q_head.w_v_d, &self.dueling_grad_w_v_d)
            .context("dueling_q_w_v_adam.step")?;
        self.dueling_q_b_v_adam
            .step(&mut self.dueling_q_head.b_v_d, &self.dueling_grad_b_v_d)
            .context("dueling_q_b_v_adam.step")?;
        self.dueling_q_w_a_adam
            .step(&mut self.dueling_q_head.w_a_d, &self.dueling_grad_w_a_d)
            .context("dueling_q_w_a_adam.step")?;
        self.dueling_q_b_a_adam
            .step(&mut self.dueling_q_head.b_a_d, &self.dueling_grad_b_a_d)
            .context("dueling_q_b_a_adam.step")?;

        // Phase 4.3: soft-update target net AFTER Adam (target absorbs
        // latest online weights with τ from ISV[RL_TARGET_TAU_INDEX]).
        self.dueling_q_head
            .soft_update_target(&self.isv_dev_ptr)
            .context("dueling_q_head.soft_update_target")?;

        // Ensemble Q
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(self.iqn_expected_q_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.ensemble_q_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_ensemble_action_value_fn.cu_function(),
                    (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_ensemble_action_value: {:?}", e))?;
            }
        }

        // NoisyNet exploration
        self.noisy_exploration
            .resample_noise()
            .context("step_with_lobsim_gpu: noisy_exploration.resample_noise")?;
        self.noisy_exploration
            .forward(h_t_borrow, b_size, &mut self.noisy_output_d)
            .context("step_with_lobsim_gpu: noisy_exploration.forward")?;
        {
            let n_elems = (b_size * N_ACTIONS) as i32;
            let grid_x = ((n_elems as u32) + 255) / 256;
            let mut args = RawArgs::new();
            args.push_ptr(self.ensemble_q_d.raw_ptr());
            args.push_ptr(self.noisy_output_d.raw_ptr());
            args.push_i32(n_elems);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.aux_vec_add_fn.cu_function(),
                    (grid_x, 1, 1), (256, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("aux_vec_add_inplace (noisy -> ensemble_q): {:?}", e))?;
            }
        }

        // pi logits
        // Phase 2A-C: gate the GPU-path pi forward as well.
        if self.use_multi_head_policy {
            let regime_h = self.perception.step_regime_d_view();
            let mhp = self.multi_head_policy
                .as_ref()
                .expect("use_multi_head_policy=true but multi_head_policy=None (construction bug)");
            mhp.forward(h_t_borrow, regime_h, &mut self.pi_logits_d)
                .context("multi_head_policy.forward [step_with_lobsim_gpu_body]")?;
            // Phase 2A-D — see comment in step_synthetic_body. This forward
            // path runs inside the captured CUDA graph; the aggregator launch
            // is part of the graph and replays deterministically.
            mhp.emit_diag_stats(self.isv_dev_ptr)
                .context("multi_head_policy.emit_diag_stats [step_with_lobsim_gpu_body]")?;
        } else {
            self.policy_head
                .forward_logits(h_t_borrow, b_size, &mut self.pi_logits_d)
                .context("step_with_lobsim_gpu: policy_head.forward_logits")?;
        }

        // Q-vs-pi agree
        {
            let block_dim = (b_size.next_power_of_two() as u32).max(1);
            let smem = (block_dim as usize * std::mem::size_of::<f32>()) as u32;
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_d.raw_ptr());
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_q_pi_agree_b_fn.cu_function(),
                    (1, 1, 1), (block_dim, 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_q_pi_agree_b: {:?}", e))?;
            }
        }

        // ── Phase 7b F5 (2026-06-05) — state-conditional action mask ──
        // Sets `pi_logits[b][a] = -INFINITY` for state-illegal actions
        // BEFORE the action sampler reads them. Device-side master gate
        // at slot 823 makes this a no-op when ≤ 0.5 — preserves
        // bit-equality with Phase 7a HEAD. See step_with_lobsim mirror
        // call site for the full design rationale.
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let pos_bytes_u = lobsim.pos_bytes();
            let b_size_i = b_size as i32;
            let pos_bytes_i = pos_bytes_u as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(self.unit_trail_distance_d.raw_ptr());
            args.push_ptr(self.unit_initial_r_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_state_action_mask_fn.cu_function(),
                    (b_size as u32, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_state_action_mask (Phase 7b F5): {:?}", e))?;
            }
        }

        // pi-driven action selection
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(self.prng_state_d.raw_ptr());
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_pi_action_kernel_fn.cu_function(),
                    (b_size as u32, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_pi_action_kernel: {:?}", e))?;
            }
        }

        // argmax expected Q for Bellman target
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.q_logits_tp1_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(self.next_actions_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.argmax_expected_q_fn.cu_function(),
                    (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("argmax_expected_q (h_tp1): {:?}", e))?;
            }
        }

            if capturing_prefill {
                let graph = self
                    .stream
                    .end_capture(
                        CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH,
                    )
                    .context("prefill end_capture")?
                    .ok_or_else(|| anyhow::anyhow!("prefill end_capture returned None"))?;
                self.prefill_graph = Some(graph);
            }
        } // end else (warmup / capture dispatch)

        // ── Gates + log_pi: OUTSIDE graph capture (GPU data path).
        //
        // Phase 4-A (2026-06-03): band mask runs FIRST per spec §2.1 /
        // §2.3. Master gate at slot 799 makes the kernel a device-side
        // no-op until the operator flips the gate — preserves bit-equality
        // with Phase 3D baseline `bd811a774`.
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            self.band_head
                .launch_mask(
                    &self.actions_d,
                    pos_d_ref,
                    &self.isv_dev_ptr,
                    b_size,
                    lobsim.pos_bytes(),
                )
                .context("step_with_lobsim_gpu: band_head.launch_mask (Phase 4-A)")?;
        }

        // Phase 4-B (2026-06-04) — adaptive turnover controller + backward chain.
        //
        // Sequence (all outside graph capture, same stream so kernels are
        // ordered by issue):
        //   1. `rl_band_frac_aggregate` reads `band_out_d` + live position
        //      state and writes per-step `frac_not_masked` to ISV slot 812.
        //   2. `rl_band_turnover_controller` reads slot 812 and updates
        //      EMA (slot 809) + adaptive target (slot 811) using the
        //      first-observation bootstrap pattern + asymmetric
        //      Schulman-bounded adapter (spec §3.1 Option c).
        //   3. `rl_band_turnover_loss` reads the freshly-updated slot 811
        //      and emits per-batch loss/grad scratches.
        //   4. `rl_band_head_backward` propagates `grad_band_per_b_d`
        //      through the activation + linear layer into per-batch
        //      weight/bias grad scratches + `grad_h_t_d` (OVERWRITE).
        //
        // Host-side gated by `RL_BAND_ENABLED_INDEX > 0.5` — every kernel
        // also has its own device-side master-gate guard for defense in
        // depth, so flag-off paths produce zeros even if the host branch
        // were ever to fall through.
        let band_enabled_host = self
            .read_isv_host(crate::rl::isv_slots::RL_BAND_ENABLED_INDEX)
            > 0.5;
        if band_enabled_host {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let pos_bytes = lobsim.pos_bytes();
            self.band_head
                .launch_frac_aggregate(
                    pos_d_ref,
                    &self.isv_dev_ptr,
                    b_size,
                    pos_bytes,
                )
                .context("step_with_lobsim_gpu: band_head.launch_frac_aggregate (Phase 4-B)")?;
            self.band_head
                .launch_turnover_controller(&self.isv_dev_ptr)
                .context("step_with_lobsim_gpu: band_head.launch_turnover_controller (Phase 4-B)")?;
            // `turnover_t` host arg is read from the controller-updated EMA
            // (slot 809) so the loss diff equals (turnover_ema − adaptive_target).
            // Both terms have a one-step delay — the EMA was updated by the
            // controller on the same stream; the host read picks up the
            // value after stream completion at the next step's diag emit.
            // For the loss this delay is irrelevant: the gradient direction
            // is what matters.
            let turnover_t = self
                .read_isv_host(crate::rl::isv_slots::RL_BAND_TURNOVER_EMA_INDEX);
            self.band_head
                .launch_turnover_loss(
                    pos_d_ref,
                    &self.isv_dev_ptr,
                    turnover_t,
                    b_size,
                    pos_bytes,
                )
                .context("step_with_lobsim_gpu: band_head.launch_turnover_loss (Phase 4-B)")?;
            // Backward needs h_t — bind separately so the borrow from
            // `&self.perception` does not collide with `&mut self.band_head`.
            let h_t_borrow_band: &CudaSlice<f32> = self.perception.h_t_view();
            self.band_head
                .backward(h_t_borrow_band, &self.isv_dev_ptr, b_size)
                .context("step_with_lobsim_gpu: band_head.backward (Phase 4-B)")?;
        }
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let b_size_i = b_size as i32;
            let pos_bytes_i = lobsim.pos_bytes() as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.q_logits_d.raw_ptr());
            args.push_ptr(self.atom_supports_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_confidence_gate_fn.cu_function(),
                    (b_size as u32, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_confidence_gate (gpu): {:?}", e))?;
            }
        }
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let b_size_i = b_size as i32;
            let pos_bytes_i = lobsim.pos_bytes() as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.frd_logits_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_frd_gate_fn.cu_function(),
                    (b_size as u32, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_frd_gate (gpu): {:?}", e))?;
            }
        }
        // Action entropy EMA (prefill path) — POST-gate actions.
        {
            let slot_i = crate::rl::isv_slots::RL_ACTION_ENTROPY_EMA_INDEX as i32;
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(slot_i);
            args.push_f32(0.4_f32);
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.action_entropy_per_step_fn.cu_function(),
                    (1, 1, 1),
                    (N_ACTIONS as u32, 1, 1),
                    0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("action_entropy_per_step (gpu): {:?}", e))?;
            }
        }
        {
            let grid_x = ((b_size as u32) + 31) / 32;
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.pi_logits_d.raw_ptr());
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.log_pi_old_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.log_pi_at_action_fn.cu_function(),
                    (grid_x.max(1), 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("log_pi_at_action (post-gate gpu): {:?}", e))?;
            }
        }

        // ── Step 5: GPU-pure env step (GPU data path). ─────────────────
        //
        // apply_snapshot from device: copy batch 0's last-snapshot book
        // data from the SoA buffers into lobsim's staging arrays, then
        // launch the broadcast kernel. This replaces the HtoD path used
        // by step_with_lobsim and the previous "SKIPPED" placeholder that
        // left the lobsim book stale (producing dones=0 / pnl=$0).
        //
        // Layout: SoA arrays are [B * K * BOOK_LEVELS] f32. Batch 0's
        // last snapshot is at position (K-1), byte offset =
        // (seq_len - 1) * BOOK_LEVELS * sizeof(f32).
        {
            let book_levels = crate::cfc::snap_features::BOOK_LEVELS;
            let snap_byte_offset =
                ((seq_len - 1) * book_levels * std::mem::size_of::<f32>()) as u64;
            lobsim.apply_snapshot_from_device(
                soa.bid_px + snap_byte_offset,
                soa.bid_sz + snap_byte_offset,
                soa.ask_px + snap_byte_offset,
                soa.ask_sz + snap_byte_offset,
            ).context("step_with_lobsim_gpu: apply_snapshot_from_device")?;
        }
        //
        // ts_ns: DtoD from the SoA's last-snapshot ts_ns into self.ts_ns_d
        // for the multires kernel. The lobsim fill kernel receives 0 as
        // the timestamp (diagnostic-only, PnL tracking timestamps).
        {
            let last_ts_offset = ((seq_len - 1) * std::mem::size_of::<u64>()) as u64;
            unsafe {
                raw_memcpy_dtod_async(
                    self.ts_ns_d.raw_ptr(),
                    soa.ts_ns + last_ts_offset,
                    std::mem::size_of::<u64>(),
                    self.raw_stream,
                ).map_err(|e| anyhow::anyhow!("ts_ns DtoD from SoA: {:?}", e))?;
            }
        }

        let pos_bytes_i = lobsim.pos_bytes() as i32;
        let b_size_i = b_size as i32;

        // ── Graph A2: post-snapshot / pre-fill kernel pipeline ─────────
        if self.postfill_graph.is_some() {
            unsafe {
                raw_graph_launch(
                    self.postfill_graph.as_ref().unwrap().cu_graph_exec,
                    self.raw_stream,
                ).map_err(|e| anyhow::anyhow!("postfill graph launch: {:?}", e))?;
            }
        } else {
            let capturing_postfill = self.prefill_graph.is_some();
            if capturing_postfill {
                self.stream
                    .begin_capture(CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_RELAXED)
                    .map_err(|e| anyhow::anyhow!("postfill begin_capture: {e}"))?;
            }

        // Session risk check
        {
            let b_size_i = b_size as i32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.rewards_d.raw_ptr());
            args.push_ptr(self.dones_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_session_risk_check_fn.cu_function(),
                    (1, 1, 1), (1, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_session_risk_check: {:?}", e))?;
            }
        }

        // Min hold check
        {
            let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.steps_since_done_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_min_hold_check_fn.cu_function(),
                    (grid_x, 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_min_hold_check: {:?}", e))?;
            }
        }

        // Asymmetric trail decay
        {
            let bid_px_d = lobsim.bid_px_d();
            let ask_px_d = lobsim.ask_px_d();
            let mut args = RawArgs::new();
            args.push_ptr(self.unit_trail_distance_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.unit_entry_price_d.raw_ptr());
            args.push_ptr(self.unit_initial_r_d.raw_ptr());
            args.push_ptr(self.unit_lots_d.raw_ptr());
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_asymmetric_trail_decay_fn.cu_function(),
                    (b_size as u32, 1, 1), (4, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_asymmetric_trail_decay: {:?}", e))?;
            }
        }

        // Trail mutate — Phase 5 reads unit_initial_r_d for per-unit
        // absolute ceiling on TrailLoosen (slot 814 ratio).
        {
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.unit_initial_r_d.raw_ptr());
            args.push_ptr(self.unit_trail_distance_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_trail_mutate_fn.cu_function(),
                    (b_size as u32, 1, 1), (4, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_trail_mutate: {:?}", e))?;
            }
        }

        // Trail stop check
        {
            let bid_px_d = lobsim.bid_px_d();
            let ask_px_d = lobsim.ask_px_d();
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.unit_entry_price_d.raw_ptr());
            args.push_ptr(self.unit_lots_d.raw_ptr());
            args.push_ptr(self.unit_trail_distance_d.raw_ptr());
            args.push_ptr(self.pyramid_units_count_d.raw_ptr());
            args.push_ptr(self.close_unit_index_d.raw_ptr());
            args.push_i32(b_size_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_trail_stop_check_fn.cu_function(),
                    (b_size as u32, 1, 1), (4, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_trail_stop_check: {:?}", e))?;
            }
        }

        // Position heat cap
        {
            let pos_d_ref_heat: &CudaSlice<u8> = lobsim.pos_d();
            let block = (b_size as u32).min(256);
            let grid = ((b_size as u32) + block - 1) / block;
            let smem = 256 * std::mem::size_of::<i32>() as u32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(pos_d_ref_heat.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.rl_position_heat_check_fn.cu_function(),
                    (grid.max(1), 1, 1), (block.max(1), 1, 1), smem,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("rl_position_heat_check: {:?}", e))?;
            }
        }

        // actions_to_market_targets
        {
            let (pos_d_ref, bid_px_d, ask_px_d, market_targets_d) =
                lobsim.pos_book_and_market_targets_mut();
            let grid_x = ((b_size as u32) + 31) / 32;
            let mut args = RawArgs::new();
            args.push_ptr(self.actions_d.raw_ptr());
            args.push_ptr(pos_d_ref.raw_ptr());
            args.push_ptr(market_targets_d.raw_ptr());
            args.push_ptr(self.isv_dev_ptr);
            args.push_ptr(bid_px_d.raw_ptr());
            args.push_ptr(ask_px_d.raw_ptr());
            args.push_ptr(self.unit_entry_price_d.raw_ptr());
            args.push_ptr(self.unit_active_d.raw_ptr());
            args.push_ptr(self.unit_lots_d.raw_ptr());
            args.push_ptr(self.pyramid_units_count_d.raw_ptr());
            args.push_ptr(self.close_unit_index_d.raw_ptr());
            args.push_ptr(self.outcome_ema_d.raw_ptr());
            // CMDP per-batch state — one DD-trigger / cooldown per session.
            args.push_ptr(self.session_dd_triggered_per_batch_d.raw_ptr());
            args.push_ptr(self.cooldown_remaining_per_batch_d.raw_ptr());
            args.push_i32(b_size_i);
            args.push_i32(pos_bytes_i);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.actions_to_market_targets_fn.cu_function(),
                    (grid_x, 1, 1), (32, 1, 1), 0,
                    self.raw_stream,
                    &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("actions_to_market_targets: {:?}", e))?;
            }
        }

            if capturing_postfill {
                let graph = self
                    .stream
                    .end_capture(
                        CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH,
                    )
                    .context("postfill end_capture")?
                    .ok_or_else(|| anyhow::anyhow!("postfill end_capture returned None"))?;
                self.postfill_graph = Some(graph);
            }
        } // end else (postfill warmup / capture dispatch)

        // Fill kernel — ts_ns=0 since we don't have host-side timestamp
        // in the GPU data path. The timestamp is diagnostic-only (trade
        // log entries); RL training quality is unaffected.
        lobsim
            .step_fill_from_market_targets(0)
            .context("step_with_lobsim_gpu: lobsim.step_fill_from_market_targets")?;

        // Activate GPU encoder path for the K-loop's step_synthetic call.
        // step_synthetic checks this flag and dispatches
        // forward_encoder_from_device() instead of forward_encoder().
        // The SoA buffers were last filled by gather_current_window in
        // step 1b, so the encoder re-forward inside step_synthetic
        // produces the same h_t. Cleared after the helper returns.
        self.gpu_encoder_active = true;
        // Empty slice: step_synthetic ignores snapshots when
        // gpu_encoder_active is set.
        let empty: &[Mbp10RawInput] = &[];
        let result = self.step_with_lobsim_reward_and_train(lobsim, empty, b_size);
        self.gpu_encoder_active = false;
        result
    }

    /// Launch `rl_lr_controller` to emit per-head learning rates into
    /// `ISV[412..417]` via ReduceLROnPlateau-style monotone decay.
    /// Per-head observed loss (l_q / l_pi / l_v from step_synthetic's
    /// stats this step) feeds the controller's slow loss EMA; the
    /// controller halves LR when 1000 consecutive steps elapse
    /// without a 1% improvement. BCE / AUX use placeholder zero
    /// losses (the plateau function early-returns for those because
    /// loss_ema_slot = -1).
    ///
    /// Loss observations are passed as scalar floats (host-side
    /// values from the trainer's last step). Per-head state lives
    /// in ISV slots 427..439 (loss_ema, best, counter, warmup ×3).
    fn launch_rl_lr_controller(
        &self,
        observed_loss_q: f32,
        observed_loss_pi: f32,
        observed_loss_v: f32,
    ) -> Result<()> {
        let mut args = RawArgs::new();
        args.push_ptr(self.isv_dev_ptr);
        args.push_f32(0.0_f32);  // observed_loss_bce
        args.push_f32(observed_loss_q);
        args.push_f32(observed_loss_pi);
        args.push_f32(observed_loss_v);
        args.push_f32(0.0_f32);  // observed_loss_aux
        args.push_i32(crate::rl::isv_slots::RL_LR_Q_LOSS_EMA_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_Q_BEST_LOSS_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_Q_STEPS_SINCE_BEST_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_Q_WARMUP_COUNTER_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_PI_LOSS_EMA_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_PI_BEST_LOSS_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_PI_STEPS_SINCE_BEST_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_PI_WARMUP_COUNTER_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_V_LOSS_EMA_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_V_BEST_LOSS_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_V_STEPS_SINCE_BEST_INDEX as i32);
        args.push_i32(crate::rl::isv_slots::RL_LR_V_WARMUP_COUNTER_INDEX as i32);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_lr_controller_fn.cu_function(),
                (1, 1, 1), (1, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_lr_controller: {:?}", e))?;
        }
        Ok(())
    }



    /// Phase E.2 helper: combine a per-head grad_h_t into the encoder's
    /// accumulator slot via `grad_h_encoder[i] += λ × grad_h_head[i]`.
    /// Public method delegates to the free function `accumulate_grad_h`
    /// so the same launch path is reachable both from external callers
    /// and from `step_synthetic` (where the borrow checker forbids a
    /// `&self` + `&mut self.grad_h_t_combined_d` combination).
    pub fn launch_grad_h_accumulate(
        &self,
        grad_h_head: &CudaSlice<f32>,
        lambda: f32,
        grad_h_encoder: &mut CudaSlice<f32>,
    ) -> Result<()> {
        accumulate_grad_h(
            &self.stream,
            &self.grad_h_accumulate_fn,
            grad_h_head,
            lambda,
            grad_h_encoder,
        )
    }

    /// Phase E.3b evaluation helper: forward the encoder on `snapshots`,
    /// forward the C51 Q-head, and return the expected Q-value E[Q(s, a)]
    /// per action, averaged across the batch. Lets the toy-bandit gate
    /// tests inspect the trained Q-distribution's argmax without
    /// re-implementing the device-to-host readback inside the test
    /// crate.
    ///
    /// Returns a `Vec<f32>` of length `N_ACTIONS = 9`. Index = action id
    /// (see `crate::rl::common::Action`). Caller asserts on `argmax`.
    pub fn eval_expected_q_per_action(
        &mut self,
        snapshots: &[Mbp10RawInput],
    ) -> Result<Vec<f32>> {
        let b_size = self.cfg.perception.n_batch;
        let _ = self
            .perception
            .forward_encoder(snapshots)
            .context("eval_expected_q: forward_encoder")?;
        let h_t = self.perception.h_t_view();
        let k = N_ACTIONS * Q_N_ATOMS;
        let mut logits_d = self.stream.alloc_zeros::<f32>(b_size * k)?;
        self.dqn_head
            .forward(h_t, b_size, &mut logits_d)
            .context("eval_expected_q: dqn_head.forward")?;

        let logits_host = read_slice_d(&self.stream, &logits_d, b_size * k)?;
        let atom_step = (Q_V_MAX - Q_V_MIN) / ((Q_N_ATOMS - 1) as f32);
        let atom_supports: Vec<f32> = (0..Q_N_ATOMS)
            .map(|i| Q_V_MIN + (i as f32) * atom_step)
            .collect();
        let mut eq = vec![0.0_f32; N_ACTIONS];
        for b in 0..b_size {
            for a in 0..N_ACTIONS {
                let off = b * k + a * Q_N_ATOMS;
                let row = &logits_host[off..off + Q_N_ATOMS];
                let max_l = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
                let mut sum = 0.0_f32;
                let mut probs = [0.0_f32; Q_N_ATOMS];
                for (i, &l) in row.iter().enumerate() {
                    probs[i] = (l - max_l).exp();
                    sum += probs[i];
                }
                if sum > 0.0 {
                    for p in probs.iter_mut() {
                        *p /= sum;
                    }
                }
                let mut ev = 0.0_f32;
                for i in 0..Q_N_ATOMS {
                    ev += probs[i] * atom_supports[i];
                }
                eq[a] += ev;
            }
        }
        for e in eq.iter_mut() {
            *e /= b_size as f32;
        }
        Ok(eq)
    }

    /// Phase E.3b evaluation helper: forward the encoder on `snapshots`,
    /// forward the PPO policy head, return softmax(π) per action,
    /// averaged across the batch. Used by the `ppo_toy` gate test to
    /// inspect the trained policy's mode without re-implementing the
    /// device-to-host readback in the test crate.
    pub fn eval_policy_probs_per_action(
        &mut self,
        snapshots: &[Mbp10RawInput],
    ) -> Result<Vec<f32>> {
        let b_size = self.cfg.perception.n_batch;
        let _ = self
            .perception
            .forward_encoder(snapshots)
            .context("eval_policy_probs: forward_encoder")?;
        let h_t = self.perception.h_t_view();
        let mut logits_d = self.stream.alloc_zeros::<f32>(b_size * N_ACTIONS)?;
        // Phase 2A-C: gate the eval-time pi forward. NOT inside a graph
        // capture (eval is single-shot), so the branch is run host-side
        // every call — cost is one `bool` read.
        if self.use_multi_head_policy {
            let regime_h = self.perception.step_regime_d_view();
            self.multi_head_policy
                .as_ref()
                .expect("use_multi_head_policy=true but multi_head_policy=None (construction bug)")
                .forward(h_t, regime_h, &mut logits_d)
                .context("multi_head_policy.forward [eval_policy_probs_per_action]")?;
        } else {
            self.policy_head
                .forward_logits(h_t, b_size, &mut logits_d)
                .context("eval_policy_probs: policy_head.forward_logits")?;
        }

        let logits_host = read_slice_d(&self.stream, &logits_d, b_size * N_ACTIONS)?;
        let mut probs = vec![0.0_f32; N_ACTIONS];
        for b in 0..b_size {
            let off = b * N_ACTIONS;
            let row = &logits_host[off..off + N_ACTIONS];
            let max_l = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
            let mut sum = 0.0_f32;
            let mut p = [0.0_f32; N_ACTIONS];
            for (i, &l) in row.iter().enumerate() {
                p[i] = (l - max_l).exp();
                sum += p[i];
            }
            if sum > 0.0 {
                for x in p.iter_mut() {
                    *x /= sum;
                }
            }
            for i in 0..N_ACTIONS {
                probs[i] += p[i];
            }
        }
        for p in probs.iter_mut() {
            *p /= b_size as f32;
        }
        Ok(probs)
    }

    /// Determinism Phase 1 (2026-06-02) — per-step component checksums.
    ///
    /// Launches `rl_deterministic_checksum_{f32,i32}` against 15 major
    /// trainer-state device tensors, synchronises the stream, and copies
    /// the f64 results into `self.checksum_host`. Each checksum is a
    /// provably-deterministic sum-of-squares (single-block, single-thread
    /// f64 accumulation). Cost: ~27 launches × ~5-20 μs each + one sync +
    /// one 28-double dtoh ≈ 200-500 μs/step. Dev-mode only.
    ///
    /// Slot map (see `checksum_scratch_d` doc-comment):
    ///   0-10 leaves 0-10 written directly by single launches
    ///   11    `adam_m_sum` — folded host-side from scratch[16..22]
    ///   12    `adam_v_sum` — folded host-side from scratch[22..28]
    ///   13-14 leaves 13-14 written directly by single launches
    ///   15    reserved
    ///   16-21 Adam first-moment sub-buffers (dqn_w/b, policy_w/b, value_w/b)
    ///   22-27 Adam second-moment sub-buffers (same order)
    ///   28-44 Phase 2.2 encoder-localisation leaves (mamba2 L1/L2
    ///         weights + encoder intermediate activations along the
    ///         VSN → Mamba2 L1 → LN_a → Mamba2 L2 → LN_b → attn → CfC chain)
    ///
    /// The 32 leaves emitted under the diag `checksums` key:
    ///
    /// | idx | leaf                       | source tensor                              |
    /// |-----|----------------------------|--------------------------------------------|
    /// |  0  | `encoder_output`           | `perception.h_t_view()` (B × HIDDEN_DIM)   |
    /// |  1  | `q_logits`                 | `q_logits_d` (B × N_ACTIONS × Q_N_ATOMS)   |
    /// |  2  | `pi_logits`                | `pi_logits_d` (B × N_ACTIONS)              |
    /// |  3  | `v_value`                  | `v_pred_d` (B)                             |
    /// |  4  | `replay_sample_indices`    | `gpu_replay.sample_indices_d` (B, u32→i32) |
    /// |  5  | `rewards_after_shape`      | `rewards_d` (B)                            |
    /// |  6  | `advantages`               | `advantages_d` (B)                         |
    /// |  7  | `q_grad`                   | `ss_q_grad_w_d` (Q-head reduced grad_w)    |
    /// |  8  | `pi_grad`                  | `ss_pi_grad_w_d` (π-head reduced grad_w)   |
    /// |  9  | `v_grad`                   | `ss_v_grad_w_d` (V-head reduced grad_w)    |
    /// | 10  | `encoder_grad`             | `grad_h_t_combined_d` (B × HIDDEN_DIM)     |
    /// | 11  | `adam_m_sum`               | Σ over Q/π/V w+b Adam first-moment buffers |
    /// | 12  | `adam_v_sum`               | Σ over Q/π/V w+b Adam second-moment buffers|
    /// | 13  | `isv_state`                | ISV[0..RL_SLOTS_END]                       |
    /// | 14  | `popart_sigma_state`       | ISV[POPART_MEAN..POPART_ALPHA+1] (6 slots) |
    /// | 15  | `mamba2_l1_w_in`           | mamba2 L1 W_in weight                      |
    /// | 16  | `mamba2_l1_w_a`            | mamba2 L1 W_a weight                       |
    /// | 17  | `mamba2_l1_w_b`            | mamba2 L1 W_b weight                       |
    /// | 18  | `mamba2_l1_w_c`            | mamba2 L1 W_c weight                       |
    /// | 19  | `mamba2_l2_w_in`           | mamba2 L2 W_in weight                      |
    /// | 20  | `mamba2_l2_w_c`            | mamba2 L2 W_c weight                       |
    /// | 21  | `vsn_w`                    | VSN weight `trunk.vsn_w_d`                 |
    /// | 22  | `cfc_w_in`                 | CfC w_in weight `trunk.w_in_d`             |
    /// | 23  | `cfc_w_rec`                | CfC w_rec weight `trunk.w_rec_d`           |
    /// | 24  | `attn_q`                   | attention pool query `trunk.attn_q_d`      |
    /// | 25  | `vsn_out`                  | VSN forward output                         |
    /// | 26  | `mamba2_l1_out`            | mamba2 L1 h_enriched_seq                   |
    /// | 27  | `ln_a_out`                 | LN_a forward output                        |
    /// | 28  | `mamba2_l2_out`            | mamba2 L2 h_enriched_seq                   |
    /// | 29  | `ln_b_out`                 | LN_b forward output                        |
    /// | 30  | `attn_context`             | attention pool context                     |
    /// | 31  | `mamba2_grad_w_c_l1`       | mamba2 L1 dw_c (reduced backward output)   |
    ///
    /// Per spec §1.3, the goal is to localise the FIRST divergent leaf
    /// across same-seed runs — that points at the non-determinism culprit
    /// (encoder vs head GEMM vs grad reduction vs Adam vs PER vs ISV).
    fn compute_diag_checksums(&mut self) -> Result<[f64; 32]> {
        // Launch geometry: 1 thread, single block — deterministic by
        // construction (see kernel header comment).
        let grid = (1u32, 1u32, 1u32);
        let block = (1u32, 1u32, 1u32);
        let smem: u32 = 0;
        let raw_stream = self.raw_stream;
        let f32_fn = self.rl_deterministic_checksum_f32_fn.cu_function();
        let i32_fn = self.rl_deterministic_checksum_i32_fn.cu_function();
        let scratch_base = self.checksum_scratch_d.raw_ptr();

        // Slot byte stride for the f64 scratch (each slot holds one
        // checksum). The kernel writes `out[0]`, so we offset the base
        // pointer to land each result in its own slot.
        const F64_BYTES: u64 = std::mem::size_of::<f64>() as u64;

        // Inline launch helper. `slot` indexes f64 slots in
        // `checksum_scratch_d`; pointer arithmetic stays in u64-space.
        let launch = |fn_ptr,
                      data_ptr: u64,
                      n: i32,
                      slot: u64,
                      label: &'static str|
         -> Result<()> {
            let mut args = RawArgs::new();
            args.push_ptr(data_ptr);
            args.push_i32(n);
            args.push_u64(scratch_base + slot * F64_BYTES);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(fn_ptr, grid, block, smem, raw_stream, &mut ptrs[..args.len()])
                    .map_err(|e| anyhow::anyhow!("checksum launch ({label}): {:?}", e))?;
            }
            Ok(())
        };

        // ── 0: encoder_output ─────────────────────────────────────────
        let h_t = self.perception.h_t_view();
        launch(f32_fn, h_t.raw_ptr(), h_t.len() as i32, 0, "encoder_output")?;
        // ── 1: q_logits ──────────────────────────────────────────────
        launch(
            f32_fn,
            self.q_logits_d.raw_ptr(),
            self.q_logits_d.len() as i32,
            1,
            "q_logits",
        )?;
        // ── 2: pi_logits ─────────────────────────────────────────────
        launch(
            f32_fn,
            self.pi_logits_d.raw_ptr(),
            self.pi_logits_d.len() as i32,
            2,
            "pi_logits",
        )?;
        // ── 3: v_value ──────────────────────────────────────────────
        launch(
            f32_fn,
            self.v_pred_d.raw_ptr(),
            self.v_pred_d.len() as i32,
            3,
            "v_value",
        )?;
        // ── 4: replay_sample_indices (u32 → i32 reinterpret) ─────────
        // `sample_indices_d` is `CudaSlice<u32>`; the kernel reads `int`
        // (32-bit) from the same memory bits. Sum-of-squares cast through
        // int64 in the kernel handles potentially-large indices safely.
        let sample_indices = &self.gpu_replay.sample_indices_d;
        launch(
            i32_fn,
            sample_indices.raw_ptr(),
            sample_indices.len() as i32,
            4,
            "replay_sample_indices",
        )?;
        // ── 5: rewards_after_shape ───────────────────────────────────
        launch(
            f32_fn,
            self.rewards_d.raw_ptr(),
            self.rewards_d.len() as i32,
            5,
            "rewards_after_shape",
        )?;
        // ── 6: advantages ────────────────────────────────────────────
        launch(
            f32_fn,
            self.advantages_d.raw_ptr(),
            self.advantages_d.len() as i32,
            6,
            "advantages",
        )?;
        // ── 7: q_grad (reduced grad_w of the Q-head) ─────────────────
        launch(
            f32_fn,
            self.ss_q_grad_w_d.raw_ptr(),
            self.ss_q_grad_w_d.len() as i32,
            7,
            "q_grad",
        )?;
        // ── 8: pi_grad (reduced grad_w of the π-head) ────────────────
        launch(
            f32_fn,
            self.ss_pi_grad_w_d.raw_ptr(),
            self.ss_pi_grad_w_d.len() as i32,
            8,
            "pi_grad",
        )?;
        // ── 9: v_grad (reduced grad_w of the V-head) ─────────────────
        launch(
            f32_fn,
            self.ss_v_grad_w_d.raw_ptr(),
            self.ss_v_grad_w_d.len() as i32,
            9,
            "v_grad",
        )?;
        // ── 10: encoder_grad (combined grad_h_t into encoder) ────────
        launch(
            f32_fn,
            self.grad_h_t_combined_d.raw_ptr(),
            self.grad_h_t_combined_d.len() as i32,
            10,
            "encoder_grad",
        )?;

        // ── 11: adam_m_sum + 12: adam_v_sum (staged) ────────────────
        // Adam buffers are separate allocations per parameter group; the
        // kernel can only sum a contiguous range, so we launch one
        // checksum per sub-buffer into staging slots 16-27, then host-
        // fold into slots 11 and 12 below.
        let adam_m_groups: [(u64, u64, &'static str); 6] = [
            (self.dqn_w_adam.m().raw_ptr(),    self.dqn_w_adam.m().len()    as u64, "dqn_w_m"),
            (self.dqn_b_adam.m().raw_ptr(),    self.dqn_b_adam.m().len()    as u64, "dqn_b_m"),
            (self.policy_w_adam.m().raw_ptr(), self.policy_w_adam.m().len() as u64, "policy_w_m"),
            (self.policy_b_adam.m().raw_ptr(), self.policy_b_adam.m().len() as u64, "policy_b_m"),
            (self.value_w_adam.m().raw_ptr(),  self.value_w_adam.m().len()  as u64, "value_w_m"),
            (self.value_b_adam.m().raw_ptr(),  self.value_b_adam.m().len()  as u64, "value_b_m"),
        ];
        let adam_v_groups: [(u64, u64, &'static str); 6] = [
            (self.dqn_w_adam.v().raw_ptr(),    self.dqn_w_adam.v().len()    as u64, "dqn_w_v"),
            (self.dqn_b_adam.v().raw_ptr(),    self.dqn_b_adam.v().len()    as u64, "dqn_b_v"),
            (self.policy_w_adam.v().raw_ptr(), self.policy_w_adam.v().len() as u64, "policy_w_v"),
            (self.policy_b_adam.v().raw_ptr(), self.policy_b_adam.v().len() as u64, "policy_b_v"),
            (self.value_w_adam.v().raw_ptr(),  self.value_w_adam.v().len()  as u64, "value_w_v"),
            (self.value_b_adam.v().raw_ptr(),  self.value_b_adam.v().len()  as u64, "value_b_v"),
        ];
        for (i, (ptr, n, label)) in adam_m_groups.iter().enumerate() {
            launch(f32_fn, *ptr, *n as i32, 16 + i as u64, label)?;
        }
        for (i, (ptr, n, label)) in adam_v_groups.iter().enumerate() {
            launch(f32_fn, *ptr, *n as i32, 22 + i as u64, label)?;
        }

        // ── 13: isv_state (slots 0..RL_SLOTS_END) ───────────────────
        // ISV is a single mapped-pinned f32 buffer. The dev pointer is
        // `self.isv_dev_ptr`.
        launch(
            f32_fn,
            self.isv_dev_ptr,
            crate::rl::isv_slots::RL_SLOTS_END as i32,
            13,
            "isv_state",
        )?;

        // ── 14: popart_sigma_state ───────────────────────────────────
        // PopArt occupies a contiguous slot range
        // [POPART_MEAN .. POPART_ALPHA+1] = [553..559] = 6 slots. ISV is
        // f32; pointer offset = slot_index * 4 bytes.
        let popart_start = crate::rl::isv_slots::RL_POPART_MEAN_INDEX;
        let popart_end_inclusive = crate::rl::isv_slots::RL_POPART_ALPHA_INDEX;
        let popart_len = popart_end_inclusive - popart_start + 1;
        let popart_ptr = self.isv_dev_ptr
            + (popart_start as u64) * (std::mem::size_of::<f32>() as u64);
        launch(
            f32_fn,
            popart_ptr,
            popart_len as i32,
            14,
            "popart_sigma_state",
        )?;

        // ── 28-44: Phase 2.2 encoder-localisation checksums ──────────
        // The `encoder_output` checksum was Phase 2's "next divergent
        // leaf" after the PER tree-rebuild fix landed.  Encoder is a
        // long chain (VSN → Mamba2 L1 → LN_a → Mamba2 L2 → LN_b → attn
        // → CfC).  Add weight + activation checksums for each layer so
        // the FIRST diverging encoder leaf identifies the culprit
        // kernel.
        //
        // SLOT MAPPING NOTE: the new leaves are stored in scratch slots
        // 28-44 (after the Adam staging region at 16-27). The host-side
        // fold copies them into output indices 15-31 after the Adam
        // m/v sums are placed at indices 11/12.
        {
            let m1 = self.perception.trunk.mamba2_l1();
            launch(f32_fn, m1.w_in.weight.raw_ptr(), m1.w_in.weight.len() as i32, 28, "mamba2_l1_w_in")?;
            launch(f32_fn, m1.w_a.weight.raw_ptr(),  m1.w_a.weight.len()  as i32, 29, "mamba2_l1_w_a")?;
            launch(f32_fn, m1.w_b.weight.raw_ptr(),  m1.w_b.weight.len()  as i32, 30, "mamba2_l1_w_b")?;
            launch(f32_fn, m1.w_c.raw_ptr(),         m1.w_c.len()         as i32, 31, "mamba2_l1_w_c")?;
            let m2 = self.perception.trunk.mamba2_l2();
            launch(f32_fn, m2.w_in.weight.raw_ptr(), m2.w_in.weight.len() as i32, 32, "mamba2_l2_w_in")?;
            launch(f32_fn, m2.w_c.raw_ptr(),         m2.w_c.len()         as i32, 33, "mamba2_l2_w_c")?;
            launch(
                f32_fn,
                self.perception.trunk.vsn_w_d.raw_ptr(),
                self.perception.trunk.vsn_w_d.len() as i32,
                34,
                "vsn_w",
            )?;
            launch(
                f32_fn,
                self.perception.trunk.w_in_d.raw_ptr(),
                self.perception.trunk.w_in_d.len() as i32,
                35,
                "cfc_w_in",
            )?;
            launch(
                f32_fn,
                self.perception.trunk.w_rec_d.raw_ptr(),
                self.perception.trunk.w_rec_d.len() as i32,
                36,
                "cfc_w_rec",
            )?;
            launch(
                f32_fn,
                self.perception.trunk.attn_q_d.raw_ptr(),
                self.perception.trunk.attn_q_d.len() as i32,
                37,
                "attn_q",
            )?;
        }
        // Encoder intermediate activations — read from accessor views
        // (kept in a separate scope so the immutable trunk borrow above
        // is released before we re-borrow perception via the views).
        {
            let vsn = self.perception.debug_vsn_out_view();
            launch(f32_fn, vsn.raw_ptr(), vsn.len() as i32, 38, "vsn_out")?;
            let l1_out = self.perception.debug_mamba2_l1_out_view();
            launch(f32_fn, l1_out.raw_ptr(), l1_out.len() as i32, 39, "mamba2_l1_out")?;
            let lna = self.perception.debug_ln_a_out_view();
            launch(f32_fn, lna.raw_ptr(), lna.len() as i32, 40, "ln_a_out")?;
            let l2_out = self.perception.debug_mamba2_l2_out_view();
            launch(f32_fn, l2_out.raw_ptr(), l2_out.len() as i32, 41, "mamba2_l2_out")?;
            let lnb = self.perception.debug_ln_b_out_view();
            launch(f32_fn, lnb.raw_ptr(), lnb.len() as i32, 42, "ln_b_out")?;
            let attn = self.perception.debug_attn_context_view();
            launch(f32_fn, attn.raw_ptr(), attn.len() as i32, 43, "attn_context")?;
            let l1_dwc = self.perception.debug_mamba2_l1_grad_w_c_view();
            launch(f32_fn, l1_dwc.raw_ptr(), l1_dwc.len() as i32, 44, "mamba2_grad_w_c_l1")?;
        }

        // Synchronise the stream so the host-side memcpy_dtoh sees the
        // final values. We're already on the trainer's main stream so
        // sync alone (no event) is sufficient.
        unsafe {
            crate::trainer::raw_launch::raw_stream_sync(raw_stream)
                .map_err(|e| anyhow::anyhow!("checksum sync: {:?}", e))?;
        }

        // DtoH the 28-slot scratch buffer via the mapped-pinned read
        // helper (`read_slice_d_into`). The helper allocates a
        // `MappedRecordBuffer<f64>` staging buffer, copies DtoD on the
        // trainer stream, syncs, and volatile-reads into the caller's
        // host slice — the only permitted CPU↔GPU path per
        // `feedback_no_htod_htoh_only_mapped_pinned`. Raw
        // `stream.memcpy_dtoh` on a non-pinned `Vec<f64>` would fall
        // back to a blocking driver HtoH bounce inside cudarc.
        read_slice_d_into(
            &self.stream,
            &self.checksum_scratch_d,
            self.checksum_host.as_mut_slice(),
        )
        .context("checksum_scratch_d → checksum_host")?;

        // Host-fold the Adam staging slots into leaves 11 and 12. Fixed
        // iteration order — deterministic by construction.
        let mut adam_m_sum: f64 = 0.0;
        for i in 0..6 {
            adam_m_sum += self.checksum_host[16 + i];
        }
        let mut adam_v_sum: f64 = 0.0;
        for i in 0..6 {
            adam_v_sum += self.checksum_host[22 + i];
        }
        self.checksum_host[11] = adam_m_sum;
        self.checksum_host[12] = adam_v_sum;

        // 32 leaves total: 15 original (idx 0-14) + 17 Phase 2.2
        // encoder-localisation leaves (scratch slots 28..45 → output
        // indices 15..32).
        let mut out = [0.0_f64; 32];
        out[0..15].copy_from_slice(&self.checksum_host[0..15]);
        out[15..32].copy_from_slice(&self.checksum_host[28..45]);
        Ok(out)
    }

    /// Determinism Phase 2 sub-investigation: dump raw PER state to disk.
    ///
    /// When `FOXHUNT_DETERMINISM_DEBUG=1` and `step ∈ {0, 1, 2}`, this method
    /// copies `gpu_replay.sample_prng_d`, `gpu_replay.priority_tree_d`, and
    /// `gpu_replay.sample_indices_d` to the host and writes them as raw
    /// little-endian binary files into `$FOXHUNT_DEBUG_DUMP_DIR`. Two-run
    /// comparison of these dumps determines whether the PRNG seeding,
    /// the priority tree itself, or the fp comparison in the tree-walk is
    /// the source of non-determinism (spec §2.F sub-investigation).
    ///
    /// Output layout (per dump dir):
    ///   - `step_<N>_prng.bin`    raw `[u32; b_size]` (size = b_size × 4)
    ///   - `step_<N>_tree.bin`    raw `[f32; 2*capacity]` (size = 2*cap × 4)
    ///   - `step_<N>_indices.bin` raw `[u32; b_size]` (size = b_size × 4)
    ///
    /// Per `feedback_no_stubs`: real DtoH copies on the trainer stream;
    /// per `feedback_cpu_is_read_only`: dump is the host-side READ of
    /// device-resident state, no compute.  The method synchronises the
    /// stream before reading so the dumps reflect post-step state.
    ///
    /// No-op when the env var is unset — keeps regular runs cost-free.
    fn dump_per_state_for_debug(&mut self, step: u64) -> Result<()> {
        // Env gate — single fast path for the common case.
        let enabled = std::env::var("FOXHUNT_DETERMINISM_DEBUG")
            .map(|v| v == "1")
            .unwrap_or(false);
        if !enabled {
            return Ok(());
        }
        if step > 2 {
            return Ok(());
        }

        let dump_dir = std::env::var("FOXHUNT_DEBUG_DUMP_DIR")
            .unwrap_or_else(|_| "/tmp/foxhunt-determinism-debug".to_string());
        std::fs::create_dir_all(&dump_dir)
            .with_context(|| format!("dump_per_state_for_debug: create_dir_all {dump_dir}"))?;

        // Allocate host scratch on demand — these dumps are diagnostic-only
        // and not on the hot path. Capacities are known from gpu_replay.
        let b_size = self.gpu_replay.b_size;
        let capacity = self.gpu_replay.capacity;
        let mut prng_host: Vec<u32> = vec![0u32; b_size];
        let mut indices_host: Vec<u32> = vec![0u32; b_size];
        let mut tree_host: Vec<f32> = vec![0.0f32; 2 * capacity];

        // Synchronise the main stream so any in-flight train kernels have
        // committed their writes to gpu_replay buffers before we read them.
        unsafe {
            crate::trainer::raw_launch::raw_stream_sync(self.raw_stream)
                .map_err(|e| anyhow::anyhow!("dump sync stream: {:?}", e))?;
            crate::trainer::raw_launch::raw_stream_sync(self.raw_train_stream)
                .map_err(|e| anyhow::anyhow!("dump sync train_stream: {:?}", e))?;
        }

        // DtoH copies via the mapped-pinned helper — raw
        // `stream.memcpy_dtoh` on a regular `Vec<T>` is forbidden by
        // `feedback_no_htod_htoh_only_mapped_pinned` (the source slice
        // is not page-locked, so cudarc falls back to a blocking HtoH
        // bounce). The helper allocates a `MappedRecordBuffer<T>`,
        // does a DtoD onto its mapped page, syncs, and volatile-reads.
        read_slice_d_into(
            &self.stream,
            &self.gpu_replay.sample_prng_d,
            prng_host.as_mut_slice(),
        )
        .context("dump_per_state_for_debug: dtoh sample_prng_d")?;
        read_slice_d_into(
            &self.stream,
            &self.gpu_replay.sample_indices_d,
            indices_host.as_mut_slice(),
        )
        .context("dump_per_state_for_debug: dtoh sample_indices_d")?;
        read_slice_d_into(
            &self.stream,
            &self.gpu_replay.priority_tree_d,
            tree_host.as_mut_slice(),
        )
        .context("dump_per_state_for_debug: dtoh priority_tree_d")?;

        // Write raw little-endian bytes; compare-per-state.py reads them
        // back as native (host is x86_64 LE — matches CUDA device LE).
        let write_u32 = |path: &std::path::Path, data: &[u32]| -> Result<()> {
            let bytes: &[u8] = unsafe {
                std::slice::from_raw_parts(
                    data.as_ptr() as *const u8,
                    data.len() * std::mem::size_of::<u32>(),
                )
            };
            std::fs::write(path, bytes)
                .with_context(|| format!("write_u32 {path:?}"))?;
            Ok(())
        };
        let write_f32 = |path: &std::path::Path, data: &[f32]| -> Result<()> {
            let bytes: &[u8] = unsafe {
                std::slice::from_raw_parts(
                    data.as_ptr() as *const u8,
                    data.len() * std::mem::size_of::<f32>(),
                )
            };
            std::fs::write(path, bytes)
                .with_context(|| format!("write_f32 {path:?}"))?;
            Ok(())
        };

        let base = std::path::PathBuf::from(&dump_dir);
        write_u32(&base.join(format!("step_{step}_prng.bin")), &prng_host)?;
        write_u32(&base.join(format!("step_{step}_indices.bin")), &indices_host)?;
        write_f32(&base.join(format!("step_{step}_tree.bin")), &tree_host)?;

        eprintln!(
            "[determinism-debug] dumped PER state step={step} to {dump_dir} \
             (prng={b}u32 indices={b}u32 tree={t}f32)",
            b = b_size,
            t = 2 * capacity,
        );

        Ok(())
    }

    /// Determinism Phase 2.5 sub-investigation: dump raw RL-loop state to
    /// disk to localise the residual divergence that survives Phase 2.4
    /// (which falsified mamba2 / cuBLAS / trunk backward as the source).
    ///
    /// Phase 2.4 outcome: at step-1 diag emission, ALL 52 mamba2 + trunk
    /// buffers are EQUAL between same-seed runs; at step-2 diag emission,
    /// mamba2 L1/L2 weights diverge. The bug lives in `step_with_lobsim_gpu`
    /// between step 1's dump and step 2's dump. The not-yet-instrumented
    /// candidates fall into 6 groups:
    ///
    ///   A. CfC recurrent cross-step carry (`attn_context_d`, `h_t_d`) —
    ///      already covered by Phase 2.2 dumps; included here for sanity.
    ///   B. PER replay state at step-2 sample time (`priority_tree_d`,
    ///      `sample_prng_d`, `sample_indices_d`) — Phase 2.2 dumps cover
    ///      these for step 0/1/2; included here for direct correlation
    ///      with the RL state at the same step.
    ///   C. Action sampling RNG (`prng_state_d`, `iqn_prng_state_d`) —
    ///      xorshift32 state per batch, advanced by `rl_action_kernel`.
    ///   D. LOB simulator + reward state (`pyramid_units_count_d`,
    ///      `unit_entry_step_d`, `unit_entry_price_d`, `unit_lots_d`,
    ///      `unit_active_d`, `prev_realized_pnl_d`, `prev_position_lots_d`,
    ///      `steps_since_done_d`).
    ///   E. ISV controllers (full mapped-pinned ISV array `RL_SLOTS_END`
    ///      floats) — cross-step state of popart / kelly / wr_ema / PH.
    ///   F. Reward shaping outputs (`rewards_d`, `raw_rewards_d`,
    ///      `outcome_ema_d`, `reward_abs_d`, `dones_d`, `actions_d`,
    ///      `next_actions_d`, `log_pi_old_d`, `advantages_d`, `returns_d`).
    ///
    /// All dumps are device-resident slices DtoH-copied on the trainer
    /// stream, with a stream sync first. The ISV mapped-pinned host slice
    /// is copied via host-side slice copy (no DtoH needed). All writes
    /// are raw little-endian binary; `scripts/compare-rl-state.py` reads
    /// them back.
    ///
    /// Env gate: `FOXHUNT_DETERMINISM_DEBUG_RL=1` enables the dump.
    /// Output dir: `$FOXHUNT_DEBUG_DUMP_DIR` (default
    /// `/tmp/foxhunt-determinism-debug`). Only fires for `step ∈ {0,1,2,3}`.
    ///
    /// Per `feedback_no_stubs`: real DtoH copies on the trainer stream,
    /// wired end-to-end. Per `feedback_cpu_is_read_only`: this is a pure
    /// host READ of device state, no compute affects training.
    fn dump_rl_state_for_debug(&mut self, step: u64) -> Result<()> {
        let enabled = std::env::var("FOXHUNT_DETERMINISM_DEBUG_RL")
            .map(|v| v == "1")
            .unwrap_or(false);
        if !enabled || step > 3 {
            return Ok(());
        }

        let dump_dir = std::env::var("FOXHUNT_DEBUG_DUMP_DIR")
            .unwrap_or_else(|_| "/tmp/foxhunt-determinism-debug".to_string());
        std::fs::create_dir_all(&dump_dir).with_context(|| {
            format!("dump_rl_state_for_debug: create_dir_all {dump_dir}")
        })?;

        // Sync both trainer streams so all in-flight train-step kernels
        // have committed before we read.
        unsafe {
            crate::trainer::raw_launch::raw_stream_sync(self.raw_stream)
                .map_err(|e| anyhow::anyhow!("dump_rl sync stream: {:?}", e))?;
            crate::trainer::raw_launch::raw_stream_sync(self.raw_train_stream)
                .map_err(|e| anyhow::anyhow!("dump_rl sync train_stream: {:?}", e))?;
        }

        let base = std::path::PathBuf::from(&dump_dir);

        // Generic dumpers (f32 / i32 / u32 / u8). DtoH transfers go
        // through `read_slice_d_into`, which stages through a
        // mapped-pinned `MappedRecordBuffer<T>` buffer + DtoD copy +
        // stream sync + volatile host reads. The raw
        // `stream.memcpy_dtoh` form is forbidden by
        // `feedback_no_htod_htoh_only_mapped_pinned` because the
        // destination `Vec<T>` is not page-locked, so cudarc would
        // fall back to a blocking driver HtoH bounce. The closures
        // each clone the trainer stream (`Arc<CudaStream>` ref-count++)
        // and forward to the helper.
        let stream = self.stream.clone();
        let dump_f32 = |path: &std::path::Path,
                        slice: &CudaSlice<f32>|
         -> Result<()> {
            let mut host: Vec<f32> = vec![0.0f32; slice.len()];
            read_slice_d_into(&stream, slice, host.as_mut_slice())
                .with_context(|| format!("dump_rl dtoh f32 {path:?}"))?;
            let bytes: &[u8] = unsafe {
                std::slice::from_raw_parts(
                    host.as_ptr() as *const u8,
                    host.len() * std::mem::size_of::<f32>(),
                )
            };
            std::fs::write(path, bytes)
                .with_context(|| format!("dump_rl write f32 {path:?}"))?;
            Ok(())
        };
        let dump_i32 = |path: &std::path::Path,
                        slice: &CudaSlice<i32>|
         -> Result<()> {
            let mut host: Vec<i32> = vec![0i32; slice.len()];
            read_slice_d_into(&stream, slice, host.as_mut_slice())
                .with_context(|| format!("dump_rl dtoh i32 {path:?}"))?;
            let bytes: &[u8] = unsafe {
                std::slice::from_raw_parts(
                    host.as_ptr() as *const u8,
                    host.len() * std::mem::size_of::<i32>(),
                )
            };
            std::fs::write(path, bytes)
                .with_context(|| format!("dump_rl write i32 {path:?}"))?;
            Ok(())
        };
        let dump_u32 = |path: &std::path::Path,
                        slice: &CudaSlice<u32>|
         -> Result<()> {
            let mut host: Vec<u32> = vec![0u32; slice.len()];
            read_slice_d_into(&stream, slice, host.as_mut_slice())
                .with_context(|| format!("dump_rl dtoh u32 {path:?}"))?;
            let bytes: &[u8] = unsafe {
                std::slice::from_raw_parts(
                    host.as_ptr() as *const u8,
                    host.len() * std::mem::size_of::<u32>(),
                )
            };
            std::fs::write(path, bytes)
                .with_context(|| format!("dump_rl write u32 {path:?}"))?;
            Ok(())
        };
        let dump_u8 = |path: &std::path::Path,
                       slice: &CudaSlice<u8>|
         -> Result<()> {
            let mut host: Vec<u8> = vec![0u8; slice.len()];
            read_slice_d_into(&stream, slice, host.as_mut_slice())
                .with_context(|| format!("dump_rl dtoh u8 {path:?}"))?;
            std::fs::write(path, host.as_slice())
                .with_context(|| format!("dump_rl write u8 {path:?}"))?;
            Ok(())
        };

        // ── Group C: action sampling PRNG state ───────────────────────
        dump_u32(
            &base.join(format!("step_{step}_action_prng_state.bin")),
            &self.prng_state_d,
        )?;
        dump_u32(
            &base.join(format!("step_{step}_iqn_prng_state.bin")),
            &self.iqn_prng_state_d,
        )?;

        // ── Group D: LOB simulator + unit-state arrays ────────────────
        dump_i32(
            &base.join(format!("step_{step}_pyramid_units_count.bin")),
            &self.pyramid_units_count_d,
        )?;
        dump_i32(
            &base.join(format!("step_{step}_unit_entry_step.bin")),
            &self.unit_entry_step_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_unit_entry_price.bin")),
            &self.unit_entry_price_d,
        )?;
        dump_i32(
            &base.join(format!("step_{step}_unit_lots.bin")),
            &self.unit_lots_d,
        )?;
        dump_u8(
            &base.join(format!("step_{step}_unit_active.bin")),
            &self.unit_active_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_unit_initial_r.bin")),
            &self.unit_initial_r_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_unit_trail_distance.bin")),
            &self.unit_trail_distance_d,
        )?;
        dump_i32(
            &base.join(format!("step_{step}_unit_prev_pos_lots.bin")),
            &self.unit_prev_pos_lots_d,
        )?;
        dump_i32(
            &base.join(format!("step_{step}_close_unit_index.bin")),
            &self.close_unit_index_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_prev_realized_pnl.bin")),
            &self.prev_realized_pnl_d,
        )?;
        dump_i32(
            &base.join(format!("step_{step}_prev_position_lots.bin")),
            &self.prev_position_lots_d,
        )?;
        dump_i32(
            &base.join(format!("step_{step}_steps_since_done.bin")),
            &self.steps_since_done_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_trade_duration_emit.bin")),
            &self.trade_duration_emit_d,
        )?;

        // ── Group F: reward shaping + RL outputs ───────────────────────
        dump_f32(
            &base.join(format!("step_{step}_rewards.bin")),
            &self.rewards_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_raw_rewards.bin")),
            &self.raw_rewards_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_outcome_ema.bin")),
            &self.outcome_ema_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_reward_abs.bin")),
            &self.reward_abs_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_dones.bin")),
            &self.dones_d,
        )?;
        dump_i32(
            &base.join(format!("step_{step}_actions.bin")),
            &self.actions_d,
        )?;
        dump_i32(
            &base.join(format!("step_{step}_next_actions.bin")),
            &self.next_actions_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_log_pi_old.bin")),
            &self.log_pi_old_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_advantages.bin")),
            &self.advantages_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_returns.bin")),
            &self.returns_d,
        )?;

        // CMDP + edge-decay per-batch state (cross-step EMAs).
        dump_f32(
            &base.join(format!("step_{step}_session_pnl.bin")),
            &self.session_pnl_per_batch_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_consec_loss.bin")),
            &self.consec_loss_per_batch_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_cooldown_remaining.bin")),
            &self.cooldown_remaining_per_batch_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_ph_mu.bin")),
            &self.ph_mu_per_batch_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_ph_count.bin")),
            &self.ph_count_per_batch_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_ph_m.bin")),
            &self.ph_m_per_batch_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_ph_mmin.bin")),
            &self.ph_mmin_per_batch_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_ph_stat.bin")),
            &self.ph_stat_per_batch_d,
        )?;

        // Sync the DtoH copies before reading the mapped-pinned ISV.
        unsafe {
            crate::trainer::raw_launch::raw_stream_sync(self.raw_stream)
                .map_err(|e| anyhow::anyhow!("dump_rl post-dtoh sync: {:?}", e))?;
        }

        // ── Group E: full ISV (mapped-pinned, host-readable directly) ──
        // The ISV is mapped-pinned so reads from `isv_host_slice()` reflect
        // the latest device writes once the stream sync above completes.
        {
            let isv = self.isv_host_slice();
            let path = base.join(format!("step_{step}_isv_full.bin"));
            let bytes: &[u8] = unsafe {
                std::slice::from_raw_parts(
                    isv.as_ptr() as *const u8,
                    isv.len() * std::mem::size_of::<f32>(),
                )
            };
            std::fs::write(&path, bytes)
                .with_context(|| format!("dump_rl write isv_full {path:?}"))?;
        }

        eprintln!(
            "[determinism-debug] dumped RL state step={step} to {dump_dir}"
        );

        Ok(())
    }

    /// Determinism Phase 2.6 sub-investigation: gradient-emission chain
    /// dump. Phase 2.5 falsified all 6 RL-loop candidate groups (CfC
    /// carry, PER state, action RNG, LOB sim, ISV, reward shaping); at
    /// step-2 dump every forward activation + every RL-loop scalar was
    /// EQUAL, yet every trunk weight + every trunk gradient + every
    /// mamba2 backward output DIVERGED. The bug is therefore in step 2's
    /// BACKWARD KERNEL CHAIN — somewhere between identical loss-input
    /// scalars and divergent reduced gradients.
    ///
    /// This method captures the 4 not-yet-instrumented buffer groups
    /// per the Phase 2.6 dispatch ask:
    ///
    /// * **Group G — PER replay sample outputs**: `sampled_h_t_d`,
    ///   `sampled_h_tp1_d`, `sampled_actions_d`, `sampled_rewards_d`,
    ///   `sampled_dones_d`, `sampled_log_pi_old_d`. Captured at step
    ///   boundary (end of step's K-loop — last k_iter wins, but each
    ///   k_iter overwrites all batch slots so end-state mirrors last
    ///   iter exactly). If divergent → PER K-loop sample is non-det.
    /// * **Group H — Head forward outputs (pre-backward)**: `q_logits_d`,
    ///   `pi_logits_d`, `v_pred_d`, `iqn_q_values_d`,
    ///   `dueling_q_composed_d`. Captured at step boundary (post the
    ///   last head forward, still un-overwritten because backward only
    ///   reads from them). If divergent given EQUAL h_t + EQUAL weights
    ///   → head forward kernel (likely NoisyLinear noise resampling
    ///   between forward and backward) is non-det.
    /// * **Group I — Pre-reduce per-batch / per-row param-grad
    ///   scratches**: `vsn_grad_w_scratch_d`, `vsn_grad_b_scratch_d`,
    ///   `cfc_grad_w_in_scratch_d`, `cfc_grad_w_rec_scratch_d`,
    ///   `cfc_grad_b_scratch_d`, `cfc_grad_tau_scratch_d`,
    ///   `attn_grad_q_scratch_d`, `grad_ln_a_gain_per_row_d`,
    ///   `grad_ln_a_bias_per_row_d`, `grad_ln_b_gain_per_row_d`,
    ///   `grad_ln_b_bias_per_row_d`. Captured AT step boundary AFTER
    ///   `reduce_axis0` has consumed them (they live until next step's
    ///   `memset_zeros` reset). If EQUAL across runs but post-reduce
    ///   final grads DIVERGE → `reduce_axis0` (or `layer_norm_reduce`)
    ///   is the culprit (canonical Phase-2-PER-rebuild fix applies).
    ///   If already divergent → upstream per-batch backward kernel.
    /// * **Group J — Per-head grad_h_t + combined**: `ss_q_grad_h_t_d`,
    ///   `ss_pi_grad_h_t_d`, `ss_v_grad_h_t_d`, `ss_frd_grad_h_t_d`,
    ///   `ss_outcome_grad_h_t_d`, `grad_h_t_combined_d`. The combined
    ///   slot is the upstream gradient that feeds `backward_encoder
    ///   _with_grad_h_t`. If divergent → either the head backward
    ///   kernel writing grad_h_t or `grad_h_accumulate_scaled` is the
    ///   non-deterministic kernel.
    ///
    /// Gated by `FOXHUNT_DETERMINISM_DEBUG_BACKWARD=1`. Dumps for
    /// step ∈ {0, 1, 2, 3} only. No-op when unset.
    fn dump_backward_state_for_debug(&mut self, step: u64) -> Result<()> {
        let enabled = std::env::var("FOXHUNT_DETERMINISM_DEBUG_BACKWARD")
            .map(|v| v == "1")
            .unwrap_or(false);
        if !enabled || step > 3 {
            return Ok(());
        }

        let dump_dir = std::env::var("FOXHUNT_DEBUG_DUMP_DIR")
            .unwrap_or_else(|_| "/tmp/foxhunt-determinism-debug".to_string());
        std::fs::create_dir_all(&dump_dir).with_context(|| {
            format!("dump_backward_state_for_debug: create_dir_all {dump_dir}")
        })?;

        // Sync both trainer streams so all in-flight kernels (head
        // forward + backward + reduce + Adam) have committed before we
        // read. Phase 2.5 pattern.
        unsafe {
            crate::trainer::raw_launch::raw_stream_sync(self.raw_stream)
                .map_err(|e| anyhow::anyhow!("dump_backward sync stream: {:?}", e))?;
            crate::trainer::raw_launch::raw_stream_sync(self.raw_train_stream)
                .map_err(|e| anyhow::anyhow!("dump_backward sync train_stream: {:?}", e))?;
        }

        let base = std::path::PathBuf::from(&dump_dir);
        // DtoH transfers go through `read_slice_d_into` (mapped-pinned
        // staging + DtoD + sync + volatile host read), matching
        // `dump_rl_state_for_debug`. Raw `stream.memcpy_dtoh` on a
        // non-pinned `Vec<T>` is forbidden by
        // `feedback_no_htod_htoh_only_mapped_pinned`.
        let stream = self.stream.clone();
        let dump_f32 = |path: &std::path::Path,
                        slice: &CudaSlice<f32>|
         -> Result<()> {
            let mut host: Vec<f32> = vec![0.0f32; slice.len()];
            read_slice_d_into(&stream, slice, host.as_mut_slice())
                .with_context(|| format!("dump_backward dtoh f32 {path:?}"))?;
            let bytes: &[u8] = unsafe {
                std::slice::from_raw_parts(
                    host.as_ptr() as *const u8,
                    host.len() * std::mem::size_of::<f32>(),
                )
            };
            std::fs::write(path, bytes)
                .with_context(|| format!("dump_backward write f32 {path:?}"))?;
            Ok(())
        };
        let dump_i32 = |path: &std::path::Path,
                        slice: &CudaSlice<i32>|
         -> Result<()> {
            let mut host: Vec<i32> = vec![0i32; slice.len()];
            read_slice_d_into(&stream, slice, host.as_mut_slice())
                .with_context(|| format!("dump_backward dtoh i32 {path:?}"))?;
            let bytes: &[u8] = unsafe {
                std::slice::from_raw_parts(
                    host.as_ptr() as *const u8,
                    host.len() * std::mem::size_of::<i32>(),
                )
            };
            std::fs::write(path, bytes)
                .with_context(|| format!("dump_backward write i32 {path:?}"))?;
            Ok(())
        };

        // ── Group G: PER replay sample outputs (end-of-step state) ────
        // Phase 2.5 already dumped sampled_actions/rewards/dones/log_pi
        // via Group F naming. The hidden-state samples were NOT dumped
        // (they overflow Group F's scope), so add them here for Group G.
        // Re-dump the scalar samples under group-G prefixed names so the
        // comparison script can verdict them in one pass.
        dump_f32(
            &base.join(format!("step_{step}_g_sampled_h_t.bin")),
            &self.sampled_h_t_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_g_sampled_h_tp1.bin")),
            &self.sampled_h_tp1_d,
        )?;
        dump_i32(
            &base.join(format!("step_{step}_g_sampled_actions.bin")),
            &self.sampled_actions_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_g_sampled_rewards.bin")),
            &self.sampled_rewards_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_g_sampled_dones.bin")),
            &self.sampled_dones_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_g_sampled_log_pi_old.bin")),
            &self.sampled_log_pi_old_d,
        )?;

        // ── Group H: Head forward outputs (post-fwd, pre-bwd) ─────────
        // Captured at end-of-step. step_synthetic's path runs head
        // forward → loss → backward in sequence on the same stream,
        // overwriting these slots in each k_iter. End-of-step values
        // = last k_iter's forward outputs (Q + π + V + IQN + Dueling
        // all touched in dqn_replay_step / step_synthetic).
        dump_f32(
            &base.join(format!("step_{step}_h_q_logits.bin")),
            &self.q_logits_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_h_pi_logits.bin")),
            &self.pi_logits_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_h_v_pred.bin")),
            &self.v_pred_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_h_iqn_q_values.bin")),
            &self.iqn_q_values_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_h_dueling_q_composed.bin")),
            &self.dueling_q_composed_d,
        )?;

        // ── Group I: Pre-reduce per-batch / per-row param-grad scratches
        // These live in perception.rs (private fields → accessor methods).
        // They are zeroed at step start, accumulated by per-K backward
        // kernels via +=, reduced by reduce_axis0 / layer_norm_reduce
        // before Adam. End-of-step value = sum of all K-iter
        // contributions, which is what reduce_axis0 read just before
        // we dump.
        dump_f32(
            &base.join(format!("step_{step}_i_cfc_grad_w_in_scratch.bin")),
            self.perception.debug_cfc_grad_w_in_scratch_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_cfc_grad_w_rec_scratch.bin")),
            self.perception.debug_cfc_grad_w_rec_scratch_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_cfc_grad_b_scratch.bin")),
            self.perception.debug_cfc_grad_b_scratch_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_cfc_grad_tau_scratch.bin")),
            self.perception.debug_cfc_grad_tau_scratch_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_vsn_grad_w_scratch.bin")),
            self.perception.debug_vsn_grad_w_scratch_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_vsn_grad_b_scratch.bin")),
            self.perception.debug_vsn_grad_b_scratch_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_attn_grad_q_scratch.bin")),
            self.perception.debug_attn_grad_q_scratch_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_grad_ln_a_gain_per_row.bin")),
            self.perception.debug_grad_ln_a_gain_per_row_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_grad_ln_a_bias_per_row.bin")),
            self.perception.debug_grad_ln_a_bias_per_row_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_grad_ln_b_gain_per_row.bin")),
            self.perception.debug_grad_ln_b_gain_per_row_view(),
        )?;
        dump_f32(
            &base.join(format!("step_{step}_i_grad_ln_b_bias_per_row.bin")),
            self.perception.debug_grad_ln_b_bias_per_row_view(),
        )?;

        // ── Group J: Per-head grad_h_t + combined accumulator ─────────
        // The heads' backward kernels write [B, HIDDEN_DIM] gradient
        // tensors (one per head). `grad_h_accumulate_scaled` folds them
        // into `grad_h_t_combined_d` which becomes the input to the
        // encoder backward via `backward_encoder_with_grad_h_t`.
        // Q's grad_h_t is currently stop-gradded (R7d) — see the
        // accumulate call site at line ~5817 — so it should be EQUAL
        // because it isn't used; π/V/FRD/outcome are the live channels.
        dump_f32(
            &base.join(format!("step_{step}_j_grad_h_t_q.bin")),
            &self.ss_q_grad_h_t_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_j_grad_h_t_pi.bin")),
            &self.ss_pi_grad_h_t_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_j_grad_h_t_v.bin")),
            &self.ss_v_grad_h_t_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_j_grad_h_t_frd.bin")),
            &self.ss_frd_grad_h_t_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_j_grad_h_t_outcome.bin")),
            &self.ss_outcome_grad_h_t_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_j_grad_h_t_combined.bin")),
            &self.grad_h_t_combined_d,
        )?;

        // ── Group K: Outcome head inputs (debug Phase 2.6 sub-2) ─────
        // The `grad_h_t_outcome` divergence at step 2 (large Δ ~ 0.003,
        // far above fp32 noise) localised here. The fused outcome
        // kernel is sole-writer-per-(batch,c) so it CAN'T introduce
        // divergence from equal inputs. Dump labels_d + outcome.w_d to
        // verify which input drifted.
        dump_i32(
            &base.join(format!("step_{step}_k_outcome_labels.bin")),
            &self.outcome_head.labels_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_k_outcome_w.bin")),
            &self.outcome_head.w_d,
        )?;
        dump_f32(
            &base.join(format!("step_{step}_k_outcome_b.bin")),
            &self.outcome_head.b_d,
        )?;

        // Sync the DtoH copies before returning.
        unsafe {
            crate::trainer::raw_launch::raw_stream_sync(self.raw_stream)
                .map_err(|e| anyhow::anyhow!("dump_backward post-dtoh sync: {:?}", e))?;
        }

        eprintln!(
            "[determinism-debug] dumped backward state step={step} to {dump_dir}"
        );

        Ok(())
    }

    /// Construct the per-step diag JSONL record.
    ///
    /// Used by both the train loop and the eval loop in `alpha_rl_train.rs`
    /// — guarantees schema parity between `diag.jsonl` and `eval_diag.jsonl`
    /// per `feedback_single_source_of_truth_no_duplicates`.  All ISV reads
    /// come from the trainer's mapped-pinned host slice (`isv_host_slice`);
    /// host-side per-step state is passed via `DiagInputs`.
    ///
    /// Caller pre-conditions:
    ///   * `inputs.b_size` matches the slice lengths in `inputs`.
    ///   * The diag staging buffer's `sync_and_swap` has been called so
    ///     the staging reads point at the most recent completed step.
    ///   * The trainer's ISV one-step-latency state is consistent with
    ///     the staging reads.
    ///
    /// 643-leaf schema (642 scalars + 1 boolean; `jq paths(scalars)` only
    /// counts the scalars) documented in
    /// `docs/superpowers/specs/2026-05-31-checkpoints-and-eval-diag-design.md`
    /// §3.5 and the baseline snapshot at `/tmp/diag_keys_baseline.txt`.
    pub fn build_diag_value(
        &mut self,
        step: u64,
        elapsed_s: f32,
        inputs: &DiagInputs<'_>,
    ) -> Result<serde_json::Value> {
        // Determinism Phase 1: compute the 15 component checksums up-front
        // (before any borrow conflicts on `self`). Per spec
        // `docs/superpowers/specs/2026-06-02-determinism-foundation.md` §1.2.
        let checksums = self
            .compute_diag_checksums()
            .context("build_diag_value: compute_diag_checksums")?;
        // Determinism Phase 2 sub-investigation: env-gated raw PER dump
        // for step ∈ {0, 1, 2} to distinguish PRNG-divergence vs tree-walk
        // vs tree-rebuild as the non-determinism source. No-op when env
        // var unset. See spec §2.F sub-investigation.
        self.dump_per_state_for_debug(step)
            .context("build_diag_value: dump_per_state_for_debug")?;
        // Determinism Phase 2.2 sub-investigation: env-gated mamba2
        // weight + intermediate-activation dump for step ∈ {0,1,2,3} to
        // localise the `encoder_output` step-3 divergence to a specific
        // layer (weights drifted upstream vs forward/backward kernel
        // non-determinism). No-op when env unset. Spec §2.A.
        self.perception
            .dump_mamba2_state_for_debug(step)
            .context("build_diag_value: dump_mamba2_state_for_debug")?;
        // Determinism Phase 2.5 sub-investigation: env-gated RL-loop state
        // dump for step ∈ {0,1,2,3}. Phase 2.4 falsified mamba2 / cuBLAS /
        // trunk backward — 52 buffers EQUAL at step 1, divergence enters
        // during step-2's `step_with_lobsim_gpu`. This dump captures the
        // 6 not-yet-instrumented candidate groups (CfC carry, PER state,
        // action RNG, LOB sim, ISV controllers, reward shaping). No-op
        // when env var unset. Spec §2.5 (see investigation note appendix).
        self.dump_rl_state_for_debug(step)
            .context("build_diag_value: dump_rl_state_for_debug")?;
        // Determinism Phase 2.6 sub-investigation: env-gated backward-
        // pipeline dump for step ∈ {0,1,2,3}. Phase 2.5 falsified all 6
        // RL-loop candidate groups + showed forward activations EQUAL
        // at step 2 while backward outputs DIVERGED. This dump captures
        // the 4 not-yet-instrumented buffer groups (PER replay sample
        // hidden states / head fwd outputs / pre-reduce param-grad
        // scratches / per-head grad_h_t + combined). No-op when env
        // var unset. Decision tree: which group diverges first?
        self.dump_backward_state_for_debug(step)
            .context("build_diag_value: dump_backward_state_for_debug")?;
        let b_size = inputs.b_size;
        anyhow::ensure!(
            inputs.rewards.len() == b_size
                && inputs.dones.len() == b_size
                && inputs.actions.len() == b_size
                && inputs.raw_rewards.len() == b_size
                && inputs.trade_duration.len() == b_size
                && inputs.outcome_ema.len() == b_size
                && inputs.position_lots.len() == b_size
                && inputs.pyramid_count.len() == b_size
                && inputs.close_unit_index.len() == b_size,
            "build_diag_value: per-batch slice length mismatch (expected {b_size})"
        );
        anyhow::ensure!(
            inputs.unit_entry_price.len() == b_size * 4
                && inputs.unit_entry_step.len() == b_size * 4
                && inputs.unit_lots.len() == b_size * 4
                && inputs.unit_trail.len() == b_size * 4,
            "build_diag_value: per-unit (B×4) slice length mismatch (expected {})",
            b_size * 4
        );
        anyhow::ensure!(
            inputs.frd_logits.len() == b_size * FRD_OUT_DIM,
            "build_diag_value: frd_logits length mismatch (expected {})",
            b_size * FRD_OUT_DIM
        );
        anyhow::ensure!(
            inputs.windowed_act_hist.len() == N_ACTIONS,
            "build_diag_value: windowed_act_hist length must be {N_ACTIONS}"
        );

        let isv = self.isv_host_slice();
        let stats = inputs.stats;

        // ── Per-step derived stats ──────────────────────────────────
        let mut act_hist = [0u32; N_ACTIONS];
        for &a in inputs.actions {
            if (a as usize) < N_ACTIONS {
                act_hist[a as usize] += 1;
            }
        }
        // Action entropy on the EMA-smoothed windowed distribution.
        let win_sum: f32 = inputs.windowed_act_hist.iter().sum();
        let action_entropy: f32 = if win_sum > 1e-9 {
            inputs
                .windowed_act_hist
                .iter()
                .filter(|&&p| p > 1e-9)
                .map(|&p_raw| {
                    let p = p_raw / win_sum;
                    -p * p.ln()
                })
                .sum()
        } else {
            0.0
        };
        let reward_sum: f32 = inputs.rewards.iter().sum();
        let reward_max = inputs
            .rewards
            .iter()
            .cloned()
            .fold(f32::NEG_INFINITY, f32::max);
        let reward_min = inputs
            .rewards
            .iter()
            .cloned()
            .fold(f32::INFINITY, f32::min);
        let reward_abs_max = inputs
            .rewards
            .iter()
            .map(|r| r.abs())
            .fold(0.0f32, f32::max);
        let done_count: u32 = inputs
            .dones
            .iter()
            .map(|&d| if d > 0.5 { 1 } else { 0 })
            .sum();

        let win_rate = if inputs.total_trades > 0 {
            inputs.win_count as f64 / inputs.total_trades as f64
        } else {
            0.0
        };
        let avg_hold = if inputs.total_trades > 0 {
            inputs.hold_time_sum / inputs.total_trades as f64
        } else {
            0.0
        };

        // Per-step counters derived from action histogram + ISV diag slots.
        // trail_fired_step and conf_gate_step both read RL_CONF_GATE_FIRED_COUNT_INDEX
        // — actions_to_market_targets shares a single counter for both gate types.
        // Keep aliased to preserve diag schema (.trail.fired_step vs .confidence_gate.fired_step).
        let trail_fired_step = isv[RL_CONF_GATE_FIRED_COUNT_INDEX] as u64;
        let tighten_step = act_hist[crate::rl::common::Action::TrailTighten as usize] as u64;
        let loosen_step = act_hist[crate::rl::common::Action::TrailLoosen as usize] as u64;
        let pyramid_add_step = isv[RL_PYRAMID_ADD_COUNT_INDEX] as u64;
        let half_flat_long_step = act_hist[crate::rl::common::Action::HalfFlatLong as usize] as u64;
        let half_flat_short_step = act_hist[crate::rl::common::Action::HalfFlatShort as usize] as u64;
        let partial_flat_step = half_flat_long_step + half_flat_short_step;
        let conf_gate_step = isv[RL_CONF_GATE_FIRED_COUNT_INDEX] as u64;
        let frd_gate_step = isv[RL_FRD_GATE_FIRED_COUNT_INDEX] as u64;
        let heat_cap_step = isv[RL_HEAT_CAP_FIRED_COUNT_INDEX] as u64;

        // Phase 7a F2-diag (2026-06-04) — state-conditional surfer-
        // preservation measurement. `frac_batches_{flat,long,short}` are
        // host-side reductions over `inputs.position_lots` (already on
        // host as `&'a [i32]` per the DiagInputs contract — no D2H).
        // Per `pearl_fleet_fraction_not_aggregate` we emit fractions,
        // not aggregates, so the b=1024 cluster diag retains per-batch
        // resolution.
        let inv_b_f2: f32 = if b_size > 0 { 1.0 / b_size as f32 } else { 1.0 };
        let mut n_flat = 0u32;
        let mut n_long = 0u32;
        let mut n_short = 0u32;
        for &p in inputs.position_lots {
            if p == 0 {
                n_flat += 1;
            } else if p > 0 {
                n_long += 1;
            } else {
                n_short += 1;
            }
        }
        let frac_batches_flat = (n_flat as f32) * inv_b_f2;
        let frac_batches_long = (n_long as f32) * inv_b_f2;
        let frac_batches_short = (n_short as f32) * inv_b_f2;

        // hold_frac_flat = count(action == HOLD AND pos == 0) /
        // count(pos == 0). Direct F2-G5 measurement: if F2's hinge
        // pushes Open mass too hard from flat states, this drops below
        // 0.30 (surfer→trend). If F2 fails to break the inaction
        // collapse, this stays above 0.85. Healthy regime: [0.30, 0.85]
        // at step 5000.
        //
        // open_action_mass_flat = count(action ∈ Open AND pos == 0) /
        // count(pos == 0). Open actions: ShortLarge=0, ShortSmall=1,
        // LongSmall=5, LongLarge=6.
        let action_hold = crate::rl::common::Action::Hold as usize;
        let action_short_large = crate::rl::common::Action::ShortLarge as usize;
        let action_short_small = crate::rl::common::Action::ShortSmall as usize;
        let action_long_small = crate::rl::common::Action::LongSmall as usize;
        let action_long_large = crate::rl::common::Action::LongLarge as usize;
        let mut n_hold_from_flat = 0u32;
        let mut n_open_from_flat = 0u32;
        for (i, &p) in inputs.position_lots.iter().enumerate() {
            if p != 0 {
                continue;
            }
            let a = inputs.actions[i] as usize;
            if a == action_hold {
                n_hold_from_flat += 1;
            } else if a == action_short_large
                || a == action_short_small
                || a == action_long_small
                || a == action_long_large
            {
                n_open_from_flat += 1;
            }
        }
        let inv_flat: f32 = if n_flat > 0 { 1.0 / n_flat as f32 } else { 0.0 };
        let hold_frac_flat = (n_hold_from_flat as f32) * inv_flat;
        let open_action_mass_flat = (n_open_from_flat as f32) * inv_flat;

        // Phase 7a F2-diag (2026-06-04) — degenerate-attractor metrics.
        // All four are pure host-side reductions over the existing
        // `act_hist` (length N_ACTIONS, computed above). Zero marginal
        // compute cost.
        //
        // max_action_share: maximum action mass / b_size — detects
        // single-action collapse (the going-in-circles signature). The
        // attractors in the historical trajectory are Hold (mq2pc),
        // TrailLoosen, sometimes LongLarge. Above 0.80 means a single
        // action dominates — likely a re-collapse.
        //
        // modal_action_id: which action is at the max. Lets the
        // implementer / cluster observer identify the attractor without
        // re-deriving from the histogram.
        //
        // noop_action_mass_{total,max}: total and max mass on the no-op
        // set {Hold=2, TrailTighten=7, TrailLoosen=8}. The F2 hinge
        // baseline is this set; if F2 redirects gradient correctly,
        // this drops over training as Open actions earn positive
        // advantage. noop_max separates "Hold-collapse" from
        // "TrailLoosen-collapse" — both produce high noop_total but
        // different noop_max.
        let act_trail_tighten = crate::rl::common::Action::TrailTighten as usize;
        let act_trail_loosen = crate::rl::common::Action::TrailLoosen as usize;
        let mut max_count = 0u32;
        let mut modal_action_id: i32 = 0;
        for (a, &c) in act_hist.iter().enumerate() {
            if c > max_count {
                max_count = c;
                modal_action_id = a as i32;
            }
        }
        let max_action_share = (max_count as f32) * inv_b_f2;
        let noop_hold = act_hist[action_hold] as f32;
        let noop_tighten = act_hist[act_trail_tighten] as f32;
        let noop_loosen = act_hist[act_trail_loosen] as f32;
        let noop_action_mass_total =
            (noop_hold + noop_tighten + noop_loosen) * inv_b_f2;
        let noop_action_mass_max =
            noop_hold.max(noop_tighten).max(noop_loosen) * inv_b_f2;

        // Phase 7a F2-diag (2026-06-04) — read kernel-emitted per-batch
        // F2 mechanism counters and reduce host-side. Cost ~4·B·4 bytes
        // D2H per step, well under the existing band reduction's cost.
        // Per `feedback_no_htod_htoh_only_mapped_pinned` reads go via
        // the same `read_slice_d` / `read_slice_i32_d` helpers used by
        // the existing band reduction below.
        let f2_baseline_host =
            read_slice_d(&self.stream, &self.f2_diag_baseline_per_b_d, b_size)
                .context("build_diag_value: read f2_diag_baseline_per_b_d")?;
        let f2_adv_max_host =
            read_slice_d(&self.stream, &self.f2_diag_adv_max_per_b_d, b_size)
                .context("build_diag_value: read f2_diag_adv_max_per_b_d")?;
        let f2_hinge_zero_host =
            read_slice_d(&self.stream, &self.f2_diag_hinge_zero_per_b_d, b_size)
                .context("build_diag_value: read f2_diag_hinge_zero_per_b_d")?;
        let f2_target_argmax_host: Vec<i32> = read_slice_i32_d(
            &self.stream,
            &self.f2_diag_target_argmax_per_b_d,
            b_size,
        )
        .context("build_diag_value: read f2_diag_target_argmax_per_b_d")?;

        // Means over the [B] axis.
        let f2_baseline_mean: f32 = f2_baseline_host.iter().sum::<f32>() * inv_b_f2;
        let f2_advantage_max_mean: f32 =
            f2_adv_max_host.iter().sum::<f32>() * inv_b_f2;
        let f2_hinge_zero_rate: f32 =
            f2_hinge_zero_host.iter().sum::<f32>() * inv_b_f2;

        // Modal argmax across the batch: histogram + argmax.
        let mut argmax_hist = [0u32; N_ACTIONS];
        for &a in &f2_target_argmax_host {
            if a >= 0 && (a as usize) < N_ACTIONS {
                argmax_hist[a as usize] += 1;
            }
        }
        let mut max_a_count = 0u32;
        let mut f2_target_argmax_action: i32 = -1;
        for (a, &c) in argmax_hist.iter().enumerate() {
            if c > max_a_count {
                max_a_count = c;
                f2_target_argmax_action = a as i32;
            }
        }

        // Mirror into ISV so any controller / external probe can read
        // these without re-reducing. Writes go through the mapped-pinned
        // ISV handle (single-thread, deterministic). The ISV slots are
        // diag-only — no kernel reads them — so the host write does not
        // race with the GPU.
        self.isv_mapped.write_record(
            crate::rl::isv_slots::RL_F2_DIAG_BASELINE_MEAN_INDEX,
            f2_baseline_mean,
        );
        self.isv_mapped.write_record(
            crate::rl::isv_slots::RL_F2_DIAG_ADV_MAX_MEAN_INDEX,
            f2_advantage_max_mean,
        );
        self.isv_mapped.write_record(
            crate::rl::isv_slots::RL_F2_DIAG_HINGE_ZERO_RATE_INDEX,
            f2_hinge_zero_rate,
        );
        self.isv_mapped.write_record(
            crate::rl::isv_slots::RL_F2_DIAG_TARGET_ARGMAX_INDEX,
            f2_target_argmax_action as f32,
        );

        // Derive active mask from unit_lots (lots != 0 → active).
        let unit_active_host: Vec<u8> = inputs
            .unit_lots
            .iter()
            .map(|&l| if l != 0 { 1u8 } else { 0u8 })
            .collect();

        // Per-batch per-unit arrays for diag.
        let units_diag = {
            let mut entry_price_arr = Vec::with_capacity(b_size);
            let mut entry_step_arr = Vec::with_capacity(b_size);
            let mut lots_arr = Vec::with_capacity(b_size);
            let mut trail_arr = Vec::with_capacity(b_size);
            let mut active_arr = Vec::with_capacity(b_size);
            for batch in 0..b_size {
                let off = batch * 4;
                entry_price_arr.push(&inputs.unit_entry_price[off..off + 4]);
                entry_step_arr.push(&inputs.unit_entry_step[off..off + 4]);
                lots_arr.push(&inputs.unit_lots[off..off + 4]);
                trail_arr.push(&inputs.unit_trail[off..off + 4]);
                active_arr.push(&unit_active_host[off..off + 4]);
            }
            serde_json::json!({
                "entry_price": entry_price_arr,
                "entry_step": entry_step_arr,
                "lots": lots_arr,
                "trail_distance": trail_arr,
                "active_mask": active_arr,
                "unit_count": inputs.pyramid_count,
            })
        };

        // FRD diag — per-horizon softmax entropy + argmax index (averaged
        // across the batch).
        let frd_diag = {
            let mut per_h_entropy = [0.0_f32; FRD_N_HORIZONS];
            let mut per_h_argmax_sum = [0.0_f32; FRD_N_HORIZONS];
            for b in 0..b_size {
                for h in 0..FRD_N_HORIZONS {
                    let off = b * FRD_OUT_DIM + h * FRD_N_ATOMS;
                    let row = &inputs.frd_logits[off..off + FRD_N_ATOMS];
                    let max_l = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
                    let denom: f32 = row.iter().map(|x| (x - max_l).exp()).sum();
                    let mut entropy = 0.0_f32;
                    let mut argmax_idx = 0;
                    let mut argmax_v = f32::NEG_INFINITY;
                    for (a, x) in row.iter().enumerate() {
                        let p = (x - max_l).exp() / denom;
                        if p > 1e-9 {
                            entropy -= p * p.ln();
                        }
                        if *x > argmax_v {
                            argmax_v = *x;
                            argmax_idx = a;
                        }
                    }
                    per_h_entropy[h] += entropy;
                    per_h_argmax_sum[h] += argmax_idx as f32;
                }
            }
            let b_f = b_size.max(1) as f32;
            serde_json::json!({
                "h1": { "entropy_mean": per_h_entropy[0] / b_f, "argmax_mean": per_h_argmax_sum[0] / b_f },
                "h2": { "entropy_mean": per_h_entropy[1] / b_f, "argmax_mean": per_h_argmax_sum[1] / b_f },
                "h3": { "entropy_mean": per_h_entropy[2] / b_f, "argmax_mean": per_h_argmax_sum[2] / b_f },
            })
        };

        // v9 eval_warmup pre-compute (json! macro can't take if-else).
        let eval_warmup_remaining = isv[RL_REGIME_TRANSITION_REMAINING_INDEX];
        let eval_warmup_decay_steps = isv[RL_REGIME_TRANSITION_DECAY_STEPS_CONFIG_INDEX];
        let eval_warmup_active = if eval_warmup_remaining > 0.0 { 1.0_f32 } else { 0.0_f32 };
        let eval_warmup_blend = if eval_warmup_remaining < 0.0 {
            0.0_f32
        } else if eval_warmup_remaining > eval_warmup_decay_steps {
            1.0_f32
        } else if eval_warmup_decay_steps > 0.0 {
            eval_warmup_remaining / eval_warmup_decay_steps
        } else if eval_warmup_remaining > 0.0 {
            1.0_f32
        } else {
            0.0_f32
        };

        let replay_len = self.gpu_replay.capacity.min((step as usize) + 1);

        // Phase 4-A (2026-06-03) No-transaction-band diag aggregates.
        //
        // Host-side reads of `band_out_d [B × 2]` + per-batch `position_lots`
        // (already on host as `inputs.position_lots`). Cost: 2 × B floats
        // per step, mirrors the existing `frd_logits` read pattern. Reads
        // are skipped when the master gate is OFF — the device buffer is
        // still allocated but its contents are uninitialised w.r.t. the
        // "intended use", and we don't want to surface noise as diag.
        // Spec §5.
        let band_enabled = isv[crate::rl::isv_slots::RL_BAND_ENABLED_INDEX] > 0.5;
        let (band_lower_mean,
             band_upper_mean,
             band_width_mean,
             band_frac_in_band,
             band_frac_masked,
             band_collapse_warning) = if band_enabled {
            let band_out = read_slice_d(&self.stream, &self.band_head.band_out_d, b_size * 2)
                .context("build_diag_value: read band_out_d")?;
            let inv_b = 1.0_f32 / (b_size.max(1) as f32);
            let mut sum_l = 0.0_f32;
            let mut sum_u = 0.0_f32;
            let mut sum_w = 0.0_f32;
            let mut in_band_count: u32 = 0;
            // Fleet-fraction-style per-batch tally — see
            // `pearl_fleet_fraction_not_aggregate`. `frac_in_band` measures
            // how often the band default IS engaged (≥ frac_masked because
            // the actual mask is only fired when position ∈ band AND the
            // batch wasn't sampled to Hold already).
            for b in 0..b_size {
                let bl = band_out[b * 2 + 0];
                let bu = band_out[b * 2 + 1];
                sum_l += bl;
                sum_u += bu;
                sum_w += bu - bl;
                let pos = inputs.position_lots[b] as f32;
                if pos >= bl && pos <= bu {
                    in_band_count += 1;
                }
            }
            let lower_mean = sum_l * inv_b;
            let upper_mean = sum_u * inv_b;
            let width_mean = sum_w * inv_b;
            let frac_in_band = (in_band_count as f32) * inv_b;
            // Phase 4-A2 (2026-06-04): `frac_masked` discounts the
            // exploration bypass — the lowest-index `min_explore`
            // batches NEVER get masked even when their position is in
            // band. Verdict gate `frac_masked at step 1999 ∈ [0.3, 0.9]`
            // depends on this being the post-bypass true mask rate, not
            // the geometric `frac_in_band` (which now overstates).
            let max_mask = isv[crate::rl::isv_slots::RL_BAND_MAX_MASK_FRAC_INDEX];
            let min_explore = ((b_size as f32) * (1.0_f32 - max_mask)) as usize;
            // Re-tally in-band only for batches >= min_explore (the masked
            // population). Mirrors the kernel's deterministic-lowest-index
            // bypass — bypass batches contribute 0 to actual mask count.
            let mut masked_count: u32 = 0;
            for b in min_explore..b_size {
                let bl = band_out[b * 2 + 0];
                let bu = band_out[b * 2 + 1];
                let pos = inputs.position_lots[b] as f32;
                if pos >= bl && pos <= bu {
                    masked_count += 1;
                }
            }
            let frac_masked = (masked_count as f32) * inv_b;
            let width_min = isv[crate::rl::isv_slots::RL_BAND_WIDTH_MIN_INDEX];
            let width_max = isv[crate::rl::isv_slots::RL_BAND_WIDTH_MAX_INDEX];
            // Compare to N_max-scaled clamp bounds (slots 806/807 are in
            // [-1, 1] tanh-normalised space — multiply by N_max_eff to get
            // lots-space thresholds).
            let n_max_eff = isv[crate::rl::isv_slots::RL_HEAT_CAP_MAX_LOTS_INDEX];
            // Width range in lots: [width_min · N_max, width_max · N_max].
            // band_width = bu - bl ∈ [0, 2 · N_max] (since each ±|tanh| is
            // bounded by 1.0 in normalised space).
            let warn = if width_mean < width_min * n_max_eff
                || width_mean > width_max * n_max_eff {
                1.0_f32
            } else {
                0.0_f32
            };
            (lower_mean, upper_mean, width_mean, frac_in_band, frac_masked, warn)
        } else {
            (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
        };

        let mut record = serde_json::json!({
            "step": step,
            "elapsed_s": elapsed_s,
            "loss": {
                "bce":   stats.l_bce,
                "q":     stats.l_q,
                "pi":    stats.l_pi,
                "v":     stats.l_v,
                "aux":   stats.l_aux,
                "frd":   stats.l_frd,
                "total": stats.l_total,
            },
            "lambdas": {
                "bce": stats.lambdas.bce,
                "q":   stats.lambdas.q,
                "pi":  stats.lambdas.pi,
                "v":   stats.lambdas.v,
                "aux": stats.lambdas.aux,
                "frd": stats.lambdas.frd,
            },
            "isv_out": {
                "gamma":           isv[RL_GAMMA_INDEX],
                "target_tau":      isv[RL_TARGET_TAU_INDEX],
                "ppo_clip_eps":    isv[RL_PPO_CLIP_INDEX],
                "entropy_coef":    isv[RL_ENTROPY_COEF_INDEX],
                "n_rollout_steps": isv[RL_N_ROLLOUT_STEPS_INDEX],
                "per_alpha":       isv[RL_PER_ALPHA_INDEX],
                "reward_scale":    isv[RL_REWARD_SCALE_INDEX],
                "reward_scale_warmed_flag": isv[crate::rl::isv_slots::RL_REWARD_SCALE_CONTROLLER_WARMED_FLAG_INDEX],
                "pos_max_ema_cold_start":       isv[crate::rl::isv_slots::RL_POS_MAX_EMA_COLD_START_INDEX],
                "pos_max_ema_growth_cap_base":  isv[crate::rl::isv_slots::RL_POS_MAX_EMA_GROWTH_CAP_BASE_INDEX],
                "pos_max_ema_growth_cap_cv_gain": isv[crate::rl::isv_slots::RL_POS_MAX_EMA_GROWTH_CAP_CV_GAIN_INDEX],
                "kelly_bootstrap_floor":        isv[crate::rl::isv_slots::RL_KELLY_BOOTSTRAP_FLOOR_INDEX],
                "ema_alpha_slow_min":           isv[crate::rl::isv_slots::RL_EMA_ALPHA_SLOW_MIN_INDEX],
                "ema_trust_full_threshold":     isv[crate::rl::isv_slots::RL_EMA_TRUST_FULL_THRESHOLD_INDEX],
                "ema_cv_gain":                  isv[crate::rl::isv_slots::RL_EMA_CV_GAIN_INDEX],
            },
            "isv_lr": {
                "bce": isv[RL_LR_BCE_INDEX],
                "q":   isv[RL_LR_Q_INDEX],
                "pi":  isv[RL_LR_PI_INDEX],
                "v":   isv[RL_LR_V_INDEX],
                "aux": isv[RL_LR_AUX_INDEX],
            },
            "isv_ema_in": {
                "mean_trade_duration": isv[RL_MEAN_TRADE_DURATION_EMA_INDEX],
                "q_divergence":        isv[RL_Q_DIVERGENCE_EMA_INDEX],
                "kl_pi":               isv[RL_KL_PI_EMA_INDEX],
                "entropy_observed":    isv[RL_ENTROPY_OBSERVED_EMA_INDEX],
                "advantage_var_ratio": isv[RL_ADVANTAGE_VAR_RATIO_EMA_INDEX],
                "td_kurtosis":         isv[RL_TD_KURTOSIS_EMA_INDEX],
                "mean_abs_pnl":        isv[RL_MEAN_ABS_PNL_EMA_INDEX],
            },
            "grad_norm_ema": {
                "q":  isv[RL_Q_GRAD_NORM_EMA_INDEX],
                "pi": isv[RL_PI_GRAD_NORM_EMA_INDEX],
                "v":  isv[RL_V_GRAD_NORM_EMA_INDEX],
            },
            "lr_plateau": {
                "q":  { "loss_ema": isv[RL_LR_Q_LOSS_EMA_INDEX],
                         "best":     isv[RL_LR_Q_BEST_LOSS_INDEX],
                         "stale":    isv[RL_LR_Q_STEPS_SINCE_BEST_INDEX],
                         "warmup":   isv[RL_LR_Q_WARMUP_COUNTER_INDEX] },
                "pi": { "loss_ema": isv[RL_LR_PI_LOSS_EMA_INDEX],
                         "best":     isv[RL_LR_PI_BEST_LOSS_INDEX],
                         "stale":    isv[RL_LR_PI_STEPS_SINCE_BEST_INDEX],
                         "warmup":   isv[RL_LR_PI_WARMUP_COUNTER_INDEX] },
                "v":  { "loss_ema": isv[RL_LR_V_LOSS_EMA_INDEX],
                         "best":     isv[RL_LR_V_BEST_LOSS_INDEX],
                         "stale":    isv[RL_LR_V_STEPS_SINCE_BEST_INDEX],
                         "warmup":   isv[RL_LR_V_WARMUP_COUNTER_INDEX] },
            },
            "risk_stack": {
                "cmdp": {
                    "session_pnl_usd_mean":     isv[RL_SESSION_PNL_USD_INDEX],
                    "session_pnl_worst":        isv[RL_SESSION_PNL_WORST_INDEX],
                    "session_dd_limit_usd":     isv[RL_SESSION_DD_LIMIT_USD_INDEX],
                    "session_dd_triggered":     isv[RL_SESSION_DD_TRIGGERED_INDEX],
                    "consec_loss_count":        isv[RL_CONSEC_LOSS_COUNT_INDEX],
                    "consec_loss_limit":        isv[RL_CONSEC_LOSS_LIMIT_INDEX],
                    "cooldown_remaining_steps": isv[RL_COOLDOWN_REMAINING_STEPS_INDEX],
                    "cooldown_duration":        isv[RL_COOLDOWN_DURATION_INDEX],
                    "max_open_units":           isv[RL_MAX_OPEN_UNITS_INDEX],
                    "net_inventory_limit_usd":  isv[RL_NET_INVENTORY_LIMIT_USD_INDEX],
                    // Fleet-fraction observability (2026-06-01). Distinguishes
                    // "single batch near limit" (aggregate worst stays at limit)
                    // from "majority of batches constrained" (real fleet trap).
                    // Predetermined falsification: frac_in_cooldown >0.5 sustained
                    // → trap is real; <0.1 throughout → look elsewhere.
                    "frac_in_cooldown":         isv[crate::rl::isv_slots::RL_CMDP_FRAC_IN_COOLDOWN_INDEX],
                    "frac_consec_near_limit":   isv[crate::rl::isv_slots::RL_CMDP_FRAC_CONSEC_NEAR_LIMIT_INDEX],
                    "frac_dd_triggered":        isv[crate::rl::isv_slots::RL_CMDP_FRAC_DD_TRIGGERED_INDEX],
                    "frac_session_neg":         isv[crate::rl::isv_slots::RL_CMDP_FRAC_SESSION_NEG_INDEX],
                    // Edge-decay detector Phase 1 (2026-06-01).
                    // Page-Hinkley on σ_welford-normalized per-trade PnL.
                    // Diagnostic-only — no behavior change. See pearl
                    // pearl_edge_decay_detection_is_a_missing_abstraction_layer.
                    "edge_ph_mean":             isv[crate::rl::isv_slots::RL_EDGE_PH_MEAN_INDEX],
                    "edge_ph_frac_alerted":     isv[crate::rl::isv_slots::RL_EDGE_PH_FRAC_ALERTED_INDEX],
                    "edge_ph_frac_warmup":      isv[crate::rl::isv_slots::RL_EDGE_PH_FRAC_WARMUP_INDEX],
                },
                "iqn_tau": {
                    "action_tau":      isv[RL_IQN_ACTION_TAU_INDEX],
                    "tau_min":         isv[RL_IQN_ACTION_TAU_MIN_INDEX],
                    "dd_sensitivity":  isv[RL_IQN_ACTION_TAU_DD_SENSITIVITY_INDEX],
                },
                "inventory": {
                    "penalty_beta":   isv[RL_INVENTORY_PENALTY_BETA_INDEX],
                    "variance_ema":   isv[RL_INVENTORY_VARIANCE_EMA_INDEX],
                },
                "kelly": {
                    "fraction":              isv[RL_KELLY_FRACTION_INDEX],
                    "win_rate_ema":          isv[RL_WIN_RATE_EMA_INDEX],
                    "avg_win_usd_ema":       isv[RL_AVG_WIN_USD_EMA_INDEX],
                    "avg_loss_usd_ema":      isv[RL_AVG_LOSS_USD_EMA_INDEX],
                    "safety_frac":           isv[RL_KELLY_SAFETY_FRAC_INDEX],
                    "min_trades_for_release": isv[RL_KELLY_MIN_TRADES_FOR_RELEASE_INDEX],
                    "cumulative_dones":      isv[RL_CUMULATIVE_DONES_INDEX],
                },
                "trail_factors": {
                    "tighten": isv[RL_TRAIL_TIGHTEN_FACTOR_INDEX],
                    "loosen":  isv[RL_TRAIL_LOOSEN_FACTOR_INDEX],
                },
                "eval_warmup": {
                    "remaining":           eval_warmup_remaining,
                    "active":              eval_warmup_active,
                    "blend":               eval_warmup_blend,
                    "warmup_steps_config": isv[RL_REGIME_TRANSITION_STEPS_CONFIG_INDEX],
                    "decay_steps_config":  eval_warmup_decay_steps,
                    "floor_kelly_safety":  isv[RL_KELLY_SAFETY_FRAC_INDEX],
                    "floor_iqn_tau_min":   isv[RL_IQN_ACTION_TAU_MIN_INDEX],
                    "floor_entropy_min":   isv[RL_ENTROPY_COEF_MIN_INDEX],
                    "floor_ppo_eps_min":   isv[RL_PPO_CLIP_EPS_MIN_INDEX],
                    "target_defensive": {
                        "kelly_safety": isv[RL_EVAL_KELLY_SAFETY_DEFENSIVE_INDEX],
                        "iqn_tau_min":  isv[RL_EVAL_IQN_TAU_MIN_DEFENSIVE_INDEX],
                        "entropy_min":  isv[RL_EVAL_ENTROPY_COEF_MIN_DEFENSIVE_INDEX],
                        "ppo_eps_min":  isv[RL_EVAL_PPO_CLIP_EPS_MIN_DEFENSIVE_INDEX],
                    },
                    "target_normal": {
                        "kelly_safety": isv[RL_EVAL_KELLY_SAFETY_NORMAL_INDEX],
                        "iqn_tau_min":  isv[RL_EVAL_IQN_TAU_MIN_NORMAL_INDEX],
                        "entropy_min":  isv[RL_EVAL_ENTROPY_COEF_MIN_NORMAL_INDEX],
                        "ppo_eps_min":  isv[RL_EVAL_PPO_CLIP_EPS_MIN_NORMAL_INDEX],
                    },
                },
                "regime": {
                    "dead_zone": {
                        "flag":         isv[RL_REGIME_DEAD_ZONE_FLAG_INDEX],
                        "duration":     isv[RL_REGIME_DEAD_ZONE_DURATION_INDEX],
                        "timeout_flag": isv[RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX],
                        "max_duration": isv[RL_REGIME_DEAD_ZONE_MAX_DURATION_INDEX],
                    },
                    "tail": {
                        "recency":                  isv[RL_REGIME_TAIL_EVENT_RECENCY_INDEX],
                        "session_pnl_variance_ema": isv[RL_REGIME_SESSION_PNL_VARIANCE_EMA_INDEX],
                        "sigma_threshold":          isv[RL_REGIME_TAIL_SIGMA_THRESHOLD_INDEX],
                        "welford_count":            isv[RL_REGIME_SESSION_PNL_VAR_COUNT_INDEX],
                    },
                    "kelly_eps_recovery": {
                        "factor":     isv[RL_REGIME_RECOVERY_FACTOR_INDEX],
                        "live":       isv[RL_KELLY_EPS_RECOVERY_LIVE_INDEX],
                        "min":        isv[RL_KELLY_EPS_RECOVERY_MIN_INDEX],
                        "max":        isv[RL_KELLY_EPS_RECOVERY_MAX_INDEX],
                        "n_recovery": isv[RL_KELLY_EPS_RECOVERY_N_RECOVERY_INDEX],
                    },
                    "popart_envelope": {
                        "max_abs_reward_ema": isv[RL_POPART_MAX_ABS_REWARD_EMA_INDEX],
                        "decay_alpha":        isv[RL_POPART_MAX_DECAY_ALPHA_INDEX],
                    },
                    "iqn_tau_boost": {
                        "factor":   isv[RL_IQN_TAU_TAIL_BOOST_FACTOR_INDEX],
                        "n_window": isv[RL_IQN_TAU_TAIL_BOOST_N_WINDOW_INDEX],
                    },
                },
                "atom_calibration": {
                    "win_bound":           isv[RL_REWARD_CLAMP_WIN_INDEX],
                    "atom_max":            isv[RL_C51_V_MAX_INDEX],
                    "gamma":               isv[RL_GAMMA_INDEX],
                    "dynamic_bound":       isv[RL_REWARD_CLAMP_WIN_INDEX]
                                         + isv[RL_GAMMA_INDEX]
                                         * isv[RL_C51_V_MAX_INDEX],
                    "atom_max_headroom":   isv[RL_C51_V_MAX_INDEX]
                                         - (isv[RL_REWARD_CLAMP_WIN_INDEX]
                                            + isv[RL_GAMMA_INDEX]
                                            * isv[RL_C51_V_MAX_INDEX]),
                    "popart_sigma":        isv[RL_POPART_SIGMA_INDEX],
                    "v_target_max_3sigma": 3.0_f32 * isv[RL_POPART_SIGMA_INDEX],
                    "atom_max_over_3sigma": isv[RL_C51_V_MAX_INDEX]
                                         / (3.0_f32 * isv[RL_POPART_SIGMA_INDEX] + 1e-9_f32),
                    // B-9 (2026-06-01): C51 Bellman-target saturation rates.
                    // `target_*_rate` = fraction of (b, atom_z) pairs per step
                    // where pre-clamp t_z escapes [V_MIN_eff, V_MAX_eff].
                    // `target_{max,min}_pre_proj` show overshoot magnitude vs
                    // V_MAX_eff / V_MIN_eff above. Distinct from
                    // risk_stack.regime.popart_envelope.max_abs_reward_ema:
                    // the popart envelope is per-step reward magnitude
                    // (Welford+F4); these measure per-step Bellman target
                    // `r + γ·atom_value` which can exceed the reward by
                    // γ·V_MAX_eff and bypasses popart normalization.
                    "target_top_saturation_rate":
                        isv[crate::rl::isv_slots::RL_Q_TARGET_TOP_SATURATION_RATE_INDEX],
                    "target_bot_saturation_rate":
                        isv[crate::rl::isv_slots::RL_Q_TARGET_BOT_SATURATION_RATE_INDEX],
                    "target_max_pre_proj":
                        isv[crate::rl::isv_slots::RL_Q_TARGET_MAX_PRE_PROJ_INDEX],
                    "target_min_pre_proj":
                        isv[crate::rl::isv_slots::RL_Q_TARGET_MIN_PRE_PROJ_INDEX],
                },
            },
            "replay_len": replay_len,
            "rewards": {
                "sum":                 reward_sum,
                "max":                 reward_max,
                "min":                 reward_min,
                "abs_max":             reward_abs_max,
                "scaled_pre_clamp_max":
                    isv[RL_MAX_ABS_SCALED_REWARD_PRE_CLAMP_INDEX],
                "pos_scaled_max":
                    isv[RL_POS_SCALED_REWARD_MAX_INDEX],
                "pos_scaled_max_ema":
                    isv[RL_POS_SCALED_REWARD_MAX_EMA_INDEX],
                "clip_rate_ema":
                    isv[RL_REWARD_CLAMP_CLIP_RATE_EMA_INDEX],
                "c51_v_max":
                    isv[RL_C51_V_MAX_INDEX],
                "c51_v_min":
                    isv[RL_C51_V_MIN_INDEX],
                "q_distill_kl_ema":
                    isv[RL_Q_DISTILL_KL_EMA_INDEX],
                "neg_scaled_max":
                    isv[RL_NEG_SCALED_REWARD_MAX_INDEX],
                "neg_scaled_max_ema":
                    isv[RL_NEG_SCALED_REWARD_MAX_EMA_INDEX],
                "surfer_scaffold_weight":
                    isv[crate::rl::isv_slots::RL_SURFER_SCAFFOLD_WEIGHT_INDEX],
                "surfer_scaffold_force_pin":
                    isv[crate::rl::isv_slots::RL_SURFER_SCAFFOLD_FORCE_PIN_INDEX],
                // Phase 3D-B (2026-06-03): quadratic-cost slot snapshot.
                // No per-step batch-aggregated cost is emitted here — the
                // reward kernel applies the penalty per-batch element
                // and the change is already visible via `rewards.sum`/`min`
                // vs. legacy. Slot values are stable per session unless
                // an operator tunes them mid-run, so emitting them every
                // step preserves config visibility in the diag.
                "quadratic_cost_alpha":
                    isv[crate::rl::isv_slots::RL_QUADRATIC_COST_ALPHA_INDEX],
                "quadratic_cost_beta":
                    isv[crate::rl::isv_slots::RL_QUADRATIC_COST_BETA_INDEX],
                "quadratic_cost_enabled":
                    isv[crate::rl::isv_slots::RL_QUADRATIC_COST_ENABLED_INDEX],
                // Phase 3D-C (2026-06-03): PPO surrogate blend slots.
                "ppo_surrogate_weight":
                    isv[crate::rl::isv_slots::RL_PPO_SURROGATE_WEIGHT_INDEX],
                "ppo_surrogate_enabled":
                    isv[crate::rl::isv_slots::RL_PPO_SURROGATE_ENABLED_INDEX],
            },
            "ppo": {
                "ratio_clamp_max":   isv[RL_PPO_RATIO_CLAMP_MAX_INDEX],
                "log_ratio_abs_max": isv[RL_PPO_LOG_RATIO_ABS_MAX_INDEX],
            },
            "streaming": {
                "adv_var": {
                    "mean":  isv[RL_ADV_VAR_STREAM_MEAN_INDEX],
                    "m2":    isv[RL_ADV_VAR_STREAM_M2_INDEX],
                    "clamp": isv[RL_ADV_VAR_RATIO_CLAMP_INDEX],
                },
                "td_kurt": {
                    "mean":  isv[RL_TD_KURT_STREAM_MEAN_INDEX],
                    "m2":    isv[RL_TD_KURT_STREAM_M2_INDEX],
                    "m4":    isv[RL_TD_KURT_STREAM_M4_INDEX],
                    "clamp": isv[RL_TD_KURTOSIS_CLAMP_INDEX],
                },
            },
            "k_loop": {
                "k_updates": self.last_k_updates,
                "divisor":   isv[RL_K_LOOP_DIVISOR_INDEX],
                "max":       isv[RL_K_LOOP_MAX_INDEX],
            },
            "isv_config": {
                "reward_clamp_win":      isv[RL_REWARD_CLAMP_WIN_INDEX],
                "reward_clamp_loss":     isv[RL_REWARD_CLAMP_LOSS_INDEX],
                "kl_target":             isv[RL_KL_TARGET_INDEX],
                "improvement_threshold": isv[RL_IMPROVEMENT_THRESHOLD_INDEX],
                "plateau_patience":      isv[RL_PLATEAU_PATIENCE_INDEX],
                "div_target":            isv[RL_DIV_TARGET_INDEX],
                "entropy_target_frac":   isv[RL_ENTROPY_TARGET_FRAC_INDEX],
                "kurt_lift_scale":       isv[RL_KURT_LIFT_SCALE_INDEX],
                "ppo_clamp_margin":      isv[RL_PPO_CLAMP_MARGIN_INDEX],
                "lr_warmup_steps":       isv[RL_LR_WARMUP_STEPS_INDEX],
                "lr_bootstrap":          isv[RL_LR_BOOTSTRAP_INDEX],
                "lr_min":                isv[RL_LR_MIN_INDEX],
                "lr_max":                isv[RL_LR_MAX_INDEX],
                "lr_loss_ema_alpha":     isv[RL_LR_LOSS_EMA_ALPHA_INDEX],
                "lr_decay_factor":       isv[RL_LR_DECAY_FACTOR_INDEX],
                "loss_lambda_aux":       isv[RL_LOSS_LAMBDA_AUX_INDEX],
                "schulman_tolerance":    isv[RL_SCHULMAN_TOLERANCE_INDEX],
                "schulman_adjust_rate":  isv[RL_SCHULMAN_ADJUST_RATE_INDEX],
                "stream_alpha":          isv[RL_STREAM_ALPHA_INDEX],
                "kurt_gaussian":         isv[RL_KURT_GAUSSIAN_INDEX],
                "kurt_noise_floor":      isv[RL_KURT_NOISE_FLOOR_INDEX],
                "tau_bootstrap":         isv[RL_TAU_BOOTSTRAP_INDEX],
                "eps_bootstrap":         isv[RL_EPS_BOOTSTRAP_INDEX],
                "rollout_bootstrap":     isv[RL_ROLLOUT_BOOTSTRAP_INDEX],
                "reward_scale_bootstrap":isv[RL_REWARD_SCALE_BOOTSTRAP_INDEX],
                "ppo_ratio_clamp_bootstrap": isv[RL_PPO_RATIO_CLAMP_BOOTSTRAP_INDEX],
                "reward_clamp_margin":   isv[RL_REWARD_CLAMP_MARGIN_INDEX],
                "reward_clamp_ratio":    isv[RL_REWARD_CLAMP_RATIO_INDEX],
                "reward_clamp_clip_rate_target": isv[RL_REWARD_CLAMP_CLIP_RATE_TARGET_INDEX],
                "q_distill_lambda":      isv[RL_Q_DISTILL_LAMBDA_INDEX],
                "q_distill_temperature": isv[RL_Q_DISTILL_TEMPERATURE_INDEX],
                "q_distill_kl_target":   isv[RL_Q_DISTILL_KL_TARGET_INDEX],
                "sac_alpha":             isv[RL_SAC_ALPHA_INDEX],
                "sac_entropy_target":    isv[RL_SAC_ENTROPY_TARGET_INDEX],
                "action_entropy_ema":    isv[RL_ACTION_ENTROPY_EMA_INDEX],
                "reward_scale_min":      isv[RL_REWARD_SCALE_MIN_INDEX],
            },
            "q_pi_agree_ema": isv[RL_Q_ARG_VS_PI_AGREE_INDEX],
            "controller_branch": {
                "rollout_steps_input":  isv[RL_ADVANTAGE_VAR_RATIO_EMA_INDEX],
                "rollout_steps_target": isv[RL_ADV_VAR_RATIO_TARGET_INDEX],
                "ppo_clip_input":       isv[RL_KL_PI_EMA_INDEX],
                "ppo_clip_target":      0.01f32,
                "target_tau_input":     isv[RL_Q_DIVERGENCE_EMA_INDEX],
                "target_tau_target":    0.01f32,
                "per_alpha_input":      isv[RL_TD_KURTOSIS_EMA_INDEX],
                "per_alpha_target":     0.6f32,
            },
            "done_count": done_count,
            "action_hist": act_hist.to_vec(),
            "action_entropy": action_entropy,
            "position": {
                "lots": inputs.position_lots,
            },
            "units": units_diag,
            "trail": {
                "fired_count_step": trail_fired_step,
                "fired_count_total": inputs.trail_fired_total,
                "tightened_count_step": tighten_step,
                "loosened_count_step": loosen_step,
                "tightened_count_total": inputs.trail_tighten_total,
                "loosened_count_total": inputs.trail_loosen_total,
            },
            "pyramid": {
                "added_count_step": pyramid_add_step,
                "added_count_total": inputs.pyramid_added_total,
                "units_distribution": inputs.pyramid_count,
                "max_units_reached": inputs.pyramid_count.iter().any(|&c| c >= 4),
            },
            "partial_flat": {
                "fired_count_step": partial_flat_step,
                "fired_count_total": inputs.partial_flat_total,
                "long_count_total": inputs.partial_flat_long_total,
                "short_count_total": inputs.partial_flat_short_total,
                "close_unit_index": inputs.close_unit_index,
            },
            "confidence_gate": {
                "gated_count_step": conf_gate_step,
                "gated_count_total": inputs.conf_gate_total,
            },
            // Phase 4-A (2026-06-03) No-transaction-band diag. All means /
            // fractions reflect THIS step (no EMA — that's Phase 4-B
            // territory). `enabled` mirrors slot 799 so an operator can see
            // immediately whether the band fired; `frac_in_band` and
            // `frac_masked` are fleet-fraction tallies per
            // `pearl_fleet_fraction_not_aggregate`; `collapse_warning`
            // flags band collapse / saturation per spec §9.1.
            "band": {
                "enabled":           band_enabled,
                "loss_weight":       isv[crate::rl::isv_slots::RL_BAND_LOSS_WEIGHT_INDEX],
                "turnover_target":   isv[crate::rl::isv_slots::RL_BAND_TURNOVER_TARGET_INDEX],
                "grad_sharpness":    isv[crate::rl::isv_slots::RL_BAND_GRAD_SHARPNESS_INDEX],
                "lower_mean":        band_lower_mean,
                "upper_mean":        band_upper_mean,
                "width_mean":        band_width_mean,
                "frac_in_band":      band_frac_in_band,
                "frac_masked":       band_frac_masked,
                "collapse_warning":  band_collapse_warning,
                // Phase 4-B (2026-06-04) adaptive controller + observability.
                "controller_target":          isv[crate::rl::isv_slots::RL_BAND_TURNOVER_TARGET_ADAPTIVE_INDEX],
                "turnover_ema":               isv[crate::rl::isv_slots::RL_BAND_TURNOVER_EMA_INDEX],
                "frac_not_masked_observed":   isv[crate::rl::isv_slots::RL_BAND_FRAC_NOT_MASKED_OBSERVED_INDEX],
                "controller_boot_done":       isv[crate::rl::isv_slots::RL_BAND_CONTROLLER_BOOT_DONE_INDEX],
                "width_min":                  isv[crate::rl::isv_slots::RL_BAND_WIDTH_MIN_INDEX],
                "width_max":                  isv[crate::rl::isv_slots::RL_BAND_WIDTH_MAX_INDEX],
            },
            "position_heat": {
                "capped_count_step": heat_cap_step,
                "capped_count_total": inputs.heat_cap_total,
                "heat_max_lots": isv[RL_HEAT_CAP_MAX_LOTS_INDEX],
            },
            "anti_martingale": {
                "outcome_ema": inputs.outcome_ema,
                "kappa": isv[RL_ANTIMARTINGALE_KAPPA_INDEX],
            },
            "frd_gate": {
                "gated_count_step": frd_gate_step,
                "gated_count_total": inputs.frd_gate_total,
            },
            "trading": {
                // Phase 0 reward-redesign (2026-06-02): cumulative SHAPED
                // reward at close events (sum of raw_rewards[b] when done).
                // NOT in USD — pts × lots × Phase-5 shaping. Renamed from
                // `realized_pnl_cum_usd` (the previous name lied about
                // units). Compare against `realized_pnl_usd_cum` below to
                // see the gradient-signal vs authoritative-pnl gap.
                "shaped_reward_close_event_cum": inputs.shaped_reward_close_event_cum,
                // Phase 0 reward-redesign (2026-06-02): authoritative USD
                // pnl signals sourced from `pnl_track_step` (same fp/100
                // arithmetic as `eval_summary.total_pnl_usd`). `_delta` is
                // this step's USD pnl across all batches; `_cum` is the
                // running phase total (reset at train→eval boundary). Last
                // eval row's `_cum` MUST match `eval_summary.total_pnl_usd`
                // within $50.
                "realized_pnl_usd_delta": inputs.realized_pnl_usd_delta,
                "realized_pnl_usd_cum":   inputs.realized_pnl_usd_cum,
                "total_trades": inputs.total_trades,
                "win_rate": win_rate,
                "avg_hold_steps": avg_hold,
                "raw_reward_sum": inputs.raw_rewards.iter().sum::<f32>(),
                // Phase 7a F2-diag (2026-06-04) — state-conditional
                // denominators (fleet fractions per
                // `pearl_fleet_fraction_not_aggregate`). flat + long +
                // short MUST sum to 1.0 within fp noise.
                "frac_batches_flat":  frac_batches_flat,
                "frac_batches_long":  frac_batches_long,
                "frac_batches_short": frac_batches_short,
            },
            "frd": frd_diag,
            "popart": {
                "mean":  isv[RL_POPART_MEAN_INDEX],
                "sigma": isv[RL_POPART_SIGMA_INDEX],
                "var":   isv[RL_POPART_VAR_INDEX],
                // B-8 (2026-06-01): Welford-only σ — pre F4 envelope floor.
                // Identity: popart.sigma == max(popart.sigma_welford,
                // risk_stack.popart_envelope.max_abs_reward_ema). Disaggregates
                // σ shocks for attribution under B-7's unclamped rewards.
                "sigma_welford": isv[crate::rl::isv_slots::RL_POPART_SIGMA_WELFORD_INDEX],
            },
            // B-10 (2026-06-01): policy-quality cascade diagnostic.
            // Pure observability — 4 groups attribute the degenerate
            // equilibrium surfaced by alpha-rl-8gtk2 (l_pi diverged 15× /
            // l_q + l_v bit-flat / π near-uniform). Falsification criteria
            // in spec §2.G5 select the B-11 path (α Q-collapse / β
            // Q-distill amplification / γ heavy-tailed advantage) the
            // next cluster run motivates.
            "policy_diagnostic": {
                // G1 — Q-distribution informativeness (detects Q-collapse).
                "q_dist_entropy_mean":       isv[crate::rl::isv_slots::RL_Q_DIST_ENTROPY_MEAN_INDEX],
                "q_value_range_mean":        isv[crate::rl::isv_slots::RL_Q_VALUE_RANGE_MEAN_INDEX],
                "q_value_abs_max":           isv[crate::rl::isv_slots::RL_Q_VALUE_ABS_MAX_INDEX],
                // G2 — Q→π distill effectiveness (detects uniform-target case).
                "q_distill_target_entropy":  isv[crate::rl::isv_slots::RL_Q_DISTILL_TARGET_ENTROPY_MEAN_INDEX],
                "q_distill_pi_entropy_diff": isv[crate::rl::isv_slots::RL_Q_DISTILL_PI_ENTROPY_DIFF_INDEX],
                // G3 — PPO advantage normalization (detects heavy-tailed var_pre_norm).
                "ppo_a_norm_abs_max":        isv[crate::rl::isv_slots::RL_PPO_A_NORM_ABS_MAX_INDEX],
                "ppo_a_norm_abs_mean":       isv[crate::rl::isv_slots::RL_PPO_A_NORM_ABS_MEAN_INDEX],
                "ppo_a_unnorm_abs_max":      isv[crate::rl::isv_slots::RL_PPO_A_UNNORM_ABS_MAX_INDEX],
                "ppo_a_unnorm_abs_mean":     isv[crate::rl::isv_slots::RL_PPO_A_UNNORM_ABS_MEAN_INDEX],
                "ppo_a_sigma_used":          isv[crate::rl::isv_slots::RL_PPO_A_SIGMA_USED_INDEX],
                // G4 — PPO surrogate decomposition (detects ratio vs advantage cause).
                "ppo_ratio_dev_abs_mean":    isv[crate::rl::isv_slots::RL_PPO_RATIO_DEV_ABS_MEAN_INDEX],
                "ppo_ratio_clip_rate":       isv[crate::rl::isv_slots::RL_PPO_RATIO_CLIP_RATE_INDEX],
                "ppo_l_surrogate":           isv[crate::rl::isv_slots::RL_PPO_L_SURROGATE_INDEX],
                // Audit follow-up (resolved B-10 Open Decision 4): rl_q_pi_agree_b
                // kernel was writing slot 407 every step but never reached diag.
                // Q-π agreement is THE canonical Q-collapse discriminator.
                "q_pi_agree_ema":            isv[crate::rl::isv_slots::RL_Q_ARG_VS_PI_AGREE_INDEX],
                // Phase 7a F2-diag (2026-06-04) — surfer-preservation
                // (F2-G5 direct measurement, locally observable):
                "hold_frac_flat":        hold_frac_flat,
                "open_action_mass_flat": open_action_mass_flat,
                // Phase 7a F2-diag — degenerate-attractor monitoring:
                "max_action_share":       max_action_share,
                "modal_action_id":        modal_action_id,
                "noop_action_mass_total": noop_action_mass_total,
                "noop_action_mass_max":   noop_action_mass_max,
                // Phase 7a F2-diag — F2 mechanism counters (kernel-
                // reduced into ISV slots 819-822, mirrored here for
                // diag-only consumers). f2_target_argmax_action is an
                // integer index in {0..10}; consumers should compare
                // against `modal_action_id` (sampled actions) — if F2 is
                // reshaping the gradient toward Open, these SHOULD
                // differ during the surfer→adapt phase.
                "f2_baseline_mean":        f2_baseline_mean,
                "f2_advantage_max_mean":   f2_advantage_max_mean,
                "f2_hinge_zero_rate":      f2_hinge_zero_rate,
                "f2_target_argmax_action": f2_target_argmax_action,
            },
            "spectral": {
                "norm_max_config":  isv[RL_SPECTRAL_NORM_MAX_INDEX],
                "decouple_lambda": isv[RL_SPECTRAL_DECOUPLE_LAMBDA_INDEX],
            },
            "q_bias": {
                "ema":        isv[RL_Q_BIAS_EMA_INDEX],
                "correction": isv[RL_Q_BIAS_CORRECTION_INDEX],
            },
            "per_branch_lr": {
                "scale_q":   isv[RL_LR_SCALE_Q_INDEX],
                "scale_pi":  isv[RL_LR_SCALE_PI_INDEX],
                "scale_v":   isv[RL_LR_SCALE_V_INDEX],
                "scale_iqn": isv[RL_LR_SCALE_IQN_INDEX],
            },
            "outcome_aux": {
                "lambda": isv[RL_OUTCOME_AUX_LAMBDA_INDEX],
            },
            // Determinism Phase 1 (2026-06-02): per-step component
            // checksums — provably-deterministic sum-of-squares of 15
            // major trainer-state tensors. See
            // `docs/superpowers/specs/2026-06-02-determinism-foundation.md`
            // §1.2 and `IntegratedTrainer::compute_diag_checksums`.
            //
            // Use `scripts/determinism-check.sh` to localise the FIRST
            // divergent leaf across same-seed runs — that names the
            // non-determinism culprit (encoder vs head GEMM vs grad
            // reduction vs Adam vs PER vs ISV vs popart).
            "checksums": {
                "encoder_output":        checksums[0],
                "q_logits":              checksums[1],
                "pi_logits":             checksums[2],
                "v_value":               checksums[3],
                "replay_sample_indices": checksums[4],
                "rewards_after_shape":   checksums[5],
                "advantages":            checksums[6],
                "q_grad":                checksums[7],
                "pi_grad":               checksums[8],
                "v_grad":                checksums[9],
                "encoder_grad":          checksums[10],
                "adam_m_sum":            checksums[11],
                "adam_v_sum":            checksums[12],
                "isv_state":             checksums[13],
                "popart_sigma_state":    checksums[14],
                // Phase 2.2 encoder-localisation leaves
                // (docs/superpowers/notes/2026-06-02-determinism-phase2.2-mamba2-investigation.md).
                "mamba2_l1_w_in":        checksums[15],
                "mamba2_l1_w_a":         checksums[16],
                "mamba2_l1_w_b":         checksums[17],
                "mamba2_l1_w_c":         checksums[18],
                "mamba2_l2_w_in":        checksums[19],
                "mamba2_l2_w_c":         checksums[20],
                "vsn_w":                 checksums[21],
                "cfc_w_in":              checksums[22],
                "cfc_w_rec":             checksums[23],
                "attn_q":                checksums[24],
                "vsn_out":               checksums[25],
                "mamba2_l1_out":         checksums[26],
                "ln_a_out":              checksums[27],
                "mamba2_l2_out":         checksums[28],
                "ln_b_out":              checksums[29],
                "attn_context":          checksums[30],
                "mamba2_grad_w_c_l1":    checksums[31],
            },
        });

        // ── Phase 2A-C / 2A-D (2026-06-03) MultiHeadPolicy diag emit ─
        //
        // Only emitted when `FOXHUNT_USE_MULTI_HEAD_POLICY=1` so the
        // flag-off diag.jsonl stays byte-equal to HEAD `3e36f4a0e`.
        //
        // Phase 2A-C: ISV-resident summary leaves (K, gating/head entropy
        // floors, aux β). Phase 2A-D adds the device-aggregated gate-
        // and-head specialization signals produced by the aggregator
        // kernel `multi_head_policy_aggregate_diag`:
        //
        //   * gate_probs_mean[K]       — mean over B of gate_probs[b,k]
        //   * gate_argmax_mass[K]      — fraction of B where head k is argmax
        //   * gate_entropy_mean        — mean over B of −Σ_k p·log p
        //   * per_head_entropy_mean[K] — mean over B of −Σ_a π_k·log π_k
        //
        // The four array fields are emitted at MAX_K_HEADS=8 stride so
        // the schema is constant for K ∈ [1, 8]; tail entries past
        // runtime K are kernel-written 0.0. These four stats are the
        // load-bearing specialization verdict signal — without them we
        // can't tell whether the mixture is genuinely regime-conditional
        // or accidentally acting as a single-head regularizer.
        if self.use_multi_head_policy {
            use crate::rl::isv_slots::{
                RL_POLICY_AUX_PRIOR_BETA_INDEX,
                RL_POLICY_GATE_ARGMAX_MASS_BASE_INDEX,
                RL_POLICY_GATE_CONTROLLER_BOOTSTRAP_DONE_INDEX,
                RL_POLICY_GATE_ENTROPY_EMA_INDEX,
                RL_POLICY_GATE_ENTROPY_MEAN_INDEX,
                RL_POLICY_GATE_LR_MULTIPLIER_INDEX,
                RL_POLICY_GATE_PROBS_MEAN_BASE_INDEX,
                RL_POLICY_GATING_ENTROPY_FLOOR_INDEX,
                RL_POLICY_HEAD_ENTROPY_FLOOR_INDEX,
                RL_POLICY_NUM_HEADS_INDEX,
                RL_POLICY_PER_HEAD_ENTROPY_MEAN_BASE_INDEX,
            };
            // MAX_K_HEADS stride — must match `multi_head_policy::MAX_K_HEADS`
            // and the `#define MAX_K_HEADS 8` mirrors in the kernel sources.
            const K_STRIDE: usize = crate::rl::multi_head_policy::MAX_K_HEADS;
            let gpm_base = RL_POLICY_GATE_PROBS_MEAN_BASE_INDEX;
            let gam_base = RL_POLICY_GATE_ARGMAX_MASS_BASE_INDEX;
            let phe_base = RL_POLICY_PER_HEAD_ENTROPY_MEAN_BASE_INDEX;
            let gate_probs_mean: Vec<f32> =
                (0..K_STRIDE).map(|k| isv[gpm_base + k]).collect();
            let gate_argmax_mass: Vec<f32> =
                (0..K_STRIDE).map(|k| isv[gam_base + k]).collect();
            let per_head_entropy_mean: Vec<f32> =
                (0..K_STRIDE).map(|k| isv[phe_base + k]).collect();
            if let Some(obj) = record.as_object_mut() {
                obj.insert(
                    "multi_head_policy".to_string(),
                    serde_json::json!({
                        "active":                 self.use_multi_head_policy,
                        "k":                      isv[RL_POLICY_NUM_HEADS_INDEX],
                        "gating_entropy_floor":   isv[RL_POLICY_GATING_ENTROPY_FLOOR_INDEX],
                        "head_entropy_floor":     isv[RL_POLICY_HEAD_ENTROPY_FLOOR_INDEX],
                        "aux_prior_beta":         isv[RL_POLICY_AUX_PRIOR_BETA_INDEX],
                        "gate_probs_mean":        gate_probs_mean,
                        "gate_argmax_mass":       gate_argmax_mass,
                        "gate_entropy_mean":      isv[RL_POLICY_GATE_ENTROPY_MEAN_INDEX],
                        "per_head_entropy_mean":  per_head_entropy_mean,
                        // Phase 2A-D fix B1.3 (2026-06-03) — adaptive
                        // gate-LR multiplier controller state. Slot 790
                        // is the controller output (consumed by the
                        // host-side LR-set at the top of the next step's
                        // `step_*_body`); slot 791 is the EMA over slot
                        // 781; slot 792 is the first-observation
                        // bootstrap-done flag (monotonic 0 → 1).
                        "gate_lr_multiplier":            isv[RL_POLICY_GATE_LR_MULTIPLIER_INDEX],
                        "gate_entropy_ema":              isv[RL_POLICY_GATE_ENTROPY_EMA_INDEX],
                        "gate_controller_bootstrap_done": isv[RL_POLICY_GATE_CONTROLLER_BOOTSTRAP_DONE_INDEX],
                    }),
                );
            }
        }

        Ok(record)
    }
}

/// Free-function entry point for the `grad_h_accumulate_scaled` kernel.
/// Lives outside the impl so callers can hold a `&mut` reference to a
/// trainer-owned grad slot at the same time as a `&self.stream` /
/// `&self.grad_h_accumulate_fn` borrow.
fn accumulate_grad_h(
    stream: &Arc<CudaStream>,
    grad_h_accumulate_fn: &CudaFunction,
    grad_h_head: &CudaSlice<f32>,
    lambda: f32,
    grad_h_encoder: &mut CudaSlice<f32>,
) -> Result<()> {
    let n = grad_h_head.len();
    debug_assert_eq!(grad_h_encoder.len(), n);
    let n_i = n as i32;
    let grid_x = (n as u32).div_ceil(256);
    let mut args = RawArgs::new();
    args.push_ptr(grad_h_head.raw_ptr());
    args.push_f32(lambda);
    args.push_i32(n_i);
    args.push_ptr(grad_h_encoder.raw_ptr());
    let mut ptrs = args.build_arg_ptrs();
    unsafe {
        raw_launch(
            grad_h_accumulate_fn.cu_function(),
            (grid_x, 1, 1), (256, 1, 1), 0,
            stream.cu_stream(),
            &mut ptrs[..args.len()],
        ).map_err(|e| anyhow::anyhow!("grad_h_accumulate: {:?}", e))?;
    }
    Ok(())
}

/// Free-function version of `launch_reduce_axis0` — lives outside the impl
/// so callers can hold `&mut` references to trainer-owned output fields at
/// the same time as `&stream` / `&reduce_axis0_fn` borrows (same pattern as
/// `accumulate_grad_h`). Required for CUDA Graph stable pointer migration
/// where both input and output are `self` fields.
fn reduce_axis0_free(
    stream: &Arc<CudaStream>,
    reduce_fn: &CudaFunction,
    per_batch: &CudaSlice<f32>,
    b_size: usize,
    n_tail: usize,
    out: &mut CudaSlice<f32>,
) -> Result<()> {
    debug_assert_eq!(per_batch.len(), b_size * n_tail);
    debug_assert_eq!(out.len(), n_tail);
    reduce_axis0_raw(stream, reduce_fn, per_batch.raw_ptr(), b_size, n_tail, out.raw_ptr())
}

/// Variant of `reduce_axis0_free` that takes a raw `CUdeviceptr` for the
/// output buffer. Used when the output is a mapped-pinned `MappedF32Buffer`
/// whose `dev_ptr` is a stable `u64` — avoids requiring a `CudaSlice`.
fn reduce_axis0_mapped(
    stream: &Arc<CudaStream>,
    reduce_fn: &CudaFunction,
    per_batch: &CudaSlice<f32>,
    b_size: usize,
    n_tail: usize,
    out_dev_ptr: u64,
) -> Result<()> {
    debug_assert_eq!(per_batch.len(), b_size * n_tail);
    reduce_axis0_raw(stream, reduce_fn, per_batch.raw_ptr(), b_size, n_tail, out_dev_ptr)
}

fn reduce_axis0_raw(
    stream: &Arc<CudaStream>,
    reduce_fn: &CudaFunction,
    per_batch_ptr: u64,
    b_size: usize,
    n_tail: usize,
    out_ptr: u64,
) -> Result<()> {
    let n_batch_i = b_size as i32;
    let n_tail_i = n_tail as i32;
    let grid_x = (n_tail as u32).div_ceil(32);
    let mut args = RawArgs::new();
    args.push_ptr(per_batch_ptr);
    args.push_i32(n_batch_i);
    args.push_i32(n_tail_i);
    args.push_ptr(out_ptr);
    let mut ptrs = args.build_arg_ptrs();
    unsafe {
        raw_launch(
            reduce_fn.cu_function(),
            (grid_x, 1, 1), (32, 8, 1), 0,
            stream.cu_stream(),
            &mut ptrs[..args.len()],
        ).map_err(|e| anyhow::anyhow!("reduce_axis0: {:?}", e))?;
    }
    Ok(())
}

// ── helpers ──────────────────────────────────────────────────────────
//
// Phase R7b: `upload_f32`, `upload_i32`, and `argmax_f32` are deleted
// because all step_with_lobsim host loops they served are GPU-resident
// now (R3/R4/R5/R6/R7a kernels). Per `feedback_no_hiding` the
// retired helpers are removed, not `#[allow(dead_code)]`'d. The
// remaining `read_*_d` helpers are still load-bearing for the per-step
// loss-scalar readback inside step_synthetic's Q + V backward chain.

/// Read `n` `i32`s from a device buffer into a host `Vec<i32>` via
/// mapped-pinned staging + DtoD + stream sync. Same pattern as
/// `read_slice_d` (f32 version) — the only permitted CPU↔GPU path
/// per `feedback_no_htod_htoh_only_mapped_pinned`. Used by R7d's
/// GPU PER push path to read the per-step actions device buffer for
/// PER metadata staging.
pub fn read_slice_i32_d_pub(
    stream: &Arc<CudaStream>,
    src: &CudaSlice<i32>,
    n: usize,
) -> Result<Vec<i32>> {
    read_slice_i32_d(stream, src, n)
}

/// Public mapped-pinned write helper (`&[f32]` → device).
/// Per `feedback_no_htod_htoh_only_mapped_pinned`: regular un-pinned
/// host→device transfers are forbidden because the host slice isn't
/// page-locked. Use this for tests and any other caller outside this
/// module that needs a host→device write.
pub fn write_slice_f32_d_pub(
    stream: &Arc<CudaStream>,
    src: &[f32],
    dst: &mut CudaSlice<f32>,
) -> Result<()> {
    write_slice_f32_d(stream, src, dst)
}

/// Public mapped-pinned write helper (`&[i32]` → device).
/// See `write_slice_f32_d_pub`.
pub fn write_slice_i32_d_pub(
    stream: &Arc<CudaStream>,
    src: &[i32],
    dst: &mut CudaSlice<i32>,
) -> Result<()> {
    write_slice_i32_d(stream, src, dst)
}

/// Public mapped-pinned write helper for raw byte buffers
/// (`&[u8]` → device). Used for `PosFlat`-encoded test fixtures.
/// Stages through a `MappedI32Buffer` (4-byte alignment), so `src.len()`
/// MUST be a multiple of 4. The byte→i32 reinterpretation is
/// little-endian on x86_64 + L40S/H100 — matches the device's int
/// load semantics for the same byte sequence.
pub fn write_slice_u8_d_pub(
    stream: &Arc<CudaStream>,
    src: &[u8],
    dst: &mut CudaSlice<u8>,
) -> Result<()> {
    debug_assert!(dst.len() >= src.len());
    let n_bytes = src.len();
    if n_bytes == 0 {
        return Ok(());
    }
    assert_eq!(
        n_bytes % 4,
        0,
        "write_slice_u8_d_pub requires 4-byte-aligned length (got {n_bytes})"
    );
    let n_i32 = n_bytes / 4;
    let mut staging = unsafe { crate::pinned_mem::MappedI32Buffer::new(n_i32) }
        .map_err(|e| anyhow::anyhow!("write_slice_u8_d_pub staging: {e}"))?;
    // Reinterpret &[u8] as &[i32] for the staging copy.
    let host_i32 = staging.host_slice_mut();
    for (i, chunk) in src.chunks_exact(4).enumerate() {
        host_i32[i] = i32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
    }
    unsafe {
        let dst_ptr = dst.raw_ptr();
        raw_memcpy_dtod_async(
            dst_ptr,
            staging.dev_ptr,
            n_bytes,
            stream.cu_stream(),
        )
        .map_err(|e| anyhow::anyhow!("write_slice_u8_d_pub DtoD: {e:?}"))?;
        raw_stream_sync(stream.cu_stream())
            .map_err(|e| anyhow::anyhow!("write_slice_u8_d_pub sync: {e:?}"))?;
    }
    Ok(())
}

/// Public mapped-pinned read helper for raw byte buffers
/// (device → `Vec<u8>`). Staging via `MappedI32Buffer`; `n_bytes`
/// MUST be a multiple of 4. Counterpart to `write_slice_u8_d_pub`.
pub fn read_slice_u8_d_pub(
    stream: &Arc<CudaStream>,
    src: &CudaSlice<u8>,
    n_bytes: usize,
) -> Result<Vec<u8>> {
    debug_assert!(src.len() >= n_bytes);
    if n_bytes == 0 {
        return Ok(Vec::new());
    }
    assert_eq!(
        n_bytes % 4,
        0,
        "read_slice_u8_d_pub requires 4-byte-aligned length (got {n_bytes})"
    );
    let n_i32 = n_bytes / 4;
    let mut staging = unsafe { crate::pinned_mem::MappedI32Buffer::new(n_i32) }
        .map_err(|e| anyhow::anyhow!("read_slice_u8_d_pub staging: {e}"))?;
    unsafe {
        let src_ptr = src.raw_ptr();
        raw_memcpy_dtod_async(
            staging.dev_ptr,
            src_ptr,
            n_bytes,
            stream.cu_stream(),
        )
        .map_err(|e| anyhow::anyhow!("read_slice_u8_d_pub DtoD: {e:?}"))?;
        raw_stream_sync(stream.cu_stream())
            .map_err(|e| anyhow::anyhow!("read_slice_u8_d_pub sync: {e:?}"))?;
    }
    let mut out = Vec::with_capacity(n_bytes);
    for word in staging.host_slice_mut().iter() {
        out.extend_from_slice(&word.to_ne_bytes());
    }
    Ok(out)
}


pub fn read_slice_d_pub(
    stream: &Arc<CudaStream>,
    src: &CudaSlice<f32>,
    n: usize,
) -> Result<Vec<f32>> {
    read_slice_d(stream, src, n)
}

fn read_slice_i32_d(
    stream: &Arc<CudaStream>,
    src: &CudaSlice<i32>,
    n: usize,
) -> Result<Vec<i32>> {
    debug_assert!(src.len() >= n);
    if n == 0 {
        return Ok(Vec::new());
    }
    let mut staging = unsafe { MappedI32Buffer::new(n) }
        .map_err(|e| anyhow::anyhow!("read_slice_i32_d staging: {e}"))?;
    unsafe {
        let src_ptr = src.raw_ptr();
        raw_memcpy_dtod_async(
            staging.dev_ptr,
            src_ptr,
            n * std::mem::size_of::<i32>(),
            stream.cu_stream(),
        )
        .map_err(|e| anyhow::anyhow!("read_slice_i32_d DtoD: {e:?}"))?;
        raw_stream_sync(stream.cu_stream())
            .map_err(|e| anyhow::anyhow!("read_slice_i32_d sync: {e:?}"))?;
    }
    Ok(staging.host_slice_mut().to_vec())
}

/// Write a host `&[f32]` slice into a device buffer via mapped-pinned
/// staging + DtoD. The only permitted CPU→GPU path per
/// `feedback_no_htod_htoh_only_mapped_pinned` — `stream.memcpy_htod`
/// on a regular `&[T]` is forbidden (the source slice is not
/// page-locked, so the driver does an internal HtoD blocking copy).
/// Mapped-pinned staging keeps the host write zero-copy on the
/// device side.
fn write_slice_f32_d(
    stream: &Arc<CudaStream>,
    src: &[f32],
    dst: &mut CudaSlice<f32>,
) -> Result<()> {
    debug_assert!(dst.len() >= src.len());
    let n = src.len();
    if n == 0 {
        return Ok(());
    }
    let staging = unsafe { MappedF32Buffer::new(n) }
        .map_err(|e| anyhow::anyhow!("write_slice_f32_d staging: {e}"))?;
    staging.write_from_slice(src);
    unsafe {
        let dst_ptr = dst.raw_ptr();
        raw_memcpy_dtod_async(
            dst_ptr,
            staging.dev_ptr,
            n * std::mem::size_of::<f32>(),
            stream.cu_stream(),
        )
        .map_err(|e| anyhow::anyhow!("write_slice_f32_d DtoD: {e:?}"))?;
        raw_stream_sync(stream.cu_stream())
            .map_err(|e| anyhow::anyhow!("write_slice_f32_d sync: {e:?}"))?;
    }
    Ok(())
}

/// Write a host `&[i32]` slice into a device buffer via mapped-pinned
/// staging + DtoD. See `write_slice_f32_d` for rationale.
fn write_slice_i32_d(
    stream: &Arc<CudaStream>,
    src: &[i32],
    dst: &mut CudaSlice<i32>,
) -> Result<()> {
    debug_assert!(dst.len() >= src.len());
    let n = src.len();
    if n == 0 {
        return Ok(());
    }
    let mut staging = unsafe { MappedI32Buffer::new(n) }
        .map_err(|e| anyhow::anyhow!("write_slice_i32_d staging: {e}"))?;
    staging.host_slice_mut()[..n].copy_from_slice(src);
    unsafe {
        let dst_ptr = dst.raw_ptr();
        raw_memcpy_dtod_async(
            dst_ptr,
            staging.dev_ptr,
            n * std::mem::size_of::<i32>(),
            stream.cu_stream(),
        )
        .map_err(|e| anyhow::anyhow!("write_slice_i32_d DtoD: {e:?}"))?;
        raw_stream_sync(stream.cu_stream())
            .map_err(|e| anyhow::anyhow!("write_slice_i32_d sync: {e:?}"))?;
    }
    Ok(())
}

/// Phase E.3b helper: read `n` floats from a device buffer into a host
/// `Vec<f32>` via mapped-pinned staging + DtoD copy + stream sync. Used
/// by `step_with_lobsim` to pull Q-logits / V / π-logits to host for
/// Thompson sampling. Per `feedback_no_htod_htoh_only_mapped_pinned`:
/// the staging buffer is mapped-pinned, the device → staging copy uses
/// `memcpy_dtod_async`, and the host read happens after a stream sync.
fn read_slice_d(
    stream: &Arc<CudaStream>,
    src: &CudaSlice<f32>,
    n: usize,
) -> Result<Vec<f32>> {
    debug_assert_eq!(src.len(), n);
    let staging = unsafe { MappedF32Buffer::new(n) }
        .map_err(|e| anyhow::anyhow!("read_slice_d staging: {e}"))?;
    if n > 0 {
        unsafe {
            let src_ptr = src.raw_ptr();
            raw_memcpy_dtod_async(
                staging.dev_ptr,
                src_ptr,
                n * std::mem::size_of::<f32>(),
                stream.cu_stream(),
            )
            .map_err(|e| anyhow::anyhow!("read_slice_d DtoD: {e:?}"))?;
        }
    }
    unsafe {
        raw_stream_sync(stream.cu_stream())
            .map_err(|e| anyhow::anyhow!("read_slice_d sync: {e:?}"))?;
    }
    Ok(staging.read_all())
}

/// Generic mapped-pinned DtoH read helper.
///
/// Copies `dst.len()` records of `T` from the device-resident `src`
/// buffer into the caller-owned host slice `dst`. The transfer goes
/// through a freshly allocated `MappedRecordBuffer<T>` staging buffer
/// (host-mapped pinned memory + DtoD copy on `stream` + stream sync +
/// volatile host reads), so the project rule
/// `feedback_no_htod_htoh_only_mapped_pinned` is satisfied: there is
/// no raw `stream.memcpy_dtoh` call on a non-pinned host slice, and
/// therefore no driver-internal blocking HtoH bounce buffer.
///
/// Generic over any `T: Copy` (POD). Used by the determinism dump
/// methods (f32 / i32 / u32 / u8) and the per-step diag checksum
/// (f64). For the hot path, `read_slice_d` / `read_slice_i32_d` keep
/// their typed signatures to preserve their existing call sites.
pub fn read_slice_d_into<T: Copy>(
    stream: &Arc<CudaStream>,
    src: &CudaSlice<T>,
    dst: &mut [T],
) -> Result<()> {
    let n = dst.len();
    debug_assert!(src.len() >= n);
    if n == 0 {
        return Ok(());
    }
    let staging = unsafe { crate::pinned_mem::MappedRecordBuffer::<T>::new(n) }
        .map_err(|e| anyhow::anyhow!("read_slice_d_into staging: {e}"))?;
    unsafe {
        let src_ptr = src.raw_ptr();
        raw_memcpy_dtod_async(
            staging.dev_ptr,
            src_ptr,
            n * std::mem::size_of::<T>(),
            stream.cu_stream(),
        )
        .map_err(|e| anyhow::anyhow!("read_slice_d_into DtoD: {e:?}"))?;
        raw_stream_sync(stream.cu_stream())
            .map_err(|e| anyhow::anyhow!("read_slice_d_into sync: {e:?}"))?;
    }
    // Volatile host reads — mapped-pinned coherence requires the
    // compiler not reorder these past the stream-sync barrier above.
    unsafe {
        for i in 0..n {
            dst[i] = std::ptr::read_volatile(staging.host_ptr.add(i));
        }
    }
    Ok(())
}

// Phase R7b: `argmax_f32` deleted — R4's `argmax_expected_q` kernel
// is the canonical Bellman-target argmax now.

