//! Block-per-backtest LOB simulator.
//!
//! One CUDA block = one parallel backtest. The simulator owns per-backtest
//! book state (and, in later commits, orders / pos / ISV-Kelly state +
//! mapped-pinned audit & trade-log rings).
//!
//! See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md §2 + §5b.

// cudarc kernel launches are inherently `unsafe`: the driver API has no way
// to type-check kernel args against the .cubin's signature. Per
// pearl_no_host_branches_in_captured_graph + the surrounding GPU-contract
// pearls, all launches are pre-compiled and the args are matched by hand in
// review. The workspace-wide `-W unsafe-code` lint is therefore noise here.
#![allow(unsafe_code)]

mod batched_config;
pub use batched_config::{BatchedSimConfig, ResolvedSimVariant, UniformSimParams};

use anyhow::{Context, Result};
use cudarc::driver::{CudaSlice, CudaStream, DevicePtr, LaunchConfig, PushKernelArg};
use ml_alpha::pinned_mem::MappedF32Buffer;
use ml_core::device::MlDevice;
use std::sync::Arc;

use crate::lob::{BookFlat, PosFlat, BOOK_LEVELS};

const BOOK_FIELDS: usize = 4; // bid_px, bid_sz, ask_px, ask_sz

/// Precompiled cubins embedded at compile time.
const BOOK_UPDATE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/book_update.cubin"));
const ORDER_MATCH_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/order_match.cubin"));
const PNL_TRACK_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/pnl_track.cubin"));
const DECISION_POLICY_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/decision_policy.cubin"));
const RESTING_ORDERS_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/resting_orders.cubin"));

const N_HORIZONS: usize = ml_alpha::heads::N_HORIZONS;
const ISV_KELLY_STATE_BYTES: usize = 24;  // matches lob_state.cuh::IsvKellyState

/// Per-backtest NaN instrumentation counters (S1.20).
///
/// Incremented by the CUDA kernels at specific NaN-producing arithmetic sites.
/// Read via `LobSimCuda::read_nan_counters` at end-of-smoke to identify which
/// code path produces the residual 97 zero + 85 i32::MAX sentinel trade records.
pub struct NanCounters {
    /// avg_px = total_cost/filled_lots was NaN/Inf in apply_fill_to_pos.
    pub nan_avg_px: Vec<u32>,
    /// realised = (avg_px - vwap_entry) * dir * unwind was NaN/Inf.
    pub nan_realised: Vec<u32>,
    /// pos.realized_pnl became non-finite after += or -= in apply_fill_to_pos.
    pub nan_realized_pnl: Vec<u32>,
    /// pnl_track open branch saw vwap_entry == 0.
    pub zero_vwap_at_open: Vec<u32>,
    /// pnl_track open branch saw |vwap_entry| > 21M or NaN/Inf.
    pub saturated_vwap_at_open: Vec<u32>,
    /// pnl_track close branch defensive clamp fired.
    pub defensive_exit_clamp: Vec<u32>,
}

/// Per-backtest per-vwap-write-site NaN instrumentation counters (S1.21).
///
/// Finer-grained than NanCounters: each counter corresponds to exactly one
/// vwap_entry write site inside apply_fill_to_pos (or the session-gap path).
/// Read via `LobSimCuda::read_nan_counters_v2`.
///
/// Path ids stored in last_bad_path:
///   1 = open-from-flat (line 131)
///   2 = scale-in zero  (line 136)
///   3 = scale-in huge  (line 136)
///   4 = flip-beyond zero (line 150)
///   5 = flip-beyond huge (line 150)
///   6 = session-gap saw already-corrupt vwap_entry
pub struct NanCountersV2 {
    /// apply_fill_to_pos open-from-flat branch wrote vwap_entry == 0.
    pub vwap_zero_open_flat: Vec<u32>,
    /// apply_fill_to_pos open-from-flat branch wrote |vwap_entry| > 21M or non-finite.
    pub vwap_huge_open_flat: Vec<u32>,
    /// apply_fill_to_pos scale-in branch wrote vwap_entry == 0.
    pub vwap_zero_scale_in: Vec<u32>,
    /// apply_fill_to_pos scale-in branch wrote |vwap_entry| > 21M or non-finite.
    pub vwap_huge_scale_in: Vec<u32>,
    /// apply_fill_to_pos flip-beyond branch wrote vwap_entry == 0.
    pub vwap_zero_flip: Vec<u32>,
    /// apply_fill_to_pos flip-beyond branch wrote |vwap_entry| > 21M or non-finite.
    pub vwap_huge_flip: Vec<u32>,
    /// session-gap force-close saw pos.vwap_entry already corrupt (zero / non-finite / > 21M).
    pub vwap_session_gap_was_bad: Vec<u32>,
    /// Last bad vwap_entry value observed (per backtest; 0.0 if none).
    pub last_bad_vwap: Vec<f32>,
    /// Path id of the last bad vwap_entry write (per backtest; 0 if none).
    pub last_bad_path: Vec<u32>,
}

/// CRT.diag: end-of-run empirical measurement battery dump.
///
/// All vectors are flat per-backtest layouts; the harness aggregates
/// across backtests for the log summary. See `LobSimCuda::read_diagnostics`
/// for the read-back path (single batched DtoV at smoke termination).
pub struct CrtDiagnostics {
    /// Per-horizon flip count. Layout: `[backtest * N_HORIZONS + h]`.
    pub flip_count: Vec<u32>,
    /// Per-horizon cumulative run length over completed runs (u64 to avoid
    /// overflow at multi-million-event smoke). Layout: `[backtest * N_HORIZONS + h]`.
    pub sum_run_length: Vec<u64>,
    /// Per-horizon 5-bucket log-scale run-length histogram.
    /// Layout: `[(backtest * N_HORIZONS + h) * 5 + bucket]`. Buckets:
    /// 0=1-9, 1=10-99, 2=100-999, 3=1k-9.9k, 4=10k+.
    pub run_length_hist: Vec<u32>,
    /// 10-bucket histogram of smoothed conviction at decision time.
    /// Layout: `[backtest * 10 + bucket]`. Bucket k = [k/10, (k+1)/10).
    pub conv_hist: Vec<u32>,
    /// 6-bucket log-scale hold-time histogram at trade close.
    /// Layout: `[backtest * 6 + bucket]`. Buckets:
    /// 0=<1s, 1=1-10s, 2=10-60s, 3=1-10m, 4=10-60m, 5=>1h.
    pub hold_hist: Vec<u32>,
    /// Trade count per entry-conviction bucket. Layout: `[backtest * 10 + bucket]`.
    pub outcome_n: Vec<u32>,
    /// Cumulative realized PnL per entry-conviction bucket (price-units × lots,
    /// pre USD-fp conversion). Layout: `[backtest * 10 + bucket]`.
    pub outcome_sum_pnl: Vec<f32>,
    /// Win count per entry-conviction bucket (segment_realized > 0).
    /// Layout: `[backtest * 10 + bucket]`.
    pub outcome_n_wins: Vec<u32>,
    /// CRT.diag.2 Group E: per-horizon SMOOTHED-direction flip count.
    /// Layout: `[backtest * N_HORIZONS + h]`. Compare head-to-head with
    /// `flip_count` (which counts RAW alpha_probs direction flips).
    pub smoothed_flip_count: Vec<u32>,
    /// CRT.diag.2 Group E: per-horizon cumulative SMOOTHED run length over
    /// completed runs (u64 to avoid overflow at multi-million-event smoke).
    /// Layout: `[backtest * N_HORIZONS + h]`.
    pub smoothed_sum_run_length: Vec<u64>,
}

/// S2.1/S2.2: max_hold enforcement diagnostic counters.
///
/// After S2.2, max_hold enforcement lives in `resting_orders_step` (event-rate).
/// The decision-rate max_hold code in `stop_check_isv` was removed. Two counters
/// remain as ongoing generics:
///   - `mh_kernel_calls`: every `stop_check_isv` entry with position_lots != 0
///     (SL/trail are being evaluated). Should be non-zero whenever a position is
///     open and a decision fires.
///   - `mh_force_flat_seen_by_seed`: every `seed_inflight_limits_batched` entry
///     that reads market_targets[b*2+0] == 3 (force-flat from SL or trail).
pub struct NanCountersMaxHold {
    /// Every stop_check_isv entry with position_lots != 0.
    pub mh_kernel_calls: Vec<u32>,
    /// seed_inflight_limits_batched read market_targets[b*2+0] == 3.
    pub mh_force_flat_seen_by_seed: Vec<u32>,
}

pub struct LobSimCuda {
    n_backtests: usize,
    stream: Arc<CudaStream>,
    book_update_fn: cudarc::driver::CudaFunction,
    submit_market_fn: cudarc::driver::CudaFunction,
    pnl_track_fn: cudarc::driver::CudaFunction,
    decision_fn: cudarc::driver::CudaFunction,
    decision_program_fn: cudarc::driver::CudaFunction,
    isv_kelly_update_fn: cudarc::driver::CudaFunction,
    /// CRT Phase A0.5 corrective: tiny kernel that writes one max_conv
    /// f32 per decision into `convictions_d` (read end-of-run for the
    /// percentile side-channel). Replaces the host-side max-of-5 loop the
    /// harness used to run on `self.last_probs` after every decision.
    record_max_conviction_fn: cudarc::driver::CudaFunction,
    resting_orders_fn: cudarc::driver::CudaFunction,
    seed_limit_fn: cudarc::driver::CudaFunction,
    /// P2: GPU-side seed of in-flight Limit slots from market_targets[b].
    /// Replaces the host roundtrip loop in dispatch_latent_market_orders.
    seed_inflight_limits_fn: cudarc::driver::CudaFunction,
    /// P3: GPU-side snapshot of pre-submit Pos state + close-transition detection.
    /// Replace the host snapshot loop + host close-detect loop at the latency path.
    snapshot_pos_state_fn: cudarc::driver::CudaFunction,
    detect_close_fn: cudarc::driver::CudaFunction,
    seed_stop_fn: cudarc::driver::CudaFunction,

    books_d: CudaSlice<f32>,
    pos_d: CudaSlice<u8>,
    bid_px_d: CudaSlice<f32>,
    bid_sz_d: CudaSlice<f32>,
    ask_px_d: CudaSlice<f32>,
    ask_sz_d: CudaSlice<f32>,
    market_targets_d: CudaSlice<i32>,

    // Trade-log + open-trade-state buffers (C6).
    open_trade_state_d: CudaSlice<u8>,    // [n_backtests * 24]
    trade_log_d: CudaSlice<u8>,           // [n_backtests * TRADE_LOG_CAP * 40]
    trade_log_head_d: CudaSlice<u32>,     // [n_backtests]

    // Decision + ISV-Kelly buffers (C7).
    isv_kelly_d: CudaSlice<u8>,             // [n_backtests * 5 * 24]
    alpha_probs_d: CudaSlice<f32>,          // [N_HORIZONS] — broadcast
    /// CRT Phase A0.5 corrective: on-device circular conviction history.
    /// One f32 per decision written by `record_max_conviction_to_slot`.
    /// Capacity sized at construction (`MAX_DECISIONS`). The host owns the
    /// write index and increments it once per `record_max_conviction`
    /// call. End-of-run, `read_convictions(n)` DtoVs the populated prefix
    /// in a single sync — matching the lifecycle of the trade_log / pnl
    /// curve reads that already happen after `run()`.
    convictions_d: CudaSlice<f32>,
    /// Capacity of `convictions_d` in elements. Decisions beyond this are
    /// silently dropped by the recording kernel (bounds-check inside the
    /// kernel); the host counter still reflects the real decision count.
    convictions_capacity: usize,
    open_horizon_mask_d: CudaSlice<u32>,    // [n_backtests] — entry attribution
    closed_horizon_mask_d: CudaSlice<u32>,  // [n_backtests] — close-time snapshot
    realised_return_d: CudaSlice<f32>,      // [n_backtests] — per-block close-trade return

    // Resting orders (C11 follow-up).
    orders_d: CudaSlice<u8>,                // [n_backtests * ORDERS_BYTES]
    overflow_flag_d: CudaSlice<u32>,        // [1] — set by seed kernels on slot-in-use error
    // Audit ring for OrderEvent emission (deferred from C2; needed by C11).
    audit_d: CudaSlice<u8>,                 // [n_backtests * RING_CAP_EVENTS * ORDER_EVENT_BYTES]
    audit_head_d: CudaSlice<u32>,           // [n_backtests]

    // Bytecode program upload (C13 — per-backtest custom compositions).
    program_table_d: CudaSlice<u8>,         // [n_backtests * MAX_PROGRAM_INSTRUCTIONS * INSTRUCTION_BYTES]
    program_lens_d:  CudaSlice<u32>,        // [n_backtests]
    regimes_d:       CudaSlice<u32>,        // [n_backtests] — host-set current regime id

    // P1: per-backtest sim parameter buffers. Uploaded by step_decision_with_latency
    // from a host-side BatchedSimConfig. Held as Sim fields to avoid per-call alloc.
    target_annual_vol_units_d:  CudaSlice<f32>,    // [n_backtests]
    annualisation_factor_d:     CudaSlice<f32>,    // [n_backtests]
    max_lots_d:                 CudaSlice<i32>,    // [n_backtests] — stored as i32 to match kernel arg
    latency_ns_d:               CudaSlice<u32>,    // [n_backtests] — used by P2 kernel
    kelly_frac_floor_d:         CudaSlice<f32>,    // [n_backtests]
    sharpe_weight_floor_d:      CudaSlice<f32>,    // [n_backtests]

    // P3: pre-submit Pos snapshot buffers for GPU close-transition detection.
    prev_pos_lots_d:            CudaSlice<i32>,    // [n_backtests]
    prev_realized_pnl_d:        CudaSlice<f32>,    // [n_backtests]
    prev_open_horizon_mask_d:   CudaSlice<u32>,    // [n_backtests]

    // P4: threshold gate + per-fill cost integration.
    threshold_d:                CudaSlice<f32>,    // [n_backtests]
    cost_per_lot_per_side_d:    CudaSlice<f32>,    // [n_backtests]
    total_fees_per_b_d:         CudaSlice<f32>,    // [n_backtests] — accumulator

    // Stop-controller state slots (ISV-driven stop controller).
    prev_mid_d:      CudaSlice<f32>,    // [n_backtests] — previous-event mid, sentinel 0 = first-obs bootstrap
    atr_mid_ema_d:   CudaSlice<f32>,    // [n_backtests] — Wiener-α=0.4 EMA on |Δmid|; floor for §5 SL/trail
    trail_hwm_d:     CudaSlice<f32>,    // [n_backtests] — per-position unrealized-P&L-per-lot high-water-mark

    // Follow-up gp74n: max-hold force-close per-backtest config.
    max_hold_ns_d:   CudaSlice<u64>,    // [n_backtests] — 0 = disabled

    // Bug C-b (smoke v74v4 2026-05-20): corrupt-top-of-book skip counter.
    snapshots_skipped_d: CudaSlice<u32>, // [n_backtests] — incremented each time apply_snapshot skips a corrupt snapshot

    // Fix 3 (smoke stx9p 2026-05-20): session-gap force-close. Tracks the
    // previous event ts per backtest so resting_orders_step can detect halts.
    last_event_ts_d: CudaSlice<u64>,    // [n_backtests] — 0 = sentinel (first event)

    // S1.19: parameterized price-range sanitization. Uploaded once via
    // upload_price_range() before the first apply_snapshot call. Defaults
    // 0.0 / f32::INFINITY mean no-check (backward compatible).
    min_reasonable_px_d: CudaSlice<f32>, // [n_backtests]
    max_reasonable_px_d: CudaSlice<f32>, // [n_backtests]

    // S1.20: NaN instrumentation slots. Per-backtest counters incremented
    // at kernel-internal arithmetic sites that can produce NaN/Inf/sentinel
    // values in trade records. Read at end-of-smoke to identify which path
    // produces the 97 zero + 85 i32::MAX sentinels.
    nan_avg_px_d:               CudaSlice<u32>,    // [n_backtests]
    nan_realised_d:             CudaSlice<u32>,    // [n_backtests]
    nan_realized_pnl_d:         CudaSlice<u32>,    // [n_backtests]
    zero_vwap_at_open_d:        CudaSlice<u32>,    // [n_backtests]
    saturated_vwap_at_open_d:   CudaSlice<u32>,    // [n_backtests]
    defensive_exit_clamp_d:     CudaSlice<u32>,    // [n_backtests]
    // S1.21: per-vwap-write-site counters + last-bad-vwap capture.
    vwap_zero_open_flat_d:      CudaSlice<u32>,    // [n_backtests]
    vwap_huge_open_flat_d:      CudaSlice<u32>,    // [n_backtests]
    vwap_zero_scale_in_d:       CudaSlice<u32>,    // [n_backtests]
    vwap_huge_scale_in_d:       CudaSlice<u32>,    // [n_backtests]
    vwap_zero_flip_d:           CudaSlice<u32>,    // [n_backtests]
    vwap_huge_flip_d:           CudaSlice<u32>,    // [n_backtests]
    vwap_session_gap_was_bad_d: CudaSlice<u32>,    // [n_backtests]
    last_bad_vwap_d:            CudaSlice<f32>,    // [n_backtests]
    last_bad_path_d:            CudaSlice<u32>,    // [n_backtests]

    // S2.1/S2.2: max_hold diagnostic counters (2 remain after S2.2 prune).
    mh_kernel_calls_d:            CudaSlice<u32>,  // [n_backtests]
    mh_force_flat_seen_by_seed_d: CudaSlice<u32>,  // [n_backtests]

    // Anti-calibration fix (2026-05-22): Wiener-α adaptive EMA on the
    // SIGNED multi-horizon conviction scalar. The prior magnitude-only
    // EMA produced higher-conviction-→-more-negative-PnL anti-calibration
    // because the sizing path multiplied `sign(instantaneous) × EMA(|x|)`
    // — magnitude and direction came from different signals. Now both
    // come from the same smoothed signed EMA: when per-horizon directions
    // disagree event-to-event, EMA(signed_x) → 0 → |EMA| → 0 → size 0.
    // All three slots start at zero (sentinel = "no observation yet") so
    // the first decision-kernel call replaces them directly per
    // pearl_first_observation_bootstrap. Updated in-place by both
    // decision_policy_default and decision_policy_program.
    conv_signed_ema_d:              CudaSlice<f32>,  // [n_backtests] — smoothed signed conviction ∈ [-1, +1]
    conv_signed_diff_var_ema_d:     CudaSlice<f32>,  // [n_backtests] — 2nd-order EMA of (new−prev)²
    conv_signed_sample_var_ema_d:   CudaSlice<f32>,  // [n_backtests] — 2nd-order EMA of x² (x is signed; x² is non-negative)

    // CRT.1 C1.3: no-trade band. seed_inflight_limits_batched skips seeding
    // when |target_signed − effective| < delta_floor[b]. Uploaded once per
    // run via step_decision_with_latency from BatchedSimConfig (config-upload
    // block). Read by the kernel on every event; zero device-to-host traffic
    // on the per-event hot path.
    pub(crate) delta_floor_d:       CudaSlice<f32>,  // [n_backtests] — no-trade band in lots

    // CRT.diag: empirical measurement battery (observe-only, end-of-run dump).
    // All counters allocated via alloc_zeros and incremented kernel-side; no
    // host roundtrip per event. `read_diagnostics()` materialises them at
    // smoke termination via a single batch of memcpy_dtov calls.
    //
    // Group A — per-horizon signal persistence:
    pub(crate) diag_flip_count_d:           CudaSlice<u32>,  // [n_backtests * N_HORIZONS]
    pub(crate) diag_current_run_length_d:   CudaSlice<u32>,  // [n_backtests * N_HORIZONS]
    pub(crate) diag_sum_run_length_d:       CudaSlice<u64>,  // [n_backtests * N_HORIZONS]
    pub(crate) diag_run_length_hist_d:      CudaSlice<u32>,  // [n_backtests * N_HORIZONS * 5]
    pub(crate) diag_prev_dir_signed_d:      CudaSlice<i8>,   // [n_backtests * N_HORIZONS]
    // Group B — conviction-EMA histogram:
    pub(crate) diag_conv_hist_d:            CudaSlice<u32>,  // [n_backtests * 10]
    // Group C — hold-time histogram at trade close:
    pub(crate) diag_hold_hist_d:            CudaSlice<u32>,  // [n_backtests * 6]
    // Group D — outcome by entry-conviction:
    pub(crate) diag_outcome_n_d:            CudaSlice<u32>,  // [n_backtests * 10]
    pub(crate) diag_outcome_sum_pnl_d:      CudaSlice<f32>,  // [n_backtests * 10]
    pub(crate) diag_outcome_n_wins_d:       CudaSlice<u32>,  // [n_backtests * 10]

    // CRT.diag.2: per-horizon Wiener-α EMA on raw alpha_probs. Smoothed
    // alpha enters the multi-horizon §4.4 conviction formula instead of
    // the raw broadcast values — hypothesis test for whether per-event
    // output noise is hiding a slower signal. All zero-init: sentinel "no
    // observation yet" so the first decision-kernel call bootstraps per
    // pearl_first_observation_bootstrap. Floor on Wiener α = 0.1 (stronger
    // smoothing than the 0.4 output-side floor; testing input vs output
    // smoothing differ). Touched by both decision_policy_default and
    // decision_policy_program every event; no host roundtrip.
    pub(crate) alpha_ema_per_b_per_h_d:        CudaSlice<f32>,  // [n_backtests * N_HORIZONS]
    pub(crate) alpha_diff_var_per_b_per_h_d:   CudaSlice<f32>,  // [n_backtests * N_HORIZONS]
    pub(crate) alpha_sample_var_per_b_per_h_d: CudaSlice<f32>,  // [n_backtests * N_HORIZONS]

    // CRT.diag.2 Group E: per-horizon SMOOTHED direction-flip + mean-run-length.
    // Mirrors Group A but reading alpha_smoothed[h] instead of alpha_probs[h].
    // Skipping the 5-bucket histogram (Group A keeps it). Head-to-head
    // comparison: raw flip rate vs smoothed flip rate, per horizon.
    pub(crate) diag_smoothed_flip_count_d:         CudaSlice<u32>,  // [n_backtests * N_HORIZONS]
    pub(crate) diag_smoothed_current_run_length_d: CudaSlice<u32>,  // [n_backtests * N_HORIZONS]
    pub(crate) diag_smoothed_sum_run_length_d:     CudaSlice<u64>,  // [n_backtests * N_HORIZONS]
    pub(crate) diag_smoothed_prev_dir_d:           CudaSlice<i8>,   // [n_backtests * N_HORIZONS]

    // Phase 0 reward-redesign (2026-06-02): per-batch authoritative USD pnl
    // delta written by pnl_track_step's close branch. Buffer is zeroed
    // host-side at the start of every `step_pnl_track` call; a non-zero
    // value at index b means batch b closed a trade during this step's
    // pnl_track pass. The trainer reads this per-step via
    // `read_slice_d_into<f32>` and emits both the per-step sum
    // (`trading.realized_pnl_usd_delta`) and a cumulative
    // (`trading.realized_pnl_usd_cum`) in the diag JSONL. Same source as
    // `eval_summary.total_pnl_usd` (realised_pnl_usd_fp / 100), making the
    // diag's pnl cross-source consistent with eval_summary within $0.01.
    pub(crate) pnl_step_close_usd_d: CudaSlice<f32>,  // [n_backtests]
}

impl LobSimCuda {
    pub fn new(n_backtests: usize, dev: &MlDevice) -> Result<Self> {
        anyhow::ensure!(n_backtests > 0, "n_backtests must be > 0");
        let stream = dev.cuda_stream().context("cuda_stream")?.clone();
        let ctx = dev.cuda_context().context("cuda_context")?;

        let book_module = ctx
            .load_cubin(BOOK_UPDATE_CUBIN.to_vec())
            .context("load book_update cubin")?;
        let book_update_fn = book_module
            .load_function("book_update_apply_snapshot")
            .context("load book_update_apply_snapshot")?;
        let match_module = ctx
            .load_cubin(ORDER_MATCH_CUBIN.to_vec())
            .context("load order_match cubin")?;
        let submit_market_fn = match_module
            .load_function("submit_market_immediate")
            .context("load submit_market_immediate")?;
        let pnl_track_module = ctx
            .load_cubin(PNL_TRACK_CUBIN.to_vec())
            .context("load pnl_track cubin")?;
        let pnl_track_fn = pnl_track_module
            .load_function("pnl_track_step")
            .context("load pnl_track_step")?;
        let snapshot_pos_state_fn = pnl_track_module
            .load_function("snapshot_pos_state")
            .context("load snapshot_pos_state")?;
        let detect_close_fn = pnl_track_module
            .load_function("detect_close_transitions_batched")
            .context("load detect_close_transitions_batched")?;
        let decision_module = ctx
            .load_cubin(DECISION_POLICY_CUBIN.to_vec())
            .context("load decision_policy cubin")?;
        let decision_fn = decision_module
            .load_function("decision_policy_default")
            .context("load decision_policy_default")?;
        let decision_program_fn = decision_module
            .load_function("decision_policy_program")
            .context("load decision_policy_program")?;
        let isv_kelly_update_fn = decision_module
            .load_function("isv_kelly_update_on_close")
            .context("load isv_kelly_update_on_close")?;
        let record_max_conviction_fn = decision_module
            .load_function("record_max_conviction_to_slot")
            .context("load record_max_conviction_to_slot")?;
        let resting_module = ctx
            .load_cubin(RESTING_ORDERS_CUBIN.to_vec())
            .context("load resting_orders cubin")?;
        let resting_orders_fn = resting_module
            .load_function("resting_orders_step")
            .context("load resting_orders_step")?;
        let seed_limit_fn = resting_module
            .load_function("seed_limit_slot")
            .context("load seed_limit_slot")?;
        let seed_inflight_limits_fn = resting_module
            .load_function("seed_inflight_limits_batched")
            .context("load seed_inflight_limits_batched")?;
        let seed_stop_fn = resting_module
            .load_function("seed_stop_slot")
            .context("load seed_stop_slot")?;

        let books_d = stream
            .alloc_zeros::<f32>(n_backtests * BOOK_FIELDS * BOOK_LEVELS)
            .context("alloc books_d")?;
        let pos_d = stream
            .alloc_zeros::<u8>(n_backtests * std::mem::size_of::<PosFlat>())
            .context("alloc pos_d")?;
        let bid_px_d = stream.alloc_zeros::<f32>(BOOK_LEVELS).context("bid_px alloc")?;
        let bid_sz_d = stream.alloc_zeros::<f32>(BOOK_LEVELS).context("bid_sz alloc")?;
        let ask_px_d = stream.alloc_zeros::<f32>(BOOK_LEVELS).context("ask_px alloc")?;
        let ask_sz_d = stream.alloc_zeros::<f32>(BOOK_LEVELS).context("ask_sz alloc")?;
        let market_targets_d = stream
            .alloc_zeros::<i32>(n_backtests * 2)
            .context("alloc market_targets_d")?;

        let open_trade_state_d = stream
            .alloc_zeros::<u8>(n_backtests * crate::lob::OPEN_TRADE_STATE_BYTES)
            .context("alloc open_trade_state_d")?;
        let trade_log_d = stream
            .alloc_zeros::<u8>(n_backtests * crate::lob::TRADE_LOG_CAP * crate::lob::TRADE_RECORD_BYTES)
            .context("alloc trade_log_d")?;
        let trade_log_head_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc trade_log_head_d")?;

        let isv_kelly_d = stream
            .alloc_zeros::<u8>(n_backtests * N_HORIZONS * ISV_KELLY_STATE_BYTES)
            .context("alloc isv_kelly_d")?;
        let alpha_probs_d = stream
            .alloc_zeros::<f32>(N_HORIZONS)
            .context("alloc alpha_probs_d")?;
        let open_horizon_mask_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc open_horizon_mask_d")?;
        let closed_horizon_mask_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc closed_horizon_mask_d")?;
        let realised_return_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc realised_return_d")?;

        let orders_d = stream
            .alloc_zeros::<u8>(n_backtests * crate::lob::ORDERS_BYTES)
            .context("alloc orders_d")?;
        let overflow_flag_d = stream
            .alloc_zeros::<u32>(1)
            .context("alloc overflow_flag_d")?;
        let audit_d = stream
            .alloc_zeros::<u8>(n_backtests * crate::lob::RING_CAP_EVENTS * crate::lob::ORDER_EVENT_BYTES)
            .context("alloc audit_d")?;
        let audit_head_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc audit_head_d")?;

        let program_table_d = stream
            .alloc_zeros::<u8>(n_backtests * crate::lob::MAX_PROGRAM_INSTRUCTIONS * crate::lob::INSTRUCTION_BYTES)
            .context("alloc program_table_d")?;
        let program_lens_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc program_lens_d")?;
        let regimes_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc regimes_d")?;

        // P1: per-backtest sim parameter device buffers (uploaded each
        // step_decision_with_latency from BatchedSimConfig).
        let target_annual_vol_units_d = stream.alloc_zeros::<f32>(n_backtests)
            .context("alloc target_annual_vol_units_d")?;
        let annualisation_factor_d    = stream.alloc_zeros::<f32>(n_backtests)
            .context("alloc annualisation_factor_d")?;
        let max_lots_d                = stream.alloc_zeros::<i32>(n_backtests)
            .context("alloc max_lots_d")?;
        let latency_ns_d              = stream.alloc_zeros::<u32>(n_backtests)
            .context("alloc latency_ns_d")?;
        let kelly_frac_floor_d        = stream.alloc_zeros::<f32>(n_backtests)
            .context("alloc kelly_frac_floor_d")?;
        let sharpe_weight_floor_d     = stream.alloc_zeros::<f32>(n_backtests)
            .context("alloc sharpe_weight_floor_d")?;

        // P3: pre-submit Pos snapshot buffers for GPU close-transition detection.
        let prev_pos_lots_d         = stream.alloc_zeros::<i32>(n_backtests)
            .context("alloc prev_pos_lots_d")?;
        let prev_realized_pnl_d     = stream.alloc_zeros::<f32>(n_backtests)
            .context("alloc prev_realized_pnl_d")?;
        let prev_open_horizon_mask_d = stream.alloc_zeros::<u32>(n_backtests)
            .context("alloc prev_open_horizon_mask_d")?;
        // P4: threshold + cost + fees accumulator.
        let threshold_d              = stream.alloc_zeros::<f32>(n_backtests)
            .context("alloc threshold_d")?;
        let cost_per_lot_per_side_d  = stream.alloc_zeros::<f32>(n_backtests)
            .context("alloc cost_per_lot_per_side_d")?;
        let total_fees_per_b_d       = stream.alloc_zeros::<f32>(n_backtests)
            .context("alloc total_fees_per_b_d")?;

        let prev_mid_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc prev_mid_d")?;
        let atr_mid_ema_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc atr_mid_ema_d")?;
        let trail_hwm_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc trail_hwm_d")?;
        let max_hold_ns_d = stream
            .alloc_zeros::<u64>(n_backtests)
            .context("alloc max_hold_ns_d")?;
        let snapshots_skipped_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc snapshots_skipped_d")?;
        let last_event_ts_d = stream
            .alloc_zeros::<u64>(n_backtests)
            .context("alloc last_event_ts_d")?;

        // S1.19: price-range device buffers. Default to no-check: min=0.0, max=+inf.
        // Callers invoke upload_price_range() once before the first apply_snapshot.
        let min_reasonable_px_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc min_reasonable_px_d")?;
        // Initialize max_reasonable_px_d to f32::INFINITY (all bytes 0x7F80_0000).
        let max_reasonable_px_init = vec![f32::INFINITY; n_backtests];
        let mut max_reasonable_px_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc max_reasonable_px_d")?;
        stream.memcpy_htod(&max_reasonable_px_init, &mut max_reasonable_px_d)
            .context("init max_reasonable_px_d")?;

        // S1.20: NaN instrumentation counters, all zero-initialized.
        let nan_avg_px_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc nan_avg_px_d")?;
        let nan_realised_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc nan_realised_d")?;
        let nan_realized_pnl_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc nan_realized_pnl_d")?;
        let zero_vwap_at_open_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc zero_vwap_at_open_d")?;
        let saturated_vwap_at_open_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc saturated_vwap_at_open_d")?;
        let defensive_exit_clamp_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc defensive_exit_clamp_d")?;
        // S1.21: per-vwap-write-site counters + last-bad-vwap capture.
        let vwap_zero_open_flat_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc vwap_zero_open_flat_d")?;
        let vwap_huge_open_flat_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc vwap_huge_open_flat_d")?;
        let vwap_zero_scale_in_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc vwap_zero_scale_in_d")?;
        let vwap_huge_scale_in_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc vwap_huge_scale_in_d")?;
        let vwap_zero_flip_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc vwap_zero_flip_d")?;
        let vwap_huge_flip_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc vwap_huge_flip_d")?;
        let vwap_session_gap_was_bad_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc vwap_session_gap_was_bad_d")?;
        let last_bad_vwap_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc last_bad_vwap_d")?;
        let last_bad_path_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc last_bad_path_d")?;

        // S2.1/S2.2: max_hold diagnostic counters (2 remain after S2.2 prune).
        let mh_kernel_calls_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc mh_kernel_calls_d")?;
        let mh_force_flat_seen_by_seed_d = stream
            .alloc_zeros::<u32>(n_backtests)
            .context("alloc mh_force_flat_seen_by_seed_d")?;

        // Anti-calibration fix (2026-05-22): SIGNED conviction-EMA state.
        // Zero-init means "first observation pending" — the decision kernel
        // branches on prev_ema == 0 to bootstrap directly (no warmup zero-
        // bias). The fact that 0.0 is also a legitimate near-disagreement
        // outcome is harmless: a first observation that lands at exactly
        // zero produces the same bootstrap → zero outcome regardless.
        let conv_signed_ema_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc conv_signed_ema_d")?;
        let conv_signed_diff_var_ema_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc conv_signed_diff_var_ema_d")?;
        let conv_signed_sample_var_ema_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc conv_signed_sample_var_ema_d")?;

        // CRT.1 C1.3: no-trade band. Uploaded from BatchedSimConfig in the
        // config-upload block of step_decision_with_latency. Default zero
        // disables the band until the first config upload sets delta_floor.
        let delta_floor_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc delta_floor_d")?;

        // CRT.diag: empirical measurement battery (observe-only). Per-backtest
        // memory footprint ≈ 4*(4+4+8+5*4)+10*4+6*4+10*(4+4+4) = 144+40+24+120
        // = 328 bytes. Negligible vs other state. All counters zero-init.
        let diag_flip_count_d = stream
            .alloc_zeros::<u32>(n_backtests * N_HORIZONS)
            .context("alloc diag_flip_count_d")?;
        let diag_current_run_length_d = stream
            .alloc_zeros::<u32>(n_backtests * N_HORIZONS)
            .context("alloc diag_current_run_length_d")?;
        let diag_sum_run_length_d = stream
            .alloc_zeros::<u64>(n_backtests * N_HORIZONS)
            .context("alloc diag_sum_run_length_d")?;
        let diag_run_length_hist_d = stream
            .alloc_zeros::<u32>(n_backtests * N_HORIZONS * 5)
            .context("alloc diag_run_length_hist_d")?;
        let diag_prev_dir_signed_d = stream
            .alloc_zeros::<i8>(n_backtests * N_HORIZONS)
            .context("alloc diag_prev_dir_signed_d")?;
        let diag_conv_hist_d = stream
            .alloc_zeros::<u32>(n_backtests * 10)
            .context("alloc diag_conv_hist_d")?;
        let diag_hold_hist_d = stream
            .alloc_zeros::<u32>(n_backtests * 6)
            .context("alloc diag_hold_hist_d")?;
        let diag_outcome_n_d = stream
            .alloc_zeros::<u32>(n_backtests * 10)
            .context("alloc diag_outcome_n_d")?;
        let diag_outcome_sum_pnl_d = stream
            .alloc_zeros::<f32>(n_backtests * 10)
            .context("alloc diag_outcome_sum_pnl_d")?;
        let diag_outcome_n_wins_d = stream
            .alloc_zeros::<u32>(n_backtests * 10)
            .context("alloc diag_outcome_n_wins_d")?;

        // CRT.diag.2: per-horizon alpha-input EMA state. Zero-init = "no
        // observation yet" — first decision-kernel call bootstraps per
        // pearl_first_observation_bootstrap. Same pattern as the
        // conviction-level Wiener-α EMA above (zero-init = sentinel).
        let alpha_ema_per_b_per_h_d = stream
            .alloc_zeros::<f32>(n_backtests * N_HORIZONS)
            .context("alloc alpha_ema_per_b_per_h_d")?;
        let alpha_diff_var_per_b_per_h_d = stream
            .alloc_zeros::<f32>(n_backtests * N_HORIZONS)
            .context("alloc alpha_diff_var_per_b_per_h_d")?;
        let alpha_sample_var_per_b_per_h_d = stream
            .alloc_zeros::<f32>(n_backtests * N_HORIZONS)
            .context("alloc alpha_sample_var_per_b_per_h_d")?;

        // CRT.diag.2 Group E: per-horizon SMOOTHED direction-flip counters.
        // Same shape as Group A's flip/run-length slots (minus the histogram).
        let diag_smoothed_flip_count_d = stream
            .alloc_zeros::<u32>(n_backtests * N_HORIZONS)
            .context("alloc diag_smoothed_flip_count_d")?;
        let diag_smoothed_current_run_length_d = stream
            .alloc_zeros::<u32>(n_backtests * N_HORIZONS)
            .context("alloc diag_smoothed_current_run_length_d")?;
        let diag_smoothed_sum_run_length_d = stream
            .alloc_zeros::<u64>(n_backtests * N_HORIZONS)
            .context("alloc diag_smoothed_sum_run_length_d")?;
        let diag_smoothed_prev_dir_d = stream
            .alloc_zeros::<i8>(n_backtests * N_HORIZONS)
            .context("alloc diag_smoothed_prev_dir_d")?;

        // Phase 0 reward-redesign (2026-06-02): per-batch authoritative USD
        // pnl-delta buffer. Zero-init; reset per-step by `step_pnl_track`.
        let pnl_step_close_usd_d = stream
            .alloc_zeros::<f32>(n_backtests)
            .context("alloc pnl_step_close_usd_d")?;

        // CRT Phase A0.5 corrective: on-device conviction history buffer.
        // Capacity = 5M decisions × 4B/f32 = 20MB. Matches the prior
        // host-side `Vec::with_capacity(3_000_000)` plus headroom for
        // post-A1 (stride=1 → decisions == events ≈ 5M for a one-day ES
        // smoke; multi-day cluster runs that exceed this silently stop
        // recording past the cap, the host counter still reports actual
        // decision_count). Beats the alternative (Vec<[f32; 5]> per
        // decision = 100MB at 5M decisions) by 5x.
        let convictions_capacity = 5_000_000_usize;
        let convictions_d = stream
            .alloc_zeros::<f32>(convictions_capacity)
            .context("alloc convictions_d")?;

        Ok(Self {
            n_backtests,
            stream,
            book_update_fn,
            submit_market_fn,
            pnl_track_fn,
            decision_fn,
            decision_program_fn,
            isv_kelly_update_fn,
            record_max_conviction_fn,
            resting_orders_fn,
            seed_limit_fn,
            seed_inflight_limits_fn,
            seed_stop_fn,
            books_d,
            pos_d,
            bid_px_d,
            bid_sz_d,
            ask_px_d,
            ask_sz_d,
            market_targets_d,
            open_trade_state_d,
            trade_log_d,
            trade_log_head_d,
            isv_kelly_d,
            alpha_probs_d,
            convictions_d,
            convictions_capacity,
            open_horizon_mask_d,
            closed_horizon_mask_d,
            realised_return_d,
            orders_d,
            overflow_flag_d,
            audit_d,
            audit_head_d,
            program_table_d,
            program_lens_d,
            regimes_d,
            target_annual_vol_units_d,
            annualisation_factor_d,
            max_lots_d,
            latency_ns_d,
            kelly_frac_floor_d,
            sharpe_weight_floor_d,
            prev_pos_lots_d,
            prev_realized_pnl_d,
            prev_open_horizon_mask_d,
            snapshot_pos_state_fn,
            detect_close_fn,
            threshold_d,
            cost_per_lot_per_side_d,
            total_fees_per_b_d,
            prev_mid_d,
            atr_mid_ema_d,
            trail_hwm_d,
            max_hold_ns_d,
            snapshots_skipped_d,
            last_event_ts_d,
            min_reasonable_px_d,
            max_reasonable_px_d,
            nan_avg_px_d,
            nan_realised_d,
            nan_realized_pnl_d,
            zero_vwap_at_open_d,
            saturated_vwap_at_open_d,
            defensive_exit_clamp_d,
            vwap_zero_open_flat_d,
            vwap_huge_open_flat_d,
            vwap_zero_scale_in_d,
            vwap_huge_scale_in_d,
            vwap_zero_flip_d,
            vwap_huge_flip_d,
            vwap_session_gap_was_bad_d,
            last_bad_vwap_d,
            last_bad_path_d,
            mh_kernel_calls_d,
            mh_force_flat_seen_by_seed_d,
            conv_signed_ema_d,
            conv_signed_diff_var_ema_d,
            conv_signed_sample_var_ema_d,
            delta_floor_d,
            diag_flip_count_d,
            diag_current_run_length_d,
            diag_sum_run_length_d,
            diag_run_length_hist_d,
            diag_prev_dir_signed_d,
            diag_conv_hist_d,
            diag_hold_hist_d,
            diag_outcome_n_d,
            diag_outcome_sum_pnl_d,
            diag_outcome_n_wins_d,
            alpha_ema_per_b_per_h_d,
            alpha_diff_var_per_b_per_h_d,
            alpha_sample_var_per_b_per_h_d,
            diag_smoothed_flip_count_d,
            diag_smoothed_current_run_length_d,
            diag_smoothed_sum_run_length_d,
            diag_smoothed_prev_dir_d,
            pnl_step_close_usd_d,
        })
    }

    /// Upload a flattened bytecode Program for one backtest. The kernel
    /// `decision_policy_program` interprets it if program_lens[b] > 0;
    /// otherwise it returns immediately and the host orchestrator falls
    /// back to `decision_policy_default`.
    pub fn upload_program(
        &mut self,
        backtest_idx: usize,
        program: &crate::policy::Program,
    ) -> Result<()> {
        anyhow::ensure!(backtest_idx < self.n_backtests);
        anyhow::ensure!(
            program.instructions.len() <= crate::lob::MAX_PROGRAM_INSTRUCTIONS,
            "program length {} exceeds MAX_PROGRAM_INSTRUCTIONS={}",
            program.instructions.len(),
            crate::lob::MAX_PROGRAM_INSTRUCTIONS
        );

        // Read back current program_table, mutate slot, push back.
        let stride = crate::lob::MAX_PROGRAM_INSTRUCTIONS * crate::lob::INSTRUCTION_BYTES;
        let mut raw = vec![0u8; self.n_backtests * stride];
        self.stream.memcpy_dtoh(&self.program_table_d, raw.as_mut_slice())?;
        let off = backtest_idx * stride;
        let bytes: &[u8] = bytemuck::cast_slice(program.instructions.as_slice());
        raw[off..off + bytes.len()].copy_from_slice(bytes);
        // Zero out trailing slots in this backtest's row beyond program length.
        for b in &mut raw[off + bytes.len()..off + stride] {
            *b = 0;
        }
        self.stream.memcpy_htod(raw.as_slice(), &mut self.program_table_d)?;

        // Update program_lens[b].
        let mut lens = vec![0u32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.program_lens_d, lens.as_mut_slice())?;
        lens[backtest_idx] = program.instructions.len() as u32;
        self.stream.memcpy_htod(lens.as_slice(), &mut self.program_lens_d)?;
        Ok(())
    }

    /// Set the current regime id for a backtest. Read by
    /// `decision_policy_program` for OP_EVAL_REGIME / OP_BRANCH_IF_REGIME.
    pub fn set_regime(&mut self, backtest_idx: usize, regime_id: u32) -> Result<()> {
        anyhow::ensure!(backtest_idx < self.n_backtests);
        let mut regimes = vec![0u32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.regimes_d, regimes.as_mut_slice())?;
        regimes[backtest_idx] = regime_id;
        self.stream.memcpy_htod(regimes.as_slice(), &mut self.regimes_d)?;
        Ok(())
    }

    /// Read the audit-ring head count + records for one backtest.
    /// Returns the raw bytes; tests parse via bytemuck::pod_read_unaligned.
    pub fn read_audit_records(
        &self,
        backtest_idx: usize,
    ) -> Result<Vec<crate::order::OrderEvent>> {
        anyhow::ensure!(backtest_idx < self.n_backtests);
        let mut heads = vec![0u32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.audit_head_d, heads.as_mut_slice())?;
        let head = heads[backtest_idx] as usize;
        if head == 0 {
            return Ok(vec![]);
        }
        let cap = crate::lob::RING_CAP_EVENTS;
        let n_to_read = head.min(cap);
        let rec_bytes = crate::lob::ORDER_EVENT_BYTES;
        let mut raw = vec![0u8; self.n_backtests * cap * rec_bytes];
        self.stream.memcpy_dtoh(&self.audit_d, raw.as_mut_slice())?;
        let base = backtest_idx * cap * rec_bytes;
        let mut out = Vec::with_capacity(n_to_read);
        for i in 0..n_to_read {
            let off = base + i * rec_bytes;
            let r: crate::order::OrderEvent =
                bytemuck::pod_read_unaligned(&raw[off..off + rec_bytes]);
            out.push(r);
        }
        Ok(out)
    }

    pub fn n_backtests(&self) -> usize {
        self.n_backtests
    }

    /// Sum of per-backtest trade-log head counters. Cheap DtoH copy of
    /// `n_backtests * 4` bytes — used by the harness progress line to
    /// surface intra-run trade firing without reading the full ring.
    /// Heads count writes (clamped to ring cap inside the kernel), so the
    /// sum is a reliable monotone trade counter across the sweep.
    pub fn read_total_trade_count(&self) -> Result<u64> {
        let mut heads = vec![0u32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.trade_log_head_d, heads.as_mut_slice())?;
        Ok(heads.iter().map(|&h| h as u64).sum())
    }

    /// Read `atr_mid_ema_d[backtest_idx]`. Test-only accessor.
    pub fn read_atr_mid_ema(&self, backtest_idx: usize) -> Result<f32> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let mut buf = vec![0.0f32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.atr_mid_ema_d, buf.as_mut_slice())?;
        Ok(buf[backtest_idx])
    }

    /// Read `prev_mid_d[backtest_idx]`. Test-only accessor.
    pub fn read_prev_mid(&self, backtest_idx: usize) -> Result<f32> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let mut buf = vec![0.0f32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.prev_mid_d, buf.as_mut_slice())?;
        Ok(buf[backtest_idx])
    }

    /// Read `trail_hwm_d[backtest_idx]`. Test-only accessor.
    pub fn read_trail_hwm(&self, backtest_idx: usize) -> Result<f32> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let mut buf = vec![0.0f32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.trail_hwm_d, buf.as_mut_slice())?;
        Ok(buf[backtest_idx])
    }

    /// Read the SIGNED conviction EMA for one backtest. Returns the raw
    /// signed value in [-1, +1]; callers asserting on magnitude take
    /// `.abs()`. Test-only accessor — production paths inspect smoothed
    /// conviction on-device only. One DtoH per call.
    ///
    /// Post-fix 2026-05-22: returns the signed EMA, not the magnitude-only
    /// EMA from the prior buggy implementation. Sign of the returned value
    /// is the trade direction the kernel will commit to on the next event;
    /// `|returned|` is the sizing magnitude (post-threshold-gate).
    pub fn read_conv_signed_ema(&self, backtest_idx: usize) -> Result<f32> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let mut buf = vec![0.0f32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.conv_signed_ema_d, buf.as_mut_slice())?;
        Ok(buf[backtest_idx])
    }

    /// Read `snapshots_skipped_d`. Returns per-backtest count of snapshots skipped
    /// due to corrupt top-of-book (NaN/Inf price or zero/negative size at level 0).
    /// Used by tests to verify the skip logic fires; production code can sum for
    /// diagnostics.
    pub fn read_snapshots_skipped(&self) -> Result<Vec<u32>> {
        let mut buf = vec![0u32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.snapshots_skipped_d, buf.as_mut_slice())?;
        Ok(buf)
    }

    /// Read the 6 per-backtest NaN instrumentation counters (S1.20).
    /// Returns a `NanCounters` with one `Vec<u32>` per counter, length = n_backtests.
    /// All counters are zero at construction and increment on NaN-producing events
    /// in the fill + pnl_track kernels.
    pub fn read_nan_counters(&self) -> Result<NanCounters> {
        let read = |slot: &CudaSlice<u32>| -> Result<Vec<u32>> {
            let mut buf = vec![0u32; self.n_backtests];
            self.stream.memcpy_dtoh(slot, buf.as_mut_slice())?;
            Ok(buf)
        };
        Ok(NanCounters {
            nan_avg_px:             read(&self.nan_avg_px_d)?,
            nan_realised:           read(&self.nan_realised_d)?,
            nan_realized_pnl:       read(&self.nan_realized_pnl_d)?,
            zero_vwap_at_open:      read(&self.zero_vwap_at_open_d)?,
            saturated_vwap_at_open: read(&self.saturated_vwap_at_open_d)?,
            defensive_exit_clamp:   read(&self.defensive_exit_clamp_d)?,
        })
    }

    /// S1.21: per-vwap-write-site counters.
    ///
    /// All counters are zero at construction. Incremented in
    /// `apply_fill_to_pos` at each vwap_entry write site and in the
    /// session-gap force-close path in `resting_orders_step`.
    pub fn read_nan_counters_v2(&self) -> Result<NanCountersV2> {
        let read_u32 = |slot: &CudaSlice<u32>| -> Result<Vec<u32>> {
            let mut buf = vec![0u32; self.n_backtests];
            self.stream.memcpy_dtoh(slot, buf.as_mut_slice())?;
            Ok(buf)
        };
        let read_f32 = |slot: &CudaSlice<f32>| -> Result<Vec<f32>> {
            let mut buf = vec![0f32; self.n_backtests];
            self.stream.memcpy_dtoh(slot, buf.as_mut_slice())?;
            Ok(buf)
        };
        Ok(NanCountersV2 {
            vwap_zero_open_flat:      read_u32(&self.vwap_zero_open_flat_d)?,
            vwap_huge_open_flat:      read_u32(&self.vwap_huge_open_flat_d)?,
            vwap_zero_scale_in:       read_u32(&self.vwap_zero_scale_in_d)?,
            vwap_huge_scale_in:       read_u32(&self.vwap_huge_scale_in_d)?,
            vwap_zero_flip:           read_u32(&self.vwap_zero_flip_d)?,
            vwap_huge_flip:           read_u32(&self.vwap_huge_flip_d)?,
            vwap_session_gap_was_bad: read_u32(&self.vwap_session_gap_was_bad_d)?,
            last_bad_vwap:            read_f32(&self.last_bad_vwap_d)?,
            last_bad_path:            read_u32(&self.last_bad_path_d)?,
        })
    }

    /// S2.1/S2.2: max_hold diagnostic counters.
    ///
    /// After S2.2 the 6 max_hold-specific decision-rate counters were removed.
    /// Two generic counters remain:
    ///   - `mh_kernel_calls`: stop_check_isv entries with position_lots != 0
    ///     (SL/trail evaluation is running; non-zero when open + decision fires).
    ///   - `mh_force_flat_seen_by_seed`: force-flat signals (SL or trail) seen
    ///     by seed_inflight_limits_batched.
    pub fn read_max_hold_counters(&self) -> Result<NanCountersMaxHold> {
        let read = |slot: &CudaSlice<u32>| -> Result<Vec<u32>> {
            let mut buf = vec![0u32; self.n_backtests];
            self.stream.memcpy_dtoh(slot, buf.as_mut_slice())?;
            Ok(buf)
        };
        Ok(NanCountersMaxHold {
            mh_kernel_calls:            read(&self.mh_kernel_calls_d)?,
            mh_force_flat_seen_by_seed: read(&self.mh_force_flat_seen_by_seed_d)?,
        })
    }

    /// CRT.diag: end-of-run materialisation of the empirical measurement
    /// battery. Reads all 8 host-visible diagnostic slots in one batch (the
    /// 9th — `diag_current_run_length_d` — and 10th — `diag_prev_dir_signed_d`
    /// — are transient kernel-internal state, not part of the summary).
    ///
    /// Called ONCE at smoke termination from `BacktestHarness::run`. Each
    /// `memcpy_dtoh` is a single device-to-host hop; per-event hot path
    /// touches none of these.
    pub fn read_diagnostics(&self) -> Result<CrtDiagnostics> {
        let n_b = self.n_backtests;
        let mut flip_count             = vec![0u32; n_b * N_HORIZONS];
        let mut sum_run_length         = vec![0u64; n_b * N_HORIZONS];
        let mut run_length_hist        = vec![0u32; n_b * N_HORIZONS * 5];
        let mut conv_hist              = vec![0u32; n_b * 10];
        let mut hold_hist              = vec![0u32; n_b * 6];
        let mut outcome_n              = vec![0u32; n_b * 10];
        let mut outcome_sum_pnl        = vec![0f32; n_b * 10];
        let mut outcome_n_wins         = vec![0u32; n_b * 10];
        // CRT.diag.2 Group E: smoothed-direction flip/run-length.
        let mut smoothed_flip_count    = vec![0u32; n_b * N_HORIZONS];
        let mut smoothed_sum_run_length = vec![0u64; n_b * N_HORIZONS];
        self.stream.memcpy_dtoh(&self.diag_flip_count_d,      flip_count.as_mut_slice())
            .context("diag_flip_count DtoH")?;
        self.stream.memcpy_dtoh(&self.diag_sum_run_length_d,  sum_run_length.as_mut_slice())
            .context("diag_sum_run_length DtoH")?;
        self.stream.memcpy_dtoh(&self.diag_run_length_hist_d, run_length_hist.as_mut_slice())
            .context("diag_run_length_hist DtoH")?;
        self.stream.memcpy_dtoh(&self.diag_conv_hist_d,       conv_hist.as_mut_slice())
            .context("diag_conv_hist DtoH")?;
        self.stream.memcpy_dtoh(&self.diag_hold_hist_d,       hold_hist.as_mut_slice())
            .context("diag_hold_hist DtoH")?;
        self.stream.memcpy_dtoh(&self.diag_outcome_n_d,       outcome_n.as_mut_slice())
            .context("diag_outcome_n DtoH")?;
        self.stream.memcpy_dtoh(&self.diag_outcome_sum_pnl_d, outcome_sum_pnl.as_mut_slice())
            .context("diag_outcome_sum_pnl DtoH")?;
        self.stream.memcpy_dtoh(&self.diag_outcome_n_wins_d,  outcome_n_wins.as_mut_slice())
            .context("diag_outcome_n_wins DtoH")?;
        self.stream.memcpy_dtoh(&self.diag_smoothed_flip_count_d,
                                smoothed_flip_count.as_mut_slice())
            .context("diag_smoothed_flip_count DtoH")?;
        self.stream.memcpy_dtoh(&self.diag_smoothed_sum_run_length_d,
                                smoothed_sum_run_length.as_mut_slice())
            .context("diag_smoothed_sum_run_length DtoH")?;
        Ok(CrtDiagnostics {
            flip_count,
            sum_run_length,
            run_length_hist,
            conv_hist,
            hold_hist,
            outcome_n,
            outcome_sum_pnl,
            outcome_n_wins,
            smoothed_flip_count,
            smoothed_sum_run_length,
        })
    }

    /// Upload per-backtest price-range bounds. Call ONCE before the first
    /// apply_snapshot. min=0.0 / max=f32::INFINITY disables the check for
    /// that backtest (default after LobSimCuda::new).
    ///
    /// Used by smoke/production to configure instrument-specific plausible
    /// ranges (e.g. ES futures: 1000.0 / 20000.0) so the CUDA kernel can
    /// reject real source-data outliers (negative bids, sub-$100 bids,
    /// $53k+ asks) that the old hardcoded `< 1e8` threshold missed.
    pub fn upload_price_range(&mut self, min: &[f32], max: &[f32]) -> Result<()> {
        anyhow::ensure!(
            min.len() == self.n_backtests,
            "upload_price_range: min len {} != n_backtests {}",
            min.len(), self.n_backtests
        );
        anyhow::ensure!(
            max.len() == self.n_backtests,
            "upload_price_range: max len {} != n_backtests {}",
            max.len(), self.n_backtests
        );
        self.stream.memcpy_htod(min, &mut self.min_reasonable_px_d)?;
        self.stream.memcpy_htod(max, &mut self.max_reasonable_px_d)?;
        Ok(())
    }

    /// Apply one MBP-10 snapshot's top-10 levels to every backtest's book.
    /// v1 inputs are single-snapshot broadcast (same market data to all
    /// parallel backtests — see spec §7 inference scope, policy sweeps only).
    pub fn apply_snapshot(
        &mut self,
        bid_px: &[f32; BOOK_LEVELS],
        bid_sz: &[f32; BOOK_LEVELS],
        ask_px: &[f32; BOOK_LEVELS],
        ask_sz: &[f32; BOOK_LEVELS],
    ) -> Result<()> {
        self.stream.memcpy_htod(bid_px.as_slice(), &mut self.bid_px_d)?;
        self.stream.memcpy_htod(bid_sz.as_slice(), &mut self.bid_sz_d)?;
        self.stream.memcpy_htod(ask_px.as_slice(), &mut self.ask_px_d)?;
        self.stream.memcpy_htod(ask_sz.as_slice(), &mut self.ask_sz_d)?;

        let cfg = LaunchConfig {
            grid_dim: (self.n_backtests as u32, 1, 1),
            block_dim: (32, 1, 1),
            shared_mem_bytes: 0,
        };
        let n = self.n_backtests as i32;
        let mut launch = self.stream.launch_builder(&self.book_update_fn);
        unsafe {
            launch
                .arg(&self.bid_px_d)
                .arg(&self.bid_sz_d)
                .arg(&self.ask_px_d)
                .arg(&self.ask_sz_d)
                .arg(&mut self.books_d)
                .arg(&mut self.prev_mid_d)
                .arg(&mut self.atr_mid_ema_d)
                .arg(&mut self.snapshots_skipped_d)
                .arg(&self.min_reasonable_px_d)
                .arg(&self.max_reasonable_px_d)
                .arg(&n)
                .launch(cfg)?;
        }
        self.stream.synchronize()?;
        Ok(())
    }

    /// Device-to-device variant of [`apply_snapshot`]. Copies 4 ×
    /// `BOOK_LEVELS` floats from source device pointers into the lobsim's
    /// staging buffers, then launches `book_update_apply_snapshot` to
    /// broadcast the 10-level book to every backtest.
    ///
    /// Each `*_src` argument is a raw device pointer (`CUdeviceptr`)
    /// addressing `BOOK_LEVELS` contiguous `f32` values. The copies and
    /// the kernel launch are stream-ordered on `self.stream` — no sync.
    pub fn apply_snapshot_from_device(
        &mut self,
        bid_px_src: u64,
        bid_sz_src: u64,
        ask_px_src: u64,
        ask_sz_src: u64,
    ) -> Result<()> {
        use ml_alpha::trainer::raw_launch::{RawArgs, raw_launch, raw_memcpy_dtod_async};

        let bytes = BOOK_LEVELS * std::mem::size_of::<f32>();
        let raw_stream = self.stream.cu_stream();
        unsafe {
            raw_memcpy_dtod_async(self.bid_px_d.raw_ptr(), bid_px_src, bytes, raw_stream)
                .map_err(|e| anyhow::anyhow!("apply_snapshot_from_device: bid_px DtoD: {:?}", e))?;
            raw_memcpy_dtod_async(self.bid_sz_d.raw_ptr(), bid_sz_src, bytes, raw_stream)
                .map_err(|e| anyhow::anyhow!("apply_snapshot_from_device: bid_sz DtoD: {:?}", e))?;
            raw_memcpy_dtod_async(self.ask_px_d.raw_ptr(), ask_px_src, bytes, raw_stream)
                .map_err(|e| anyhow::anyhow!("apply_snapshot_from_device: ask_px DtoD: {:?}", e))?;
            raw_memcpy_dtod_async(self.ask_sz_d.raw_ptr(), ask_sz_src, bytes, raw_stream)
                .map_err(|e| anyhow::anyhow!("apply_snapshot_from_device: ask_sz DtoD: {:?}", e))?;
        }

        let n = self.n_backtests as i32;
        let mut args = RawArgs::new();
        args.push_ptr(self.bid_px_d.raw_ptr());
        args.push_ptr(self.bid_sz_d.raw_ptr());
        args.push_ptr(self.ask_px_d.raw_ptr());
        args.push_ptr(self.ask_sz_d.raw_ptr());
        args.push_ptr(self.books_d.raw_ptr());
        args.push_ptr(self.prev_mid_d.raw_ptr());
        args.push_ptr(self.atr_mid_ema_d.raw_ptr());
        args.push_ptr(self.snapshots_skipped_d.raw_ptr());
        args.push_ptr(self.min_reasonable_px_d.raw_ptr());
        args.push_ptr(self.max_reasonable_px_d.raw_ptr());
        args.push_i32(n);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.book_update_fn.cu_function(),
                (self.n_backtests as u32, 1, 1),
                (32, 1, 1),
                0,
                raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("apply_snapshot_from_device: book_update kernel: {:?}", e))?;
        }
        Ok(())
    }

    /// Submit a market order to one specific backtest. side: 0=buy, 1=sell.
    /// Walks the book + updates Pos, then runs pnl_track to detect
    /// segment_complete and emit TradeRecord if the position closed.
    /// Convenience entry for fixture testing; production path goes
    /// through the decision-policy kernel (C7).
    pub fn submit_market(
        &mut self,
        backtest_idx: usize,
        side: u8,
        size: u16,
        current_ts_ns: u64,
    ) -> Result<()> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let mut targets = vec![0i32; self.n_backtests * 2];
        for b in 0..self.n_backtests {
            targets[b * 2] = 2; // no-op
            targets[b * 2 + 1] = 0;
        }
        targets[backtest_idx * 2] = side as i32;
        targets[backtest_idx * 2 + 1] = size as i32;
        self.stream
            .memcpy_htod(targets.as_slice(), &mut self.market_targets_d)?;

        let cfg = LaunchConfig {
            grid_dim: (self.n_backtests as u32, 1, 1),
            block_dim: (32, 1, 1),
            shared_mem_bytes: 0,
        };
        let n = self.n_backtests as i32;
        let mut launch = self.stream.launch_builder(&self.submit_market_fn);
        unsafe {
            launch
                .arg(&self.books_d)
                .arg(&self.market_targets_d)
                .arg(&mut self.pos_d)
                .arg(&self.min_reasonable_px_d)
                .arg(&self.max_reasonable_px_d)
                .arg(&self.cost_per_lot_per_side_d)
                .arg(&mut self.total_fees_per_b_d)
                .arg(&n)
                .launch(cfg)?;
        }
        self.stream.synchronize()?;

        // Run pnl_track to detect close + emit TradeRecord.
        self.step_pnl_track(current_ts_ns)?;
        Ok(())
    }

    /// Upload the per-batch fee constant (USD per contract per side).
    /// Default after `new()` is zero — callers should set this before
    /// the first `submit_market` call if they want fees to apply. ES
    /// futures default per AMP/CME pricing is ≈$1.25/contract/side
    /// (~$2.50 round-trip). The production `step_decision_with_latency`
    /// path uploads its own cost via `sim_cfg.cost_per_lot_per_side`.
    pub fn upload_cost_per_lot_per_side(&mut self, cost: &[f32]) -> Result<()> {
        anyhow::ensure!(
            cost.len() == self.n_backtests,
            "upload_cost_per_lot_per_side: len {} != n_backtests {}",
            cost.len(), self.n_backtests
        );
        self.stream.memcpy_htod(cost, &mut self.cost_per_lot_per_side_d)?;
        Ok(())
    }

    /// Phase R6: GPU-pure variant of `submit_market` for the
    /// integrated RL trainer. Skips the host-side targets-vec build
    /// + memcpy_htod (the trainer's `actions_to_market_targets`
    /// kernel has already populated `market_targets_d` on device).
    /// Runs the fill kernel + `step_pnl_track` against the existing
    /// market_targets — no host roundtrip, no per-batch orchestration.
    pub fn step_fill_from_market_targets(&mut self, current_ts_ns: u64) -> Result<()> {
        let n = self.n_backtests as i32;
        let rs = self.stream.cu_stream();
        {
            use ml_alpha::trainer::raw_launch::{RawArgs, raw_launch};
            let mut args = RawArgs::new();
            args.push_ptr(self.books_d.raw_ptr());
            args.push_ptr(self.market_targets_d.raw_ptr());
            args.push_ptr(self.pos_d.raw_ptr());
            args.push_ptr(self.min_reasonable_px_d.raw_ptr());
            args.push_ptr(self.max_reasonable_px_d.raw_ptr());
            args.push_ptr(self.cost_per_lot_per_side_d.raw_ptr());
            args.push_ptr(self.total_fees_per_b_d.raw_ptr());
            args.push_i32(n);
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.submit_market_fn.cu_function(),
                    (self.n_backtests as u32, 1, 1), (32, 1, 1), 0,
                    rs, &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("submit_market_immediate: {:?}", e))?;
            }
        }
        // No sync needed — step_pnl_track launches on the SAME stream,
        // so CUDA stream ordering guarantees submit_market completes first.

        self.step_pnl_track(current_ts_ns)?;
        Ok(())
    }

    /// Phase R6: mutable accessor for `market_targets_d` so the
    /// `RlLobBackend` trait impl (and the integrated trainer's
    /// `actions_to_market_targets` kernel) can write Thompson-sampled
    /// actions directly. Production paths
    /// (`step_decision_with_latency`) write via the decision kernel;
    /// the RL path writes via `actions_to_market_targets`.
    pub fn market_targets_d_mut(&mut self) -> &mut CudaSlice<i32> {
        &mut self.market_targets_d
    }

    /// Phase R6: read-only accessor for `pos_d` (the device-resident
    /// `Pos` byte array, sized `[n_backtests * size_of::<PosFlat>()]`).
    /// The trainer's `extract_realized_pnl_delta` +
    /// `actions_to_market_targets` kernels read pos state directly
    /// via this pointer — no DtoH.
    pub fn pos_d(&self) -> &CudaSlice<u8> {
        &self.pos_d
    }

    /// Phase 0 reward-redesign (2026-06-02): read-only accessor for the
    /// per-batch authoritative USD pnl-delta buffer. Each entry holds the
    /// USD pnl of the trade closed by batch `b` during the most recent
    /// `step_pnl_track` call, or 0.0 if no close occurred. Trainers route
    /// this device pointer through `DiagStaging::snapshot_async` so the
    /// one-step-delay double-buffered read stays aligned with the other
    /// per-batch close-event signals (dones / raw_rewards). Same source as
    /// `eval_summary.total_pnl_usd` (realised_pnl_usd_fp / 100), giving a
    /// cross-source-consistent per-step USD signal.
    pub fn pnl_step_close_usd_d(&self) -> &CudaSlice<f32> {
        &self.pnl_step_close_usd_d
    }

    /// SP20 P1+P5 trail-stop integration: read-only accessor for the
    /// shared best-bid price array (`BOOK_LEVELS` floats, lobsim-wide,
    /// not per-batch). Used by `rl_trail_stop_check` to compute
    /// current mid = (best_bid + best_ask) / 2 for per-batch
    /// trail-distance breach checks.
    pub fn bid_px_d(&self) -> &CudaSlice<f32> {
        &self.bid_px_d
    }

    /// SP20 P1+P5 trail-stop integration: read-only accessor for the
    /// shared best-ask price array (`BOOK_LEVELS` floats). Pairs with
    /// `bid_px_d()`.
    pub fn ask_px_d(&self) -> &CudaSlice<f32> {
        &self.ask_px_d
    }

    /// Phase R6: size of one `Pos` struct in bytes. Passed to device
    /// kernels that index into `pos_d` so they don't need to be rebuilt
    /// if `PosFlat` grows fields.
    pub fn pos_bytes(&self) -> usize {
        std::mem::size_of::<crate::lob::PosFlat>()
    }
}

// ─────────────────────────────────────────────────────────────────────
// Phase R6: implement ml-alpha's `RlLobBackend` trait for LobSimCuda
// so the integrated trainer's `step_with_lobsim` can drive the
// simulator without naming `LobSimCuda` directly (dep-cycle break
// only — see `ml_alpha::rl::reward` module docs).
// ─────────────────────────────────────────────────────────────────────
impl ml_alpha::rl::reward::RlLobBackend for LobSimCuda {
    fn apply_snapshot(
        &mut self,
        snapshot: &ml_alpha::cfc::snap_features::Mbp10RawInput,
    ) -> Result<()> {
        // Delegate to the existing 4-arg apply_snapshot, unpacking the
        // bid/ask top-10 arrays from the snapshot. Other Mbp10RawInput
        // fields (ts_ns, trade_signed_vol, regime) are consumed by the
        // encoder, not the book.
        LobSimCuda::apply_snapshot(
            self,
            &snapshot.bid_px,
            &snapshot.bid_sz,
            &snapshot.ask_px,
            &snapshot.ask_sz,
        )
    }

    fn pos_and_market_targets_mut(
        &mut self,
    ) -> (&CudaSlice<u8>, &mut CudaSlice<i32>) {
        // Disjoint-field borrow split: Rust's NLL accepts simultaneous
        // shared + exclusive borrows of distinct fields of the same
        // struct. Used by IntegratedTrainer's
        // actions_to_market_targets launch which needs both pos_d
        // (reads current position_lots) and market_targets_d (writes
        // resolved (side, size) pairs).
        (&self.pos_d, &mut self.market_targets_d)
    }

    fn pos_d(&self) -> &CudaSlice<u8> {
        &self.pos_d
    }

    fn pos_bytes(&self) -> usize {
        self.pos_bytes()
    }

    fn step_fill_from_market_targets(&mut self, current_ts_ns: u64) -> Result<()> {
        self.step_fill_from_market_targets(current_ts_ns)
    }

    fn n_backtests(&self) -> usize {
        self.n_backtests()
    }

    fn bid_px_d(&self) -> &CudaSlice<f32> {
        self.bid_px_d()
    }

    fn ask_px_d(&self) -> &CudaSlice<f32> {
        self.ask_px_d()
    }

    fn pos_book_and_market_targets_mut(
        &mut self,
    ) -> (&CudaSlice<u8>, &CudaSlice<f32>, &CudaSlice<f32>, &mut CudaSlice<i32>) {
        (&self.pos_d, &self.bid_px_d, &self.ask_px_d, &mut self.market_targets_d)
    }

    fn apply_snapshot_from_device(
        &mut self,
        bid_px_src: u64,
        bid_sz_src: u64,
        ask_px_src: u64,
        ask_sz_src: u64,
    ) -> Result<()> {
        LobSimCuda::apply_snapshot_from_device(
            self,
            bid_px_src,
            bid_sz_src,
            ask_px_src,
            ask_sz_src,
        )
    }
}

// Re-open the impl block for `LobSimCuda` so subsequent methods (if any
// are added below) chain through cleanly.
impl LobSimCuda {

    /// Run pnl_track_step kernel: detect segment_complete, emit TradeRecord.
    /// Called automatically by submit_market; exposed for callers that
    /// orchestrate matching themselves.
    pub fn step_pnl_track(&mut self, current_ts_ns: u64) -> Result<()> {
        let n = self.n_backtests as i32;
        let cap = crate::lob::TRADE_LOG_CAP as i32;
        let rs = self.stream.cu_stream();
        // Phase 0 reward-redesign (2026-06-02): zero the per-batch USD
        // pnl-delta buffer before launch. Close branch writes the per-trade
        // USD pnl for batches that closed THIS step; other batches keep
        // their zero. Stream-ordered with the kernel launch below.
        unsafe {
            ml_alpha::trainer::raw_launch::raw_memset_d8_zero(
                self.pnl_step_close_usd_d.raw_ptr(),
                self.pnl_step_close_usd_d.num_bytes(),
                rs,
            ).map_err(|e| anyhow::anyhow!("zero pnl_step_close_usd_d: {:?}", e))?;
        }
        {
            use ml_alpha::trainer::raw_launch::{RawArgs, raw_launch};
            let mut args = RawArgs::new();
            args.push_ptr(self.pos_d.raw_ptr());
            args.push_ptr(self.open_trade_state_d.raw_ptr());
            args.push_ptr(self.trade_log_d.raw_ptr());
            args.push_ptr(self.trade_log_head_d.raw_ptr());
            args.push_u64(current_ts_ns);
            args.push_i32(cap);
            args.push_i32(n);
            args.push_ptr(self.trail_hwm_d.raw_ptr());
            args.push_ptr(self.zero_vwap_at_open_d.raw_ptr());
            args.push_ptr(self.saturated_vwap_at_open_d.raw_ptr());
            args.push_ptr(self.defensive_exit_clamp_d.raw_ptr());
            args.push_ptr(self.conv_signed_ema_d.raw_ptr());
            args.push_ptr(self.diag_hold_hist_d.raw_ptr());
            args.push_ptr(self.diag_outcome_n_d.raw_ptr());
            args.push_ptr(self.diag_outcome_sum_pnl_d.raw_ptr());
            args.push_ptr(self.diag_outcome_n_wins_d.raw_ptr());
            args.push_ptr(self.pnl_step_close_usd_d.raw_ptr());
            let mut ptrs = args.build_arg_ptrs();
            unsafe {
                raw_launch(
                    self.pnl_track_fn.cu_function(),
                    (self.n_backtests as u32, 1, 1), (32, 1, 1), 0,
                    rs, &mut ptrs[..args.len()],
                ).map_err(|e| anyhow::anyhow!("pnl_track_step: {:?}", e))?;
            }
        }
        // No sync — downstream kernels on the same stream (fused_reward_pipeline,
        // rl_per_push) read pos_d via stream ordering. Host-side trade log reads
        // (read_trade_records) must sync explicitly when called.
        Ok(())
    }

    /// Read all closed-trade records for a backtest. Returns up to
    /// TRADE_LOG_CAP records (oldest dropped if the ring wrapped, which
    /// the v1 head counter exposes as `head_count`).
    pub fn read_trade_records(
        &self,
        backtest_idx: usize,
    ) -> Result<Vec<crate::order::TradeRecord>> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let mut heads = vec![0u32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.trade_log_head_d, heads.as_mut_slice())?;
        let head = heads[backtest_idx] as usize;
        if head == 0 {
            return Ok(vec![]);
        }
        let n_to_read = head.min(crate::lob::TRADE_LOG_CAP);
        let rec_bytes = crate::lob::TRADE_RECORD_BYTES;
        let off = backtest_idx * crate::lob::TRADE_LOG_CAP * rec_bytes;
        let mut raw = vec![0u8; self.n_backtests * crate::lob::TRADE_LOG_CAP * rec_bytes];
        self.stream.memcpy_dtoh(&self.trade_log_d, raw.as_mut_slice())?;
        let mut out = Vec::with_capacity(n_to_read);
        for i in 0..n_to_read {
            let rec_off = off + i * rec_bytes;
            let r: crate::order::TradeRecord =
                bytemuck::pod_read_unaligned(&raw[rec_off..rec_off + rec_bytes]);
            out.push(r);
        }
        Ok(out)
    }

    /// Read per-backtest cumulative trade counters. Returns a `Vec<u32>`
    /// of length `n_backtests` where entry `i` is the total number of
    /// closed-trade records ever pushed to backtest `i`'s ring buffer
    /// (may exceed `TRADE_LOG_CAP` if the ring wrapped).
    ///
    /// Used by `alpha_rl_train` to snapshot the eval-phase boundary per
    /// account: `head_before_per_b[i] = read_per_backtest_trade_counts()[i]`
    /// at eval start; post-eval `head_after_per_b[i] - head_before_per_b[i]`
    /// gives the true eval-phase trade count for backtest `i`.
    ///
    /// Uses mapped-pinned staging per `feedback_no_htod_htoh_only_mapped_pinned`:
    /// allocate a `MappedRecordBuffer<u32>`, DtoD copy from
    /// `trade_log_head_d` into its dev_ptr, sync, read via host_ptr.
    ///
    /// Replaces the (`head_before_eval = read_total_trade_count()`,
    /// `all_records = read_trade_records(0)`) pattern that conflated
    /// aggregate counts with per-account ring contents and produced
    /// meaningless eval_summary metrics — see spec
    /// `docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md`.
    pub fn read_per_backtest_trade_counts(&self) -> Result<Vec<u32>> {
        use ml_alpha::pinned_mem::MappedRecordBuffer;
        use ml_alpha::trainer::raw_launch::raw_memcpy_dtod_async;
        let raw_stream = self.stream.cu_stream();
        let staging: MappedRecordBuffer<u32> = unsafe { MappedRecordBuffer::new(self.n_backtests) }
            .map_err(|e| anyhow::anyhow!("alloc trade_head staging: {e}"))?;
        unsafe {
            raw_memcpy_dtod_async(
                staging.dev_ptr,
                self.trade_log_head_d.raw_ptr(),
                self.n_backtests * std::mem::size_of::<u32>(),
                raw_stream,
            )
            .map_err(|e| anyhow::anyhow!("DtoD trade_log_head: {e:?}"))?;
        }
        self.stream
            .synchronize()
            .context("sync after trade_log_head DtoD")?;
        let mut out = Vec::with_capacity(self.n_backtests);
        for i in 0..self.n_backtests {
            // SAFETY: mapped-pinned host_ptr is valid for [0, len); sync
            // above ensures the DtoD writes are visible to host.
            unsafe {
                out.push(std::ptr::read_volatile(staging.host_ptr.add(i)));
            }
        }
        Ok(out)
    }

    /// Read all backtests' trade record rings. Returns
    /// `Vec<Vec<TradeRecord>>` of length `n_backtests`; entry `i` is the
    /// most recent up-to-`TRADE_LOG_CAP` records for backtest `i` (oldest
    /// dropped on wrap).
    ///
    /// Uses mapped-pinned staging for both the per-backtest head counters
    /// and the trade-log payload (per `feedback_no_htod_htoh_only_mapped_pinned`).
    /// At cluster scale (b=1024, cap=4096) the payload staging buffer
    /// is 167 MB — allocated once per call (end-of-run). Acceptable
    /// because this is invoked once per fold-eval, not in the hot path.
    ///
    /// Callers compose this with
    /// [`read_per_backtest_trade_counts`](Self::read_per_backtest_trade_counts)
    /// to identify each account's eval-phase-only slice.
    pub fn read_trade_records_all(
        &self,
    ) -> Result<Vec<Vec<crate::order::TradeRecord>>> {
        use ml_alpha::pinned_mem::MappedRecordBuffer;
        use ml_alpha::trainer::raw_launch::raw_memcpy_dtod_async;
        let raw_stream = self.stream.cu_stream();
        let rec_bytes = crate::lob::TRADE_RECORD_BYTES;
        let cap = crate::lob::TRADE_LOG_CAP;
        let payload_len_u8 = self.n_backtests * cap * rec_bytes;

        let head_staging: MappedRecordBuffer<u32> =
            unsafe { MappedRecordBuffer::new(self.n_backtests) }
                .map_err(|e| anyhow::anyhow!("alloc head staging: {e}"))?;
        let payload_staging: MappedRecordBuffer<u8> =
            unsafe { MappedRecordBuffer::new(payload_len_u8) }
                .map_err(|e| anyhow::anyhow!("alloc payload staging ({payload_len_u8} B): {e}"))?;

        unsafe {
            raw_memcpy_dtod_async(
                head_staging.dev_ptr,
                self.trade_log_head_d.raw_ptr(),
                self.n_backtests * std::mem::size_of::<u32>(),
                raw_stream,
            )
            .map_err(|e| anyhow::anyhow!("DtoD trade_log_head: {e:?}"))?;
            raw_memcpy_dtod_async(
                payload_staging.dev_ptr,
                self.trade_log_d.raw_ptr(),
                payload_len_u8,
                raw_stream,
            )
            .map_err(|e| anyhow::anyhow!("DtoD trade_log payload: {e:?}"))?;
        }
        self.stream
            .synchronize()
            .context("sync after trade_log DtoD")?;

        let mut out = Vec::with_capacity(self.n_backtests);
        for b in 0..self.n_backtests {
            // SAFETY: mapped-pinned host_ptr is valid + post-sync the
            // device-side DtoD writes are visible to the host.
            let head = unsafe { std::ptr::read_volatile(head_staging.host_ptr.add(b)) } as usize;
            let n_to_read = head.min(cap);
            let off = b * cap * rec_bytes;
            let mut records = Vec::with_capacity(n_to_read);
            for i in 0..n_to_read {
                let rec_off = off + i * rec_bytes;
                // SAFETY: bounds-checked above (i < n_to_read ≤ cap; rec_off + rec_bytes ≤ payload_len_u8).
                let bytes: [u8; crate::lob::TRADE_RECORD_BYTES] = unsafe {
                    let mut buf = [0u8; crate::lob::TRADE_RECORD_BYTES];
                    for k in 0..rec_bytes {
                        buf[k] = std::ptr::read_volatile(payload_staging.host_ptr.add(rec_off + k));
                    }
                    buf
                };
                let r: crate::order::TradeRecord =
                    bytemuck::pod_read_unaligned(&bytes);
                records.push(r);
            }
            out.push(records);
        }
        Ok(out)
    }

    /// Read back the Pos state for a specific backtest.
    pub fn read_pos(&self, backtest_idx: usize) -> Result<PosFlat> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let pos_bytes = std::mem::size_of::<PosFlat>();
        let mut raw = vec![0u8; self.n_backtests * pos_bytes];
        self.stream.memcpy_dtoh(&self.pos_d, raw.as_mut_slice())?;
        let off = backtest_idx * pos_bytes;
        let pos: PosFlat = bytemuck::pod_read_unaligned(&raw[off..off + pos_bytes]);
        Ok(pos)
    }

    /// Read back the market_target written by `step_decision*` for one
    /// backtest. Returns `(side, abs_size)` where `side ∈ {0=buy, 1=sell, 2=noop}`.
    /// Used by integration tests that need to inspect decision-kernel output
    /// without going through the full submit/fill chain.
    pub fn read_market_target(&self, backtest_idx: usize) -> Result<(i32, i32)> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx, self.n_backtests
        );
        let mut raw = vec![0i32; self.n_backtests * 2];
        self.stream.memcpy_dtoh(&self.market_targets_d, raw.as_mut_slice())?;
        Ok((raw[backtest_idx * 2], raw[backtest_idx * 2 + 1]))
    }

    /// Broadcast alpha probs (per-horizon) to the decision kernel from a
    /// host-side `[f32; N_HORIZONS]`. Single source of truth for v1: all
    /// N backtests see the same probs.
    ///
    /// CRT Phase A0.5 (corrective): production hot-path callers should
    /// prefer driving `alpha_probs_d` directly via
    /// `PerceptionTrainer::forward_step_into(&mut sim.alpha_probs_d_mut())`
    /// so the probs never leave device memory. This host-input variant
    /// stays for tests + non-CUDA-trainer call sites (e.g., fixtures that
    /// inject canned probs into the decision pipeline).
    pub fn broadcast_alpha(&mut self, probs: &[f32; N_HORIZONS]) -> Result<()> {
        self.stream.memcpy_htod(probs.as_slice(), &mut self.alpha_probs_d)?;
        Ok(())
    }

    /// Mutable accessor for the on-device alpha-probs buffer.
    /// Length = `N_HORIZONS` (5). All `n_backtests` simulators read from
    /// this same buffer (broadcast layout). The CRT Phase A0.5 incremental
    /// inference path uses this so the trainer's GRN heads kernel writes
    /// probs directly into the sim's decision-input buffer — zero CPU
    /// roundtrip, no `synchronize` on the hot path.
    pub fn alpha_probs_d_mut(&mut self) -> &mut CudaSlice<f32> {
        &mut self.alpha_probs_d
    }

    /// Diagnostic-only sampler: copy the current `alpha_probs_d` (5 f32s,
    /// broadcast across backtests) into a caller-owned mapped-pinned
    /// buffer and synchronise the stream so the host view is coherent.
    ///
    /// Per `feedback_no_htod_htoh_only_mapped_pinned`: the buffer is
    /// allocated once in the harness state via `MappedF32Buffer::new(5)`
    /// and reused on every sampling tick. The transfer is DtoD into the
    /// mapped-pinned device pointer (the only permitted cross-direction
    /// channel); a single `stream.synchronize()` makes the host-side
    /// `read_record` calls deterministic.
    ///
    /// Intended for low-frequency sampling (e.g. every 1000 events). The
    /// per-event production path keeps reading `alpha_probs_d` on-device
    /// and is unaffected.
    pub fn sample_alpha_probs(&self, dst: &MappedF32Buffer) -> Result<()> {
        anyhow::ensure!(
            dst.len >= N_HORIZONS,
            "sample_alpha_probs: dst buffer len {} < N_HORIZONS {}",
            dst.len, N_HORIZONS,
        );
        let nbytes = N_HORIZONS * std::mem::size_of::<f32>();
        unsafe {
            let (src_ptr, _g) = self.alpha_probs_d.device_ptr(&self.stream);
            cudarc::driver::result::memcpy_dtod_async(
                dst.dev_ptr,
                src_ptr,
                nbytes,
                self.stream.cu_stream(),
            )
            .context("sample_alpha_probs DtoD")?;
        }
        self.stream.synchronize().context("sample_alpha_probs sync")?;
        Ok(())
    }

    /// CRT Phase A0.5 corrective: record one max-conviction f32 at
    /// `convictions_d[write_idx]` from the current `alpha_probs_d`.
    /// Single-thread kernel; stream-ordered, no sync. Caller advances
    /// `write_idx` on the host side once per decision (matches the
    /// existing `decision_count` increment in the harness).
    ///
    /// `write_idx` ≥ `convictions_capacity` is silently dropped by the
    /// kernel — the host counter still reflects the real decision count;
    /// the percentile side-channel just stops growing past the cap. For
    /// the current 5M-entry cap and ES-rate decision flow that's > a
    /// trading day of decisions at stride=1.
    pub fn record_max_conviction(&mut self, write_idx: u32) -> Result<()> {
        let cfg = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (1, 1, 1),
            shared_mem_bytes: 0,
        };
        let idx_i32: i32 = write_idx as i32;
        let cap_i32: i32 = self.convictions_capacity as i32;
        unsafe {
            self.stream
                .launch_builder(&self.record_max_conviction_fn)
                .arg(&self.alpha_probs_d)
                .arg(&mut self.convictions_d)
                .arg(&idx_i32)
                .arg(&cap_i32)
                .launch(cfg)
                .context("record_max_conviction_to_slot launch")?;
        }
        Ok(())
    }

    /// End-of-run read-back of the conviction history. Returns the first
    /// `n` entries (clamped to `convictions_capacity`). One DtoV sync.
    /// Mirrors the lifecycle of `read_trade_records` / `read_max_hold_counters`
    /// which the harness calls after `run()` to materialise diagnostic
    /// data for `write_artifacts`.
    pub fn read_convictions(&self, n: usize) -> Result<Vec<f32>> {
        let take = n.min(self.convictions_capacity);
        if take == 0 {
            return Ok(Vec::new());
        }
        let mut host = vec![0.0_f32; self.convictions_capacity];
        self.stream
            .memcpy_dtoh(&self.convictions_d, host.as_mut_slice())
            .context("convictions DtoH")?;
        host.truncate(take);
        Ok(host)
    }

    /// Backward-compat wrapper for the no-latency immediate-match path.
    /// Callers seeking immediate fill should set `cfg.latency_ns[b] = 0`.
    pub fn step_decision(
        &mut self,
        current_ts_ns: u64,
        cfg: &BatchedSimConfig,
    ) -> Result<()> {
        self.step_decision_with_latency(current_ts_ns, cfg)
    }

    /// Run the C7 decision pipeline with optional latency simulation:
    ///   1. decision_policy_default kernel — alpha × per-horizon ISV-Kelly
    ///      → market target (side, size) per backtest + open_horizon_mask
    ///      attribution if currently flat.
    ///   2a. If latency_ns == 0: submit_market_immediate kernel fills
    ///       against the current book.
    ///   2b. If latency_ns > 0: each non-noop target is converted host-side
    ///       to an in-flight aggressive Limit (active=2, price=very-high
    ///       for buy / very-low for sell so the marketability check
    ///       always crosses, arrival_ts_ns = current + latency_ns).
    ///       The resting_orders kernel promotes + fills at arrival time
    ///       against the (possibly-moved) book.
    ///   3. pnl_track_step — detect close → emit TradeRecord (immediate path only;
    ///      in-flight path defers close detection to step_resting_orders).
    ///   4. isv_kelly_update_on_close — for closed trades, update per-horizon
    ///      state. Currently runs only on the immediate path; the latency
    ///      path triggers it via step_resting_orders → step_pnl_track once
    ///      the fill lands.
    pub fn step_decision_with_latency(
        &mut self,
        current_ts_ns: u64,
        sim_cfg: &BatchedSimConfig,
    ) -> Result<()> {
        anyhow::ensure!(
            sim_cfg.n_backtests() == self.n_backtests,
            "BatchedSimConfig n_backtests {} != Sim n_backtests {}",
            sim_cfg.n_backtests(), self.n_backtests
        );
        sim_cfg.validate()?;

        // P1: upload per-backtest sim params into pre-allocated device buffers.
        self.stream.memcpy_htod(&sim_cfg.target_annual_vol_units, &mut self.target_annual_vol_units_d)?;
        self.stream.memcpy_htod(&sim_cfg.annualisation_factor,    &mut self.annualisation_factor_d)?;
        let max_lots_i32: Vec<i32> = sim_cfg.max_lots.iter().map(|&m| m as i32).collect();
        self.stream.memcpy_htod(&max_lots_i32,                    &mut self.max_lots_d)?;
        self.stream.memcpy_htod(&sim_cfg.latency_ns,              &mut self.latency_ns_d)?;
        self.stream.memcpy_htod(&sim_cfg.kelly_frac_floor,        &mut self.kelly_frac_floor_d)?;
        self.stream.memcpy_htod(&sim_cfg.sharpe_weight_floor,     &mut self.sharpe_weight_floor_d)?;
        // P4: threshold + cost arrays.
        self.stream.memcpy_htod(&sim_cfg.threshold,               &mut self.threshold_d)?;
        self.stream.memcpy_htod(&sim_cfg.cost_per_lot_per_side,   &mut self.cost_per_lot_per_side_d)?;
        // Follow-up gp74n: max-hold force-close per-backtest config.
        self.stream.memcpy_htod(&sim_cfg.max_hold_ns,             &mut self.max_hold_ns_d)?;
        // CRT.1 C1.3: no-trade band — uploaded once per run alongside other
        // config params. The kernel reads delta_floor_d per-event without any
        // host roundtrip on the hot path.
        self.stream.memcpy_htod(&sim_cfg.delta_floor,             &mut self.delta_floor_d)?;

        // Step 1: decision kernels write market_targets + open_horizon_mask.
        //   1a — bytecode program kernel handles backtests with plen > 0.
        //   1b — default hardcoded kernel handles backtests with plen == 0.
        let cfg = LaunchConfig {
            grid_dim: (self.n_backtests as u32, 1, 1),
            block_dim: (32, 1, 1),
            shared_mem_bytes: 0,
        };
        let n = self.n_backtests as i32;
        let max_instrs = crate::lob::MAX_PROGRAM_INSTRUCTIONS as i32;

        // 1a: bytecode VM kernel (skips backtests with plen==0 internally).
        let mut launch = self.stream.launch_builder(&self.decision_program_fn);
        unsafe {
            launch
                .arg(&self.alpha_probs_d)
                .arg(&self.isv_kelly_d)
                .arg(&self.pos_d)
                .arg(&self.program_table_d)
                .arg(&self.program_lens_d)
                .arg(&self.regimes_d)
                .arg(&mut self.market_targets_d)
                .arg(&mut self.open_horizon_mask_d)
                .arg(&self.target_annual_vol_units_d)
                .arg(&self.annualisation_factor_d)
                .arg(&max_instrs)
                .arg(&self.max_lots_d)
                .arg(&self.kelly_frac_floor_d)
                .arg(&self.sharpe_weight_floor_d)
                .arg(&self.threshold_d)
                .arg(&self.cost_per_lot_per_side_d)
                .arg(&self.atr_mid_ema_d)
                .arg(&mut self.trail_hwm_d)
                .arg(&self.books_d)
                // S2.1: mh_kernel_calls diagnostic counter.
                .arg(&mut self.mh_kernel_calls_d)
                // Anti-calibration fix 2026-05-22: SIGNED conviction-EMA state.
                .arg(&mut self.conv_signed_ema_d)
                .arg(&mut self.conv_signed_diff_var_ema_d)
                .arg(&mut self.conv_signed_sample_var_ema_d)
                // CRT.1 C1.4: trajectory state for composite exit_signal.
                .arg(&mut self.open_trade_state_d)
                // CRT.diag Groups A + B: signal persistence + conviction histogram.
                .arg(&mut self.diag_flip_count_d)
                .arg(&mut self.diag_current_run_length_d)
                .arg(&mut self.diag_sum_run_length_d)
                .arg(&mut self.diag_run_length_hist_d)
                .arg(&mut self.diag_prev_dir_signed_d)
                .arg(&mut self.diag_conv_hist_d)
                // CRT.diag.2: per-horizon alpha-input EMA state.
                .arg(&mut self.alpha_ema_per_b_per_h_d)
                .arg(&mut self.alpha_diff_var_per_b_per_h_d)
                .arg(&mut self.alpha_sample_var_per_b_per_h_d)
                // CRT.diag.2 Group E: smoothed-direction flip counters.
                .arg(&mut self.diag_smoothed_flip_count_d)
                .arg(&mut self.diag_smoothed_current_run_length_d)
                .arg(&mut self.diag_smoothed_sum_run_length_d)
                .arg(&mut self.diag_smoothed_prev_dir_d)
                .arg(&n)
                .launch(cfg)?;
        }
        // 1b: default kernel (skips backtests with plen>0 internally).
        // CRT.1 C1.2: the multi-horizon §4.4 conviction formula replaces
        // the v2 per-horizon Kelly + sharpe-weighted aggregate, so the
        // default kernel no longer takes target_annual_vol_units /
        // annualisation_factor / kelly_frac_floor / sharpe_weight_floor.
        // The bytecode VM (decision_policy_program) still uses those
        // params for VM opcodes — their device slots stay allocated.
        let mut launch = self.stream.launch_builder(&self.decision_fn);
        unsafe {
            launch
                .arg(&self.alpha_probs_d)
                .arg(&self.isv_kelly_d)
                .arg(&self.pos_d)
                .arg(&self.program_lens_d)
                .arg(&mut self.market_targets_d)
                .arg(&mut self.open_horizon_mask_d)
                .arg(&self.max_lots_d)
                .arg(&self.threshold_d)
                .arg(&self.cost_per_lot_per_side_d)
                .arg(&self.atr_mid_ema_d)
                .arg(&mut self.trail_hwm_d)
                .arg(&self.books_d)
                // S2.1: mh_kernel_calls diagnostic counter.
                .arg(&mut self.mh_kernel_calls_d)
                // Anti-calibration fix 2026-05-22: SIGNED conviction-EMA state.
                .arg(&mut self.conv_signed_ema_d)
                .arg(&mut self.conv_signed_diff_var_ema_d)
                .arg(&mut self.conv_signed_sample_var_ema_d)
                // CRT.1 C1.4: trajectory state for composite exit_signal.
                .arg(&mut self.open_trade_state_d)
                // CRT.diag Groups A + B: signal persistence + conviction histogram.
                .arg(&mut self.diag_flip_count_d)
                .arg(&mut self.diag_current_run_length_d)
                .arg(&mut self.diag_sum_run_length_d)
                .arg(&mut self.diag_run_length_hist_d)
                .arg(&mut self.diag_prev_dir_signed_d)
                .arg(&mut self.diag_conv_hist_d)
                // CRT.diag.2: per-horizon alpha-input EMA state.
                .arg(&mut self.alpha_ema_per_b_per_h_d)
                .arg(&mut self.alpha_diff_var_per_b_per_h_d)
                .arg(&mut self.alpha_sample_var_per_b_per_h_d)
                // CRT.diag.2 Group E: smoothed-direction flip counters.
                .arg(&mut self.diag_smoothed_flip_count_d)
                .arg(&mut self.diag_smoothed_current_run_length_d)
                .arg(&mut self.diag_smoothed_sum_run_length_d)
                .arg(&mut self.diag_smoothed_prev_dir_d)
                .arg(&n)
                .launch(cfg)?;
        }
        self.stream.synchronize()?;

        // Persist open_horizon_mask into Pos.open_horizon_mask via a small
        // helper kernel? No — the decision kernel wrote it into open_horizon_mask_d
        // already, and pnl_track only needs it when detecting close. Instead
        // of changing pnl_track's interface, we copy the device→device value
        // into Pos.open_horizon_mask field via the matching kernel's natural
        // flow: the submit_market kernel doesn't touch this field, but
        // we set it here pre-trade so it persists until close.
        //
        // Simpler approach: read pos[b] for each backtest with active orders,
        // OR the broadcast mask onto pos[b].open_horizon_mask. Done via a
        // tiny host-loop here (cold path, runs once per decision; <50 µs).
        self.merge_open_mask_into_pos()?;

        // Step 2: P3 — GPU-side snapshot of pre-submit Pos state for
        // close-detection. Replaces the host-side snapshot trio.
        let mut launch = self.stream.launch_builder(&self.snapshot_pos_state_fn);
        unsafe {
            launch
                .arg(&self.pos_d)
                .arg(&mut self.prev_pos_lots_d)
                .arg(&mut self.prev_realized_pnl_d)
                .arg(&mut self.prev_open_horizon_mask_d)
                .arg(&n)
                .launch(cfg)?;
        }

        // Step 3: always dispatch through the latency path. When a backtest's
        // latency_ns == 0 the in-flight slot's arrival_ts equals current_ts
        // and step_resting_orders promotes immediately on the next snapshot,
        // functionally equivalent to the legacy submit_market_immediate path
        // but supporting per-backtest latencies in one code path.
        self.dispatch_latent_market_orders(current_ts_ns, sim_cfg)?;

        // Step 4: pnl_track — detect close, emit TradeRecord.
        // Phase 0 reward-redesign (2026-06-02): zero per-batch USD pnl-delta
        // buffer first; kernel's close branch writes the per-trade USD pnl
        // for batches that closed this step. Stream-ordered with the launch.
        unsafe {
            ml_alpha::trainer::raw_launch::raw_memset_d8_zero(
                self.pnl_step_close_usd_d.raw_ptr(),
                self.pnl_step_close_usd_d.num_bytes(),
                self.stream.cu_stream(),
            ).map_err(|e| anyhow::anyhow!("zero pnl_step_close_usd_d (decision path): {:?}", e))?;
        }
        let cap = crate::lob::TRADE_LOG_CAP as i32;
        let mut launch = self.stream.launch_builder(&self.pnl_track_fn);
        unsafe {
            launch
                .arg(&mut self.pos_d)
                .arg(&mut self.open_trade_state_d)
                .arg(&mut self.trade_log_d)
                .arg(&mut self.trade_log_head_d)
                .arg(&current_ts_ns)
                .arg(&cap)
                .arg(&n)
                .arg(&mut self.trail_hwm_d)
                .arg(&mut self.zero_vwap_at_open_d)
                .arg(&mut self.saturated_vwap_at_open_d)
                .arg(&mut self.defensive_exit_clamp_d)
                // CRT.1 C1.4: open branch snapshots conviction_at_entry +
                // initializes the in-trade conviction_ema magnitude mirror
                // in open_trade_state. Post-fix 2026-05-22: kernel reads
                // the SIGNED EMA and takes fabsf before writing.
                .arg(&self.conv_signed_ema_d)
                // CRT.diag Groups C + D: hold-time + outcome-by-entry-conviction.
                .arg(&mut self.diag_hold_hist_d)
                .arg(&mut self.diag_outcome_n_d)
                .arg(&mut self.diag_outcome_sum_pnl_d)
                .arg(&mut self.diag_outcome_n_wins_d)
                // Phase 0 reward-redesign: per-batch USD pnl-delta buffer.
                .arg(&mut self.pnl_step_close_usd_d)
                .launch(cfg)?;
        }
        self.stream.synchronize()?;

        // Step 5: P3 — GPU-side close-transition detection + on-device
        // dispatch of isv_kelly_update_on_close. No host roundtrip needed;
        // kernel writes closed_horizon_mask_d + realised_return_d directly.
        let mut launch = self.stream.launch_builder(&self.detect_close_fn);
        unsafe {
            launch
                .arg(&self.pos_d)
                .arg(&self.prev_pos_lots_d)
                .arg(&self.prev_realized_pnl_d)
                .arg(&self.prev_open_horizon_mask_d)
                .arg(&mut self.closed_horizon_mask_d)
                .arg(&mut self.realised_return_d)
                .arg(&n)
                .launch(cfg)?;
        }
        // isv_kelly_update_on_close always launches; kernel skips backtests
        // whose mask is 0 internally. Cheaper than a host any_close branch
        // that would require a memcpy_dtoh.
        let mut launch = self.stream.launch_builder(&self.isv_kelly_update_fn);
        unsafe {
            launch
                .arg(&mut self.isv_kelly_d)
                .arg(&self.closed_horizon_mask_d)
                .arg(&self.realised_return_d)
                .arg(&n)
                .launch(cfg)?;
        }
        self.stream.synchronize()?;

        Ok(())
    }

    /// For each backtest whose market_targets[b] is a non-noop, allocate
    /// an in-flight LimitSlot with arrival_ts_ns = current + latency_ns.
    /// Price is set very-aggressive (mid ± 1000) so the marketability
    /// check unconditionally crosses at arrival; this is the simplest
    /// way to model a "market order with latency" — the slippage that
    /// matters is which book the order arrives against, not the limit
    /// price boundary.
    fn dispatch_latent_market_orders(
        &mut self,
        current_ts_ns: u64,
        _sim_cfg: &BatchedSimConfig,
    ) -> Result<()> {
        // P2: GPU-side seed of in-flight LimitSlots. Replaces the host
        // roundtrip loop. Per-backtest latency reads from self.latency_ns_d
        // (already uploaded by step_decision_with_latency from sim_cfg).
        let cfg_launch = LaunchConfig {
            grid_dim: (self.n_backtests as u32, 1, 1),
            block_dim: (32, 1, 1),
            shared_mem_bytes: 0,
        };
        let n = self.n_backtests as i32;
        let orders_bytes = crate::lob::ORDERS_BYTES as i32;
        let mut launch = self.stream.launch_builder(&self.seed_inflight_limits_fn);
        unsafe {
            launch
                .arg(&mut self.orders_d)
                .arg(&orders_bytes)
                .arg(&mut self.pos_d)
                .arg(&self.market_targets_d)
                .arg(&self.latency_ns_d)
                .arg(&current_ts_ns)
                // S2.1: counter 8 — seed_inflight saw force-flat target.
                .arg(&mut self.mh_force_flat_seen_by_seed_d)
                // CRT.1 C1.3: no-trade band — kernel skips seeding when
                // |target_signed − effective| < delta_floor_per_b[b].
                .arg(&self.delta_floor_d)
                .arg(&n)
                .launch(cfg_launch)?;
        }
        self.stream.synchronize()?;
        Ok(())
    }

    fn merge_open_mask_into_pos(&mut self) -> Result<()> {
        // Read open_horizon_mask, OR into pos[b].open_horizon_mask field, push back.
        // Pos field layout: position_lots(4) vwap_entry(4) realized_pnl(4)
        //                   peak_equity(4) submission_overflow(4) open_horizon_mask(4).
        let pos_bytes = std::mem::size_of::<PosFlat>();
        let mut raw = vec![0u8; self.n_backtests * pos_bytes];
        self.stream.memcpy_dtoh(&self.pos_d, raw.as_mut_slice())?;
        let mut masks = vec![0u32; self.n_backtests];
        self.stream.memcpy_dtoh(&self.open_horizon_mask_d, masks.as_mut_slice())?;
        for b in 0..self.n_backtests {
            let off = b * pos_bytes + 20;  // open_horizon_mask offset within PosFlat
            let mut cur_bytes = [0u8; 4];
            cur_bytes.copy_from_slice(&raw[off..off + 4]);
            let cur = u32::from_le_bytes(cur_bytes);
            let merged = cur | masks[b];
            raw[off..off + 4].copy_from_slice(&merged.to_le_bytes());
        }
        self.stream.memcpy_htod(raw.as_slice(), &mut self.pos_d)?;
        Ok(())
    }

    /// Write per-horizon ISV-Kelly state for a specific backtest (warm-start).
    pub fn write_isv_kelly(
        &mut self,
        backtest_idx: usize,
        states: &[crate::policy::IsvKellyStateHost; N_HORIZONS],
    ) -> Result<()> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let total_bytes = self.n_backtests * N_HORIZONS * ISV_KELLY_STATE_BYTES;
        let mut raw = vec![0u8; total_bytes];
        self.stream.memcpy_dtoh(&self.isv_kelly_d, raw.as_mut_slice())?;
        let off = backtest_idx * N_HORIZONS * ISV_KELLY_STATE_BYTES;
        let bytes: &[u8] = bytemuck::cast_slice(states);
        raw[off..off + bytes.len()].copy_from_slice(bytes);
        self.stream.memcpy_htod(raw.as_slice(), &mut self.isv_kelly_d)?;
        Ok(())
    }

    /// Read per-horizon ISV-Kelly state for a specific backtest.
    pub fn read_isv_kelly(
        &self,
        backtest_idx: usize,
    ) -> Result<[crate::policy::IsvKellyStateHost; N_HORIZONS]> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let mut raw = vec![0u8; self.n_backtests * N_HORIZONS * ISV_KELLY_STATE_BYTES];
        self.stream.memcpy_dtoh(&self.isv_kelly_d, raw.as_mut_slice())?;
        let off = backtest_idx * N_HORIZONS * ISV_KELLY_STATE_BYTES;
        let slice = &raw[off..off + N_HORIZONS * ISV_KELLY_STATE_BYTES];
        let mut out = [crate::policy::IsvKellyStateHost::default(); N_HORIZONS];
        let bytes_out: &mut [u8] = bytemuck::cast_slice_mut(&mut out);
        bytes_out.copy_from_slice(slice);
        Ok(out)
    }

    /// Seed a limit slot directly (host-side submission). Used for fixture
    /// testing + as a v1 entry point until the decision kernel learns to
    /// emit resting limits. side: 0=buy, 1=sell. kind: 1=Limit, 2=Ioc, 3=Fok.
    /// active_state: 1=resting immediately, 2=in-flight (waits for arrival_ts_ns).
    pub fn seed_limit_order(
        &mut self,
        backtest_idx: usize,
        slot_idx: usize,
        side: u8,
        kind: u8,
        active_state: u8,
        oco_pair: u8,
        price: f32,
        size: f32,
        queue_position_init: f32,
        arrival_ts_ns: u64,
    ) -> Result<()> {
        anyhow::ensure!(backtest_idx < self.n_backtests);
        anyhow::ensure!(slot_idx < crate::lob::MAX_LIMITS);
        // Reset overflow flag so we can detect double-allocation.
        let zero = vec![0u32; 1];
        self.stream.memcpy_htod(zero.as_slice(), &mut self.overflow_flag_d)?;

        let cfg = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (1, 1, 1),
            shared_mem_bytes: 0,
        };
        let orders_bytes = crate::lob::ORDERS_BYTES as i32;
        let b = backtest_idx as i32;
        let si = slot_idx as i32;
        let mut launch = self.stream.launch_builder(&self.seed_limit_fn);
        unsafe {
            launch
                .arg(&mut self.orders_d)
                .arg(&orders_bytes)
                .arg(&b)
                .arg(&si)
                .arg(&side)
                .arg(&kind)
                .arg(&active_state)
                .arg(&oco_pair)
                .arg(&price)
                .arg(&size)
                .arg(&queue_position_init)
                .arg(&arrival_ts_ns)
                .arg(&mut self.overflow_flag_d)
                .launch(cfg)?;
        }
        self.stream.synchronize()?;
        let mut overflow = vec![0u32; 1];
        self.stream.memcpy_dtoh(&self.overflow_flag_d, overflow.as_mut_slice())?;
        anyhow::ensure!(
            overflow[0] == 0,
            "seed_limit_order: slot {slot_idx} on backtest {backtest_idx} already in use"
        );
        Ok(())
    }

    /// Seed a stop slot. kind: 4=StopMarket, 5=StopLimit. For StopMarket
    /// pass limit_price=0.0 (unused).
    pub fn seed_stop_order(
        &mut self,
        backtest_idx: usize,
        slot_idx: usize,
        side: u8,
        kind: u8,
        active_state: u8,
        oco_pair: u8,
        trigger_price: f32,
        limit_price: f32,
        size: f32,
        arrival_ts_ns: u64,
    ) -> Result<()> {
        anyhow::ensure!(backtest_idx < self.n_backtests);
        anyhow::ensure!(slot_idx < crate::lob::MAX_STOPS);
        let zero = vec![0u32; 1];
        self.stream.memcpy_htod(zero.as_slice(), &mut self.overflow_flag_d)?;

        let cfg = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (1, 1, 1),
            shared_mem_bytes: 0,
        };
        let orders_bytes = crate::lob::ORDERS_BYTES as i32;
        let b = backtest_idx as i32;
        let si = slot_idx as i32;
        let mut launch = self.stream.launch_builder(&self.seed_stop_fn);
        unsafe {
            launch
                .arg(&mut self.orders_d)
                .arg(&orders_bytes)
                .arg(&b)
                .arg(&si)
                .arg(&side)
                .arg(&kind)
                .arg(&active_state)
                .arg(&oco_pair)
                .arg(&trigger_price)
                .arg(&limit_price)
                .arg(&size)
                .arg(&arrival_ts_ns)
                .arg(&mut self.overflow_flag_d)
                .launch(cfg)?;
        }
        self.stream.synchronize()?;
        let mut overflow = vec![0u32; 1];
        self.stream.memcpy_dtoh(&self.overflow_flag_d, overflow.as_mut_slice())?;
        anyhow::ensure!(
            overflow[0] == 0,
            "seed_stop_order: slot {slot_idx} on backtest {backtest_idx} already in use"
        );
        Ok(())
    }

    /// Run resting_orders_step kernel: in-flight promotion + queue decay
    /// + marketability check + stop trigger + OCO mutual cancellation.
    /// Should be called once per event (after apply_snapshot).
    pub fn step_resting_orders(&mut self, current_ts_ns: u64, trade_signed_vol: f32) -> Result<()> {
        let cfg = LaunchConfig {
            grid_dim: (self.n_backtests as u32, 1, 1),
            block_dim: (32, 1, 1),
            shared_mem_bytes: 0,
        };
        let n = self.n_backtests as i32;
        let orders_bytes = crate::lob::ORDERS_BYTES as i32;
        let pos_bytes = std::mem::size_of::<PosFlat>() as i32;
        let cap = crate::lob::RING_CAP_EVENTS as i32;
        let tick: f32 = 0.25;
        let mut launch = self.stream.launch_builder(&self.resting_orders_fn);
        unsafe {
            launch
                .arg(&self.books_d)
                .arg(&mut self.orders_d)
                .arg(&mut self.pos_d)
                .arg(&mut self.audit_d)
                .arg(&mut self.audit_head_d)
                .arg(&current_ts_ns)
                .arg(&trade_signed_vol)
                .arg(&orders_bytes)
                .arg(&pos_bytes)
                .arg(&cap)
                .arg(&tick)
                .arg(&self.cost_per_lot_per_side_d)
                .arg(&mut self.total_fees_per_b_d)
                .arg(&mut self.last_event_ts_d)
                // S2.2: max_hold force-close at event rate.
                .arg(&self.max_hold_ns_d)
                .arg(&self.open_trade_state_d)
                .arg(&mut self.nan_avg_px_d)
                .arg(&mut self.nan_realised_d)
                .arg(&mut self.nan_realized_pnl_d)
                .arg(&mut self.vwap_zero_open_flat_d)
                .arg(&mut self.vwap_huge_open_flat_d)
                .arg(&mut self.vwap_zero_scale_in_d)
                .arg(&mut self.vwap_huge_scale_in_d)
                .arg(&mut self.vwap_zero_flip_d)
                .arg(&mut self.vwap_huge_flip_d)
                .arg(&mut self.vwap_session_gap_was_bad_d)
                .arg(&mut self.last_bad_vwap_d)
                .arg(&mut self.last_bad_path_d)
                .arg(&self.min_reasonable_px_d)
                .arg(&self.max_reasonable_px_d)
                .arg(&n)
                .launch(cfg)?;
        }
        self.stream.synchronize()?;
        // Sync pnl_track so any close from a resting fill emits a TradeRecord.
        self.step_pnl_track(current_ts_ns)?;
        Ok(())
    }

    /// Read one limit slot. Used for fixture inspection.
    pub fn read_limit_slot(
        &self,
        backtest_idx: usize,
        slot_idx: usize,
    ) -> Result<crate::lob::LimitSlotFlat> {
        anyhow::ensure!(backtest_idx < self.n_backtests);
        anyhow::ensure!(slot_idx < crate::lob::MAX_LIMITS);
        let mut raw = vec![0u8; self.n_backtests * crate::lob::ORDERS_BYTES];
        self.stream.memcpy_dtoh(&self.orders_d, raw.as_mut_slice())?;
        let off = backtest_idx * crate::lob::ORDERS_BYTES
            + slot_idx * crate::lob::LIMIT_SLOT_BYTES;
        let s: crate::lob::LimitSlotFlat =
            bytemuck::pod_read_unaligned(&raw[off..off + crate::lob::LIMIT_SLOT_BYTES]);
        Ok(s)
    }

    /// Read one stop slot.
    pub fn read_stop_slot(
        &self,
        backtest_idx: usize,
        slot_idx: usize,
    ) -> Result<crate::lob::StopSlotFlat> {
        anyhow::ensure!(backtest_idx < self.n_backtests);
        anyhow::ensure!(slot_idx < crate::lob::MAX_STOPS);
        let mut raw = vec![0u8; self.n_backtests * crate::lob::ORDERS_BYTES];
        self.stream.memcpy_dtoh(&self.orders_d, raw.as_mut_slice())?;
        let stops_base = backtest_idx * crate::lob::ORDERS_BYTES
            + crate::lob::MAX_LIMITS * crate::lob::LIMIT_SLOT_BYTES;
        let off = stops_base + slot_idx * crate::lob::STOP_SLOT_BYTES;
        let s: crate::lob::StopSlotFlat =
            bytemuck::pod_read_unaligned(&raw[off..off + crate::lob::STOP_SLOT_BYTES]);
        Ok(s)
    }

    /// CRT.1 C1.3: count active (non-zero) limit slots for one backtest.
    /// Returns the number of LimitSlots with active != 0, summing both
    /// resting (active=1) and in-flight (active=2) slots. Used to verify
    /// that the no-trade band blocks spurious re-seeding.
    pub fn read_inflight_count(&self, backtest_idx: usize) -> Result<u32> {
        anyhow::ensure!(
            backtest_idx < self.n_backtests,
            "backtest_idx {} >= n_backtests {}",
            backtest_idx,
            self.n_backtests
        );
        let mut raw = vec![0u8; self.n_backtests * crate::lob::ORDERS_BYTES];
        self.stream.memcpy_dtoh(&self.orders_d, raw.as_mut_slice())?;
        let base = backtest_idx * crate::lob::ORDERS_BYTES;
        let mut count = 0u32;
        for s_idx in 0..crate::lob::MAX_LIMITS {
            let off = base + s_idx * crate::lob::LIMIT_SLOT_BYTES;
            // active is the first byte of LimitSlotFlat.
            if raw[off] != 0 {
                count += 1;
            }
        }
        Ok(count)
    }

    /// Read back every backtest's book state from device. For tests + diagnostics.
    pub fn read_books(&self) -> Result<Vec<BookFlat>> {
        let mut raw = vec![0.0f32; self.n_backtests * BOOK_FIELDS * BOOK_LEVELS];
        self.stream.memcpy_dtoh(&self.books_d, raw.as_mut_slice())?;
        let mut out = Vec::with_capacity(self.n_backtests);
        for b in 0..self.n_backtests {
            let off = b * BOOK_FIELDS * BOOK_LEVELS;
            let mut bf = BookFlat::default();
            for k in 0..BOOK_LEVELS {
                bf.bid_px[k] = raw[off + k];
                bf.bid_sz[k] = raw[off + BOOK_LEVELS + k];
                bf.ask_px[k] = raw[off + 2 * BOOK_LEVELS + k];
                bf.ask_sz[k] = raw[off + 3 * BOOK_LEVELS + k];
            }
            out.push(bf);
        }
        Ok(out)
    }
}
