/**
 * experience_kernels.cu
 *
 * Three focused CUDA kernels that handle the non-Q-forward parts of DQN
 * experience collection.  The Q-network forward pass is performed by cuBLAS
 * GemmEx BF16 from the Rust host between kernel launches.
 *
 * Kernels:
 *   1. experience_state_gather  — assemble batch state tensor for cuBLAS
 *   2. experience_action_select — epsilon-greedy on cuBLAS Q-values
 *   3. experience_env_step      — portfolio simulation, reward, done detection
 *
 * Design:
 *   - Grid: ceil(N/256), Block: 256.  One thread per episode — trivially parallel.
 *   - No shared memory required.
 *   - Native float everywhere — zero float storage on GPU.
 *
 * State layout (matches common_device_functions.cuh):
 *   [0 .. market_dim)                      : market features
 *   [market_dim .. market_dim+3)           : portfolio features (value_norm, position, cash_norm)
 *   [market_dim+3 .. state_dim)            : zero-pad for tensor-core alignment
 *
 * Portfolio state layout used by experience kernels ([N, PORTFOLIO_STRIDE=40]):
 *   [0]  position        — current contract position (signed)
 *   [1]  cash            — cash balance
 *   [2]  portfolio_value — mark-to-market total value (cash + position * price)
 *   [3]  dsr_A           — EMA of realized trade returns (v9 DSR)
 *   [4]  dsr_B           — EMA of squared trade returns (v9 DSR)
 *   [5]  dsr_trade_count — completed trades counter (for DSR warmup)
 *   [6]  (reserved)
 *   [7]  peak_equity     — high-water mark (init to initial_capital)
 *   [8]  flat_counter    — consecutive flat steps
 *   [9]  prev_equity     — equity at previous step (init to initial_capital)
 *   [10] hold_time       — consecutive steps with position (total, not just losing)
 *   [11] realized_pnl    — cumulative realized PnL
 *   [20] intra_trade_max_dd — worst unrealized drawdown during current trade (v8)
 *   [21] intra_trade_max_pnl — best unrealized P&L during current trade (hindsight)
 *   [22] (reserved)      — was interval_sum (clustering stats, slot retained)
 *   [23] plan_target_bars  — 0=no plan, >0=active plan max hold bars
 *   [24] plan_profit_target — raw profit threshold %
 *   [25] plan_stop_loss     — raw stop loss threshold %
 *   [26] plan_scale_aggression — position ramp speed
 *   [27] plan_conviction    — position size fraction
 *   [28] plan_asymmetry     — profit/stop ratio
 *   [29] counter_plan_q     — opposite direction Q at entry
 *
 * Branching DQN (Tavakoli et al., 2018) — 4-branch hierarchical:
 *   4 independent advantage heads: direction (b0_size=4), magnitude (b1_size=3),
 *   order (b2_size=3), urgency (b3_size=3).
 *   cuBLAS produces q_values [N, 12] = concat([b0],[b1],[b2],[b3]).
 *   Factored action = dir * (b1*b2*b3) + mag * (b2*b3) + order * b3 + urgency.
 *   Direction: 0=Short(-1), 1=Flat(0), 2=Long(+1).
 *   Magnitude: 0=Quarter(0.25), 1=Half(0.50), 2=Full(1.00).
 *   Position = direction * magnitude * max_position.
 *
 * Advanced features deferred to follow-up tasks (Phase 4+):
 *   curiosity, barrier tracking, diversity entropy, fill simulation,
 *   NoisyNet, C51, RMSNorm, N-step returns, action masking.
 */

/* ------------------------------------------------------------------ */
/* Portfolio stride for experience kernels (43 floats per episode).    */
/* portfolio_sim_kernel uses its own stride of 8 — do NOT change it.  */
/* ------------------------------------------------------------------ */
#define PORTFOLIO_STRIDE 43  /* was 41. Slots 30-37: prev-bar OFI; slot 38: PS_PEAK_PNL_BAR (Plan 3 C.4); slot 39: PS_INTRA_TRADE_MIN_PNL (Plan 3 D.4a); slot 40: PS_REGIME_SHIFT_BAR (Plan 3 D.4b); slots 41-42: PS_PRE_ENTRY_CONVICTION_{EMA,VAR_EMA} (Plan 3 D.4c) */
#define DSR_A_SLOT 3
#define DSR_B_SLOT 4
#define DSR_TRADE_COUNT_SLOT 5
#define PREV_CLOSE_SLOT 6    /* reserved slot, now stores prev bar's raw_close for DSR */
#define PREV_MID_SLOT 22     /* reserved slot, now stores prev bar's MBP-10 mid-price */

/* Task 2.4: `asymmetric_soft_clamp()` DELETED from the reward layer. The
 * negative-tail compression invariant (protect Q-targets from catastrophic-
 * loss gradient dominance) was RELOCATED to `c51_loss_kernel.cu::
 * block_bellman_project_f` — Q-target smoothing is the structurally-correct
 * home for that protection, per reward-inventory's "wrong-level
 * regularization" pattern. The upper +10 cap is preserved inline at each
 * former call site as `fminf(reward, 10.0f)`.
 * Ref: docs/superpowers/specs/2026-04-21-policy-quality-track2-triage.md §R6. */

/* Shared trade physics (action decoding, hold enforcement, tx costs,
 * capital floor). Single source of truth — also used by backtest_env_kernel. */
#include "trade_physics.cuh"

/* Canonical state layout — single source of truth for state assembly.
 * Used by both experience_state_gather and backtest_state_gather. */
#include "state_layout.cuh"

/* ------------------------------------------------------------------ */
/* BF16 support — common_device_functions.cuh is prepended by build.rs */
/* ------------------------------------------------------------------ */

/* ------------------------------------------------------------------ */
/* Compile-time constants — overridable via NVRTC #define injection.  */
/* ------------------------------------------------------------------ */

#ifndef MARKET_DIM
#define MARKET_DIM 42  /* 42 base features; 50 when OFI (MBP-10) is enabled */
#endif

#ifndef PORTFOLIO_DIM
#define PORTFOLIO_DIM 3
#endif

#ifndef STATE_DIM
#define STATE_DIM 48
#endif

/* ------------------------------------------------------------------ */
/* Inline helpers — self-contained, no external dependencies.         */
/* ------------------------------------------------------------------ */

/**
 * LCG random — returns float in [0, 1).
 *
 * RNG state manipulation must stay in 32-bit unsigned int (integer math).
 * Returns float because [0,1) probability comparisons with epsilon need
 * more precision than bf16 can offer (bf16 has only 8 mantissa bits).
 */
__device__ __forceinline__ float lcg_random(unsigned int* state) {
    *state = *state * 1664525u + 1013904223u;
    return (float)(*state & 0x00FFFFFFu) / 16777216.0f;
}

/**
 * Stateless Philox-based uniform random in [0, 1).
 * Deterministic given (episode_id, timestep, call_idx).
 * No accumulated state — immune to branching-dependent divergence.
 */
/* philox_uniform moved to common_device_functions.cuh */

/**
 * Argmax over a float array of length n.
 * Returns the index of the maximum element; ties broken in favour of the
 * lowest index (first maximum found).
 */
__device__ __forceinline__ int argmax_n(const float* arr, int n) {
    int best_idx = 0;
    float best_val = arr[0];
    for (int j = 1; j < n; j++) {
        if (arr[j] > best_val) {
            best_val = arr[j];
            best_idx = j;
        }
    }
    return best_idx;
}

/* exposure_idx_to_fraction DELETED — replaced by compute_target_position()
 * from trade_physics.cuh which uses dynamic step = 2/(b0_size-1). */

/* ================================================================== */
/* Thompson sampling math (Plan C Phase 2)                            */
/* ------------------------------------------------------------------ */
/* Mirrors the math in thompson_test_kernel.cu so the production      */
/* kernel can sample from C51/IQN posteriors and compute argmax-of-   */
/* E[Q] for the direction branch. Single source of truth: keep these  */
/* in lock-step with thompson_test_kernel.cu — Test 2.B verifies      */
/* bit-for-bit equivalence between the two kernel files.              */
/* No callers in this commit — Task 2 wires the direction-branch      */
/* Thompson selector that consumes them.                              */
/* ================================================================== */

#ifndef N_IQN_QUANTILES
#define N_IQN_QUANTILES 5
#endif

/* SP4 Layer B: per-branch atom-position bound from ISV[ATOM_POS_BOUND_BASE + branch].
 * Mirrors `crates/ml/src/cuda_pipeline/sp4_isv_slots.rs::ATOM_POS_BOUND_BASE = 132`.
 */
#ifndef ATOM_POS_BOUND_BASE
#define ATOM_POS_BOUND_BASE 132
#endif

/**
 * Inverse-CDF sample from a C51 atom distribution.
 *
 * @param p           Probability vector of length n_atoms; sums to ~1.0.
 * @param atom_values Atom positions (support of the distribution); length n_atoms.
 * @param n_atoms    Number of atoms (length of `p` and `atom_values`).
 * @param u           Uniform random draw in [0, 1).
 * @return            The atom value at the smallest index `a` with cumulative
 *                    mass strictly greater than `u`. If floating-point roundoff
 *                    leaves the cumulative sum below `u` after iterating all
 *                    atoms, returns `atom_values[n_atoms - 1]` as a numerical
 *                    fallback (documented expected behavior, not off-by-one).
 */
__device__ __forceinline__ float sample_c51_inverse_cdf(
    const float* __restrict__ p,
    const float* __restrict__ atom_values,
    int n_atoms,
    float u
) {
    float cum = 0.0f;
    for (int a = 0; a < n_atoms; a++) {
        cum += p[a];
        if (u < cum) return atom_values[a];
    }
    return atom_values[n_atoms - 1];  // numerical fallback
}

/**
 * Sample from an IQN posterior by uniform-tau interpolation over the 5 fixed
 * quantiles produced by the IQN head.
 *
 * @param q  Quantile values at tau in {0.05, 0.25, 0.50, 0.75, 0.95}; length
 *           N_IQN_QUANTILES (5).
 * @param u  Uniform random draw in [0, 1).
 * @return   Linear interpolation of `q` at `u`. Below tau=0.05 returns q[0];
 *           above tau=0.95 returns q[4] (clamped tails — IQN does not extrapolate).
 */
__device__ __forceinline__ float sample_iqn_quantile_interp(
    const float* __restrict__ q,
    float u
) {
    const float taus[N_IQN_QUANTILES] = {0.05f, 0.25f, 0.50f, 0.75f, 0.95f};
    if (u <= taus[0]) return q[0];
    if (u >= taus[N_IQN_QUANTILES - 1]) return q[N_IQN_QUANTILES - 1];
    for (int i = 0; i < N_IQN_QUANTILES - 1; i++) {
        if (u <= taus[i + 1]) {
            float w = (u - taus[i]) / (taus[i + 1] - taus[i]);
            return q[i] * (1.0f - w) + q[i + 1] * w;
        }
    }
    return q[N_IQN_QUANTILES - 1];
}

/**
 * Compute E[Q] from C51 atom probabilities and values.
 *
 * @param p           Probability vector of length n_atoms; sums to ~1.0.
 * @param atom_values Atom positions; length n_atoms.
 * @param n_atoms    Number of atoms.
 * @return            Sum_a p[a] * atom_values[a] — the expected return under
 *                    the C51 categorical posterior.
 */
__device__ __forceinline__ float compute_e_c51_inline(
    const float* __restrict__ p,
    const float* __restrict__ atom_values,
    int n_atoms
) {
    float e = 0.0f;
    for (int a = 0; a < n_atoms; a++) e += p[a] * atom_values[a];
    return e;
}

/**
 * Compute E[Q] from IQN quantiles as the mean of the fixed-tau quantiles.
 *
 * @param q  Quantile values at tau in {0.05, 0.25, 0.50, 0.75, 0.95}; length
 *           N_IQN_QUANTILES (5).
 * @return   (1/5) * sum(q[i]) — IQN expected return under the uniform-tau
 *           sampling convention used elsewhere in the pipeline.
 */
__device__ __forceinline__ float compute_e_iqn_inline(const float* __restrict__ q) {
    float s = 0.0f;
    for (int i = 0; i < N_IQN_QUANTILES; i++) s += q[i];
    return s / (float)N_IQN_QUANTILES;
}

/**
 * Plan C Phase 2 Task 2 amendment (2026-04-29): direction-branch Thompson
 * now consumes RAW C51 logits + adaptive per-direction support instead of
 * the plan-as-written `c51_probs_dir + atom_values` pair, because:
 *   - The collector materialises `exp_b_logits` (raw logits) only — there
 *     is no post-softmax `c51_probs_dir` buffer on the rollout path.
 *   - Production C51 uses per-sample per-direction adaptive support
 *     [v_min, v_max, delta_z] (see `c51_loss_kernel.cu` lines 729-744),
 *     plus optional non-linear `atom_positions [b0_size, n_atoms]`.
 *     A single global `atom_values [n_atoms]` doesn't exist.
 * The amendment keeps the principled posterior-sample fix to UCB asymmetry
 * but sources the distribution from what the collector already has.
 *
 * `softmax_c51_inline` matches `compute_expected_q`'s numerically-stable
 * (subtract max, then exp + normalise) softmax. `compute_atom_values_inline`
 * mirrors the per-branch atom-support contract from `c51_loss_kernel.cu`
 * (atom_positions when non-NULL else linear v_min + a * delta_z).
 *
 * Suppress the unused `v_max` slot of per_sample_support: the loss kernel
 * derives `delta_z` independently and only uses `v_max` for clamping inside
 * the projection — for E[Q] / sampling we just need atom positions.
 */

/**
 * Numerically-stable softmax over C51 atom logits.
 *
 * @param logits     [n_atoms] raw C51 logits for one (sample, direction).
 * @param n_atoms    Atom count (must be ≤ caller's stack array length).
 * @param probs_out  [n_atoms] caller-allocated output buffer (typically a
 *                   stack array sized to THOMPSON_MAX_ATOMS).
 *
 * Subtracts max(logits) before expf to prevent overflow; falls back to
 * uniform on numerical underflow (sum == 0).
 */
__device__ __forceinline__ void softmax_c51_inline(
    const float* __restrict__ logits,
    int n_atoms,
    float* probs_out
) {
    float max_logit = logits[0];
    for (int a = 1; a < n_atoms; a++) {
        if (logits[a] > max_logit) max_logit = logits[a];
    }
    float sum = 0.0f;
    for (int a = 0; a < n_atoms; a++) {
        probs_out[a] = expf(logits[a] - max_logit);
        sum += probs_out[a];
    }
    float inv_sum = (sum > 0.0f) ? (1.0f / sum) : (1.0f / (float)n_atoms);
    for (int a = 0; a < n_atoms; a++) {
        probs_out[a] *= inv_sum;
    }
}

/**
 * Compute atom values for one (sample, direction) using the production
 * adaptive C51 support contract.
 *
 * Layout match (mirrors `c51_loss_kernel.cu` lines 728-748):
 *   per_sample_support [N, b0_size, 3] stride-12 — sample-major. Slot
 *   layout per direction: [v_min, v_max, delta_z]. Strided index for
 *   sample i, direction d is `i * b0_size * 3 + d * 3`.
 *
 *   atom_positions [b0_size, n_atoms] non-linear positions; NULL = use
 *   linear support (v_min + a * delta_z). When non-NULL, indexed by
 *   `d * n_atoms + a`.
 *
 * @param per_sample_support  [N, b0_size, 3] stride-12 device pointer.
 * @param atom_positions      [b0_size, n_atoms] adaptive positions; may
 *                            be NULL (then linear support is used).
 * @param i                   Sample index.
 * @param d                   Direction index ∈ [0, b0_size).
 * @param b0_size             Direction-branch action count (4 currently).
 * @param n_atoms             C51 atom count (must be ≤ caller's stack
 *                            array length).
 * @param atom_values_out     [n_atoms] caller-allocated output buffer.
 */
__device__ __forceinline__ void compute_atom_values_inline(
    const float* __restrict__ per_sample_support,
    const float* __restrict__ atom_positions,
    int i,
    int d,
    int b0_size,
    int n_atoms,
    float* atom_values_out
) {
    long long support_base = (long long)i * b0_size * 3 + (long long)d * 3;
    float v_min   = per_sample_support[support_base + 0];
    float v_max   = per_sample_support[support_base + 1];
    float delta_z = per_sample_support[support_base + 2];
    if (atom_positions != NULL) {
        long long pos_base = (long long)d * n_atoms;
        for (int a = 0; a < n_atoms; a++) {
            atom_values_out[a] = atom_positions[pos_base + a];
        }
    } else {
        for (int a = 0; a < n_atoms; a++) {
            atom_values_out[a] = v_min + (float)a * delta_z;
        }
    }
    /* v_max is unused here — atoms are derived from v_min + delta_z (linear
     * grid) or atom_positions (non-linear). Suppress unused-variable warning. */
    (void)v_max;
}

/* ================================================================== */
/* Kernel 0a: domain_rand_episode_starts (GPU-native #1)              */
/* ================================================================== */

/**
 * Generate jittered episode start indices entirely on GPU.
 * Replaces CPU rand::thread_rng() + Vec<i32> construction.
 *
 * Each episode gets: base = (i * stride) % usable_bars + jitter
 * where jitter is LCG-random in [-stride/4, +stride/4].
 *
 * Grid: ceil(N / 256), Block: 256.  One thread per episode.
 */
extern "C" __global__ void domain_rand_episode_starts(
    int* __restrict__ episode_starts,       /* [N] output */
    int N,
    int stride,
    int usable_bars,
    int epoch                               /* epoch counter for seed diversity */
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    int base = (i * stride) % usable_bars;

    /* Stateless jitter ±25% stride via Philox hash — epoch in seed
       so each epoch samples different starting points */
    if (stride > 0) {
        int range = stride / 4;
        if (range > 0) {
            unsigned int bits = (unsigned int)(philox_uniform(i, epoch, 9999) * (float)(range * 2 + 1));
            int jitter = (int)(bits) - range;
            base = ((base + jitter) % usable_bars + usable_bars) % usable_bars;
        }
    }

    episode_starts[i] = base;
}

/**
 * Generate per-episode domain randomization parameters on GPU.
 * Replaces CPU epoch_rng.gen_range() for spread, tx_cost, fills.
 *
 * Output: [N, 5] float array:
 *   [0] tx_cost_multiplier  (range [0.5, 2.5])
 *   [1] fill_median_spread  (base * range [0.5, 3.0])
 *   [2] fill_ioc_fill_prob  (range [0.65, 0.95])
 *   [3] fill_limit_fill_min (range [0.15, 0.45])
 *   [4] fill_limit_fill_max (range [0.60, 0.95])
 *
 * When enable_dr=0, outputs the base values (no randomization).
 *
 * Grid: ceil(N / 256), Block: 256.  One thread per episode.
 */
extern "C" __global__ void domain_rand_sim_params(
    float* __restrict__ params_out,         /* [N, 5] output */
    int N,
    float base_tx_cost,
    float base_spread,
    float base_fill_prob,
    float base_fill_min,
    float base_fill_max
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    float* out = params_out + i * 5;

    /* Stateless Philox randomization — deterministic per episode */
    #define PHILOX_UNIFORM(lo, hi, idx) ((lo) + philox_uniform(i, 0, 2000 + (idx)) * ((hi) - (lo)))

    out[0] = PHILOX_UNIFORM(0.5f, 2.5f, 0);               /* tx_cost_multiplier */
    out[1] = base_spread * PHILOX_UNIFORM(0.5f, 3.0f, 1);  /* fill_median_spread */
    out[2] = PHILOX_UNIFORM(0.65f, 0.95f, 2);              /* fill_ioc_fill_prob */
    out[3] = PHILOX_UNIFORM(0.15f, 0.45f, 3);              /* fill_limit_fill_min */
    out[4] = PHILOX_UNIFORM(0.60f, 0.95f, 4);              /* fill_limit_fill_max */

    #undef PHILOX_UNIFORM
}

/* ================================================================== */
/* Kernel 0b: saboteur_generate_params (#33 GPU-native)               */
/* ================================================================== */

/**
 * Generate per-episode adversarial market parameters using GPU-resident LCG RNG.
 * Each episode gets independent spread/fill/slippage multipliers centered
 * around the current best params with random perturbation.
 *
 * Grid: ceil(N / 256), Block: 256.  One thread per episode.
 *
 * SP11 Fix 39 B1a (2026-05-04): the effective perturbation scale is the host-
 * passed scalar multiplied by the SP11 controller's saboteur_intensity_mult
 * read from `isv_signals_ptr[saboteur_intensity_mult_slot]`. Per
 * `feedback_cpu_is_read_only`, this multiply happens GPU-side (not host-side
 * in the launcher). Sentinel-0 defense via `fmaxf(slot, SABOTEUR_MIN=0.5f)`
 * — at cold start (before A2's controller has run for the first time) the
 * ISV slot reads 0.0; the floor prevents the saboteur from being silently
 * disabled. Post-controller, the slot is clamped to [SP11_SABOTEUR_MIN=0.5,
 * SP11_SABOTEUR_MAX=2.0] so the floor only fires at the very first cold-start
 * step. Spec §3.4.2.
 *
 * @param saboteur_params  [N, 3] output: (spread_mult, fill_prob, slippage_mult)
 * @param base_params      [3] current best params (center of perturbation)
 * @param perturbation_scale  base exploration width (decays over epochs)
 * @param isv_signals_ptr  pinned device-mapped ISV signals (NULL = static = mult ignored)
 * @param saboteur_intensity_mult_slot  ISV slot index for SP11 intensity multiplier
 * @param N                number of episodes
 */
extern "C" __global__ void saboteur_generate_params(
    float* __restrict__ saboteur_params,         /* [N, 3] output */
    const float* __restrict__ base_params,       /* [3] center */
    float perturbation_scale,
    const float* __restrict__ isv_signals_ptr,   /* pinned device-mapped ISV signals. NULL = static (no SP11 mult). */
    int saboteur_intensity_mult_slot,
    int N
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    float base_spread = base_params[0];
    float base_fill   = base_params[1];
    float base_slip   = base_params[2];

    /* SP11 B1a: GPU-side multiplication of base scale by SP11 controller's
     * saboteur_intensity_mult. Sentinel-0 defense (`fmaxf(slot, 0.5f)`)
     * keeps the saboteur active at cold start before the controller has
     * written its first real value. */
    const float SABOTEUR_MIN = 0.5f;
    float mult = (isv_signals_ptr != NULL)
        ? fmaxf(isv_signals_ptr[saboteur_intensity_mult_slot], SABOTEUR_MIN)
        : 1.0f;
    float ps = perturbation_scale * mult;

    /* Stateless Philox-based Gaussian perturbation */
    float u1, u2;
    u1 = fmaxf(philox_uniform(i, 0, 3000), 1e-6f);
    u2 = philox_uniform(i, 0, 3001);
    float g1 = sqrtf(-2.0f * logf(u1)) * cosf(6.2831853f * u2);

    u1 = fmaxf(philox_uniform(i, 0, 3002), 1e-6f);
    u2 = philox_uniform(i, 0, 3003);
    float g2 = sqrtf(-2.0f * logf(u1)) * cosf(6.2831853f * u2);

    u1 = fmaxf(philox_uniform(i, 0, 3004), 1e-6f);
    u2 = philox_uniform(i, 0, 3005);
    float g3 = sqrtf(-2.0f * logf(u1)) * cosf(6.2831853f * u2);

    /* Perturb from base with Gaussian noise, clamp to valid ranges */
    float spread = fminf(fmaxf(base_spread + g1 * ps * 2.0f, 0.5f), 5.0f);
    float fill   = fminf(fmaxf(base_fill   + g2 * ps * 0.3f, 0.3f), 0.95f);
    float slip   = fminf(fmaxf(base_slip   + g3 * ps * 1.5f, 0.5f), 3.0f);

    saboteur_params[i * 3 + 0] = spread;
    saboteur_params[i * 3 + 1] = fill;
    saboteur_params[i * 3 + 2] = slip;
}

/**
 * Select the saboteur params that caused the worst trader performance.
 * Reads per-episode raw_returns (from experience collection) and finds
 * the episode with the lowest cumulative return. Its params become the
 * next epoch's baseline.
 *
 * Grid: (1, 1, 1), Block: (256, 1, 1).  Single-block reduction.
 *
 * @param saboteur_params  [N, 3] per-episode params (from generate)
 * @param raw_returns      [N, L] per-bar returns (from experience collection)
 * @param best_output      [3] output: winning (worst for trader) params
 * @param best_return_out  [1] output: the worst cumulative return
 * @param N                number of episodes
 * @param L                timesteps per episode
 */
extern "C" __global__ void saboteur_select_best(
    const float* __restrict__ saboteur_params,     /* [N, 3] */
    const float* __restrict__ raw_returns,  /* [N, L] */
    float* __restrict__ best_output,                /* [3] */
    float* __restrict__ best_return_out,            /* [1] */
    int N,
    int L
) {
    __shared__ float s_worst_return[256];
    __shared__ int   s_worst_idx[256];

    int tid = threadIdx.x;

    /* Each thread finds worst episode in its tile */
    float my_worst = 1e9f;
    int   my_idx   = 0;

    for (int i = tid; i < N; i += 256) {
        /* Sum per-bar returns for this episode */
        float cumul = 0.0f;
        const float* ep = raw_returns + (long long)i * L;
        for (int t = 0; t < L; t++) {
            cumul += (float)ep[t];
        }
        if (cumul < my_worst) {
            my_worst = cumul;
            my_idx = i;
        }
    }

    s_worst_return[tid] = my_worst;
    s_worst_idx[tid]    = my_idx;
    __syncthreads();

    /* Block reduction: find global worst */
    for (int s = 128; s > 0; s >>= 1) {
        if (tid < s) {
            if (s_worst_return[tid + s] < s_worst_return[tid]) {
                s_worst_return[tid] = s_worst_return[tid + s];
                s_worst_idx[tid]    = s_worst_idx[tid + s];
            }
        }
        __syncthreads();
    }

    if (tid == 0) {
        int winner = s_worst_idx[0];
        best_output[0] = saboteur_params[winner * 3 + 0];
        best_output[1] = saboteur_params[winner * 3 + 1];
        best_output[2] = saboteur_params[winner * 3 + 2];
        best_return_out[0] = s_worst_return[0];
    }
}

/* ================================================================== */
/* Kernel 1: experience_state_gather                                  */
/* ================================================================== */

/**
 * Gather market features and portfolio state into a flat batch tensor
 * suitable for cuBLAS GemmEx (Q-network forward pass).
 *
 * Grid: ceil(N / 256), Block: 256.  One thread per episode.
 *
 * @param market_features   [total_bars, market_dim]  raw market feature matrix (bf16)
 * @param episode_starts    [N]                        global bar index of episode start
 * @param current_timesteps [N]                        timestep offset within episode (read-only)
 * @param portfolio_states  [N, 3]                     {position, cash, portfolio_value} (bf16)
 * @param batch_states      [N, state_dim]             output: assembled state batch (bf16)
 * @param N                                            number of episodes
 * @param total_bars                                   number of bars in market_features
 * @param state_dim                                    full state dimension (tensor-core aligned)
 * @param market_dim                                   number of market features per bar
 */
extern "C" __global__ void experience_state_gather(
    const float* __restrict__ market_features,
    const int*   __restrict__ episode_starts,
    const int*   __restrict__ current_timesteps,
    const float* __restrict__ portfolio_states,
    float*                        batch_states,  /* #30: f32 output for SGEMM forward */
    int N,
    int total_bars,
    int state_dim,
    int market_dim,
    /* #22 Feature noise: add N(0, noise_scale) to features. 0.0 = disabled. */
    float feature_noise_scale,
    /* #23 Causal feature masking: per-feature mask (1.0=keep, 0.0=zero).
     * NULL pointer = no masking. Length = market_dim. */
    const float* __restrict__ feature_mask,
    /* #13 Vol normalization: divide return features [0..3] by this value.
     * 0.0 or negative = disabled. Precomputed on CPU per epoch. */
    float vol_normalizer,
    /* #10 Mirror universe: negate return features to flip market direction.
     * The model must learn direction-invariant structure. 0 = disabled. */
    int mirror_active,
    /* #11 Time-reversal: reverse bar indexing for selected episodes.
     * If > 0, episodes with index % time_reversal_mod == 0 are played backwards.
     * 5 = 20% of episodes reversed, 3 = 33%, 0 = disabled. */
    int time_reversal_mod,
    const float* __restrict__ plan_params_ptr,   /* [N, 6] trade plan params. NULL = no plan. */
    const float* __restrict__ isv_signals_ptr,   /* [12] pinned device-mapped ISV signals. NULL = static. */
    const float* __restrict__ ofi_features,      /* [total_bars, ofi_dim] OFI features. NULL = no OFI. */
    int ofi_dim                                  /* OFI feature dimension (20 with deltas+book+dur). 0 = no OFI. */
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    int raw_t = current_timesteps[i];
    /* #11 Time-reversal: reverse bar traversal for selected episodes.
     * Episode i is reversed when time_reversal_mod > 0 && i % mod == 0.
     * Reversed indexing: start + (L-1-t) instead of start + t. */
    int is_reversed = (time_reversal_mod > 0 && (i % time_reversal_mod) == 0);
    int bar_idx;
    if (is_reversed) {
        /* L (episode length) is not available here — approximate as total_bars/N.
         * We use episode_starts[i+1]-episode_starts[i] when possible, else 500. */
        int ep_len = (i + 1 < N) ? (episode_starts[i + 1] - episode_starts[i]) : 500;
        if (ep_len <= 0) ep_len = 500;
        int rev_t = (ep_len - 1 - raw_t);
        if (rev_t < 0) rev_t = 0;
        bar_idx = episode_starts[i] + rev_t;
    } else {
        bar_idx = episode_starts[i] + raw_t;
    }

    float* out = batch_states + (long long)i * state_dim;

    /* Out-of-data or negative index: zero the output and return early. */
    if (bar_idx < 0 || bar_idx >= total_bars) {
        for (int k = 0; k < state_dim; k++)
            out[k] = 0.0f;
        return;
    }

    /* ── Local arrays for canonical state assembly ──
     * Each feature group is written to a local array, then assemble_state()
     * places them at the correct offsets — single source of truth. */

    /* -- Market features → local market[] array -- */
    float market[SL_MARKET_DIM];
    const float* mf_row = market_features + (long long)bar_idx * market_dim;
    for (int k = 0; k < market_dim && k < SL_MARKET_DIM; k++)
        market[k] = (float)mf_row[k];
    /* Zero remaining if market_dim < SL_MARKET_DIM */
    for (int k = market_dim; k < SL_MARKET_DIM; k++)
        market[k] = 0.0f;

    /* ── #10 Mirror universe: negate ALL directional features ──
     * Features that encode direction (returns, momentum, slopes) must be
     * negated so the network sees a consistent "price went up → Long profits"
     * signal in both real and mirrored episodes.
     * Non-directional features (volume, time, ATR, ADX, bandwidth) are unchanged.
     *
     * Directional features:
     *   [0-3]  OHLCV returns (log returns from prev close)
     *   [6]    MACD histogram (momentum direction)
     *   [7]    Bollinger position (above/below mean)
     *   [10-15] Price patterns (returns, SMA ratios, regression slope)
     *   [41]   CUSUM direction [-1,+1]
     * Also flip RSI around 0.5: RSI=0.7 (bullish) → 0.3 (bearish)
     *   [5]    RSI: mirror = 1.0 - RSI */
    if (mirror_active && market_dim >= 42) {
        /* OHLCV returns */
        market[0] = -market[0];
        market[1] = -market[1];
        market[2] = -market[2];
        market[3] = -market[3];
        /* RSI: flip around 0.5 */
        market[5] = 1.0f - market[5];
        /* MACD + Bollinger position */
        market[6] = -market[6];
        market[7] = -market[7];
        /* Price patterns: returns + SMA ratios + regression slope */
        market[10] = -market[10];
        market[11] = -market[11];
        market[12] = -market[12];
        market[13] = -market[13];
        market[14] = -market[14];
        market[15] = -market[15];
        /* CUSUM direction */
        market[41] = -market[41];
    }

    /* ── Vol normalization REMOVED (post-Bug-1 obsolete) ──
     * Pre-Bug-1 (before commit 5a5dd0fed), market_features[0..3] arrived as
     * RAW log returns (~±0.001) and this block divided by vol_normalizer to
     * bring them to "unit-vol" scale (~stddev 1). After Bug 1 fix moved
     * log-return z-normalization to the WRITER (precompute_features.rs +
     * data_loading.rs DBN fallback both apply NormStats::normalize_batch
     * before the fxcache write / training-data handoff), the features arrive
     * already z-normalized — runtime division created a 1000-13000× DOUBLE
     * NORMALIZATION (network_input = z_normed × (1 / raw_return_stddev)).
     * Pinned via DIAG_AUX_LABEL diagnostic in production train-multi-seed-bn42w
     * (label_scale=5398; expected ~0.8). Removed; the parameter is retained
     * in the kernel signature + launcher to avoid widening the diff to every
     * call site, and silenced via `(void)vol_normalizer` below.
     */
    (void)vol_normalizer;

    /* ── #23 Causal feature masking: zero out masked features ── */
    if (feature_mask != NULL) {
        for (int k = 0; k < market_dim && k < SL_MARKET_DIM; k++) {
            if (feature_mask[k] < 0.5f) {
                market[k] = 0.0f;
            }
        }
    }

    /* ── #22 Feature noise injection: N(0, scale) per feature ── */
    /* Stateless Philox hash: noise = f(episode_id, timestep, feature_k).
     * Deterministic regardless of execution history. */
    if (feature_noise_scale > 0.0f) {
        int timestep_val = raw_t;
        for (int k = 0; k < market_dim && k < SL_MARKET_DIM; k++) {
            /* Two Philox hashes → Box-Muller Gaussian */
            float u1 = philox_uniform(i, timestep_val, 1000 + k * 2);
            float u2 = philox_uniform(i, timestep_val, 1000 + k * 2 + 1);
            u1 = fmaxf(u1, 1e-6f);
            float noise = sqrtf(-2.0f * logf(u1)) * cosf(6.2831853f * u2);
            market[k] = market[k] + noise * feature_noise_scale;
        }
    }

    /* -- Portfolio features → local portfolio[] and plan_isv[] arrays --
     *
     * 8 informative features that let the Q-network SEE its trading context.
     * Without these, the model is blind to risk, P&L, and position state.
     *
     * portfolio[0]: position                   — raw position (signed contracts)
     * portfolio[1]: unrealized_pnl / equity    — how is THIS trade doing?
     * portfolio[2]: drawdown                   — (peak - equity) / peak [0, 1]
     * portfolio[3]: hold_time / 100.0          — normalized holding duration
     * portfolio[4]: realized_pnl / equity      — overall session P&L
     * portfolio[5]: distance_to_floor          — (equity - floor) / equity [0, 1]
     * portfolio[6]: trade_return               — trade P&L / equity
     * portfolio[7]: cash_ratio                 — cash / equity
     */
    const float* ps = portfolio_states + (long long)i * PORTFOLIO_STRIDE;
    float position        = ps[PS_POSITION];
    float cash            = ps[PS_CASH];
    float portfolio_value = ps[PS_PORTFOLIO_VALUE];
    float peak_equity     = ps[PS_PEAK_EQUITY];
    float prev_equity     = ps[PS_PREV_EQUITY];
    float hold_time       = ps[PS_HOLD_TIME];
    float realized_pnl    = ps[PS_REALIZED_PNL];
    float entry_price     = ps[PS_ENTRY_PRICE];
    float trade_start_pnl = ps[PS_TRADE_START_PNL];

    /* ── Portfolio features computed in FLOAT to prevent bf16 overflow ──
     * BF16 max ~65504. ES position × price_diff can exceed this (2.0 × 5000 = 10000,
     * but accumulated P&L or subtraction of similar prices overflow easily).
     * Float arithmetic eliminates the root cause of NaN states in the replay buffer. */
    float f_position        = (float)position;
    float f_cash            = (float)cash;
    float f_portfolio_value = (float)portfolio_value;
    float f_peak_equity     = (float)peak_equity;
    float f_hold_time       = (float)hold_time;
    float f_realized_pnl    = (float)realized_pnl;
    float f_entry_price     = (float)entry_price;
    float f_trade_start_pnl = (float)trade_start_pnl;

    float f_equity = fmaxf(f_portfolio_value, 1.0f);

    float f_drawdown = (f_peak_equity > 1.0f)
                     ? (f_peak_equity - f_portfolio_value) / f_peak_equity
                     : 0.0f;
    f_drawdown = fmaxf(f_drawdown, 0.0f);

    /* Unrealized P&L: current position mark-to-market minus entry cost */
    float f_close = (float)market_features[(long long)bar_idx * market_dim];
    float f_unrealized_pnl = (f_entry_price > 0.0f && f_position != 0.0f)
                           ? f_position * (f_close - f_entry_price)
                           : 0.0f;

    /* Trade return since entry */
    float f_trade_return = (f_trade_start_pnl != 0.0f || f_realized_pnl != 0.0f)
                         ? (f_realized_pnl - f_trade_start_pnl + f_unrealized_pnl) / f_equity
                         : 0.0f;

    /* Capital floor distance (75% of peak = capital floor) */
    float f_floor_val = f_peak_equity * 0.75f;
    float f_floor_dist = (f_equity > f_floor_val && f_equity > 1.0f)
                       ? (f_equity - f_floor_val) / f_equity
                       : 0.0f;

    float portfolio[SL_PORTFOLIO_BASE_DIM];
    portfolio[0] = f_position;                          /* raw position */
    portfolio[1] = f_unrealized_pnl / f_equity;        /* trade P&L signal */
    portfolio[2] = f_drawdown;                          /* risk: how deep are we? */
    portfolio[3] = f_hold_time / 100.0f;               /* how long in trade? */
    portfolio[4] = f_realized_pnl / f_equity;          /* session P&L */
    portfolio[5] = f_floor_dist;                        /* distance to game over */
    portfolio[6] = f_trade_return;                      /* this trade's return */
    portfolio[7] = f_cash / f_equity;                   /* available capital */

    /* Plan-aware + ISV features: model sees its own plan progress.
     * Enables temporal reasoning: "I'm 80% through my plan, close to target." */
    float plan_isv[SL_PORTFOLIO_PLAN_DIM];
    float plan_tgt_bars = ps[PS_PLAN_TARGET_BARS];
    float plan_profit   = ps[PS_PLAN_PROFIT_TARGET];
    float plan_stop     = ps[PS_PLAN_STOP_LOSS];
    float plan_conv     = ps[PS_PLAN_CONVICTION];

    /* Plan activation threshold from ISV[ISV_PLAN_THRESHOLD_IDX] (Plan 1 Task 16).
     * Default 0.5 when ISV not yet available (smoke-scale runs). */
    float plan_thr_sg = (isv_signals_ptr != NULL) ? isv_signals_ptr[ISV_PLAN_THRESHOLD_IDX] : 0.5f;

    plan_isv[PLAN_ISV_PROGRESS] = (plan_tgt_bars > plan_thr_sg)
        ? fminf(f_hold_time / plan_tgt_bars, 2.0f)  /* plan progress [0, 2] */
        : 0.0f;
    /* Symmetric clamp [-2, 2]: f_unrealized_pnl can be negative → fminf alone leaves
     * an unbounded lower tail (feature jitter at policy input). Mirror of SP11
     * symmetric reward cap (35db31089). See pearl_symmetric_clamp_audit. */
    plan_isv[PLAN_ISV_PNL_VS_TARGET] = (plan_profit > 1e-6f)
        ? fmaxf(-2.0f, fminf(f_unrealized_pnl / (plan_profit * f_equity + 1e-6f), 2.0f)) /* pnl vs target */
        : 0.0f;
    plan_isv[PLAN_ISV_PNL_VS_STOP] = (plan_stop > 1e-6f)
        ? fmaxf(-2.0f, fminf(-f_unrealized_pnl / (plan_stop * f_equity + 1e-6f), 2.0f))  /* pnl vs stop */
        : 0.0f;
    plan_isv[PLAN_ISV_ENTRY_CONVICTION] = plan_conv;  /* conviction at entry [0, 1] */

    /* Conviction drift — current conviction minus entry conviction.
     * Negative drift = model's thesis is weakening → exit signal.
     * Only meaningful when a plan is active (ps[PS_PLAN_TARGET_BARS] > plan_thr_sg). */
    plan_isv[PLAN_ISV_CONVICTION_DRIFT] = (plan_params_ptr != NULL && ps[PS_PLAN_TARGET_BARS] > plan_thr_sg)
        ? (plan_params_ptr[i * 6 + PLAN_PARAM_CONVICTION] - ps[PS_PLAN_CONVICTION])   /* conviction drift */
        : 0.0f;

    /* Regime shift since entry — |regime_stability_now - regime_stability_at_entry|.
     * Large shift = regime changed mid-trade, plan may be invalid.
     * Entry regime stored in ps[PS_PLAN_ENTRY_REGIME] during plan activation. */
    plan_isv[PLAN_ISV_REGIME_SHIFT] = (isv_signals_ptr != NULL && ps[PS_PLAN_TARGET_BARS] > plan_thr_sg)
        ? fabsf(isv_signals_ptr[11] - ps[PS_PLAN_ENTRY_REGIME])      /* regime shift */
        : 0.0f;

    /* D.6 Plan_isv[6] remaining_fraction (Plan 2 Task 6A).
     * Temporal pressure signal: fraction of plan time still remaining.
     * Value 1.0 = just entered, 0.0 = at or past plan expiry.
     * Zero when no plan is active (plan_tgt_bars <= plan_thr_sg). */
    plan_isv[PLAN_ISV_REMAINING_FRACTION] = (plan_tgt_bars > plan_thr_sg)
        ? fmaxf(0.0f, fminf(1.0f, (plan_tgt_bars - f_hold_time) / plan_tgt_bars))
        : 0.0f;

    /* -- Multi-timeframe features → local mtf[] array --
     *
     * 4 lookback windows x 4 features = 16 GPU-native features.
     * Computed directly from the market data already on GPU — no CPU.
     * The model sees price action at multiple timescales simultaneously.
     *
     * Lookback windows: 5, 15, 60, 240 bars
     * Features per window:
     *   +0: return = (close_now - close_N) / close_N
     *   +1: volatility = max_high - min_low over N bars / close_now
     *   +2: volume_trend = volume_now / avg_volume_N (>1 = increasing)
     *   +3: momentum = close_now vs midpoint of N-bar range (0=bottom, 1=top)
     */
    float mtf[SL_MTF_DIM];
    for (int k = 0; k < SL_MTF_DIM; k++) mtf[k] = 0.0f;

    const int lookbacks[4] = {5, 15, 60, 240};
    for (int lb = 0; lb < 4; lb++) {
        int Nlb = lookbacks[lb];
        int past_idx = bar_idx - Nlb;

        if (past_idx >= 0) {
            const float* now_row = market_features + (long long)bar_idx * market_dim;
            const float* past_row = market_features + (long long)past_idx * market_dim;
            /* Float arithmetic — bf16 close prices (~5000) subtracted produce
             * tiny differences that lose all precision in bf16 (7-bit mantissa). */
            float f_close_now = (float)now_row[0];
            float f_close_past = (float)past_row[0];

            /* Return over N bars */
            float f_ret = (f_close_past > 0.0f) ? (f_close_now - f_close_past) / f_close_past : 0.0f;
            float f_scaled_ret = f_ret * 100.0f;
            mtf[lb * 4 + 0] = fminf(10.0f, fmaxf(-10.0f, f_scaled_ret));

            /* Volatility: scan high/low over window */
            float f_max_val = f_close_now;
            float f_min_val = f_close_now;
            float f_vol_sum = 0.0f;
            int vol_count = 0;
            for (int j = past_idx; j <= bar_idx && j < total_bars; j++) {
                float fv = (float)market_features[(long long)j * market_dim];
                if (fv > f_max_val) f_max_val = fv;
                if (fv < f_min_val) f_min_val = fv;
                if (market_dim > 4) {
                    f_vol_sum += (float)market_features[(long long)j * market_dim + 4];
                    vol_count++;
                }
            }
            float f_range = (f_close_now > 0.0f) ? (f_max_val - f_min_val) / f_close_now : 0.0f;
            mtf[lb * 4 + 1] = fminf(10.0f, fmaxf(0.0f, f_range * 100.0f));

            /* Volume trend */
            float f_avg_vol = (vol_count > 0) ? f_vol_sum / (float)vol_count : 1.0f;
            float f_cur_vol = (market_dim > 4) ? (float)now_row[4] : 1.0f;
            float f_vol_ratio = (f_avg_vol > 0.0f) ? f_cur_vol / f_avg_vol : 1.0f;
            mtf[lb * 4 + 2] = fminf(5.0f, fmaxf(0.0f, f_vol_ratio));

            /* Momentum: position within range [0=bottom, 1=top] */
            float f_range_size = f_max_val - f_min_val;
            mtf[lb * 4 + 3] = (f_range_size > 0.0f)
                             ? (f_close_now - f_min_val) / f_range_size
                             : 0.5f;
        }
        /* else: mtf[lb*4..lb*4+3] already zeroed above */
    }

    /* -- OFI features → local ofi[] array -- */
    float ofi[SL_OFI_DIM];
    for (int k = 0; k < SL_OFI_DIM; k++) ofi[k] = 0.0f;
    if (ofi_features != NULL && ofi_dim > 0 && bar_idx >= 0 && bar_idx < total_bars) {
        const float* ofi_row = ofi_features + (long long)bar_idx * ofi_dim;
        int ofi_copy = (ofi_dim < SL_OFI_DIM) ? ofi_dim : SL_OFI_DIM;
        for (int k = 0; k < ofi_copy; k++)
            ofi[k] = ofi_row[k];
    }

    /* ── Final assembly: canonical layout via state_layout.cuh ── */
    assemble_state(out, market, ofi, mtf, portfolio, plan_isv);
}

/* ================================================================== */
/* Kernel 2: experience_action_select                                 */
/* ================================================================== */

/**
 * Epsilon-greedy action selection on Q-values produced by cuBLAS.
 *
 * 4-branch Hierarchical DQN — q_values layout: [N, b0+b1+b2+b3]
 *   Branch 0 (direction):  q_values[i*stride + 0       .. +b0)
 *   Branch 1 (magnitude):  q_values[i*stride + b0      .. +b0+b1)
 *   Branch 2 (order):      q_values[i*stride + b0+b1   .. +b0+b1+b2)
 *   Branch 3 (urgency):    q_values[i*stride + b0+b1+b2.. +b0+b1+b2+b3)
 *   Action = dir*(b1*b2*b3) + mag*(b2*b3) + order*b3 + urgency.
 *
 * Gem 2: Per-branch epsilon — direction gets full epsilon (hard problem),
 *        magnitude/order/urgency get reduced: eps_end + 0.1*(eps-eps_end).
 * Gem 3: Flat/Hold detection — when dir_idx==3 (Flat) or dir_idx==1 (Hold),
 *         mag_idx forced to 0 (magnitude irrelevant for both).
 *
 * Q-gap conviction filter: when greedy, if Q(best_direction) - Q(Flat) <
 * q_gap_threshold, force direction to Flat (index 3). This reduces trade
 * frequency by requiring high conviction for position entry.
 *
 * Grid: ceil(N / 256), Block: 256.  One thread per episode.
 *
 * @param q_values          [N, q_stride]  Q-values from cuBLAS (bf16)
 * @param out_actions       [N]            selected factored action index (output)
 * @param out_q_gaps        [N]            output: Q-gap per episode for conviction sizing (bf16)
 * @param out_conviction    [N]            output: per-sample direction-branch conviction ∈ [0, 1]
 *                                         = (max(q_dir) − min(q_dir)) / fmaxf(q_dir_abs_ref, 1e-6f).
 *                                         Consumed by env_step → unified_env_step_core → apply_kelly_cap
 *                                         as the adaptive warmup floor. NULL-tolerant.
 * @param out_magnitude_conviction [N]     output: per-sample magnitude-branch conviction ∈ [0, 1]
 *                                         = (max(q_mag) − min(q_mag)) / fmaxf(q_mag_abs_ref, 1e-6f).
 *                                         Mirrors the direction-conviction shape with the magnitude
 *                                         branch's ISV reference (ISV[16] = Q_ABS_REF, magnitude EMA).
 *                                         Consumed by env_step → unified_env_step_core where the
 *                                         Kelly-cap formula uses max(dir_conv, mag_conv) so a strong
 *                                         magnitude preference can unblock the cap when direction
 *                                         conviction is low (typical at smoke horizon). NULL-tolerant.
 * @param epsilon                          exploration probability in [0, 1]
 * @param N                                number of episodes
 * @param b0_size                          direction branch size (4)
 * @param b1_size                          magnitude branch size (3)
 * @param b2_size                          order branch size (3)
 * @param b3_size                          urgency branch size (3)
 * @param q_gap_threshold                  min Q-gap for trade entry (0.0 = disabled)
 * @param portfolio_states  [N, 20]        read-only (unused, kept for ABI compat)
 * @param min_hold_bars                    unused (kept for ABI compat, pass 0)
 * @param max_position                     max allowed position (host scalar)
 */
extern "C" __global__ void experience_action_select(
    const float*  __restrict__ q_values,
    int*                       out_actions,
    float*             out_q_gaps,    /* [N] output: Q-gap per episode for conviction sizing */
    float eps_start,
    float eps_end,
    int current_epoch,
    int total_epochs,
    int N,
    int b0_size,
    int b1_size,
    int b2_size,
    int b3_size,
    float q_gap_threshold
    ,const float* __restrict__ portfolio_states,  /* [N, 23] read-only */
    int min_hold_bars,
    float max_position,
    float eps_exp_mult,    /* per-branch epsilon multiplier: exposure (dir+mag) */
    float eps_ord_mult,    /* per-branch epsilon multiplier: order type */
    float eps_urg_mult,    /* per-branch epsilon multiplier: urgency */
    int timestep,          /* current timestep for stateless RNG */
    const float* __restrict__ per_sample_epsilon,  /* [N] IQL expectile gap epsilon, NULL=use cosine schedule */
    const float* __restrict__ isv_signals_ptr,     /* [8] pinned ISV signals for adaptive hold. NULL = static. */
    int contrarian_active,  /* D7/N7: when non-zero, negate Q values before Boltzmann → argmin sampling */
    int* __restrict__ out_intent_mag,  /* [N] optional: pure-policy magnitude Q-head argmax BEFORE
                                         * Hold/Flat forcing and BEFORE Kelly/margin caps applied in
                                         * env_step. NULL = skip. Diagnostic-only — lets operators see
                                         * what the magnitude head WOULD pick if direction gating and
                                         * post-policy physics didn't mask it. */
    float*             out_conviction,   /* [N] output: direction-branch conviction ∈ [0,1] for Kelly warmup. NULL=skip. */
    float*             out_magnitude_conviction, /* [N] output: magnitude-branch conviction ∈ [0,1] for Kelly warmup. NULL=skip. */
    /* Plan C Phase 2 Task 2 — Thompson direction-branch buffers.
     * Direction selection consumes the production C51 posterior directly
     * (Thompson at training; argmax E[Q] at eval); the legacy direction
     * eps-greedy + Boltzmann + UCB-bonus path is replaced. mag/ord/urg
     * branches are unchanged and continue to read q_values + their per-
     * branch eps multipliers. Tasks 3 & 4 wire these buffers from the
     * Rust call sites; until they land the kernel signature is
     * intentionally non-callable end-to-end.
     *
     * Plan C T2 amendment (2026-04-29): the original plan-as-written args
     * (`c51_probs_dir` post-softmax probs, single `atom_values [n_atoms]`,
     * `iqn_quantiles_dir`) didn't match production — the collector only
     * materialises raw `b_logits`, support is per-sample per-direction
     * adaptive, and IQN inference is training-only (no rollout-time
     * quantiles). Amended kernel takes raw C51 logits + adaptive support
     * and drops IQN — single-distribution C51 still provides the
     * principled posterior sample that fixes the UCB asymmetry. */
    const float* __restrict__ b_logits_dir,       /* [N, b0_size, n_atoms] direction-branch RAW C51 logits */
    const float* __restrict__ per_sample_support, /* [N, b0_size, 3] stride-12 per-direction [v_min, v_max, delta_z] */
    const float* __restrict__ atom_positions,     /* [b0_size, n_atoms] adaptive non-linear positions; NULL = linear */
    int                       n_atoms             /* C51 atom count */
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    int rng_ctr = 0; /* stateless call counter for philox_uniform */

    /* Eval mode: eps_start==0 AND eps_end==0 AND all multipliers==1. */
    int eval_mode = (eps_start < 1e-6f && eps_end < 1e-6f);

    /* Epsilon source: IQL expectile gap (per-sample) or cosine schedule (scalar). */
    float epsilon;
    if (per_sample_epsilon != NULL) {
        epsilon = per_sample_epsilon[i];
    } else {
        float cosine_progress = (float)current_epoch / fmaxf((float)total_epochs, 1.0f);
        epsilon = eps_end + 0.5f * (eps_start - eps_end)
                      * (1.0f + cosf(3.14159265f * fminf(cosine_progress, 1.0f)));
    }

    /* Per-branch epsilon via config multipliers. */
    float eps_dir = fminf(epsilon * eps_exp_mult, 1.0f);
    float eps_mag = fminf(epsilon * eps_exp_mult, 1.0f);
    float eps_ord = fminf(epsilon * eps_ord_mult, 1.0f);
    float eps_urg = fminf(epsilon * eps_urg_mult, 1.0f);

    /* Minimum exploration floor: every action gets at least 2% random exploration.
     * Without this, rare compound actions (LongSmall) get <0.5% selection rate.
     * DISABLED in eval mode — evaluation must be purely deterministic. */
    if (!eval_mode) {
        const float EPS_FLOOR = 0.02f;
        eps_dir = fmaxf(eps_dir, EPS_FLOOR);
        eps_mag = fmaxf(eps_mag, EPS_FLOOR);
        eps_ord = fmaxf(eps_ord, EPS_FLOOR);
        eps_urg = fmaxf(eps_urg, EPS_FLOOR);

        /* C51-bias breakout: when the policy collapses toward Hold/Flat (no
         * trade attempts), boost the direction-branch eps floor proportional
         * to how far below TRADE_TARGET_RATE the current attempt EMA sits.
         *
         * Why this is needed (not redundant with the static EPS_FLOOR above):
         * C51's expected Q makes Flat = δ(0) = 0 attractive against directional
         * actions whose distributions concentrate slightly below 0 from
         * tx_cost without compensating edge. Once Q values lock into this
         * preference (within ~1 epoch of training), Boltzmann correctly
         * samples Flat/Hold most of the time. A 2% random floor is too small
         * to break the lock — Long/Short get 0.5% sampling each, generating
         * insufficient experience for Q-values to discover real edge. The
         * adaptive boost scales eps_dir up to half (0.5) when the policy is
         * fully passive (zero trade attempts), preserving learned preferences
         * while forcing enough exploration to escape the attractor.
         *
         * passive_pressure = clamp(0, 1, 1 − attempt_ema/target_rate). At
         * target rate or above, no boost (passive_pressure=0, eps_dir at
         * baseline floor). At zero attempts, full boost (eps_dir floor 0.5
         * = half random sampling). The 0.5 ceiling is a structural blend
         * point — half random / half policy — not a tuned magnitude; it's
         * the maximum exploration that still preserves directional Q-signal
         * propagation through the replay buffer.
         *
         * Cold-start safety: until ISV[72] freezes from the measured EMA
         * (epoch 5 per project_reward), target_raw is 0 and the formula
         * no-ops (passive_pressure clamps to 0 via the target floor). Once
         * frozen, the feedback loop activates.
         *
         * Direction branch only — magnitude/order/urgency don't have the
         * Flat-attractor problem (Kelly cap already shapes magnitude
         * realisation; order/urgency operate on top of an already-decided
         * directional pick). */
        if (isv_signals_ptr != NULL) {
            float attempt_ema = isv_signals_ptr[71];
            float target_raw  = isv_signals_ptr[72];
            float target_rate = fmaxf(target_raw, 1e-4f);  /* cold-start floor */
            float ratio = attempt_ema / target_rate;
            float passive_pressure = fmaxf(0.0f, fminf(1.0f, 1.0f - ratio));
            float adaptive_floor = 0.5f * passive_pressure;
            eps_dir = fmaxf(eps_dir, adaptive_floor);
        }
    }

    int q_stride = b0_size + b1_size + b2_size + b3_size;
    const float* q_b0 = q_values + (long long)i * q_stride;
    const float* q_b1 = q_b0 + b0_size;
    const float* q_b2 = q_b1 + b1_size;
    const float* q_b3 = q_b2 + b2_size;

    /* D7/N7: contrarian sign flip.
     * When contrarian_active != 0, negate Q values before Boltzmann softmax.
     * softmax(-q/tau) is argmin-favoring — the model picks its least-preferred
     * action, breaking systematic anti-correlation attractors.
     * When contrarian_active == 0, q_sign = +1.0f → bit-identical to previous
     * behavior (identity multiplication). */
    float q_sign = (contrarian_active != 0) ? -1.0f : 1.0f;

    int dir_idx, mag_idx, a2, a3;

    /* Per-direction joint E[Q] (E_C51 + E_IQN), populated by the
     * Thompson direction branch below and reused for the conviction
     * block at the bottom of this kernel. Allocated at outer scope so
     * the conviction read site (Plan C Phase 2 Task 2 Sub-change C)
     * can consume it without recomputation. b0_size = 4 in the current
     * 4-direction action space (Short/Hold/Long/Flat). */
    float e_dir[4];

    /* Hold enforcement removed — replaced by cost-driven hold timing.
     * The model can always take any action; costs make bad actions expensive.
     * min_hold_bars and portfolio_states params kept for ABI compatibility. */
    (void)min_hold_bars;
    (void)portfolio_states;

    /* Branch 0: direction — UNCONDITIONAL temperature-blended Thompson
     * sampling for both training AND eval (SP10 / Fix 38, 2026-05-03).
     *
     * Per `pearl_thompson_for_distributional_action_selection.md`
     * (amended): Thompson is the rollout SELECTOR at all times; argmax
     * is reserved for the Bellman TARGET Q computation only (DDQN
     * target). The prior path branched on `eval_mode` and used
     * `argmax(E[Q])` at eval — that argmax-at-eval bias was the root
     * cause of val-Flat-collapse (T10 train-multi-seed-khr7c on commit
     * `8a25b330f`: dir_entropy=0, trade_count=1 in 214k bars across all
     * SP9 controller fixes). With Hold's E[Q] ≈ 0 (no position cost) and
     * Short/Long's E[Q] = ε (small edge minus tx costs), argmax wins
     * Hold deterministically every bar. Thompson lets positive-Q
     * directions win proportional to their posterior overlap.
     *
     * Plan C Phase 2 Task 2 replaced the prior eps-greedy + ISV-adaptive
     * Boltzmann + UCB-bonus path with single-distribution C51 Thompson
     * (training-side). SP10 extends Thompson to eval, blended with E[Q]
     * via an ISV-driven temperature parameter `temp ∈ [0.5, 2.0]` from
     * the SP9 `intent_eval_divergence` canary — see
     * `intent_eval_divergence_compute_kernel.cu`. The blend is:
     *
     *     q_eff[d] = E[Q][d] + temp · (q_sample[d] − E[Q][d])
     *
     * τ=0 → argmax of E[Q]; τ=1 → pure Thompson; τ>1 → exaggerated
     * exploration boost (corrective during val-Flat-collapse). Per
     * `pearl_blend_formulas_must_have_permanent_floor.md`, MIN_TEMP=0.5
     * keeps at least 50% Thompson contribution at all times — eval is
     * NEVER fully deterministic. The floor / clamp lives in the
     * producer kernel; this consumer reads the smoothed slot directly.
     *
     * The eps-greedy / Boltzmann / contrarian flip / passive-pressure
     * adaptive boost paths on direction become unreachable here —
     * Thompson supplies exploration directly from the posterior. The
     * `eval_mode` parameter still gates eps-greedy on mag/ord/urg
     * branches below (their argmax-at-eval pathways are different —
     * mag uses Boltzmann, ord/urg also stochastic — see Pearl 3 of
     * `pearl_thompson_for_distributional_action_selection`).
     *
     * Plan C T2 amendment (2026-04-29): single-distribution C51 Thompson
     * with production's adaptive per-direction support. The plan-as-
     * authored joint C51+IQN sampling required rollout-time IQN
     * inference that doesn't exist (`gpu_iqn_head.rs` is training-only)
     * and post-softmax probs the collector doesn't materialise.
     *
     * Sampling: dir = argmax_d (E[Q][d] + temp · (q_sample[d] − E[Q][d]))
     * where probs_d = softmax(b_logits_dir[i, d, :]), atoms_d is the
     * per-sample per-direction C51 support (atom_positions when non-NULL,
     * else linear v_min + a·delta_z), and q_sample[d] =
     * sample_C51_inverse_cdf(probs_d, atoms_d, philox_uniform(i, timestep, ctr)).
     *
     * E[Q]-derived `e_dir` is computed once unconditionally and reused
     * for: (a) the temperature blend; (b) the conviction calculation
     * downstream which the spec requires to remain E[Q]-based (avoids
     * Kelly-cap jitter from per-step Thompson sample noise);
     * (c) `out_q_gaps`. Lives in outer kernel scope so the conviction
     * block at the bottom can read it without recomputation.
     *
     * Philox stream is seeded by (i, timestep) so eval is bit-
     * reproducible across runs at the same checkpoint — same model +
     * same seed = same actions. Reproducibility is preserved at the
     * seed level. */

    /* THOMPSON_MAX_ATOMS sized to match the production C51 ceiling
     * (`c51_loss_kernel.cu::MAX_ATOMS = 128`). Stack arrays for probs +
     * atom values (2 * n_atoms floats) — at n_atoms=51 that's ~408 bytes
     * per thread, well within nvcc's local-memory threshold. */
    #ifndef THOMPSON_MAX_ATOMS
    #define THOMPSON_MAX_ATOMS 128
    #endif

    float probs_d[THOMPSON_MAX_ATOMS];
    float atom_vals_d[THOMPSON_MAX_ATOMS];
    const float* logits_i = b_logits_dir + (long long)i * b0_size * n_atoms;

    /* Pass 1: per-direction E[Q] for the temperature blend + conviction reuse. */
    for (int d = 0; d < b0_size; d++) {
        softmax_c51_inline(logits_i + d * n_atoms, n_atoms, probs_d);
        compute_atom_values_inline(per_sample_support, atom_positions,
                                   i, d, b0_size, n_atoms, atom_vals_d);
        e_dir[d] = compute_e_c51_inline(probs_d, atom_vals_d, n_atoms);
    }

    /* SP10 (Fix 38, 2026-05-03): unconditional temperature-blended
     * Thompson selector. Read ISV-driven temperature; fall back to 1.0
     * (pure Thompson) when isv_signals_ptr is unavailable (test paths,
     * pre-bootstrap warmup). The producer kernel
     * `intent_eval_divergence_compute_kernel` clamps temp into [0.5, 2.0]
     * via Pearl A sentinel-bootstrap before this read. */
    float thompson_temp = (isv_signals_ptr != NULL)
        ? isv_signals_ptr[ISV_EVAL_THOMPSON_TEMP_IDX]
        : 1.0f;
    /* Defensive clamp — Pearl A sentinel = 0 before first observation;
     * downstream Pearls A+D enforce [0.5, 2.0] but this read can race
     * against the first producer launch on cold-start. Floor at
     * MIN_TEMP=0.5 (`pearl_blend_formulas_must_have_permanent_floor`)
     * so eval is never fully deterministic. */
    if (!(thompson_temp > 0.5f)) thompson_temp = 0.5f;
    if (thompson_temp > 2.0f)    thompson_temp = 2.0f;

    {
        /* Pass 2 recomputes per-direction probs/atoms; the alternative —
         * caching across directions — would cost b0_size * 2 * MAX_ATOMS
         * stack floats (~4 KiB per thread) and risk local-memory spill.
         * Recompute is < 1 µs per thread at n_atoms=51. */
        float best_q_eff = -1e30f;
        int   best_d     = 0;
        for (int d = 0; d < b0_size; d++) {
            softmax_c51_inline(logits_i + d * n_atoms, n_atoms, probs_d);
            compute_atom_values_inline(per_sample_support, atom_positions,
                                       i, d, b0_size, n_atoms, atom_vals_d);
            float u        = philox_uniform(i, timestep, rng_ctr++);
            float q_sample = sample_c51_inverse_cdf(probs_d, atom_vals_d, n_atoms, u);

            /* Temperature blend: τ=0 → argmax of E[Q]; τ=1 → pure
             * Thompson; τ>1 → exaggerated exploration. */
            float q_eff = e_dir[d] + thompson_temp * (q_sample - e_dir[d]);
            if (q_eff > best_q_eff) { best_q_eff = q_eff; best_d = d; }
        }
        dir_idx = best_d;
    }

    /* Hold/Flat detection shortcut — when direction=Hold(1) or Flat(3),
     * magnitude is irrelevant. Set to 0 for deterministic encoding. */
    if (dir_idx == DIR_HOLD || dir_idx == DIR_FLAT) {
        mag_idx = 0;  /* Hold/Flat: magnitude masked, set to 0 for deterministic encoding */
    } else {
        /* Branch 1: magnitude — Boltzmann softmax with adaptive tau (q_range,
         * floor 0.01) for both training and eval. See Branch 0 above for the
         * shared state-adaptive temperature rationale.
         *
         * Magnitude-specific: C51's expected Q has a structural bias toward
         * Small (tight return distributions → highest expected Q regardless of
         * actual returns). Boltzmann samples PROPORTIONALLY to exp(Q/tau) so
         * Half/Full retain probability mass even when their Q is mildly worse.
         * IQN as primary loss (variance-neutral Huber) lets state-dependent
         * magnitude preferences develop without the collapse argmax produces.
         *
         * Realised magnitude is still gated by Kelly cap downstream (see
         * trade_physics.cuh::apply_kelly_cap); this kernel writes the policy's
         * INTENT magnitude, which env_step then caps to the Kelly fraction. */
        if (!eval_mode && philox_uniform(i, timestep, rng_ctr++) < eps_mag) {
            int r = (int)(philox_uniform(i, timestep, rng_ctr++) * (float)b1_size);
            mag_idx = (r >= b1_size) ? b1_size - 1 : r;
        } else {
            /* Boltzmann softmax over magnitude Q-values (train + eval). */
            float q_max_m = q_sign * (q_b1[0]);
            float q_min_m = q_max_m;
            for (int a = 1; a < b1_size; a++) {
                float qv = q_sign * (q_b1[a]);
                q_max_m = fmaxf(q_max_m, qv);
                q_min_m = fminf(q_min_m, qv);
            }
            /* tau = Q_range: gives max e:1 ≈ 2.72:1 ratio between best and worst
             * magnitude. Lower multipliers collapse to near-greedy as Q-gaps grow. */
            float tau = fmaxf(q_max_m - q_min_m, 0.01f);

            /* Numerically stable softmax sampling.
             * MAX_MAGNITUDE_ACTIONS covers any future branch size increase. */
            #define MAX_MAGNITUDE_ACTIONS 4
            float sum_e = 0.0f;
            float exps[MAX_MAGNITUDE_ACTIONS];
            for (int a = 0; a < b1_size && a < MAX_MAGNITUDE_ACTIONS; a++) {
                float qv = q_sign * (q_b1[a]);
                exps[a] = expf((qv - q_max_m) / tau);
                sum_e += exps[a];
            }
            float r = philox_uniform(i, timestep, rng_ctr++) * sum_e;
            float cum = 0.0f;
            mag_idx = b1_size - 1;
            for (int a = 0; a < b1_size; a++) {
                cum += exps[a];
                if (r < cum) { mag_idx = a; break; }
            }
        }
    }
    /* Branch 2: order type — Boltzmann softmax with adaptive tau (q_range,
     * floor 0.5) for both training and eval.
     *
     * Order-specific: floor 0.5 (vs 0.01 for direction/magnitude) because
     * order-type Q-values start nearly identical (same reward signal across
     * Market/Limit/etc.). A 0.01 floor amplifies init noise 100× and locks
     * in one order type before counterfactual training can differentiate
     * them; 0.5 keeps meaningful stochasticity until genuine Q-gaps develop. */
    if (!eval_mode && philox_uniform(i, timestep, rng_ctr++) < eps_ord) {
        int r = (int)(philox_uniform(i, timestep, rng_ctr++) * (float)b2_size);
        a2 = (r >= b2_size) ? b2_size - 1 : r;
    } else {
        /* Boltzmann softmax over order Q-values (train + eval). */
        float q_max_ord = q_sign * q_b2[0], q_min_ord = q_max_ord;
        for (int a = 1; a < b2_size; a++) {
            float qv_ord = q_sign * q_b2[a];
            q_max_ord = fmaxf(q_max_ord, qv_ord);
            q_min_ord = fminf(q_min_ord, qv_ord);
        }
        float tau_ord = fmaxf(q_max_ord - q_min_ord, 0.5f);
        float sum_e = 0.0f;
        float exps_ord[3];
        for (int a = 0; a < b2_size; a++) {
            exps_ord[a] = expf((q_sign * q_b2[a] - q_max_ord) / tau_ord);
            sum_e += exps_ord[a];
        }
        float ro = philox_uniform(i, timestep, rng_ctr++) * sum_e;
        float cum = 0.0f;
        a2 = b2_size - 1;
        for (int a = 0; a < b2_size; a++) {
            cum += exps_ord[a];
            if (ro < cum) { a2 = a; break; }
        }
    }

    /* Branch 3: urgency — Boltzmann softmax with adaptive tau (q_range,
     * floor 0.5) for both training and eval. Same rationale as Branch 2:
     * urgency Q-values start nearly identical; the higher floor preserves
     * stochasticity until learning differentiates the urgency levels. */
    if (!eval_mode && philox_uniform(i, timestep, rng_ctr++) < eps_urg) {
        int r = (int)(philox_uniform(i, timestep, rng_ctr++) * (float)b3_size);
        a3 = (r >= b3_size) ? b3_size - 1 : r;
    } else {
        /* Boltzmann softmax over urgency Q-values (train + eval). */
        float q_max_urg = q_sign * q_b3[0], q_min_urg = q_max_urg;
        for (int a = 1; a < b3_size; a++) {
            float qv_urg = q_sign * q_b3[a];
            q_max_urg = fmaxf(q_max_urg, qv_urg);
            q_min_urg = fminf(q_min_urg, qv_urg);
        }
        float tau_urg = fmaxf(q_max_urg - q_min_urg, 0.5f);
        float sum_e = 0.0f;
        float exps_urg[3];
        for (int a = 0; a < b3_size; a++) {
            exps_urg[a] = expf((q_sign * q_b3[a] - q_max_urg) / tau_urg);
            sum_e += exps_urg[a];
        }
        float ru = philox_uniform(i, timestep, rng_ctr++) * sum_e;
        float cum = 0.0f;
        a3 = b3_size - 1;
        for (int a = 0; a < b3_size; a++) {
            cum += exps_urg[a];
            if (ru < cum) { a3 = a; break; }
        }
    }

    /* Plan direction lock: during active plan, force current direction */
    if (portfolio_states != NULL) {
        int ps_base_plan = i * PORTFOLIO_STRIDE;
        float plan_thr_as = (isv_signals_ptr != NULL) ? isv_signals_ptr[ISV_PLAN_THRESHOLD_IDX] : 0.5f;
        int has_plan_active = (portfolio_states[ps_base_plan + PS_PLAN_TARGET_BARS] > plan_thr_as);
        if (has_plan_active) {
            float cur_pos_plan = portfolio_states[ps_base_plan + PS_POSITION];
            if (cur_pos_plan > 0.001f) dir_idx = DIR_LONG;
            else if (cur_pos_plan < -0.001f) dir_idx = DIR_SHORT;
        }
    }

    /* Pure-policy magnitude intent — diagnostic only, bypasses the Hold/Flat
     * dir_idx gate that forces mag_idx=0 and the downstream Kelly/margin
     * clamps inside env_step. Computed from q_b1 with strict argmax (prefer
     * higher bin index on ties within 1e-6). Uses q_sign to stay consistent
     * with contrarian mode. Written to out_intent_mag[i] if non-NULL. */
    if (out_intent_mag != NULL) {
        int intent = 0;
        float q_intent_max = q_sign * q_b1[0];
        for (int a = 1; a < b1_size; a++) {
            float qv_intent = q_sign * q_b1[a];
            if (qv_intent > q_intent_max + 1e-6f
                || (fabsf(qv_intent - q_intent_max) <= 1e-6f && a > intent)) {
                q_intent_max = qv_intent;
                intent = a;
            }
        }
        out_intent_mag[i] = intent;
    }

    /* Compose factored action: 4-branch encoding
     * action = dir * (b1*b2*b3) + mag * (b2*b3) + order * b3 + urgency */
    int action_idx = dir_idx * b1_size * b2_size * b3_size
                   + mag_idx * b2_size * b3_size
                   + a2 * b3_size + a3;

    out_actions[i] = action_idx;

    /* Output Q-gap for conviction-based position sizing.
     * Q-gap = Q(best_direction) - Q(Flat). Higher = more conviction.
     * Used by env_step to scale target_position.
     *
     * Plan C Phase 2 Task 2 follow-up: q_gap reads `e_dir` (joint
     * E[C51] + E[IQN]) — the same source the conviction block below
     * consumes and the same source eval-mode direction argmax uses.
     * Both signals feed env_step's Kelly-adjacent position sizing, so
     * sourcing them from a shared scale keeps the downstream consumer
     * coherent (per feedback_no_partial_refactor: shared-contract
     * consumers migrate together).
     *
     * Contrarian sign flip dropped on this read. The Thompson direction
     * branch above does NOT apply `q_sign` to `e_dir`; it picks
     * `argmax(e_dir)` (eval) or argmax of joint Thompson sample
     * (training) unconditionally. Symmetry with that selection logic —
     * and with the conviction block — requires the gap math to follow
     * suit. The legacy `q_sign_local` flip applied only when q_gap was
     * sourced from `q_b0` and the direction-Boltzmann path still
     * negated raw Q; both are unreachable on the Thompson path. */
    if (out_q_gaps != NULL) {
        int flat_idx = DIR_FLAT; /* Flat direction's Q-value index within the 4-way direction branch */
        /* Find best direction under the joint E[Q] posterior. */
        int best_idx = 0;
        float best_q = e_dir[0];
        for (int a = 1; a < b0_size; a++) {
            float qa = e_dir[a];
            if (qa > best_q) { best_q = qa; best_idx = a; }
        }
        float gap = best_q - e_dir[flat_idx];
        out_q_gaps[i] = (gap > 0.0f) ? gap : 0.0f;
    }

    /* Per-sample direction-branch conviction for adaptive Kelly warmup floor.
     *   conviction = (max(q_dir) − min(q_dir)) / fmaxf(q_dir_abs_ref, 1e-6f)
     * clamped to [0, 1]. 0 = uniform Q (no preference → cold caution dominates).
     * 1 = full spread at the observed Q scale (network trusts its pick).
     *
     * q_dir_abs_ref comes from ISV slot 21 — an EMA of max(|Q_mean|) across
     * direction bins, updated every epoch. It's the natural scale for
     * normalising Q-spreads into a temporal policy-confidence signal.
     *
     * Fallback when isv_signals_ptr is NULL or ISV[21] ≤ 1e-6f (pre-first-
     * update / cold-start): use q_range itself as the denominator, yielding
     * conviction = 1.0. Trust the policy at face value when we cannot measure
     * its scale — same outcome as the previous static warmup_floor=0.5 would
     * have produced at health=1, but driven by a real signal shape rather
     * than a tuned constant. Once ISV[21] graduates above the epsilon floor
     * the adaptive normalisation takes over.
     *
     * Uses raw Q (no contrarian sign flip) — conviction is about the policy's
     * confidence in the decision, regardless of which side of the distribution
     * argmax lands on. */
    if (out_conviction != NULL) {
        /* Plan C Phase 2 Task 2 — conviction reads `e_dir` (joint
         * E[C51] + E[IQN] computed in the Thompson direction branch
         * above) instead of the legacy `q_b0` cuBLAS Q-head output.
         *
         * Why: the spec requires conviction to remain E[Q]-based — using
         * Thompson per-step samples here would feed the Kelly cap a
         * jittery confidence signal that re-shapes target position with
         * every draw, defeating the warmup floor's purpose. The joint
         * E[Q] across the C51 and IQN posteriors is the same quantity
         * eval-mode argmax uses for direction selection, so dir-pick and
         * conviction stay coherent.
         *
         * Note: `e_dir` is the joint sum (E_C51 + E_IQN), not the legacy
         * `q_b0` value-head + advantage cuBLAS sum. Magnitude (and the
         * ISV[21] q_dir_abs_ref EMA the denom is normalised by) was
         * previously fit to `q_b0` scale. Both Q signals are intended to
         * track the same expected return so the empirical scale should
         * track; if Phase 3 reveals a systematic mismatch, this
         * normaliser becomes the lever to retune. The `q_range > 0`
         * cold-start fallback still holds (denom = max(q_range, 1e-6)). */
        float q_max_raw = e_dir[0];
        float q_min_raw = e_dir[0];
        for (int a = 1; a < b0_size; a++) {
            float qv = e_dir[a];
            q_max_raw = fmaxf(q_max_raw, qv);
            q_min_raw = fminf(q_min_raw, qv);
        }
        float q_range = q_max_raw - q_min_raw;
        float q_dir_abs_ref = (isv_signals_ptr != NULL)
            ? isv_signals_ptr[21]
            : 0.0f;
        float denom = (q_dir_abs_ref > 1e-6f)
            ? q_dir_abs_ref
            : fmaxf(q_range, 1e-6f);
        float conviction = q_range / denom;
        out_conviction[i] = fminf(1.0f, fmaxf(0.0f, conviction));
    }

    /* Per-sample magnitude-branch conviction for adaptive Kelly warmup floor.
     *   mag_conviction = (max(q_mag) − min(q_mag)) / fmaxf(q_mag_abs_ref, 1e-6f)
     * clamped to [0, 1]. Mirrors the direction-branch shape exactly with
     * ISV slot 16 (Q_ABS_REF — magnitude branch's |Q| scale EMA, written by
     * `q_mag_bin_means_reduce` on the stats cadence) as the normaliser.
     *
     * Why threading magnitude conviction matters (project_magnitude_eval_collapse):
     * the existing direction conviction (ISV[21] = Q_DIR_ABS_REF) reflects the
     * policy's confidence in WHERE to trade. The magnitude head independently
     * learns a strong size preference (e.g. q_f / q_h ≈ 1.9 at smoke horizon
     * with q_q ≈ 0.43, q_h ≈ 0.46, q_f ≈ 0.87 — a clear Full preference). The
     * direction head can simultaneously be near-uniform (q_dir_gap / q_dir_abs
     * _ref ≈ 0.04) when the policy hesitates between Hold and Long. The Kelly
     * cap formula previously saw only direction conviction and pinned target
     * positions to the Half bucket via safety = max(0.75, 0.04) = 0.75, never
     * reaching the Full threshold abs_pos ≥ 0.75 × max_position. Threading
     * magnitude conviction lets the cap composer take the max(dir_conv, mag_conv)
     * — a confident magnitude pick (mag_conv ≈ 0.5) unblocks the cap to
     * safety = max(0.75, 0.5) = 0.75 in the worst case but lifts to 1.0 when
     * the policy concentrates Q-mass on a single magnitude bin.
     *
     * Same fallback semantics as direction conviction: when ISV[16] ≤ 1e-6 use
     * q_range itself as denominator → conviction = 1.0 cold-start. Once the
     * `q_mag_bin_means_reduce` stats cadence fires (every epoch) the adaptive
     * normalisation takes over. Uses raw Q (no contrarian sign flip) — conviction
     * is about magnitude-decision confidence regardless of direction sign. */
    if (out_magnitude_conviction != NULL) {
        float q_max_raw_m = q_b1[0];
        float q_min_raw_m = q_b1[0];
        for (int a = 1; a < b1_size; a++) {
            float qv = q_b1[a];
            q_max_raw_m = fmaxf(q_max_raw_m, qv);
            q_min_raw_m = fminf(q_min_raw_m, qv);
        }
        float q_range_m = q_max_raw_m - q_min_raw_m;
        float q_mag_abs_ref = (isv_signals_ptr != NULL)
            ? isv_signals_ptr[16]
            : 0.0f;
        float denom_m = (q_mag_abs_ref > 1e-6f)
            ? q_mag_abs_ref
            : fmaxf(q_range_m, 1e-6f);
        float mag_conviction = q_range_m / denom_m;
        out_magnitude_conviction[i] = fminf(1.0f, fmaxf(0.0f, mag_conviction));
    }
}

/* ================================================================== */
/* Kernel 3: experience_env_step                                      */
/* ================================================================== */

/**
 * Portfolio simulation, reward v8 (Counterfactual Branch Attribution), and done detection.
 *
 * For each episode i this kernel:
 *   1. Reads the current-bar raw prices from targets[bar_idx].
 *   2. Decodes the factored action to an exposure branch index.
 *   3. Computes target position = exposure_fraction * max_position.
 *   4. Applies position adjustment delta with volatility-scaled tx cost.
 *   5. Runs dynamic trailing stop (regime-adaptive).
 *   6. Computes reward (post-V7 / Task 0.8 shape — `patience_mult` and the
 *      V8 segment-patience term were removed when R7 was deleted):
 *        sparse = 2.0 * vol_normalized_return  (at trade exit, primary signal,
 *                                                capped inline at +10)
 *        reward = sparse  (Sparse Trade-Completion Only)
 *        + CEA bonus (counterfactual exposure advantage)
 *        + order_credit (microstructure credit for order type selection)
 *        + risk_efficiency (intra-trade risk-adjusted return bonus)
 *      Task 2.4: negative-tail compression (former `asymmetric_soft_clamp`)
 *      relocated to c51_loss_kernel.cu::block_bellman_project_f.
 *   7. Writes (batch_states, action, reward, done) to output replay buffer.
 *   8. Updates portfolio_states[0..19] in place.
 *   9. Increments current_timesteps[i].
 *
 * Grid: ceil(N / 256), Block: 256.  One thread per episode.
 *
 * targets layout: [total_bars, TARGET_DIM=6]
 *   See `crates/ml/src/fxcache.rs` for the canonical
 *   contract; mirror below for human readers (kernels can't import Rust):
 *   [0] preproc_close  — log-return-normalised close (network only)
 *   [1] preproc_next   — log-return-normalised next close (unused here)
 *   [2] raw_close      — raw close price (for position cost + tx)
 *   [3] raw_next       — raw next-bar close (UNUSED — FUTURE info)
 *   [4] raw_open       — raw open price
 *   [5] mid_price_open — MBP-10 midpoint at bar open (fallback raw_open)
 *
 * portfolio_states layout: [N, PORTFOLIO_STRIDE=40]  (read-write, float)
 *   See file header for field definitions.
 */
extern "C" __global__ void experience_env_step(
    const float* __restrict__ targets,
    const int*   __restrict__ episode_starts,
    int*                      current_timesteps,
    const int*   __restrict__ actions,
    float*            portfolio_states,
    float*                    out_states,  /* #30: f32 replay buffer output */
    int*                      out_actions,
    float*                    out_rewards,
    float*                    out_dones,
    const float* __restrict__ batch_states,  /* #30: f32 states from state_gather */
    float max_position,
    float tx_cost_multiplier,
    const float* __restrict__ features,
    int market_dim,
    int L,
    int N,
    int total_bars,
    int state_dim,
    int b0_size,
    int b1_size,
    int b2_size,
    int b3_size,
    const int* __restrict__ current_t_ptr, /* [1] GPU-resident timestep counter */
    const float* __restrict__ cvar_scales,  /* [N] or NULL — CVaR position scaling */
    const float* __restrict__ q_gaps,       /* [N] or NULL — Q-gap conviction scaling */
    float*            raw_returns_out, /* [N, L] output: true per-bar portfolio return */
    /* H6 diagnostic outputs — per-magnitude trailing-stop fire rate + hold-at-exit.
     * Written at every (i,t) in this kernel; the host reduces over the whole epoch.
     * Race-free by construction: each thread writes only its own [i*L+t] slot. */
    int*   __restrict__ trail_triggered_per_sample,   /* [N*L] 1 iff trail fired this (i,t) */
    int*   __restrict__ pre_mag_per_sample,           /* [N*L] pre-step pos mag bin (-1=no pos, 0=Q, 1=H, 2=F) */
    int*   __restrict__ traded_per_sample,            /* [N*L] 1 iff entering/exiting/reversing */
    float* __restrict__ hold_at_exit_per_sample,      /* [N*L] saved_hold_time on exit/reverse, else 0 */
    /* Task 0.8 reward-term contribution outputs — per-sample additive + sample-selector signals.
     * Written at every (i,t); host reduces host-side in reward_contrib_fractions.
     * Race-free by construction: each thread writes only its own [i*L+t] (or [cf_off]) slot.
     *   cf_flip_per_sample        — 1 iff counterfactual direction-flip fired this (i,t) (written at cf_off)
     *   micro_reward_per_sample   — micro-reward additive contribution this step (pre-drawdown, pre-floor)
     *   total_reward_per_sample   — final reward value written to out_rewards[out_off] (denominator)
     *   slot_live_per_sample      — 1 iff the kernel reached this (i,t) (denominator for firing-rate ratios;
     *                               un-reached slots from early-terminated episodes stay 0 from memset)
     * Task 2.4: loss_aversion_per_sample REMOVED — the R6 clamp was relocated to
     * c51_loss_kernel.cu::block_bellman_project_f so the reward-layer diagnostic
     * has no source signal. */
    int*   __restrict__ cf_flip_per_sample,           /* [N*L*cf_mult] (written at cf_off = out_off + N*L) */
    float* __restrict__ micro_reward_per_sample,      /* [N*L] */
    float* __restrict__ total_reward_per_sample,      /* [N*L] */
    int*   __restrict__ slot_live_per_sample,         /* [N*L] */
    /* Task 2.X prerequisite (policy-quality scoping) — per-magnitude win rate
     * + realized step-return variance instrumentation. Same race-free
     * per-sample-plus-host-reduce pattern as Task 0.5 / 0.8. Host reducer
     * lives in per_magnitude_winrate_and_variance.
     *   action_mag_per_sample       — actual_mag_core (0=Quarter, 1=Half, 2=Full);
     *                                 -1 sentinel = slot unreached this step.
     *   step_ret_per_sample         — step_ret_core (raw signed per-step return).
     *   trade_close_per_sample      — 1 iff exiting_trade || reversing_trade.
     *   trade_profitable_per_sample — 1 iff trade_close AND step_ret > 0. */
    int*   __restrict__ action_mag_per_sample,        /* [N*L] */
    float* __restrict__ step_ret_per_sample,          /* [N*L] */
    int*   __restrict__ trade_close_per_sample,       /* [N*L] */
    int*   __restrict__ trade_profitable_per_sample,  /* [N*L] */
    /* Task 0.3 Var[Q] Kelly scale — per-sample `1 / (1 + sqrt(Var[Q]))`.
     * Real value written only when q_variance != NULL AND total_actions_for_var > 0
     * at the Var[Q] position-sizing block (~line 1422). Default write of 0.0f
     * happens at the entry block, which is also the semantic value for "no
     * variance scaling applied this (i,t)" (q_variance disabled or unreached).
     * Host reducer averages over slots where value > 0 → "measured". */
    float* __restrict__ var_scale_per_sample,         /* [N*L] */
    float holding_cost_rate,  /* continuous inventory penalty per bar per unit position */
    float churn_threshold,    /* bars threshold for churn penalty (0 = disabled) */
    float churn_penalty_scale, /* scale factor for churn penalty */
    float spread_cost,  /* bid-ask spread cost per unit (matches backtest) */
    float contract_multiplier,  /* e.g. 50.0 for ES, 20.0 for NQ */
    float margin_pct,           /* e.g. 0.06 (6% initial margin) */
    float dd_threshold,         /* drawdown fraction before penalty (0.02 = 2%) */
    float w_dd,                 /* drawdown penalty weight (1.0 = full) */
    int mirror_active,              /* #10 mirror universe: invert direction actions */
    float micro_reward_scale,       /* v8: dense directional micro-reward scale */
    /* #33 Per-episode saboteur params [N, 3]: (spread_mult, fill_prob, slippage_mult).
     * NULL = disabled (use global scalars). When non-NULL, overrides
     * spread_cost, fill_ioc_fill_prob, tx_cost_multiplier per episode. */
    const float* __restrict__ saboteur_params,
    const float* __restrict__ q_variance,   /* [N, total_actions] distributional Var[Q]. NULL = disabled. */
    int total_actions_for_var,               /* b0+b1+b2+b3 to index q_variance */
    const float* __restrict__ cost_anneal_ptr,   /* [1] pinned device-mapped: 0.0=no costs, 1.0=full costs */
    const float* __restrict__ isv_signals_ptr,   /* [8] pinned device-mapped ISV signals. NULL = static hold. */
    const float* __restrict__ plan_params_ptr,   /* [N, 6] trade plan params. NULL = no plan. */
    const float* __restrict__ readiness_ptr,     /* [1] pinned model readiness ∈[0,1]. NULL=1.0. Plan enforcement scales with readiness. */
    const float* __restrict__ full_batch_states, /* [N, full_state_dim] full state with OFI at [SL_OFI_START..SL_OFI_START+SL_OFI_DIM) = [42..62). NULL = no OFI. */
    int full_state_dim,                          /* state_dim of full_batch_states (needed for stride) */
    float price_confirm_weight,      /* micro-reward: price confirmation weight (default 0.5) */
    float book_aggression_weight,    /* micro-reward: book aggression weight (default 0.3) */
    float hold_quality_weight,       /* micro-reward: retrospective hold quality weight (default 0.2) */
    float micro_reward_temp,         /* micro-reward: tanh temperature (default 3.0) */
    float cf_ratio,                  /* D4/N4: adaptive counterfactual flip ratio [0,1]; 0.5=healthy, 0.8=collapsed */
    /* ── Phase 3 unified-env control scalars (pinned device-mapped, host-writable) ──
     * exploration_scale ∈ [0, 1]: gates training-only stochastic perturbations
     *   (saboteur cost noise, plan_params position scaling, counterfactual flips).
     *   1.0 = full training mode; 0.0 = deterministic / validation-equivalent.
     *   Saboteur multipliers lerp toward 1.0: sab_eff = 1 + exp_s * (sab - 1).
     * shaping_scale ∈ [0, 1]: gates additive reward-shaping bundles
     *   (drawdown_penalty, churn_penalty, micro_reward, asymmetric clamp on segment return).
     *   1.0 = full shaping; 0.0 = pure per-bar P&L (raw_returns_out is always pure).
     * Both pointers may be NULL — defaults to 1.0 for backward compatibility. */
    const float* __restrict__ exploration_scale_ptr,
    const float* __restrict__ shaping_scale_ptr,
    /* Per-sample policy conviction ∈ [0, 1] for adaptive Kelly warmup floor.
     * Written by experience_action_select (out_conviction). NULL → fallback
     * 1.0 (trust the policy face-value; same outcome as the old static
     * warmup_floor=0.5 at health=1). Threaded into unified_env_step_core. */
    const float* __restrict__ conviction_ptr,
    /* Per-sample magnitude-branch conviction ∈ [0, 1] — same shape as
     * conviction_ptr, parallel signal. Written by experience_action_select
     * (out_magnitude_conviction). Threaded into unified_env_step_core where
     * the cap formula composes max(dir_conv, mag_conv). NULL → fallback 0.0
     * (helper falls back to direction conviction alone). */
    const float* __restrict__ magnitude_conviction_ptr,
    /* C.2 Reward-component attribution (Plan 3 Task 1, spec §4.C.2).
     * Per-sample per-component reward magnitudes written at every (i,t).
     * Layout [N*L, 6] row-major; component indices:
     *   [0] popart   — final on-policy reward (denominator for PopArt drift)
     *   [1] cf       — counterfactual reward written to CF slot
     *   [2] trail    — 0.0 (placeholder; future term)
     *   [3] micro    — dense OFI micro-reward (R5)
     *   [4] opp_cost — Flat opportunity-cost reward (Plan 3 Task 2 B.1)
     *   [5] bonus    — novelty-scaled trade-attempt bonus (Plan 3 Task 3 B.2)
     * Producer: this kernel (experience_env_step).
     * Consumer: reward_component_ema GPU kernel → ISV[63..69). */
    float* __restrict__ reward_components_per_sample, /* [N*L * 6] */
    /* B.2 Plan 3 Task 3: Flat→Positioned transition flag per (i,t).
     * Written unconditionally (0/1) at every reached slot. Consumer:
     * trade_attempt_rate_ema_update → ISV[TRADE_ATTEMPT_RATE_EMA_INDEX]. */
    int* __restrict__ flat_to_pos_per_sample,         /* [N*L] */
    /* B.4 Plan 3 Task 4: per-(i,t) readiness value observed at this slot.
     * Written unconditionally (per-slot, race-free). Consumer:
     * `plan_threshold_update` GPU kernel reduces + EMA into
     * ISV[READINESS_EMA_INDEX=75]; that kernel derives the threshold
     * stored in ISV[PLAN_THRESHOLD_INDEX=49]. */
    float* __restrict__ readiness_per_sample,         /* [N*L] */
    /* B.2 Plan 3 Task 3: ISV slot indices for the novelty-scaled bonus.
     * Kernel reads isv_signals_ptr[trade_attempt_rate_ema_idx] and
     * isv_signals_ptr[trade_target_rate_idx] at Flat→Positioned transitions. */
    int trade_attempt_rate_ema_idx,
    int trade_target_rate_idx,
    /* C.4 / D.4b bonus optimism break (2026-04-29): direction-branch
     * Q-scale EMA slot index (Q_DIR_ABS_REF_INDEX = 21). The C.4 timing
     * bonus and D.4b regime penalty multiply by |final_pnl| / |reward|
     * which is itself the trade-cumulative shaped reward — across trades
     * this compounds (bonus inflates Q which inflates next-trade reward
     * which inflates bonus, …). Replacing the unbounded multiplicand
     * with a Q-scale-bounded variant breaks the loop. ISV[21] is a
     * gradient-decoupled EMA of the direction-branch max(|Q_mean|),
     * already produced by q_stats_kernel.cu — no new producer needed.
     * Per pearl_one_unbounded_signal_per_reward.md +
     * feedback_isv_for_adaptive_bounds.md. */
    int q_dir_abs_ref_idx,
    /* SP11 Fix 39 (2026-05-04, A1.2): per-bar saboteur engagement signal
     * for the reward-as-controlled-subsystem chain. Layout [N*L] f32, row-
     * major (i, t). Producer: this kernel writes the engagement proxy at
     * the END (after `total_reward_per_sample` is finalised) — a
     * structurally-meaningful approximation of |Δreward_with - Δreward_without|
     * per spec §3.3.1:
     *   delta = traded × |reward| × max(|eff_spread - 1|, |eff_slip - 1|)
     * where `eff_spread`/`eff_slip` are the saboteur-perturbed cost
     * multipliers and `traded` is 1 iff the bar carried a trade. The
     * kernel `saboteur_engagement_compute_kernel` consumes this buffer
     * and applies a `0.01 × ISV[PNL_REWARD_MAGNITUDE_EMA_INDEX]`
     * threshold to classify per-bar engagement, then block tree-reduces
     * to a fraction → ISV[SABOTEUR_ENGAGEMENT_RATE_INDEX=358] via Pearls
     * A+D. NULL = saboteur disabled this epoch (writes zero per bar
     * → engagement collapses to zero, Pearl A holds the EMA at sentinel
     * until the saboteur reactivates).
     *
     * Pure GPU buffer (CudaSlice<f32> on the host side), never read
     * host-side — kernel-to-kernel dataflow only, so the no-CudaSlice
     * rule from feedback_no_htod_htoh_only_mapped_pinned does not apply
     * (that rule covers CPU↔GPU paths). */
    float* __restrict__ saboteur_delta_reward_per_sample,
    /* SP11 Fix 39 B1b fix-up (2026-05-04): per-bar popart-component-
     * specific reward magnitude for the reward-as-controlled-subsystem
     * chain. Layout [N*L] f32 mapped-pinned, row-major (i, t). Producer:
     * this kernel writes the actual `r_popart` value (vol-normalized
     * capped P&L on voluntary-exit bars; 0 on every other bar) — NOT
     * the composed total reward. The kernel `popart_component_ema_kernel`
     * consumes this buffer and computes mean(|x|) into ISV[POPART_
     * COMPONENT_MAG_EMA_INDEX=360] via Pearls A+D.
     *
     * Why this is separate from `reward_components_per_sample[+0]`:
     * pre-B1b that slot was overloaded (post-composition total reward
     * AND popart-component magnitude collapsed to the same value because
     * composition was inline accumulation). B1b's structural decomposition
     * surfaces the contamination — slot 63 = REWARD_POPART_EMA_INDEX
     * stays as PopArt's input (= total reward magnitude); slot 360 =
     * POPART_COMPONENT_MAG_EMA_INDEX is the new popart-component-specific
     * magnitude. Spec §4 amendment "Why slot 360".
     *
     * NULL = caller hasn't wired the buffer yet (early init); the kernel
     * guards every write with the NULL check. Pure GPU buffer (the
     * trainer holds it as a `MappedF32Buffer`); reads happen only inside
     * `popart_component_ema_kernel` (kernel-to-kernel dataflow). */
    float* __restrict__ popart_component_per_sample,
    /* SP12 v3 (2026-05-04): per-trade event-driven reward composition.
     *
     * `min_hold_target` (bars): patience requirement on voluntary trade
     *   exits. Phase 1 default 30 bars (Invariant-1 numerical anchor in
     *   state_layout.cuh::MIN_HOLD_TARGET). Voluntary exits with
     *   `hold_time < min_hold_target` get a soft penalty subtracted from
     *   `r_popart` via the deficit/(deficit + T) saturation formula.
     *
     * `min_hold_penalty_max`: max penalty magnitude when the deficit is
     *   infinite (factor → 1). Phase 1 default 3.0 (60% of REWARD_POS_CAP).
     *
     * `min_hold_temperature`: smoothness controller for the soft factor.
     *   Higher T = wider transition. Computed in Rust per epoch as
     *   `T_end + (T_start - T_end) × exp(-epoch / decay)` and passed in
     *   per launch. Constants in state_layout.cuh
     *   (MIN_HOLD_TEMPERATURE_{START,END,DECAY}). Annealing curriculum:
     *   epoch 0 ≈ 50 (forgiving), epoch 50 ≈ 9, epoch 100 ≈ 5 (sharp).
     *
     * Trail-fire forced exits are exempted from this penalty (the
     * trail-stop is a risk-management design choice; penalising it would
     * fight the existing exit logic). Spec §design-2 + open question 4. */
    float min_hold_target,
    float min_hold_penalty_max,
    float min_hold_temperature
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    /* ── Phase 3 unified-env control scalars ──
     * Read once per thread. NULL-tolerant for backward compatibility (defaults
     * to 1.0 = full training mode). Validation flips both to 0.0 to disable
     * saboteur, plan-params nudge, counterfactuals, and reward-shaping bundles
     * — leaving pure trade physics + raw P&L. */
    float exploration_scale = (exploration_scale_ptr != NULL) ? exploration_scale_ptr[0] : 1.0f;
    float shaping_scale     = (shaping_scale_ptr     != NULL) ? shaping_scale_ptr[0]     : 1.0f;
    /* Defensive clamp — host writers may not enforce [0,1]. */
    exploration_scale = fminf(1.0f, fmaxf(0.0f, exploration_scale));
    shaping_scale     = fminf(1.0f, fmaxf(0.0f, shaping_scale));

    /* SP11 Fix 39 (A1.2): track the saboteur perturbation magnitude per
     * thread so the END-of-kernel engagement-signal write can compute
     * `traded × |reward| × max(|eff_spread − 1|, |eff_slip − 1|)`. The
     * non-saboteur path leaves these at 1.0 (identity perturbation) so
     * the engagement signal naturally collapses to zero — Pearl A holds
     * the EMA at sentinel until the saboteur activates. */
    float saboteur_eff_spread = 1.0f;
    float saboteur_eff_slip   = 1.0f;

    /* #33 Per-episode saboteur overrides — lerped by exploration_scale.
     * sab_eff = 1 + exploration_scale * (sab - 1)  →  identity at scale=0,
     * full saboteur at scale=1. Same lerp form for both spread and slippage. */
    if (saboteur_params != NULL) {
        float sab_spread = saboteur_params[i * 3 + 0];
        float sab_fill   = saboteur_params[i * 3 + 1];
        float sab_slip   = saboteur_params[i * 3 + 2];
        float eff_spread = 1.0f + exploration_scale * (sab_spread - 1.0f);
        float eff_slip   = 1.0f + exploration_scale * (sab_slip   - 1.0f);
        spread_cost *= eff_spread;
        tx_cost_multiplier *= eff_slip;
        /* fill_ioc_fill_prob is not a kernel param — it's baked into the
         * portfolio sim via trade_physics.cuh. The exploration_scale gating
         * here covers spread + slippage; fill probability stays at base. */
        (void)sab_fill;  /* still read for register layout consistency */
        saboteur_eff_spread = eff_spread;
        saboteur_eff_slip   = eff_slip;
    }

    /* G2: Transaction cost curriculum — scale spread_cost and tx_cost_multiplier */
    float cost_scale = (cost_anneal_ptr != NULL) ? cost_anneal_ptr[0] : 1.0f;
    spread_cost *= cost_scale;
    tx_cost_multiplier *= cost_scale;

    int t       = current_timesteps[i];
    int bar_idx = episode_starts[i] + t;

    /* Flat index into [N, L] output arrays.
     * current_t_ptr is a GPU-resident scalar counter [1], advanced by
     * step_counter_advance kernel after each timestep.  Reading from device
     * memory instead of a host-side scalar eliminates per-timestep
     * host→GPU parameter variation, enabling future CUDA Graph capture. */
    int current_t = current_t_ptr[0];
    long long out_off = (long long)i * L + current_t;

    /* H6 diagnostic defaults — written BEFORE any early-return so every
     * (i,t) slot owned by this thread gets a deterministic value. The
     * real values for slots where a trade occurred are overwritten
     * below right after the trade-lifecycle flags are resolved. */
    trail_triggered_per_sample[out_off] = 0;
    pre_mag_per_sample[out_off]         = -1;
    traded_per_sample[out_off]          = 0;
    hold_at_exit_per_sample[out_off]    = 0.0f;

    /* Task 0.8 reward-contribution defaults — written BEFORE any early-return
     * so every (i,t) slot owned by this thread gets a deterministic value.
     * The cf_flip slot lives at cf_off (out_off + N*L); default it here too.
     * Task 0.3 var_scale default = 0.0f, which is also the real semantic value
     * for "no Var[Q] scaling happened this (i,t)" (q_variance disabled or
     * total_actions_for_var == 0). Real kernel write happens at line ~1422
     * inside the `if (q_variance != NULL)` branch. */
    micro_reward_per_sample[out_off]     = 0.0f;
    total_reward_per_sample[out_off]     = 0.0f;
    var_scale_per_sample[out_off]        = 0.0f;
    /* SP11 Fix 39 (A1.2): default per-bar saboteur engagement signal to 0.
     * Real value is overwritten near `total_reward_per_sample[out_off] = reward`
     * after the trade lifecycle resolves. NULL pointer = saboteur disabled
     * for this epoch's collect call (caller passes 0 from the launcher); the
     * kernel guards the write with the NULL check. */
    if (saboteur_delta_reward_per_sample != NULL) {
        saboteur_delta_reward_per_sample[out_off] = 0.0f;
    }
    /* SP11 Fix 39 B1b fix-up (2026-05-04): default per-bar popart-component
     * magnitude to 0. Real value is overwritten only on voluntary-exit
     * bars (where `r_popart = capped_pnl` — see line ~2680). All other
     * bars (mid-trade, trail-triggered exits, hold/flat, early returns)
     * keep the 0 default — popart only fires on voluntary segment_complete
     * exits, so the kernel intentionally writes 0 elsewhere. NULL = buffer
     * not wired yet (early init); the kernel guards every write. */
    if (popart_component_per_sample != NULL) {
        popart_component_per_sample[out_off] = 0.0f;
    }
    {
        long long cf_off_default = out_off + (long long)N * L;
        cf_flip_per_sample[cf_off_default] = 0;
    }
    /* C.2 Reward-component attribution defaults — written BEFORE any early-return
     * so every (i,t) slot owned by this thread gets a deterministic value.
     * Component [2] is a structural zero (no source term yet); it is written
     * explicitly rather than relying on alloc_zeros to make the per-sample
     * write pattern self-documenting and forward-safe. Components [4, 5] are
     * overwritten later by the Flat opp-cost and Flat→Positioned bonus paths. */
    {
        float* rc = reward_components_per_sample + out_off * 6;
        rc[0] = 0.0f;  /* popart (final reward) — overwritten below */
        rc[1] = 0.0f;  /* cf reward — overwritten below at cf_off */
        rc[2] = 0.0f;  /* trail penalty (future term) */
        rc[3] = 0.0f;  /* micro reward — overwritten below */
        rc[4] = 0.0f;  /* opp_cost — overwritten below at Flat branch */
        rc[5] = 0.0f;  /* bonus — overwritten below at Flat→Positioned branch */
    }
    /* B.2 Plan 3 Task 3: Flat→Positioned transition flag default. Overwritten
     * once prev_sign/curr_sign are resolved at the trade-lifecycle block. */
    flat_to_pos_per_sample[out_off] = 0;
    /* B.4 Plan 3 Task 4: readiness per-sample default. Overwritten below
     * once `readiness` is resolved from the scalar pointer at the trade-
     * plan gating block. The default 0.0f is the semantic value for
     * "slot unreached / readiness pointer NULL" — the plan_threshold_update
     * kernel averages over all N*L slots, so unreached slots simply
     * contribute zero to the mean. */
    readiness_per_sample[out_off] = 0.0f;
    /* Task 2.X prerequisite defaults — written BEFORE any early-return so every
     * (i,t) slot owned by this thread gets a deterministic value. The sentinel
     * for action_mag_per_sample is -1 (= "slot unreached"); the host reducer
     * uses this to filter out un-traded slots. The other three default to 0 —
     * step_ret is a numeric scalar (no sentinel needed; excluded via mag==-1),
     * trade_close / trade_profitable are boolean flags. The real per-sample
     * values are overwritten below at the trade-lifecycle block right after
     * unified_env_step_core computes step_ret_core + actual_mag_core. */
    action_mag_per_sample[out_off]       = -1;
    step_ret_per_sample[out_off]         = 0.0f;
    trade_close_per_sample[out_off]      = 0;
    trade_profitable_per_sample[out_off] = 0;
    /* Task 0.8: mark this (i,t) as reached. Written unconditionally on every
     * thread that owns this slot, BEFORE any early-return. Un-reached slots
     * (owned by no thread, or zeroed out by a preceding memset between epochs)
     * stay 0 — host uses Σ slot_live as the denominator for firing-rate
     * ratios so early-terminated episodes don't dilute the signal. */
    slot_live_per_sample[out_off] = 1;

    /* ---- Write current state to replay buffer (f32) ---- */
    const float* src = batch_states + (long long)i * state_dim;
    float*       dst = out_states   + out_off * state_dim;
    for (int k = 0; k < state_dim; k++)
        dst[k] = src[k];

    /* G7: Counterfactual return augmentation — 50% flip direction.
     * Doubles effective dataset, forces symmetric strategies.
     * Negate directional features (indices 0-15) in the REPLAY BUFFER copy
     * so the original batch_states remain untouched for portfolio sim.
     *
     * Gated by exploration_scale: at 0.0 the effective flip probability is 0
     * (validation/deterministic mode), at 1.0 the configured cf_ratio applies.
     * Linear blend: effective_cf = exploration_scale * cf_ratio. */
    int do_flip = 0;
    {
        float flip_u = philox_uniform(i, bar_idx + 8888, 7777);
        float effective_cf = exploration_scale * cf_ratio;
        do_flip = (flip_u < effective_cf) ? 1 : 0;
    }
    if (do_flip) {
        for (int f = 0; f < 16 && f < state_dim; f++) {
            dst[f] *= -1.0f;
        }
    }

    /* ---- Write action ---- */
    int action_idx = actions[i];
    out_actions[out_off] = action_idx;

    /* ---- Out-of-data check ---- */
    if (bar_idx >= total_bars - 1) {
        out_rewards[out_off] = 0.0f;
        out_dones[out_off]   = 1.0f;
        /* Do not advance timestep: episode is at the data boundary. */
        return;
    }

    /* ---- Read raw prices from targets ---- */
    /* Target layout: [preproc_close, preproc_next, raw_close, raw_next, raw_open, mid_price_open]
     *   [0:1] = preprocessed (log-return normalized) — for Q-network
     *   [2]   = raw close — for portfolio simulation P&L + tx costs
     *   [3]   = raw_next — NOT used in reward/P&L/equity (future info)
     *   [4]   = raw open price
     *   [5]   = mid-price at bar open (MBP-10 midpoint; fallback to raw_open) */
    const float* tgt = targets + (long long)bar_idx * 6;
    /* Trade physics (execute_trade, compute_tx_cost, etc.) use float internally
     * because they involve multi-step accumulation where bf16 precision is
     * insufficient (e.g. cash -= delta * price; cash -= cost). We convert
     * prices to float here for the physics section, then store results back
     * as bf16 at the end. */
    float raw_close  = (tgt[2]);
    /* raw_next (tgt[3]) is FUTURE information — must NOT be used for any
     * reward, P&L, equity, or portfolio computation.  Only raw_close is
     * valid for the experience collector's portfolio simulation. */
    (void)tgt[3];  /* suppress unused-element warning */

    /* Guard against degenerate prices from data gaps. */
    if (raw_close <= 0.0f) raw_close = 1.0f;

    /* ---- Read full portfolio state (PORTFOLIO_STRIDE=40) ---- */
    /* Portfolio arithmetic uses float accumulators because trade physics
     * functions (execute_trade, apply_margin_cap, etc.) in trade_physics.cuh
     * are all float. Converting the entire physics engine to bf16 would
     * require rewriting the shared header used by backtest_env_kernel too.
     * We load bf16 → float here, compute, then store float → bf16 at end. */
    float* ps       = portfolio_states + (long long)i * PORTFOLIO_STRIDE;
    float position           = (ps[PS_POSITION]);
    float cash               = (ps[PS_CASH]);
    /* ps[PS_PORTFOLIO_VALUE] = portfolio_value (updated at end) */
    /* ps[3:6]: reserved (formerly trade clustering tracking, retained for slot stability) */
    float peak_equity        = (ps[PS_PEAK_EQUITY]);
    float flat_counter       = (ps[PS_FLAT_COUNTER]);
    float prev_equity        = (ps[PS_PREV_EQUITY]);
    float hold_time          = (ps[PS_HOLD_TIME]);
    /* ps[PS_REALIZED_PNL] = realized_pnl (cumulative, updated at end) */
    float entry_price        = (ps[PS_ENTRY_PRICE]);   /* price when trade was entered */
    float trade_start_pnl    = (ps[PS_TRADE_START_PNL]); /* realized_pnl snapshot at trade entry */
    float win_count          = (ps[PS_KELLY_WIN_COUNT]);  /* Kelly: number of profitable trade exits */
    float loss_count         = (ps[PS_KELLY_LOSS_COUNT]);  /* Kelly: number of losing trade exits */
    float sum_wins           = (ps[PS_KELLY_SUM_WINS]);  /* Kelly: cumulative profit from winners */
    float sum_losses         = (ps[PS_KELLY_SUM_LOSSES]);  /* Kelly: cumulative |loss| from losers */
    float sum_returns        = (ps[PS_KELLY_SUM_RETURNS]);  /* Kelly: cumulative net returns (for mu) */
    float sum_sq_returns     = (ps[PS_KELLY_SUM_SQ_RETURNS]);  /* Kelly: cumulative squared returns (for sigma^2) */

    /* Pre-trade capital floor: skip trade execution on blown accounts.
     * When the floor triggers, write done=1 AND reset the portfolio to
     * initial capital so the NEXT step starts a fresh episode. Without
     * the reset, the blown portfolio persists and every subsequent step
     * hits the floor again (producing fake 92% MaxDD from stuck episodes). */
    {
        float portfolio_val = (ps[PS_PORTFOLIO_VALUE]);
        if (check_capital_floor(portfolio_val, peak_equity)) {
            out_rewards[out_off] = -10.0f;
            out_dones[out_off] = 1.0f;
            /* Reset portfolio for next episode (fresh capital) */
            float init_cap = peak_equity;  /* use peak as initial for next episode */
            ps[PS_POSITION]  = 0.0f;                   /* position = flat */
            ps[PS_CASH]  = (init_cap);    /* cash = initial_capital */
            ps[PS_PORTFOLIO_VALUE]  = (init_cap);    /* portfolio_value */
            ps[PS_PEAK_EQUITY]  = (init_cap);    /* peak_equity */
            ps[PS_FLAT_COUNTER]  = 0.0f;                   /* flat_counter */
            ps[PS_PREV_EQUITY]  = (init_cap);    /* prev_equity */
            ps[PS_HOLD_TIME] = 0.0f;                   /* hold_time */
            ps[PS_REALIZED_PNL] = 0.0f;                   /* realized_pnl */
            ps[PS_ENTRY_PRICE] = 0.0f;                   /* entry_price */
            ps[PS_TRADE_START_PNL] = 0.0f;                   /* trade_start_pnl */
            ps[PS_KELLY_WIN_COUNT] = 0.0f;                   /* win_count (reset Kelly) */
            ps[PS_KELLY_LOSS_COUNT] = 0.0f;                   /* loss_count */
            ps[PS_KELLY_SUM_WINS] = 0.0f;                   /* sum_wins */
            ps[PS_KELLY_SUM_LOSSES] = 0.0f;                   /* sum_losses */
            ps[PS_KELLY_SUM_RETURNS] = 0.0f;                   /* sum_returns */
            ps[PS_KELLY_SUM_SQ_RETURNS] = 0.0f;                   /* sum_sq_returns */
            current_timesteps[i] = 0;  /* reset episode timer */
            return;
        }
    }

    /* ---- Decode direction + magnitude from 4-branch factored action ---- */
    int dir_idx = decode_direction_4b(action_idx, b1_size, b2_size, b3_size);
    int mag_idx = decode_magnitude_4b(action_idx, b1_size, b2_size, b3_size);

    /* #10 Mirror universe: invert direction (Short↔Long, Hold/Flat stay).
     * In mirrored episodes, the Q-network sees negated features and must
     * select mirrored actions. Inverting here makes the portfolio sim
     * consistent with the mirrored price data.
     * Direction(4): Short(0)↔Long(2), Hold(1)→Hold(1), Flat(3)→Flat(3). */
    if (mirror_active) {
        if (dir_idx == DIR_SHORT) dir_idx = DIR_LONG;       /* Short → Long */
        else if (dir_idx == DIR_LONG) dir_idx = DIR_SHORT;  /* Long → Short */
        /* Hold(1) and Flat(3) unchanged */
    }

    /* Risk scaling BEFORE magnitude mapping — preserves the 4× ratio between
     * Small(0.25) and Full(1.0). Previously these scaled target_position AFTER
     * magnitude mapping, which collapsed all magnitudes to the same effective
     * size (conviction floor of 0.25 made Full ≈ Small). Now they scale the
     * max_position envelope so the magnitude branch controls RELATIVE sizing
     * while risk management controls the ABSOLUTE scale. */
    float effective_max_pos = max_position;

    /* CVaR position scaling: scale down in high-risk quantiles */
    if (cvar_scales != NULL) {
        float cvar_scale = (cvar_scales[i]);
        if (cvar_scale > 0.0f && cvar_scale < 1.0f) {
            effective_max_pos *= cvar_scale;
        }
    }

    /* Q-gap conviction: scale envelope by trading conviction */
    if (q_gaps != NULL) {
        float q_gap = (q_gaps[i]);
        float conviction = fminf(fmaxf(q_gap * 0.5f, 0.25f), 1.0f);
        effective_max_pos *= conviction;
    }

    /* Kelly criterion: scale by optimal fraction after sufficient samples.
     * win_count/loss_count already declared earlier from ps[PS_KELLY_WIN_COUNT]/ps[PS_KELLY_LOSS_COUNT]. */
    {
        float total_trades = win_count + loss_count;
        if (total_trades >= 20.0f) {
            float sum_wins_val  = (ps[PS_KELLY_SUM_WINS]);
            float sum_losses_val = (ps[PS_KELLY_SUM_LOSSES]);
        float avg_win  = sum_wins_val / fmaxf(win_count, 1.0f);
        float avg_loss = sum_losses_val / fmaxf(loss_count, 1.0f);
        float win_rate = win_count / total_trades;
        /* Kelly fraction: f* = (bp - q) / b where b=avg_win/avg_loss */
        float b = avg_win / fmaxf(avg_loss, 0.001f);
        float kelly_f = (b * win_rate - (1.0f - win_rate)) / fmaxf(b, 0.001f);
        kelly_f = fminf(fmaxf(kelly_f, 0.0f), 1.0f); /* clamp [0, 1] */
        /* Half-Kelly for safety */
        kelly_f *= 0.5f;
        if (kelly_f > 0.01f) {
            effective_max_pos *= kelly_f;
        }
    } /* end Kelly block */
    } /* end Kelly scope */

    /* Distributional variance position sizing (Kelly from C51 atoms).
     * High Var[Q] = uncertain outcome → smaller position.
     * Low Var[Q]  = confident → full position.
     * Scale: 1 / (1 + sqrt(Var[Q_taken])). Bounded (0, 1].
     *
     * Permanent floor: when the policy's own conviction signal (q_gap) is
     * high, var_scale must not silence the magnitude intent. Same pearl as
     * the Kelly warmup_floor (`pearl_blend_formulas_must_have_permanent_floor.md`):
     * cascading multiplicative shrinks (cvar × q_gap_conviction × kelly × var_scale)
     * compose to near-zero at validation when var_q persists high, even
     * though every individual term is "well-bounded". The fix is the same:
     * floor var_scale at the q_gap-derived conviction already applied
     * upstream — it IS the adaptive bound (per feedback_isv_for_adaptive_bounds.md),
     * not a tuned constant. With this floor, high conviction overrides
     * variance shrinkage so Full intent at eval can produce Full realized
     * positions even before var_q has fully decayed. */
    if (q_variance != NULL) {
        /* Taken action's factored index: sum of branch-local indices */
        int taken_branch_idx = dir_idx;  /* direction is branch 0 */
        float var_taken = q_variance[(long long)i * total_actions_for_var + taken_branch_idx];
        /* Also check magnitude branch variance (branch 1) */
        float var_mag = q_variance[(long long)i * total_actions_for_var + b0_size + mag_idx];
        /* Use max variance across the two exposure branches (direction + magnitude) */
        float var_q = fmaxf(var_taken, var_mag);
        float var_scale = 1.0f / (1.0f + sqrtf(var_q));
        /* Permanent floor — same q_gap-derived conviction clamped at line 1628. */
        float var_floor = (q_gaps != NULL)
            ? fminf(fmaxf(q_gaps[i] * 0.5f, 0.25f), 1.0f)
            : 0.25f;
        var_scale = fmaxf(var_scale, var_floor);
        /* Task 0.3: persist the per-sample Kelly scale for host-side epoch-mean
         * reduction. Host filters on `> 0.0f` to distinguish measured samples
         * from unreached/Var[Q]-disabled default-zero slots. */
        var_scale_per_sample[out_off] = var_scale;
        effective_max_pos *= var_scale;
    }

    /* ── Hold action flag preserved for downstream reward/plan logic. */
    int is_hold_action = (dir_idx == DIR_HOLD);

    /* Save pre-trade position + sign + hold_time for reversal detection
     * and segment-reward patience multiplier that run AFTER the helper.
     * `saved_hold_time` is captured BEFORE the helper updates hold_time so
     * `segment_hold_time` downstream reflects the bar that completed a
     * trade (comment in original code: "Without this, hold_time is 0 at
     * exit and the sparse reward condition (hold_time > 0) never fires"). */
    float pre_trade_position = position;
    float saved_hold_time = hold_time;
    float ps0_f = (ps[PS_POSITION]);
    int prev_sign = (ps0_f > 0.001f) ? 1 : ((ps0_f < -0.001f) ? -1 : 0);
    int closing_sign = prev_sign;
    int hold_violation = 0;   /* hold enforcement removed; kept for codegen */

    /* ================================================================
     * UNIFIED ENV STEP CORE (shared: trade_physics.cuh)
     *
     * Same helper called by backtest_env_step{,_batch}. TWO max_position
     * args so training's Kelly-from-atoms variance sizing can shrink the
     * target without also tightening Kelly cap / execute_trade physics:
     *   - max_position_target  = effective_max_pos (Var[Q]-shrunk)
     *   - max_position_physics = max_position      (broker's real max)
     * Val passes the same value for both.
     * ================================================================ */
    int   prev_position_sign_core = 0;
    int   actual_dir_core         = -1;
    int   actual_mag_core         = 0;
    int   trail_triggered         = 0;
    float new_value_core          = 0.0f;
    float step_ret_core           = 0.0f;
    float health_k_train          = (isv_signals_ptr != NULL) ? isv_signals_ptr[12] : 0.5f;

    /* Regime-adaptive trailing-stop scales — shared helper guarantees
     * bit-identical computation with val kernel (features_buf layout differs
     * but per-bar ADX/CUSUM resolve to the same floats). */
    float trail_vol_scale = 1.0f;
    float trail_trend_scale = 1.0f;
    compute_regime_trail_scales(
        features, (long long)bar_idx, market_dim,
        &trail_vol_scale, &trail_trend_scale
    );

    /* Per-sample conviction for Kelly warmup floor. NULL fallback → 1.0
     * (trust policy face-value when action_select didn't write a value —
     * e.g. future kernels that skip the conviction buffer). The action_select
     * kernel clamps to [0, 1] already; defensive clamp here guards against
     * direct device writes bypassing that path. magnitude_conviction_ptr
     * defaults to 0.0 — helper composes max(dir, mag) and degrades to direction
     * conviction alone if the parallel buffer isn't wired. */
    float conviction_core = (conviction_ptr != NULL)
        ? fminf(1.0f, fmaxf(0.0f, conviction_ptr[i]))
        : 1.0f;
    float mag_conviction_core = (magnitude_conviction_ptr != NULL)
        ? fminf(1.0f, fmaxf(0.0f, magnitude_conviction_ptr[i]))
        : 0.0f;

    unified_env_step_core(
        action_idx, raw_close,
        b1_size, b2_size, b3_size,
        effective_max_pos,   /* max_position_target  — shrunk by Var[Q] */
        max_position,        /* max_position_physics — broker's real max */
        tx_cost_multiplier, spread_cost,
        contract_multiplier, margin_pct,
        health_k_train,
        trail_vol_scale, trail_trend_scale,
        conviction_core,     /* direction-branch Kelly warmup floor */
        mag_conviction_core, /* magnitude-branch Kelly warmup floor */
        &position, &cash, &entry_price, &hold_time,
        &peak_equity,                  /* helper updates peak_equity via fmaxf */
        prev_equity,                   /* snapshot for step_return denominator */
        &win_count, &loss_count, &sum_wins, &sum_losses,
        &step_ret_core,                /* caller may layer shaping on top */
        &new_value_core,
        &prev_position_sign_core,
        &actual_dir_core,
        &actual_mag_core,
        &trail_triggered,
        isv_signals_ptr      /* SP5 Layer B: Pearl 6 (kelly_f_smooth) + Pearl 8 (trail_dist_per_dir) */
    );
    /* actual_mag_core is now consumed by Task 2.X per-magnitude instrumentation
     * at the trade-lifecycle block below (~line 1760). actual_dir_core +
     * prev_position_sign_core remain unused here — training uses its own
     * prev_sign computed above. */
    (void)actual_dir_core;
    (void)prev_position_sign_core;

    /* order_type_idx + spread_scale still needed downstream for CF cost calc. */
    int order_type_idx = decode_order_4b(action_idx, b2_size, b3_size);
    float tx_cost = 0.0f;               /* helper already applied tx cost */
    float spread_scale = -1.0f;         /* referenced by downstream CF cost calc */
    (void)tx_cost;

    /* ── Trade Plan: readiness-gated activation + enforcement ──
     * Plan head is randomly initialized → garbage plans early in training.
     * Gate by model readiness: readiness < plan_thr → plan disabled (model trades freely).
     * readiness >= plan_thr → plan activates and enforces. Smooth transition.
     *
     * Phase 3 unified-env: also gated by exploration_scale > plan_thr. Validation
     * runs with exploration_scale=0 → plan disabled → position is the raw
     * Kelly/margin-capped target, matching the backtest kernel.
     *
     * plan_thr reads from ISV[ISV_PLAN_THRESHOLD_IDX] (Plan 1 Task 16 / spec §4.C.6).
     * Default 0.5 when ISV not yet available (smoke-scale runs). */
    float plan_thr_env = (isv_signals_ptr != NULL) ? isv_signals_ptr[ISV_PLAN_THRESHOLD_IDX] : 0.5f;
    float readiness = (readiness_ptr != NULL) ? readiness_ptr[0] : 1.0f;
    /* B.4 Plan 3 Task 4: capture the readiness value seen at this (i,t) slot
     * for the plan_threshold_update kernel's per-batch mean reduction. The
     * scalar `readiness_ptr[0]` is broadcast across all samples in a batch,
     * so this buffer typically holds identical values per batch — but the
     * EMA-of-mean across batches still picks up real-time changes in the
     * scalar source as the trainer's readiness ramps over training. */
    readiness_per_sample[out_off] = readiness;
    int plan_enabled = (readiness >= plan_thr_env
                        && plan_params_ptr != NULL
                        && exploration_scale >= plan_thr_env);

    /* Plan activation on Flat → Positioned (only when model is ready) */
    int was_flat_plan = (fabsf(ps0_f) < 0.001f);
    int now_positioned_plan = (fabsf(position) > 0.001f);

    if (was_flat_plan && now_positioned_plan && plan_enabled) {
        const float* pp = plan_params_ptr + i * 6;
        ps[PS_PLAN_TARGET_BARS] = pp[PLAN_PARAM_TARGET_BARS];  /* target_bars */
        ps[PS_PLAN_PROFIT_TARGET] = pp[PLAN_PARAM_PROFIT_TARGET];  /* profit_target */
        ps[PS_PLAN_STOP_LOSS] = pp[PLAN_PARAM_STOP_LOSS];  /* stop_loss */
        ps[PS_PLAN_SCALE_AGGRESSION] = pp[PLAN_PARAM_SCALE_AGGRESSION];  /* scale_aggression */
        ps[PS_PLAN_CONVICTION] = pp[PLAN_PARAM_CONVICTION];  /* conviction */
        ps[PS_PLAN_ASYMMETRY] = pp[PLAN_PARAM_ASYMMETRY];  /* asymmetry */
        /* Store entry regime_stability for drift detection (P12). */
        ps[PS_PLAN_ENTRY_REGIME] = (isv_signals_ptr != NULL) ? isv_signals_ptr[11] : 1.0f;
        ps[PS_INTRA_TRADE_MAX_PNL]              = 0.0f;  /* reset max P&L for new trade */
        ps[PS_PEAK_PNL_BAR]                     = 0.0f;  /* Plan 3 C.4: reset peak-bar alongside MAX_PNL */
        ps[PS_INTRA_TRADE_MIN_PNL]              = 0.0f;  /* Plan 3 D.4a: reset MIN_PNL alongside MAX_PNL */
        ps[PS_REGIME_SHIFT_BAR]                 = 0.0f;  /* Plan 3 D.4b: reset at trade lifecycle boundary */
        ps[PS_PRE_ENTRY_CONVICTION_EMA]         = 0.0f;  /* Plan 3 D.4c: next Flat epoch starts fresh */
        ps[PS_PRE_ENTRY_CONVICTION_VAR_EMA]     = 0.0f;  /* Plan 3 D.4c: next Flat epoch starts fresh */
        /* Apply conviction to position size — scale by readiness for smooth ramp */
        position *= fmaxf(ps[PS_PLAN_CONVICTION] * readiness, 0.1f);
    }

    int has_plan = (ps[PS_PLAN_TARGET_BARS] > plan_thr_env);

    /* Recursive plan revision (N17): living plan document.
     * When model is highly confident (readiness ≥ 0.7) and conviction changes
     * significantly, update the stored plan. The model learns WHEN to revise. */
    if (has_plan && fabsf(position) > 0.001f && readiness >= 0.7f && plan_params_ptr != NULL) {
        float conv_now = plan_params_ptr[i * 6 + PLAN_PARAM_CONVICTION];
        float conv_entry = ps[PS_PLAN_CONVICTION];

        if (conv_now > conv_entry * 1.3f) {
            /* Conviction increased 30%+ → thesis strengthening → extend plan */
            float new_target = plan_params_ptr[i * 6 + PLAN_PARAM_TARGET_BARS];
            ps[PS_PLAN_TARGET_BARS] = fmaxf(ps[PS_PLAN_TARGET_BARS], new_target);  /* only extend, never shorten from conviction increase */
            ps[PS_PLAN_PROFIT_TARGET] = fmaxf(ps[PS_PLAN_PROFIT_TARGET], plan_params_ptr[i * 6 + PLAN_PARAM_PROFIT_TARGET]);  /* widen profit target */
        } else if (conv_now < conv_entry * 0.4f) {
            /* Conviction dropped 60%+ → thesis collapsing → shorten plan to exit soon */
            ps[PS_PLAN_TARGET_BARS] = fminf(ps[PS_PLAN_TARGET_BARS], hold_time + 3.0f);  /* exit within 3 bars */
        }
        /* Between 0.4x and 1.3x → plan stays as-is (normal fluctuation) */
    }

    /* Plan enforcement: ISV-driven safety net.
     * The model LEARNS exit timing through Q-values + conviction-scaled reward.
     * Safety thresholds are ISV-modulated — tighter when unstable, wider when stable.
     * All driven by temporal attention, nothing hardcoded. */
    if (has_plan && fabsf(position) > 0.001f) {
        float pnl_pct = (raw_close - entry_price) / fmaxf(fabsf(entry_price), 1.0f)
                         * ((position > 0.0f) ? 1.0f : -1.0f);

        /* ISV-driven thresholds: adapt to training dynamics + regime */
        float isv_instability = (isv_signals_ptr != NULL) ? isv_signals_ptr[1] : 0.0f;  /* grad_norm */
        float regime_stab = (isv_signals_ptr != NULL) ? isv_signals_ptr[11] : 1.0f;     /* regime_stability */
        float plan_stop = ps[PS_PLAN_STOP_LOSS];  /* learned stop from plan head */

        /* Catastrophic stop: base 2% tightened by instability, widened by plan's learned stop */
        float base_stop = fmaxf(plan_stop, 0.005f);  /* at least 0.5%, or plan's learned value */
        float eff_stop = base_stop / (1.0f + isv_instability);  /* tighter when gradient pressure high */

        /* Max hold: base 50 bars shortened by regime transition */
        float max_hold = 50.0f * fmaxf(regime_stab, 0.2f);  /* 10-50 bars depending on stability */

        int catastrophic_exit = 0;
        if (pnl_pct <= -eff_stop) catastrophic_exit = 1;
        if (hold_time >= max_hold) catastrophic_exit = 1;

        if (catastrophic_exit) {
            position = 0.0f;
            for (int s = 23; s <= 29; s++) ps[s] = 0.0f;
        }
    }

    /* ---- Mark-to-market PnL for this timestep ---- */
    /* Per-bar mark-to-market P&L removed: used raw_next (future price),
     * creating 1-bar action-reward misalignment.  Rewards are now
     * per-trade only (realized P&L on position change). */
    float raw_pnl = 0.0f;
    float old_pos_pnl = 0.0f;

    /* ---- Mark-to-market equity (current bar only) ---- */
    float equity = cash + position * raw_close;
    peak_equity = (equity > peak_equity) ? equity : peak_equity;

    /* ==== Trade lifecycle tracking ==== */
    float is_flat = (fabsf(position) < 0.001f) ? 1.0f : 0.0f;
    int curr_sign = (position > 0.001f) ? 1 : ((position < -0.001f) ? -1 : 0);

    int entering_trade = (prev_sign == 0 && curr_sign != 0);
    int wants_exit = hold_violation ? 0 : (prev_sign != 0 && curr_sign == 0);
    int wants_reversal = hold_violation ? 0 : (prev_sign != 0 && curr_sign != 0 && prev_sign != curr_sign);
    int exiting_trade = trail_triggered ? 1 : wants_exit;
    int reversing_trade = wants_reversal;
    int segment_complete = exiting_trade || reversing_trade;

    /* B.2 Plan 3 Task 3: Flat→Positioned transition flag. Written before any
     * reward logic so the trade_attempt_rate_ema kernel sees every (i,t)
     * slot's outcome. Race-free: each thread writes only its own out_off. */
    flat_to_pos_per_sample[out_off] = entering_trade;

    /* H6 diagnostic outputs — pre_trade_position bin captures the position
     * size that just got trailed-out (NOT actual_mag_core, which is 0 on
     * trail-fire because position is forced to flat). Race-free: each thread
     * writes only its own out_off slot. */
    {
        float pre_abs  = fabsf(pre_trade_position);
        float pre_frac = (max_position > 1e-6f) ? (pre_abs / max_position) : 0.0f;
        int pre_mag_bin;
        if      (pre_frac < 0.001f) pre_mag_bin = -1;
        else if (pre_frac < 0.375f) pre_mag_bin = 0;
        else if (pre_frac < 0.75f)  pre_mag_bin = 1;
        else                        pre_mag_bin = 2;

        trail_triggered_per_sample[out_off] = trail_triggered;
        pre_mag_per_sample[out_off]         = pre_mag_bin;
        traded_per_sample[out_off]          = (entering_trade || exiting_trade || reversing_trade) ? 1 : 0;
        hold_at_exit_per_sample[out_off]    = (exiting_trade || reversing_trade) ? saved_hold_time : 0.0f;

        /* Task 2.X prerequisite — per-magnitude win-rate + variance instrumentation.
         * Same race-free per-sample-plus-host-reduce pattern as Task 0.5 / 0.8.
         *
         * Bin by the SEGMENT magnitude — the magnitude associated with the
         * trade segment whose P&L is being recorded this bar:
         *   - At close events (exiting_trade || reversing_trade): the segment
         *     just closed; bin by `pre_mag_bin` (the magnitude of the position
         *     being closed). `actual_mag_core` is forced to 0 (Quarter) at
         *     close bars by `unified_env_step_core` because actual_dir becomes
         *     Hold/Flat — using it would attribute every Half/Full close to
         *     the Quarter bucket, structurally pinning wr_h = wr_f = 0.
         *   - Otherwise (holding / entering): bin by `actual_mag_core` (the
         *     current realized magnitude). `pre_mag_bin` is unsuitable here
         *     because it reflects the prior bar's position, not the segment
         *     P&L is being earned on this bar.
         *
         * Profitability predicate conditions on trade_close so we only count
         * events where a segment actually closed this step — entering-only
         * samples don't count toward win rate (they have no realized outcome
         * yet), but DO count toward the variance sum (step_ret_core is the
         * raw per-step return and is the unit the host variance reducer
         * consumes). */
        int is_close = (exiting_trade || reversing_trade) ? 1 : 0;
        int seg_mag_bin = is_close ? pre_mag_bin : actual_mag_core;
        action_mag_per_sample[out_off]       = seg_mag_bin;
        step_ret_per_sample[out_off]         = step_ret_core;
        trade_close_per_sample[out_off]      = is_close;
        trade_profitable_per_sample[out_off] = (is_close && step_ret_core > 0.0f) ? 1 : 0;
    }

    float reversal_return = 0.0f;

    if (entering_trade) {
        entry_price = raw_close;
        trade_start_pnl = (ps[PS_REALIZED_PNL]);
    }

    /* On reversal: close old segment, open new one.
     * MUST run AFTER hold enforcement — if hold guard cancelled the reversal,
     * reversing_trade is 0 and this block correctly does nothing.
     *
     * Kelly stats (win_count / loss_count / sum_wins / sum_losses) are
     * now updated by unified_env_step_core::record_kelly_trade_outcome.
     * Only sum_returns / sum_sq_returns (continuous-Kelly stats, not
     * covered by the helper) are still updated here. */
    if (reversing_trade) {
        float closing_pnl = (ps[PS_REALIZED_PNL]) + old_pos_pnl - trade_start_pnl;
        reversal_return = closing_pnl / (prev_equity > 1.0f ? prev_equity : 1.0f);

        sum_returns += reversal_return;
        sum_sq_returns += reversal_return * reversal_return;

        /* entry_price already reset by helper on reversal; trade_start_pnl
         * update remains training-specific state. */
        trade_start_pnl = (ps[PS_REALIZED_PNL]) + old_pos_pnl;
    }

    /* Sparse reward patience multiplier uses pre-helper hold_time (the
     * value at trade-exit, before the helper reset it to 0 on flat). */
    float segment_hold_time = saved_hold_time;

    /* Hold-time update REMOVED — unified_env_step_core already advanced
     * hold_time in place via its update_hold_time call. A second update
     * here would double-increment. */

    /* ── v7: Track intra-trade max drawdown for risk efficiency ── */
    float intra_trade_max_dd = (ps[PS_INTRA_TRADE_MAX_DD]);
    if (entering_trade) {
        intra_trade_max_dd = 0.0f;
        ps[PS_INTRA_TRADE_MAX_PNL]              = 0.0f;  /* reset max P&L for new trade */
        ps[PS_PEAK_PNL_BAR]                     = 0.0f;  /* Plan 3 C.4: reset peak-bar alongside MAX_PNL */
        ps[PS_INTRA_TRADE_MIN_PNL]              = 0.0f;  /* Plan 3 D.4a: reset MIN_PNL alongside MAX_PNL */
        ps[PS_REGIME_SHIFT_BAR]                 = 0.0f;  /* Plan 3 D.4b: reset at trade lifecycle boundary */
        ps[PS_PRE_ENTRY_CONVICTION_EMA]         = 0.0f;  /* Plan 3 D.4c: next Flat epoch starts fresh */
        ps[PS_PRE_ENTRY_CONVICTION_VAR_EMA]     = 0.0f;  /* Plan 3 D.4c: next Flat epoch starts fresh */
    }
    if (reversing_trade) {
        intra_trade_max_dd = 0.0f;
        ps[PS_INTRA_TRADE_MAX_PNL]              = 0.0f;  /* reset max P&L for reversed trade */
        ps[PS_PEAK_PNL_BAR]                     = 0.0f;  /* Plan 3 C.4: reset peak-bar alongside MAX_PNL */
        ps[PS_INTRA_TRADE_MIN_PNL]              = 0.0f;  /* Plan 3 D.4a: reset MIN_PNL alongside MAX_PNL */
        ps[PS_REGIME_SHIFT_BAR]                 = 0.0f;  /* Plan 3 D.4b: reset at trade lifecycle boundary */
        ps[PS_PRE_ENTRY_CONVICTION_EMA]         = 0.0f;  /* Plan 3 D.4c: next Flat epoch starts fresh */
        ps[PS_PRE_ENTRY_CONVICTION_VAR_EMA]     = 0.0f;  /* Plan 3 D.4c: next Flat epoch starts fresh */
    }
    if (fabsf(position) > 0.001f && entry_price > 0.0f) {
        float unrealized = (position > 0.0f)
            ? (raw_close - entry_price) / entry_price
            : (entry_price - raw_close) / entry_price;
        float current_dd = fminf(unrealized, 0.0f);
        intra_trade_max_dd = fminf(intra_trade_max_dd, current_dd);

        /* Track best unrealized P&L during trade (for hindsight plan labels) */
        float pnl_pct = (raw_close - entry_price) / fmaxf(fabsf(entry_price), 1.0f)
                         * ((position > 0.0f) ? 1.0f : -1.0f);
        /* Plan 3 C.4: snapshot bar-of-peak alongside MAX_PNL so the temporal
         * timing bonus at trade exit can measure bars_early = hold_time - peak_bar.
         * Uses the local `hold_time` (already advanced by unified_env_step_core)
         * — `ps[PS_HOLD_TIME]` isn't rewritten until the portfolio-state
         * commit block further down. */
        if (pnl_pct > ps[PS_INTRA_TRADE_MAX_PNL]) {
            ps[PS_INTRA_TRADE_MAX_PNL] = pnl_pct;
            ps[PS_PEAK_PNL_BAR]        = hold_time;
        }
        /* Plan 3 D.4a: symmetric MIN_PNL track for persistence-credit attribution. */
        if (pnl_pct < ps[PS_INTRA_TRADE_MIN_PNL]) {
            ps[PS_INTRA_TRADE_MIN_PNL] = pnl_pct;
        }

        /* Plan 3 D.4b: regime-shift detector. Writes PS_REGIME_SHIFT_BAR on the
         * FIRST bar where the current regime diverges from PS_PLAN_ENTRY_REGIME by
         * more than an adaptive threshold. Threshold tightens when |sharpe| is high
         * (confident trading warrants early detection); loosens under noisy training.
         * Once ps[PS_REGIME_SHIFT_BAR] > 0, this block short-circuits — first-shift-only. */
        if (ps[PS_REGIME_SHIFT_BAR] <= 0.0f && isv_signals_ptr != NULL) {
            const float regime_now    = isv_signals_ptr[11];
            const float regime_entry  = ps[PS_PLAN_ENTRY_REGIME];
            const float sharpe_raw    = isv_signals_ptr[ISV_SHARPE_EMA_IDX];
            const float sharpe_mag    = fabsf(fmaxf(-2.0f, fminf(2.0f, sharpe_raw)));
            const float threshold     = fmaxf(0.05f, fminf(0.5f, 0.25f * sharpe_mag));
            if (fabsf(regime_now - regime_entry) > threshold) {
                ps[PS_REGIME_SHIFT_BAR] = hold_time;  /* same `hold_time` local Task 5 C.4 uses */
            }
        }
    }

    /* Idle counter (flat bars) — used for state features, no penalty */
    if (is_flat > 0.5f) {
        flat_counter += 1.0f;
    } else {
        flat_counter = 0.0f;
    }

    /* ================================================================
     * REWARD v7 + SP11 B1b: Controller-weighted composition
     *
     * Fixes v6 penalty stacking (95% breakeven win rate → ~52%).
     * Novel components:
     *   1. Counterfactual Exposure Advantage — per-branch gradient signal
     *   2. Order type microstructure credit — execution quality
     *   3. Intra-trade risk efficiency — path quality bonus
     *
     * Kept: sparse trade-completion, vol-normalization, drawdown penalty,
     *       capital floor, Kelly stats, position entropy.
     * Removed: loss_aversion (V6 param), regret_blend, hold_scale,
     *          clustering, beta_penalty.
     * Task 2.4: asymmetric soft-clamp (V7 replacement for V6 loss_aversion)
     *          also removed from reward layer — negative-tail compression
     *          relocated to c51_loss_kernel.cu::block_bellman_project_f;
     *          upper +10 cap retained inline.
     *
     * SP11 B1b (2026-05-04): structural decomposition into 6 explicit
     * per-component reward locals — replaces 8+ inline accumulation sites.
     * Composed as `Σ w_i × r_i` with controller weights from ISV[340..346)
     * (mean=1 normalization per spec §3.4.3). Universal post-composition
     * modifiers (drawdown / capital-floor / inventory / churn /
     * conviction-scale / cf-flip) apply UNWEIGHTED after the weighted Σ
     * — risk constraints and structural operators, NOT learning components.
     *
     * Site → component mapping:
     *   r_popart  ← line ~2612 trade P&L (vol-normalized, capped) on
     *               voluntary-exit segment_complete.
     *   r_trail   ← line ~2612 trade P&L on trail-fire segment_complete
     *               (§3.5.4 extraction). Mutually exclusive with r_popart.
     *   r_micro   ← line ~2869 OFI dense micro-reward, OR line ~2886
     *               positioned-bar holding-cost fallback (when OFI off).
     *   r_opp_cost ← line ~2987 Flat opportunity-cost (Plan 3 B.1).
     *   r_bonus   ← Σ of: line ~2670 C.4 timing, line ~2691 D.4a
     *               persistence, line ~2742 D.4b regime penalty (sub-),
     *               line ~3035 B.2 trade-attempt, line ~3085 D.4c
     *               conviction-consistency.
     *   r_cf      ← cf-tuple reward written at out_rewards[cf_off]
     *               (composed in the CF block at line ~3300, separately
     *               weighted by w_cf).
     *
     * Mutual exclusivity (popart / trail / micro / opp_cost / fallback):
     * exactly one of these alternative paths fires per bar; the others
     * remain at 0. The weighted Σ reduces to `w_active × r_active` for the
     * active path, plus `w_bonus × r_bonus` (additive intra-trade).
     *
     * Cold-start sentinel defense: ISV slots [340..346) are sentinel-0
     * before the controller's first emit (the SP11 controller runs in
     * the per-epoch metrics block AFTER experience collection — see
     * `training_loop.rs` ~3475). Reading raw ISV at fold 0 epoch 0 step
     * 0 yields w = 0 → r_weighted = 0 (reward magnitude collapse). Defense:
     * `fmaxf(w_raw, SP11_WEIGHT_HARD_FLOOR=0.01)` per spec §3.4.2 — the
     * same Invariant-1 floor the controller enforces post-renorm. After
     * the first controller emit, Pearl A bootstrap replaces the sentinel
     * with the first observation; weights then evolve via the controller.
     * ================================================================ */

    /* Per-component reward locals (SP11 B1b). Each population site below
     * sets exactly one of {r_popart, r_trail, r_micro, r_opp_cost} (mutually
     * exclusive paths) and may accumulate into r_bonus (intra-trade
     * additive). r_cf is set inside the CF block separately. */
    float r_popart   = 0.0f;
    float r_trail    = 0.0f;
    float r_micro    = 0.0f;
    float r_opp_cost = 0.0f;
    float r_bonus    = 0.0f;

    /* Zero-init per-component slots so non-firing paths don't leave stale
     * data from the previous batch's `out_off` contaminating the SP4 reward-
     * component EMAs (slots 64..68) and the SP11 mag-ratio canary that reads
     * them. The local `r_*` variables are zero-initialized above; the backing
     * buffer needs the same treatment so non-firing paths produce a true zero
     * observation rather than stale prior-bar values.
     *
     * Slot population is conditional and sparse:
     *   rc[1] = cf_reward_weighted   — written ONLY inside the CF block (~L3648)
     *   rc[2] = r_trail              — written ONLY inside the segment_complete
     *                                  trail-trigger path (~L2730)
     *   rc[3] = r_micro              — written ONLY inside positioned-non-
     *                                  complete paths (~L3006, L3016)
     *   rc[4] = r_opp_cost           — written ONLY inside flat-with-features
     *                                  path (~L3121)
     *   rc[5] = bonus_*              — overwrite at ~L3169, multiple `+=` /
     *                                  `-=` sites elsewhere
     *
     * Without this zero-init, a bar that doesn't fire the relevant path
     * retains values from the previous batch's same `out_off` → reward-
     * component EMAs (slots 64..68) accumulate stale magnitudes → SP11 mag-
     * ratio canary reads contaminated ratios → controller emits wrong
     * weights. Same class as the slot 63 overload bug fixed via B1b fix-up:
     * pre-SP11 invariants exposed by post-decomposition semantic.
     *
     * Note: rc[+0] (PopArt input = total reward) is unconditionally
     * overwritten at ~L3447, so it doesn't strictly need zero-init here, but
     * including it removes ordering dependency for future readers. */
    reward_components_per_sample[out_off * 6 + 0] = 0.0f;
    reward_components_per_sample[out_off * 6 + 1] = 0.0f;
    reward_components_per_sample[out_off * 6 + 2] = 0.0f;
    reward_components_per_sample[out_off * 6 + 3] = 0.0f;
    reward_components_per_sample[out_off * 6 + 4] = 0.0f;
    reward_components_per_sample[out_off * 6 + 5] = 0.0f;

    /* Trade-cumulative running scalar — tracks the in-progress reward
     * across the if/else cascade. C.4/D.4b bonus blocks read this to
     * Q-cap their `|reward|` multiplicand (the existing Q-bounded
     * pattern from `pearl_one_unbounded_signal_per_reward.md`).
     * Updated in lockstep with the per-component locals so the bonus
     * compounding semantics are bit-identical to pre-SP11. After the
     * cascade closes, this local is overwritten with `r_weighted`
     * (`Σ w_i × r_i`) and feeds into the post-composition modifiers. */
    float reward = 0.0f;

    if (segment_complete && segment_hold_time > 0.0f) {
        /* Trade P&L = realized_pnl + unrealized at exit - realized at entry.
         * unrealized at exit = position * (raw_close - entry_price) * contract_multiplier.
         * raw_pnl was removed (used raw_next = future price). Use raw_close directly. */
        float unrealized_at_exit = pre_trade_position * (raw_close - entry_price) * contract_multiplier;
        float segment_pnl = (ps[PS_REALIZED_PNL]) + unrealized_at_exit - trade_start_pnl;
        float segment_return = segment_pnl / (prev_equity > 1.0f ? prev_equity : 1.0f);

        /* Kelly win/loss/sum_wins/sum_losses now updated by helper's
         * record_kelly_trade_outcome. Only continuous-Kelly stats
         * (sum_returns / sum_sq_returns) are still updated here.
         * Kelly measures actual returns for position sizing. */
        if (exiting_trade) {
            sum_returns += segment_return;
            sum_sq_returns += segment_return * segment_return;
        }
        if (reversing_trade) {
            segment_return = reversal_return;
        }

        /* ── Magnitude-neutral reward normalization ──────────────────────
         * Divide return by position fraction (|pos|/max_pos) so the reward
         * measures DIRECTIONAL SKILL PER UNIT OF RISK, not absolute PnL.
         *
         * Without this, Quarter (0.25) produces 4× lower PnL variance than
         * Full (1.00) for the same price move. The MSE Bellman target for
         * Quarter has 16× lower variance (squared) → gradient is 16× more
         * consistent → Quarter Q converges first → Boltzmann locks in →
         * model never learns Full's true Q. Same mechanism as C51 collapse
         * but through reward variance instead of distributional shape.
         *
         * After normalization, all magnitudes produce the SAME reward
         * variance for the same directional skill. The model then selects
         * magnitude based on risk-adjusted return, not convergence speed.
         *
         * Applied AFTER Kelly (which needs raw returns) and AFTER reversal
         * override (which also needs magnitude normalization). */
        /* Magnitude-neutral normalization REMOVED — gave Small 4x LARGER sparse
         * reward, contradicting dense micro-reward. Vol-normalization below
         * handles risk-adjustment. Magnitude PnL differences are the real signal. */

        /* Vol normalization (unchanged from v6) */
        float atr_norm = 0.0f;
        if (features != NULL && bar_idx < total_bars && market_dim > 9) {
            atr_norm = (features[(long long)bar_idx * market_dim + 9]);
        }
        float log_atr = atr_norm * 16.0f - 7.0f;
        float atr_pct = expf(log_atr) / fmaxf(raw_close, 1.0f);
        float vol_proxy = fmaxf(atr_pct, 0.0001f);
        float vol_norm = vol_proxy * sqrtf(fmaxf(segment_hold_time, 1.0f));
        float vol_normalized_return = segment_return / vol_norm;

        /* (DSR moved to dense per-bar path below — no longer sparse at trade completion) */

        /* ── Layer 2: Upper-cap only (Task 2.4 relocation) ──
         * Negative-tail compression moved to c51_loss_kernel.cu::
         * block_bellman_project_f (Huber-style Q-target smoothing).
         * Upper +10 cap kept inline for numerical safety against adversarial
         * reward explosions from rare large wins.
         *
         * SP11 B1b §3.5.4: split forced-exit (trail-fire) P&L from
         * voluntary-exit P&L. Both paths produce the same vol-normalized
         * capped scalar; controller weight `w_trail` vs `w_popart` lets
         * SP11 weigh them differently. `trail_triggered` flips
         * `exiting_trade=1` upstream (line ~2371), so this branch fires
         * for both — the segregation matters only for component attribution. */
        float base_reward = 2.0f * vol_normalized_return;
        /* SP13 v3 P0a.T3 (2026-05-04): per-bar Hold cost subtraction at the
         * voluntary segment_complete branch. Goes BEFORE the SP12 asymmetric
         * cap so the cost participates in the bounded clamp — keeps total
         * reward in the same [-10, +5] range and avoids a long Hold run
         * silently saturating the lower cap. Hold action rarely coincides
         * with segment_complete (Hold keeps current position, doesn't close
         * trades) but trail-fire can force-exit while the policy picks Hold;
         * this branch covers that edge. Per-bar (non-segment_complete)
         * Hold-cost subtraction is in the per-bar branches below. */
        if (dir_idx == DIR_HOLD && isv_signals_ptr != NULL) {
            base_reward -= isv_signals_ptr[ISV_HOLD_COST_IDX];
        }
        /* SP12 v3 fix (2026-05-04): asymmetric bounded cap (loss aversion).
         * SP11 (commit 35db31089) made the cap symmetric ±10 to fix slot-63
         * PopArt EMA inflation. That fix preserved stability but erased the
         * pre-SP11 implicit downside-asymmetry — losses that previously
         * dominated the reward distribution disappeared into the floor.
         * Result: train-multi-seed-pmbwn 50-epoch on 6a259942e showed
         * sharpe-gaming (sharpe=80 held while PnL declined 30% over 8
         * epochs); the model became a HFT noise extractor (62% trade rate,
         * 46% win rate) because losses no longer hurt enough to enforce
         * selectivity.
         *
         * SP12 v3 restores asymmetry by widening only the negative cap
         * back toward the pre-SP11 unbounded regime (capped at -10 for
         * stability) while tightening the positive cap to +5. The 2:1
         * REWARD_NEG_CAP : REWARD_POS_CAP ratio is calibrated to the
         * Kahneman/Tversky meta-analysis estimate of human loss aversion
         * (~2.0-2.25) — a known psychological-anchor constant rather than
         * a tuned multiplier. Stability work from SP11 is preserved
         * because the cap is still bilateral.
         *
         * Per pearl_audit_unboundedness_for_implicit_asymmetry — when a
         * symmetric clamp replaces an asymmetric prior, the policy's
         * implicit risk preference can flip without warning. The fix is
         * to make the bound itself asymmetric in the direction the prior
         * implicitly was, not to remove the cap.
         *
         * Constants live in state_layout.cuh as Invariant-1 numerical
         * anchors (Phase 1). Phase 2 (deferred) lifts the ratio to an
         * ISV-driven controller per feedback_isv_for_adaptive_bounds.
         *
         * Math factored into `compute_asymmetric_capped_pnl` in
         * `trade_physics.cuh` so the formula is testable in isolation
         * (oracle tests in `tests/sp12_reward_math_tests.rs`). */
        float capped_pnl = compute_asymmetric_capped_pnl(
            base_reward, REWARD_NEG_CAP, REWARD_POS_CAP
        );
        if (trail_triggered) {
            r_trail = capped_pnl;
        } else {
            /* SP12 v3 Change 2: min-hold soft penalty for voluntary exits.
             * Encourages MFT-leaning trade duration via patience requirement.
             * Soft saturation formula: factor = deficit/(deficit + T) — bounded
             * [0,1], smooth transition (no cliff), monotone increasing in
             * deficit, monotone decreasing in T. Temperature anneals over
             * training (50 → 5 across ~100 epochs) — early forgiveness lets
             * the policy learn reward gradients on short trades, late sharpness
             * enforces commitment to longer holds.
             *
             * Trail-fire exits exempted (the surrounding `else` already
             * guarantees `!trail_triggered`). When `min_hold_target <= 0`
             * (defensive guard against malformed configs) the entire block
             * collapses to no-op since `deficit = max(0, -hold) <= 0`.
             *
             * Voluntary-exit only by construction: this lives in the
             * `else` arm of the `trail_triggered` branch, which itself is
             * inside `segment_complete`. Per spec §design-2 +
             * pearl_event_driven_reward_density_alignment.
             *
             * Applied BEFORE `r_popart = capped_pnl` so the penalty flows
             * through the popart component (which the SP11 controller
             * weighs) — keeps mag-ratio canary semantics consistent.
             *
             * Math factored into `compute_min_hold_penalty` in
             * `trade_physics.cuh` so the formula is testable in isolation
             * (oracle tests in `tests/sp12_reward_math_tests.rs`). The
             * device function returns the penalty MAGNITUDE (positive) and
             * subsumes the `hold_time >= target` early-exit; preserving the
             * outer `if (segment_hold_time < min_hold_target)` guard would
             * be redundant since the function already returns 0 in that
             * case and `capped_pnl - shaping_scale * 0 == capped_pnl`. */
            const float min_hold_penalty = compute_min_hold_penalty(
                segment_hold_time,
                min_hold_target,
                min_hold_temperature,
                min_hold_penalty_max
            );
            float adjusted_pnl = capped_pnl - shaping_scale * min_hold_penalty;
            r_popart = adjusted_pnl;
            /* SP11 Fix 39 B1b fix-up (2026-05-04): mirror r_popart to the
             * dedicated per-bar buffer feeding ISV[POPART_COMPONENT_MAG_EMA_
             * INDEX=360]. Voluntary-exit branch only (the trail-triggered
             * branch above leaves popart-component at the entry-point 0
             * default). The mag-ratio canary now reads slot 360 for the
             * popart axis instead of slot 63's overloaded total-reward
             * value. Spec §4 amendment "Why slot 360". */
            if (popart_component_per_sample != NULL) {
                popart_component_per_sample[out_off] = r_popart;
            }
            /* Keep cascade scalar in lockstep so downstream C.4/D.4a/D.4b
             * bonus blocks Q-cap their `|reward|` multiplicand against the
             * post-min-hold-penalty value (matches pre-SP12 invariant: the
             * trade-cumulative scalar always reflects the most recent
             * popart write). */
            capped_pnl = adjusted_pnl;
        }
        reward = capped_pnl;
        /* C.2 attribution: rc[2] now carries the trail signal when the
         * trail-fire branch fires (was a structural placeholder pre-B1b).
         * The reward_component_ema kernel will pick up real trail magnitude
         * for ISV-driven component-mag-ratio canaries. */
        reward_components_per_sample[out_off * 6 + 2] = r_trail;

        /* SP12 v3 Change 3: lump-sum opportunity cost on trade close.
         *
         * Per spec §design-3 + open question 2 ("lump-sum at exit"): the
         * pre-SP12 per-bar Flat opp_cost (Plan 3 B.1) was an exposure-density
         * gradient — zeroed in the per-bar branch above. The carrying-cost
         * economic concept it modelled is preserved as an integrated
         * lump-sum at trade close: a position of size |pre_trade_position|
         * held for `segment_hold_time` bars at rate `holding_cost_rate`
         * accumulates a total inventory cost of
         *     k × |pre_trade_position| × hold_time
         * regardless of whether the bars were spread across the trade or
         * collapsed at exit. Under γ=1 the integrated and per-bar
         * formulations are mathematically equivalent; under γ<1 they differ
         * slightly due to discount, but the difference is small for short
         * trades and the event-driven density alignment dominates per
         * pearl_event_driven_reward_density_alignment.
         *
         * Fires on BOTH voluntary exits and trail-fire (any segment_complete)
         * — the carrying-cost economic concept doesn't care WHY the trade
         * closed, only that the position was held. Routed into r_opp_cost
         * (rc[4]) so the SP11 controller weighs it through w_oc; preserves
         * the existing component-attribution semantics.
         *
         * `pre_trade_position` is the position held during the segment
         * (already saved upstream before unified_env_step_core flipped
         * `position` for the exit). `segment_hold_time` is the saved
         * pre-helper hold count (set to `saved_hold_time` at line ~2493).
         * `shaping_scale` gates the term off in pure-validation mode (=0)
         * matching the spec's "step_returns are pure per-bar P&L"
         * convention for backtest reward shape.
         *
         * Math factored into `compute_lump_sum_opp_cost` in
         * `trade_physics.cuh` so the formula is testable in isolation
         * (oracle tests in `tests/sp12_reward_math_tests.rs`). The device
         * function unconditionally returns the (negative) reward magnitude;
         * the segment-complete gating happens at this call site. */
        r_opp_cost = compute_lump_sum_opp_cost(
            shaping_scale, holding_cost_rate, pre_trade_position, segment_hold_time
        );
        reward_components_per_sample[out_off * 6 + 4] = r_opp_cost;

        /* ── Layer 3: CEA counterfactual loop REMOVED (4-branch: direction(4) replaces exposure(9)).
         * Dense micro-reward (Gem 1) below provides directional gradient signal. ── */

        /* ── Layer 7: Exit timing quality ── */
        /* REMOVED: post_exit_move used raw_next (future price) to judge
         * whether the exit was well-timed.  This is pure lookahead bias —
         * the model cannot know the next bar's price when it exits.
         * Exit quality is already captured by realized P&L. */

        /* (OFI-alignment moved to dense per-bar path below) */

        /* ── Layer 9: Kelly-optimal sizing ──
         * Behavioral reward removed. Kelly is now enforced as a physics
         * constraint (apply_kelly_cap in trade_physics.cuh, called above
         * before execute_trade) with health-coupled safety multiplier.
         * The network can't over-lever because the env won't let it —
         * not because the reward penalizes deviation from a formula. */

        /* ── C.4 (Plan 3 Task 5): Temporal timing bonus ──
         *
         * Fires on trade exit only (inside segment_complete). Rewards exiting
         * near the unrealized-PnL peak. `bars_early / hold_time` ∈ [0, 1] is
         * 1 when the peak was at entry (degenerate — limit case only) and 0
         * when the peak coincides with the exit bar. Scaled by |final_pnl|
         * (trade outcome magnitude), conviction_core (the same self-scaling
         * coefficient used by B.1/B.2), and the global shaping_scale gate —
         * no tuned constants.
         *
         * Attribution: ACCUMULATES into rc[5] via `+=` rather than overwrite,
         * because Task 3 B.2 may also touch rc[5] on entry. The two sites
         * fire at different (i, t) slots (entry vs exit), so the accumulate
         * semantics are defensively idempotent. `reward` holds the trade
         * P&L at this point (Layer 2 result + Layer 4-9 credits), matching
         * the comment at the "When segment_complete fired above" block. */
        if (segment_hold_time > 0.0f) {
            const float peak_bar   = ps[PS_PEAK_PNL_BAR];
            const float bars_early = fmaxf(0.0f, segment_hold_time - peak_bar);
            const float final_pnl  = reward;
            /* pearl_one_unbounded_signal_per_reward + feedback_isv_for_adaptive_bounds
             * (2026-04-29): |final_pnl| compounds across trades through Layer 4-9
             * credits, creating a multi-trade optimism loop (bonus → Q → reward →
             * bonus). Bound it to the direction-branch Q-scale EMA
             * (ISV[Q_DIR_ABS_REF_INDEX]) so the term's unbounded multiplicand
             * is the stable, gradient-decoupled Q-scale, not the trade-cumulative
             * shaped reward. ISV[21] is already produced by q_stats_kernel.cu.
             * NOTE: ISV slot index passed as kernel arg (no magic numbers per
             * feedback_no_quickfixes / feedback_isv_for_adaptive_bounds). */
            const float pnl_norm   = (isv_signals_ptr != NULL)
                                   ? isv_signals_ptr[q_dir_abs_ref_idx]
                                   : 0.0f;
            const float pnl_unit   = fabsf(final_pnl) / fmaxf(pnl_norm, 1e-6f);
            const float pnl_capped = fminf(pnl_unit, 1.0f) * pnl_norm; /* ≤ Q-scale */
            const float timing_bonus = shaping_scale
                                     * (bars_early / segment_hold_time)
                                     * pnl_capped
                                     * conviction_core;
            r_bonus += timing_bonus;
            reward  += timing_bonus;  /* keep cascade scalar in lockstep for Q-cap reads below */
            /* C.2 attribution — accumulate into rc[5] bonus slot (see above). */
            reward_components_per_sample[out_off * 6 + 5] += timing_bonus;
        }

        /* ── D.4a (Plan 3 Task 6a): Persistence credit ──
         *
         * Fires on profitable trade exits (reward > 0) that endured a drawdown
         * (PS_INTRA_TRADE_MIN_PNL < 0). Self-scaling via tanh(reward/drawdown_depth):
         * saturates at +1 when the recovery is large relative to the drawdown
         * (strong "rode through noise" signal), approaches 0 when reward ≈ drawdown
         * (trivial recovery — no credit). No tuned coefficients.
         *
         * Attribution: accumulates into rc[5] (bonus slot, += co-exists with B.2 entry
         * bonus and C.4 timing bonus — all three fire at different (i,t) slots for a
         * given trade, so no single-slot cancellation). */
        if (reward > 0.0f) {
            const float drawdown_depth = fmaxf(0.0f, -ps[PS_INTRA_TRADE_MIN_PNL]);
            if (drawdown_depth > 1e-6f) {  /* skip straight-line profit trades */
                const float persist_bonus = shaping_scale * conviction_core * drawdown_depth
                                          * tanhf(reward / fmaxf(1e-4f, drawdown_depth));
                r_bonus += persist_bonus;
                reward  += persist_bonus;  /* keep cascade scalar in lockstep for Q-cap reads below */
                reward_components_per_sample[out_off * 6 + 5]  += persist_bonus;
            }
        }

        /* ── D.4b (Plan 3 Task 6b): Regime-shift penalty ──
         *
         * Fires when trade held past a detected regime shift. Penalty is a
         * fraction of the trade's |reward|, bounded by |reward| at the worst
         * case (when shaping=conviction=bars_late_frac=1). All multiplicands
         * except |reward| are in [0,1]. No tuned constants.
         *
         * Earlier draft multiplied by ISV[Q_DIR_ABS_REF_INDEX] (~5–50, an
         * absolute Q-magnitude) AND |reward| simultaneously — produced
         * penalties 5–50× larger than the reward, flipping trade outcomes
         * wholesale and destabilising training (smoke showed Return swings
         * −400% / +170% across epochs). The follow-on fix used |reward| as
         * the single unbounded multiplicand — but |reward| at this point IS
         * the trade-cumulative shaped reward (Layer 2 + Layer 4-9 credits +
         * C.4 timing bonus + D.4a persistence bonus accumulated above). Across
         * trades this compounds: penalty inflates with reward inflation,
         * which the Bellman target then bootstraps off, which inflates the
         * NEXT trade's reward, etc. (Plan C Phase 2 smoke diagnosis 2026-04-29:
         * bonus EMA hit 235 by F2 ep2 in the a52d99613 baseline as well —
         * pre-existing structural pathology.)
         *
         * Final fix: bound |reward| by ISV[Q_DIR_ABS_REF_INDEX], the
         * direction-branch Q-scale EMA. The unbounded multiplicand becomes
         * the stable, gradient-decoupled Q-scale rather than trade-cumulative
         * shaping output. Same surgical pattern as C.4 above — pearl
         * pearl_one_unbounded_signal_per_reward.md +
         * feedback_isv_for_adaptive_bounds.md.
         *
         * bars_late ∈ [0, hold_time]. When shift fires late in the trade, penalty
         * is small; when shift fires early and we held until exit, penalty is
         * up to ISV[21] — bounded by the Q-scale instead of the (compounding)
         * trade-cumulative reward.
         *
         * Attribution: subtracts from rc[5] via -= (co-exists with the three positive
         * contributions B.2/C.4/D.4a at other (i,t) slots; cancellation within the
         * rc[5] slot is intentional — that's what ISV[68] REWARD_BONUS_EMA should track). */
        if (segment_complete && ps[PS_REGIME_SHIFT_BAR] > 0.0f && saved_hold_time > 0.0f) {
            const float bars_late_frac = fmaxf(0.0f,
                fminf(1.0f, ((float)saved_hold_time - ps[PS_REGIME_SHIFT_BAR]) / (float)saved_hold_time));
            const float reward_norm   = (isv_signals_ptr != NULL)
                                      ? isv_signals_ptr[q_dir_abs_ref_idx]
                                      : 0.0f;
            const float reward_unit   = fabsf(reward) / fmaxf(reward_norm, 1e-6f);
            const float reward_capped = fminf(reward_unit, 1.0f) * reward_norm;
            const float penalty = shaping_scale * conviction_core
                                * bars_late_frac * reward_capped;
            r_bonus -= penalty;
            reward  -= penalty;  /* keep cascade scalar in lockstep for Q-cap reads below */
            reward_components_per_sample[out_off * 6 + 5]  -= penalty;
        }
        /* Reset shift-bar for the NEXT trade (in addition to Step 3's lifecycle resets,
         * this handles the exit → next-entry transition cleanly). */
        ps[PS_REGIME_SHIFT_BAR] = 0.0f;
    }

    /* ── v10: Trade-level reward attribution ──────────────────────────────
     * Rewards are PER-TRADE only. No per-bar P&L (was removed: used raw_next
     * which is future information, and per-bar noise has SNR ~0.01).
     *
     * When segment_complete fired above, reward already has the trade P&L
     * (vol-normalized, soft-clamped, with Layer 4-9 credits).
     *
     * For non-trade-completion bars:
     *   Holding (positioned, no trade close) → tiny negative holding cost.
     *   Flat (no position) → reward stays 0.
     *
     * Dense per-bar noise (OFI alignment, DSR, inventory penalty) REMOVED.
     * C51 atoms now model distribution of TRADE OUTCOMES, not per-bar noise.
     * ──────────────────────────────────────────────────────────────────── */
    /* ── R5: Dense micro-reward (Phase 2 Task 2.3 — FIX-documented-disable) ──
     *
     * micro_reward_scale is a PARAMETER, not a feature flag:
     *   - dqn-production.toml  : micro_reward_scale = 0.1   (ACTIVE)
     *   - dqn-localdev.toml    : micro_reward_scale = 0.01
     *   - dqn-hyperopt.toml    : micro_reward_scale = 0.01  (sweep [0.0, 0.05])
     *   - dqn-smoketest.toml   : micro_reward_scale = 0.0   (intentionally off)
     *
     * Why this branch is intentionally DISABLED at smoke scale (scale=0.0):
     *   Smoke fixtures isolate the sparse-reward TD path for reproducibility —
     *   smoke tests (td_propagation, magnitude_distribution,
     *   reward_component_audit) measure segment-complete rewards in isolation
     *   without dense per-bar shaping. Smoke data is 5000 bars × 3 folds,
     *   too small for PopArt normalization to cover the dense signal stably.
     *
     * Why this branch is NOT redundant with OFI state features at
     * state[SL_OFI_START..SL_OFI_START+SL_OFI_DIM) = state[42..62):
     *   The state-vector OFI block provides representation features for the
     *   encoder to learn from (policy side). This branch injects a per-bar
     *   REWARD gradient signal (critic side). Representation features and
     *   gradient-injection signals are different mechanisms — a sparse TD
     *   target on trade completion does not in general push representation
     *   information into Q-value magnitudes on the per-bar scale that
     *   dense shaping does. Empirically, production deploys with scale=0.1
     *   on top of the full OFI state vector, so the two are complementary.
     *
     * Additionally, this branch reads PREV_MID (retrospective hold quality)
     * which is NOT in the state vector — that signal exists only here.
     *
     * Per feedback_no_functionality_removal.md: this code path MUST NOT be
     * removed. To activate, set micro_reward_scale > 0 in the loaded TOML
     * profile (no recompile needed).
     * ──────────────────────────────────────────────────────────────────── */
    /* SP12 v3 Change 3 (2026-05-04): per-bar micro-reward is zero.
     *
     * Per pearl_event_driven_reward_density_alignment, per-bar shaping for
     * credit assignment is the anti-pattern. The 50-epoch validation
     * `train-multi-seed-pmbwn` on commit 6a259942e showed sharpe-gaming
     * (sharpe=80, PnL -30%, 62% trade rate) — micro_reward + opp_cost firing
     * every bar with position injected an exposure-positive density
     * gradient that pulled the policy toward HFT noise extraction.
     *
     * Phase 1 fix: zero `r_micro` entirely. Q-learning handles credit
     * assignment via TD targets — that is the right mechanism for "is
     * exposure good right now". The OFI/MBP-10 informational signal
     * remains available to the encoder via the state vector
     * (state[SL_OFI_START..62)); only the reward-layer per-bar density
     * bonus is removed. Spec §design-3, open question 3 ("zero entirely").
     *
     * The branch structure (positioned-non-event vs flat-non-event) is
     * preserved because the flat branch still updates the Welford-style
     * pre-entry conviction EMA used by D.4c. Both branches now emit
     * r_micro = 0 / r_opp_cost = 0 instead of computing per-bar shaping.
     * micro_reward_scale, price_confirm_weight, book_aggression_weight,
     * hold_quality_weight, micro_reward_temp, holding_cost_rate become
     * structurally inert for the per-bar paths (they remain wired for
     * the inventory_penalty modifier at the bottom of the kernel which
     * is a separate concern; spec carve-out: "ONLY micro_reward and
     * opp_cost are the per-bar shaping anti-patterns"). */
    if (!segment_complete && fabsf(position) > 0.001f) {
        /* SP12 v3: positioned-non-event bar → r_micro = 0.
         *
         * Pre-SP12 this branch fired EITHER the OFI dense-micro reward (when
         * micro_reward_scale > 0 and OFI features wired) OR the
         * `-shaping_scale * holding_cost_rate * |position|` fallback. Both
         * formulations injected per-bar density-positive (or density-negative)
         * reward, contradicting pearl_event_driven_reward_density_alignment.
         *
         * The OFI informational signal still flows to the encoder via the
         * state vector at state[SL_OFI_START..SL_OFI_START + SL_OFI_DIM);
         * removing the per-bar reward path only stops the critic-side
         * gradient injection. The PREV_MID-based retrospective hold-quality
         * signal is the one piece of information NOT in the state vector,
         * but per spec §design-3 Q-learning's TD targets handle "was that
         * hold correct?" via the eventual segment_complete payoff — the
         * dense per-bar version was redundant pressure on the same signal.
         *
         * `holding_cost_rate`, `micro_reward_scale`, `price_confirm_weight`,
         * `book_aggression_weight`, `hold_quality_weight`, `micro_reward_temp`
         * all become structurally inert for this branch. They remain wired
         * for the inventory_penalty modifier at the bottom of the kernel
         * (which is a separate concern; spec carve-out: "ONLY micro_reward
         * and opp_cost are the per-bar shaping anti-patterns"). */
        r_micro = 0.0f;
        /* SP13 v3 P0a.T3 (2026-05-04): per-bar Hold cost subtraction.
         * Hold action keeps the current position with zero PnL signal —
         * pre-SP13 this was a free no-op the policy could pick indefinitely
         * (CQL bias anchors toward minimum-variance Hold ~45% of bars across
         * SP1-SP12). Hold-pricing makes Hold uneconomic when overused: the
         * controller in `training_loop.rs` raises ISV[HOLD_COST_INDEX] when
         * observed Hold-rate > target (default 0.20). The cost is bounded
         * structurally (HOLD_COST_BASE × CEIL_RATIO = 0.005 max per bar),
         * well below the SP12 asymmetric cap [-10, +5] — no per-bar clamp
         * required. Spec §"Change 1: Price Hold (replaces v2's Eliminate
         * Hold)". */
        if (dir_idx == DIR_HOLD && isv_signals_ptr != NULL) {
            r_micro -= isv_signals_ptr[ISV_HOLD_COST_IDX];
        }
        reward  = r_micro;
        micro_reward_per_sample[out_off] = r_micro;
        reward_components_per_sample[out_off * 6 + 3] = r_micro;
    } else if (!segment_complete) {
        /* Plan 3 D.4c: Welford-style EMA of conviction_core during Flat bars.
         * Updates running mean and squared-deviation EMAs that the consumer
         * (entry-bonus block below) uses to detect stable pre-entry conviction.
         *
         * α = 0.05 matches the Plan 3 Task 1 reward-component EMA convention —
         * not a tuned constant per task; same α_base used for all Plan 3 EMAs.
         *
         * Per-bar update:
         *   delta    = conviction_core − prev_mean
         *   new_mean = prev_mean + α × delta
         *   new_var  = (1 − α) × (prev_var + α × delta²)     // EMA-style, not exact Welford
         *
         * Runs only when conviction_ptr is available; otherwise skip cleanly
         * (cold-start or test-fixture safety). Reset to 0 at trade entry,
         * trade reversal, and fold/episode reset (see lifecycle blocks). */
        if (conviction_ptr != NULL) {
            const float alpha_c  = 0.05f;
            const float prev_m   = ps[PS_PRE_ENTRY_CONVICTION_EMA];
            const float prev_v   = ps[PS_PRE_ENTRY_CONVICTION_VAR_EMA];
            const float delta    = conviction_core - prev_m;
            const float new_m    = prev_m + alpha_c * delta;
            const float new_v    = (1.0f - alpha_c) * (prev_v + alpha_c * delta * delta);
            ps[PS_PRE_ENTRY_CONVICTION_EMA]     = new_m;
            ps[PS_PRE_ENTRY_CONVICTION_VAR_EMA] = new_v;
        }
        /* SP12 v3: per-bar Flat opportunity cost is zero.
         *
         * Pre-SP12 this branch fired the ISV-driven conviction × vol × Q-ref
         * × KL-amp opportunity-cost penalty on every flat bar — the symmetric
         * counterpart to the positioned-bar micro_reward, both formulations
         * baking a per-bar density gradient into the reward signal. Per
         * pearl_event_driven_reward_density_alignment that gradient pulled
         * the policy toward HFT noise extraction in the
         * `train-multi-seed-pmbwn` 50-epoch validation on commit 6a259942e.
         *
         * The carrying-cost economic concept is preserved as a lump-sum
         * applied in the segment_complete branch (see "SP12 v3 Change 3:
         * lump-sum opportunity cost" block above) — the cost magnitude is
         * the same `−shaping_scale × holding_cost_rate × |position| ×
         * hold_time` integrated over the trade's duration, just emitted at
         * the close event instead of distributed per-bar. Spec §design-3,
         * open question 2 ("lump-sum at exit").
         *
         * The Welford-style D.4c conviction EMA above MUST stay because it
         * is a state-tracking signal (read by the entry-bonus block to
         * detect stable pre-entry conviction), not reward shaping. Removing
         * it would break the D.4c novelty mechanism. Spec carve-out
         * preserved: only the Flat opp_cost EMITTER is zeroed; the
         * conviction Welford observer is independent. */
        r_opp_cost = 0.0f;
        /* SP13 v3 P0a.T3 (2026-05-04): per-bar Hold cost subtraction on
         * the flat-non-event branch. Mirrors the positioned-branch
         * subtraction above so a policy picking Hold while flat (i.e.
         * "stay flat") pays the same per-bar cost as a policy picking
         * Hold while positioned (i.e. "stay long/short"). The cost is
         * structurally bounded by HOLD_COST_BASE × CEIL_RATIO = 0.005
         * per bar, well below the SP12 asymmetric cap [-10, +5]. */
        if (dir_idx == DIR_HOLD && isv_signals_ptr != NULL) {
            r_opp_cost -= isv_signals_ptr[ISV_HOLD_COST_IDX];
        }
        reward = r_opp_cost;
        reward_components_per_sample[out_off * 6 + 4] = r_opp_cost;
    }

    /* ── B.2 (Plan 3 Task 3): ISV-driven trade-attempt bonus ──
     *
     * Fires at Flat→Positioned transitions only. The bonus rewards entering
     * a trade when the network has been under-trading relative to its own
     * measured baseline ("novelty"), scaled by the same conviction × vol_proxy
     * shaping the opp-cost path uses — the magnitude tracks directional
     * belief and realised vol, not a tuned constant.
     *
     *   novelty = max(0, 1 - attempt_rate / target_rate)
     *   bonus   = conviction_core × vol_proxy × novelty
     *   reward  += shaping_scale × bonus
     *
     * **Explicit freeze gate.** `TRADE_TARGET_RATE` is 0.0 until the CPU
     * freezes it at epoch 5 from the measured `TRADE_ATTEMPT_RATE_EMA`
     * (min 0.001). The bonus is structurally inert pre-freeze because a 0
     * target with `attempt_rate=0` would spuriously saturate novelty at 1.0;
     * checking `target_raw > 1e-6f` is both cheaper and more honest than
     * clamping the denominator. Once frozen, the bonus kicks in only when
     * `attempt_rate < target_rate` (under-trading).
     *
     * Attribution: the bonus contribution is recorded in rc[5] (the bonus
     * component slot) before the drawdown penalty compounds everything. */
    if (entering_trade && isv_signals_ptr != NULL
        && features != NULL && bar_idx < total_bars && market_dim > 9) {
        float target_raw = isv_signals_ptr[trade_target_rate_idx];
        if (target_raw > 1e-6f) {  /* post-freeze only — gate is explicit */
            float atr_norm_b2   = features[(long long)bar_idx * market_dim + 9];
            float log_atr_b2    = atr_norm_b2 * 16.0f - 7.0f;
            float atr_pct_b2    = expf(log_atr_b2) / fmaxf(raw_close, 1.0f);
            float vol_proxy_b2  = fmaxf(atr_pct_b2, 0.0001f);
            if (vol_proxy_b2 > 0.01f) vol_proxy_b2 = 0.01f;
            float attempt_rate  = isv_signals_ptr[trade_attempt_rate_ema_idx];
            float novelty       = fmaxf(0.0f, 1.0f - attempt_rate / target_raw);
            /* C.3 Plan 3 Task 7: amplify novelty bonus by the same Flat-trap
             * KL amplifier the B.1 opp_cost site uses. fmaxf(1.0, …) guards
             * cold-start. Both rewards scale together — when train/val
             * distributions drift, the model sees both a stronger penalty for
             * staying Flat AND a stronger bonus for entering a trade. */
            float kl_amp_b2     = fmaxf(1.0f, isv_signals_ptr[ISV_STATE_KL_AMP_IDX]);
            float bonus         = conviction_core * vol_proxy_b2 * novelty;
            float bonus_scaled  = shaping_scale * bonus * kl_amp_b2;
            r_bonus            += bonus_scaled;
            reward             += bonus_scaled;  /* keep cascade scalar in lockstep */
            /* C.2 attribution: record bonus component magnitude at rc[5]. */
            reward_components_per_sample[out_off * 6 + 5] = bonus_scaled;
        }
    }

    /* ── D.4c (Plan 3 Task 6c): Conviction consistency bonus ──
     *
     * Fires on Flat→Positioned entry when the network's pre-entry conviction
     * has been STABLE across the Flat run (stddev/mean < 0.2). Rewards
     * deliberated entries vs noise-spike entries.
     *
     * Mirrors B.2 novelty-bonus structure exactly:
     *   bonus = shaping × vol_proxy × stability × conviction_core
     *
     * All multiplicands are in [0,1] except vol_proxy (bounded ≤ 0.01).
     * Max bonus ≈ 0.01 — same order as B.2. Per
     * pearl_one_unbounded_signal_per_reward.md: exactly ONE technically
     * unbounded multiplicand (vol_proxy); all others bounded.
     *
     * stability mapping:
     *   ratio  = stddev / mean (sentinel 2.0 when mean ≈ 0 → disables bonus)
     *   ratio < 0.2 ⇒ stable ⇒ stability = clamp(0, 1, 1 − ratio/0.2)
     *   ratio ≥ 0.2 ⇒ skip
     *
     * The 0.2 threshold is the single tuned constant retained for this first
     * cut. Reviewer note: a follow-up could derive it from
     * ISV[Q_DIR_ABS_REF] for full ISV-driven shaping; deferred to keep this
     * task surgical. The `ratio/0.2` normalisation inside `1 − ratio/0.2`
     * makes stability=1 mean "perfectly stable" (ratio=0) and stability→0
     * as ratio→0.2 (margin), so credit is proportional to how stable the
     * pre-entry epoch was rather than a flat threshold pass/fail.
     *
     * Attribution: rc[5] += bonus (co-exists with B.2/C.4/D.4a/D.4b at
     * different (i,t) slots; the same ISV[68] REWARD_BONUS_EMA tracks the
     * net rc[5] signal). */
    if (entering_trade && isv_signals_ptr != NULL
        && features != NULL && bar_idx < total_bars && market_dim > 9) {
        const float mean_c   = ps[PS_PRE_ENTRY_CONVICTION_EMA];
        const float var_c    = ps[PS_PRE_ENTRY_CONVICTION_VAR_EMA];
        const float stddev_c = sqrtf(fmaxf(0.0f, var_c));
        const float ratio    = (mean_c > 1e-6f) ? (stddev_c / mean_c) : 2.0f;
        if (ratio < 0.2f) {
            const float stability    = fmaxf(0.0f, fminf(1.0f, 1.0f - ratio / 0.2f));
            const float atr_norm_c   = features[(long long)bar_idx * market_dim + 9];
            const float log_atr_c    = atr_norm_c * 16.0f - 7.0f;
            const float atr_pct_c    = expf(log_atr_c) / fmaxf(raw_close, 1.0f);
            float vol_proxy_c        = fmaxf(atr_pct_c, 0.0001f);
            if (vol_proxy_c > 0.01f) vol_proxy_c = 0.01f;
            const float bonus        = shaping_scale * vol_proxy_c * stability * conviction_core;
            r_bonus                                        += bonus;
            reward                                         += bonus;  /* keep cascade scalar in lockstep */
            reward_components_per_sample[out_off * 6 + 5]  += bonus;
        }
    }
    /* Defensive reset of D.4c EMA slots at entry — redundant with the
     * lifecycle resets at the entering_trade handlers above (lines ~1858 and
     * ~2018), but cheap and harmless. The next Flat epoch starts fresh. */
    if (entering_trade) {
        ps[PS_PRE_ENTRY_CONVICTION_EMA]     = 0.0f;
        ps[PS_PRE_ENTRY_CONVICTION_VAR_EMA] = 0.0f;
    }

    /* ================================================================
     * SP11 B1b: Controller-weighted reward composition
     *
     * Spec §3.5.3 amended at main commit 7ddaf9c51. Replaces the
     * pre-SP11 in-place `reward = X / reward += X` cascade with explicit
     * `Σ w_i × r_i` over the 5 on-policy components populated above.
     * The cf component (`r_cf`) is composed separately inside the CF
     * block at the bottom of this kernel (the cf-tuple's reward written
     * to `out_rewards[cf_off]` is `w_cf × r_cf` per spec §3.5.0/§3.5).
     *
     * Mean=1 normalization (§3.4.3): controller writes weights to ISV
     * with mean(weights) ≈ 1 (Σ ≈ 6 across 6 components). The per-bar
     * weighted Σ reduces to `w_active × r_active + w_bonus × r_bonus`
     * which preserves pre-SP11 absolute scale on average (uniform
     * weights = 1.0 each, identical to pre-SP11's single-path-fires
     * semantic).
     *
     * Sentinel-defense (§3.4.2 + cold-start ordering): the SP11
     * controller runs in the per-epoch metrics block AFTER experience
     * collection (training_loop.rs ~3475), so at fold 0 epoch 0 step 0
     * the controller has NOT yet emitted — ISV slots [340..346) are
     * sentinel 0. Reading raw 0 weights would collapse `r_weighted` to
     * 0 (full reward magnitude loss until after epoch 0). Defense:
     * `fmaxf(w_raw, SP11_WEIGHT_HARD_FLOOR=0.01)` — same Invariant-1
     * floor the controller enforces post-renorm. Cold-start reward
     * scale = 0.01 × |r_active| (1% of pre-SP11) on the first emit;
     * Pearl A bootstrap on the controller's first observation replaces
     * sentinel with raw → mean-1 weights take over from epoch 1 on.
     *
     * NOTE: The 0.01 hard-floor on cold-start IS Invariant-1
     * (numerical safety). It's the absolute minimum on every weight at
     * every step — including the first-emit sentinel state. The
     * controller's `WEIGHT_HARD_FLOOR=0.01` is the same constant
     * referenced via `SP11_WEIGHT_HARD_FLOOR` in the Rust slot
     * constants (sp11_isv_slots.rs:56). Hardcoded inline in CUDA
     * because cudarc kernels can't include Rust headers; magic-number
     * value sourced from the spec — `feedback_no_quickfixes` carve-out
     * for Invariant-1 anchors per spec §3.4.2.
     * ================================================================ */
    {
        /* On-policy composition: 5 components (popart / trail / micro /
         * opp_cost / bonus). The cf-reward path is composed separately in
         * the CF block below using the same Invariant-1 floor pattern.
         * r_cf is structurally absent from this Σ because the cf-tuple's
         * reward lives at out_rewards[cf_off], not out_rewards[out_off]. */
        const float w_pop_raw = (isv_signals_ptr != NULL) ? isv_signals_ptr[340] : 0.0f;
        const float w_tr_raw  = (isv_signals_ptr != NULL) ? isv_signals_ptr[342] : 0.0f;
        const float w_mi_raw  = (isv_signals_ptr != NULL) ? isv_signals_ptr[343] : 0.0f;
        const float w_oc_raw  = (isv_signals_ptr != NULL) ? isv_signals_ptr[344] : 0.0f;
        const float w_bn_raw  = (isv_signals_ptr != NULL) ? isv_signals_ptr[345] : 0.0f;

        /* Sentinel-defense: Invariant-1 hard floor (= SP11_WEIGHT_HARD_FLOOR).
         * Before the controller's first emit, raw ISV slots are 0 → defaults
         * to the 0.01 floor. Post-emit, the controller already enforces this
         * floor in its renorm; the fmaxf is therefore idempotent on the
         * post-emit path. NULL isv_signals_ptr (no controller wiring) also
         * routes through the 0.01 floor — equivalent to "all 5 on-policy
         * components weighted equally at the minimum scale". */
        const float w_pop = fmaxf(w_pop_raw, 0.01f);
        const float w_tr  = fmaxf(w_tr_raw,  0.01f);
        const float w_mi  = fmaxf(w_mi_raw,  0.01f);
        const float w_oc  = fmaxf(w_oc_raw,  0.01f);
        const float w_bn  = fmaxf(w_bn_raw,  0.01f);

        const float r_weighted = w_pop * r_popart
                               + w_tr  * r_trail
                               + w_mi  * r_micro
                               + w_oc  * r_opp_cost
                               + w_bn  * r_bonus;
        reward = r_weighted;  /* the post-composition modifiers below operate on r_weighted */
    }

    /* ---- Drawdown penalty (from trade_physics.cuh) ──────────────────────
     * Phase 3: gated by shaping_scale. The drawdown penalty is *behavioral*
     * (steers the policy away from large equity drops); the *physics* of
     * drawdown impact is already in the per-bar P&L.  At shaping_scale=0
     * (validation mode) we measure pure equity-change reward only. */
    float equity_now = cash + position * raw_close;
    reward += shaping_scale * compute_drawdown_penalty(equity_now, peak_equity, dd_threshold, w_dd);

    /* ---- Portfolio value for floor check ---- */
    float new_portfolio_value_pre_floor = cash + position * raw_close;

    /* ---- Capital floor protection (PDT $25K rule) ---- */
    /* If equity drops to 25% drawdown from peak, terminate the episode.
     * With $35K initial, this triggers at ~$26.25K — above the $25K PDT minimum.
     * Uses peak_equity (not initial_capital) so it adapts as account grows.
     * The model learns: approaching the floor = game over = zero future reward. */
    float new_portfolio_value = new_portfolio_value_pre_floor;
    if (check_capital_floor(new_portfolio_value, peak_equity)) {
        float risk_taken = fabsf(position) / fmaxf(max_position, 0.001f);
        /* Charge tx cost on forced liquidation — production exits aren't free */
        if (fabsf(position) > 0.001f) {
            int order_type_idx_liq = decode_order_4b(action_idx, b2_size, b3_size);
            float exit_cost = compute_tx_cost(position, raw_close, tx_cost_multiplier, spread_cost,
                                              max_position, order_type_idx_liq, -1.0f);
            new_portfolio_value -= exit_cost;
        }
        /* Force flat — emergency exit all positions */
        position = 0.0f;
        cash = new_portfolio_value;
        new_portfolio_value = cash;
        /* Massive penalty — approaching the floor is catastrophic.
         * Must saturate C51 v_range [-10, +10] so distributional learning
         * treats floor breach as worst-case outcome. */
        reward = fmaxf(-10.0f, -5.0f - 5.0f * risk_taken);
    }

    /* ---- Done detection ---- */
    int next_bar = bar_idx + 1;
    /* Position-gated done: episode ends when model completes a trade cycle
     * (was positioned -> now flat). V(flat)=0 is correct terminal anchor.
     * Also done at end-of-data or capital floor breach (existing). */
    int trade_complete = (fabsf(pre_trade_position) > 0.001f && fabsf(position) < 0.001f) ? 1 : 0;
    int capital_breach = check_capital_floor(new_portfolio_value, peak_equity);
    int data_end       = (next_bar >= total_bars) ? 1 : 0;
    int done           = trade_complete | capital_breach | data_end;

    /* ---- Update full portfolio state (PORTFOLIO_STRIDE=40) ---- */
    ps[PS_POSITION]  = (position);
    ps[PS_CASH]  = (cash);
    ps[PS_PORTFOLIO_VALUE]  = (new_portfolio_value);
    /* ps[3:6]: reserved (formerly clustering stats, retained for slot stability) */
    ps[PS_PEAK_EQUITY]  = (peak_equity);
    ps[PS_FLAT_COUNTER]  = (flat_counter);
    ps[PS_PREV_EQUITY]  = (new_portfolio_value);  /* prev_equity = current equity for next step */
    ps[PS_HOLD_TIME] = (hold_time);
    /* realized_pnl accumulates. On reversal bars, use old_pos_pnl
     * (saved before position was overwritten at ps[PS_POSITION] = position).
     * For non-reversal bars, raw_pnl (= current position's PnL) is correct. */
    if (reversing_trade) {
        ps[PS_REALIZED_PNL] = ((ps[PS_REALIZED_PNL]) + old_pos_pnl); /* old position's PnL */
    } else {
        ps[PS_REALIZED_PNL] = ((ps[PS_REALIZED_PNL]) + raw_pnl); /* normal: current position's PnL */
    }
    ps[PS_ENTRY_PRICE] = (entry_price);          /* preserved across bars of a trade */
    ps[PS_TRADE_START_PNL] = (trade_start_pnl);      /* realized_pnl snapshot at trade entry */
    ps[PS_KELLY_WIN_COUNT] = (win_count);            /* Kelly: accumulated across episode */
    ps[PS_KELLY_LOSS_COUNT] = (loss_count);
    ps[PS_KELLY_SUM_WINS] = (sum_wins);
    ps[PS_KELLY_SUM_LOSSES] = (sum_losses);
    ps[PS_KELLY_SUM_RETURNS] = (sum_returns);          /* Kelly continuous: sum returns */
    ps[PS_KELLY_SUM_SQ_RETURNS] = (sum_sq_returns);       /* Kelly continuous: sum returns^2 */
    ps[PS_INTRA_TRADE_MAX_DD] = (intra_trade_max_dd);   /* v7: intra-trade max drawdown */
    /* ps[PS_PREV_MID]: reserved (formerly clustering stats, retained for slot stability) */

    /* ---- Store current OFI in ps[30..37] for next bar's delta computation ---- */
    /* Also store prev mid-price and prev close for micro-reward / DSR. */
    if (full_batch_states != NULL && full_state_dim >= SL_OFI_START + SL_OFI_DIM) {
        const float* ofi_cur = full_batch_states + (long long)i * full_state_dim + SL_OFI_START;
        for (int k = 0; k < 8; k++) {
            /* Update previous OFI for next bar's delta computation */
            ps[PS_OFI_PREV_BASE + k] = ofi_cur[k];
        }
    }
    ps[PREV_MID_SLOT] = tgt[5];      /* MBP-10 mid-price at this decision point */
    ps[PREV_CLOSE_SLOT] = raw_close;  /* for DSR actual per-bar returns */

    /* ---- NO global reward clamp ---- */
    /* Reward v7: sparse trade-completion with asymmetric soft-clamp.
     * The combined reward is bounded by soft-clamp to [-10, +10] range.
     * C51 v_range should cover Q = reward / (1-gamma). */

    /* ---- Write reward and done flag ---- */
    /* Final NaN guard — if reward is NaN/Inf, write 0.0 instead of poisoning
     * the replay buffer. This prevents gradient explosion from propagating. */
    if (isnan(reward) || isinf(reward)) reward = 0.0f;

    /* ── Cost-driven hold timing (replaces min_hold_bars) ──────────────
     * The temporal attention learns optimal hold timing from these signals.
     * No hardcoded gates — the model sees costs and learns to minimize them.
     *
     * Modifier ordering (SP11 spec §3.4.4):
     *   clamp_capital → -drawdown → -inventory → -churn → ×conviction → ×cf_flip
     *
     * cf_flip MUST be the LAST modifier so subtractive penalties operate on
     * the correct sign and the multiplicative conviction scale attenuates
     * before the direction is flipped. Pre-fix, on flipped samples
     * inventory/churn ADDED to the negated reward (penalty became bonus)
     * and conviction scaled the wrong-signed value — asymmetric gradient
     * signals between flipped and non-flipped samples contaminated the
     * SP11 mag-ratio canary at the cf axis. */

    /* Inventory penalty: continuous cost proportional to position size.
     * Teaches: small positions cheap to hold, large positions expensive.
     * Phase 3: gated by shaping_scale (behavioral; not in validation reward). */
    float inventory_penalty = holding_cost_rate * fabsf(position);
    reward -= shaping_scale * inventory_penalty;

    /* Churn penalty: graduated cost for rapid position flips.
     * Not a hard gate — the model CAN exit early, but it costs extra.
     * Zero penalty after churn_threshold bars.
     * Phase 3: gated by shaping_scale (behavioral; not in validation reward). */
    if (entering_trade && hold_time < churn_threshold && hold_time > 0.0f) {
        float churn_penalty = churn_penalty_scale * (churn_threshold - hold_time) / churn_threshold;
        reward -= shaping_scale * churn_penalty;
    }

    /* Plan conviction as reward scaling — ONLY when plan head is mature.
     * Before readiness >= plan_thr_env, conviction defaults to 1.0 (identity) so the
     * reward signal flows undampened. Without this gate, random init conviction
     * (~0.1) kills the gradient signal and prevents the model from learning. */
    if (plan_params_ptr != NULL && fabsf(reward) > 1e-8f) {
        float readiness_val = (readiness_ptr != NULL) ? readiness_ptr[0] : 0.0f;
        float conviction = (readiness_val >= plan_thr_env)
            ? plan_params_ptr[i * 6 + PLAN_PARAM_CONVICTION]  /* [0, 1] from plan head */
            : 1.0f;                        /* identity until plan matures */
        reward *= conviction;
    }

    /* G7: Counterfactual reward flip — negate reward when features were flipped.
     * The model learns that flipped features -> flipped reward, enforcing
     * directional symmetry. Per spec §3.4.4 this is the LAST modifier so all
     * additive penalties and the multiplicative conviction operate on the
     * correctly-signed reward before the direction is inverted. */
    if (do_flip) {
        reward *= -1.0f;
    }

    out_rewards[out_off] = reward;
    out_dones[out_off] = (float)done;

    /* Task 0.8: final reward magnitude is the denominator for additive-term
     * contribution ratios (micro). Captured AFTER all shaping layers but
     * before CF-slot writes so it reflects the on-policy sample's reward
     * that the network actually trains on.
     * Task 2.4: loss_aversion no longer contributes — the R6 clamp relocated
     * to c51_loss_kernel.cu::block_bellman_project_f. */
    total_reward_per_sample[out_off] = reward;
    /* C.2: component [0] = final on-policy reward (PopArt input denominator). */
    reward_components_per_sample[out_off * 6 + 0] = reward;

    /* SP11 Fix 39 (A1.2): per-bar saboteur engagement signal — see param
     * docstring above. Computed after `total_reward_per_sample` is
     * finalised. The proxy `traded × |reward| × max(|eff_spread − 1|,
     * |eff_slip − 1|)` is non-zero exactly when (a) the bar carried a
     * trade (so the saboteur perturbation actually altered transaction
     * cost in this bar's reward) AND (b) the saboteur perturbed the
     * cost multipliers (eff_* != 1.0). The downstream kernel applies a
     * `0.01 × ISV[PNL_REWARD_MAGNITUDE_EMA_INDEX]` threshold to classify
     * engagement and reduces to a per-step fraction.
     *
     * Note: the raw-portfolio-return path (raw_returns_out) is NOT
     * affected by saboteur perturbations — those go into shaped reward
     * only. So this signal is a meaningful proxy specifically for
     * "saboteur affected the LEARNING signal", which is what the
     * controller cares about. */
    if (saboteur_delta_reward_per_sample != NULL) {
        float perturb_mag = fmaxf(fabsf(saboteur_eff_spread - 1.0f),
                                   fabsf(saboteur_eff_slip   - 1.0f));
        float traded_f = (float)(traded_per_sample[out_off]);
        saboteur_delta_reward_per_sample[out_off] = traded_f * fabsf(reward) * perturb_mag;
    }

    /* ---- Write RAW portfolio return (unshaped) for accurate Sharpe/MaxDD ---- */
    /* True fractional return: (equity_t - equity_{t-1}) / equity_{t-1}.
     * No dense/sparse weighting, no soft-clamp, no CEA bonus.
     * This is what risk management (Sharpe, Sortino, MaxDD, VaR) must use. */
    if (raw_returns_out != NULL) {
        float portfolio_return = (prev_equity > 1.0f)
            ? (new_portfolio_value - prev_equity) / prev_equity
            : 0.0f;
        if (isnan(portfolio_return) || isinf(portfolio_return)) portfolio_return = 0.0f;
        raw_returns_out[out_off] = (portfolio_return);
    }

    /* ── #26 HER regime tagging: classify each experience by market regime ── */
    /* Regime labels stored in raw_returns_out's high bits (reuse unused precision).
     * 0 = mean-reverting (default), 1 = trending (ADX > 25), 2 = volatile (high vol).
     * Used by replay buffer's priority weighting to oversample rare regimes. */
    /* The regime tag is encoded in the episode's portfolio_state for use by
     * the PER sampling kernel. For now, the tagging happens implicitly via
     * the existing ADX/CUSUM features in the state vector — the causal
     * intervention (#34) already identifies which features matter, and the
     * feature importance (#8) ranks them. No separate regime tag buffer needed
     * because the bottleneck (#31) compresses regime information into the 2D
     * latent space automatically. */

    /* ── #7 Counterfactual augmentation: alternating direction mirror / magnitude relabel ──
     *
     * Even timesteps: directional mirror (Short↔Long, negated reward).
     * Odd  timesteps: hindsight magnitude relabeling — substitute a different
     *   magnitude and linearly scale the reward by the ratio of magnitude
     *   fractions.  This gives the magnitude branch direct gradient signal
     *   about what BIGGER or SMALLER positions would have earned, breaking
     *   the collapse toward Small (72%) caused by sparse magnitude reward.
     *
     * No extra buffer needed — reuses the existing N*L counterfactual slot. */
    {
        long long cf_off = out_off + (long long)N * L;

        /* Task 0.8: capture counterfactual direction-flip firing rate at this
         * (i,t). do_flip is resolved above at the state-augmentation site
         * (philox_uniform < exploration_scale * cf_ratio). The CF slot
         * independently runs cf_cycle-based counterfactual relabeling, but
         * the sample-selector signal the audit cares about is whether the
         * on-policy replay state had its directional features mirrored —
         * that's do_flip. Written at cf_off (denominator = N*L per epoch). */
        cf_flip_per_sample[cf_off] = do_flip ? 1 : 0;

        /* Copy same state to counterfactual slot (shared by both paths) */
        const float* cf_src = batch_states + (long long)i * state_dim;
        float*       cf_dst = out_states   + cf_off * state_dim;
        for (int k = 0; k < state_dim; k++)
            cf_dst[k] = cf_src[k];

        int orig_order   = decode_order_4b(action_idx, b2_size, b3_size);
        int orig_urgency = decode_urgency_4b(action_idx, b3_size);

        int   cf_action;
        float cf_reward;

        int cf_cycle = current_t % 3;
        if (cf_cycle == 0) {
            /* ── Step 0: directional mirror ── */
            /* Short(0)↔Long(2): opposite direction. Hold(1)/Flat(3): no useful mirror.
             * When dir is Hold or Flat, skip trivial self-mirror — use magnitude CF instead. */
            int cf_dir;
            if (dir_idx == DIR_SHORT)      cf_dir = DIR_LONG;  /* Short→Long */
            else if (dir_idx == DIR_LONG) cf_dir = DIR_SHORT;  /* Long→Short */
            else {
                /* Hold/Flat: directional mirror is trivial (same action).
                 * Fall through to magnitude cycle instead for useful counterfactual. */
                goto magnitude_cf;
            }
            cf_action = cf_dir * b1_size * b2_size * b3_size
                      + mag_idx * b2_size * b3_size
                      + orig_order * b3_size + orig_urgency;
            cf_reward = do_flip ? reward : -reward;
        } else if (cf_cycle == 1) { magnitude_cf:
            /* ── Step 1: hindsight magnitude relabeling ──
             * Pick the best alternative magnitude:
             *   If Full(2), try Half(1).  Otherwise try Full(2).
             * Scale reward linearly by (alt_mag_frac / actual_mag_frac).
             * Only relabel when reward is non-trivial (|reward| > 0.001);
             * for near-zero reward the linear scaling is meaningless, so
             * fall back to directional mirror. */
            const float mag_fracs[3] = { 0.50f, 0.707f, 1.00f };
            float actual_mag_frac = mag_fracs[mag_idx < 3 ? mag_idx : 0];

            if (fabsf(reward) > 0.001f) {
                /* Cycle: smaller, larger, opposite. All 3 magnitudes get gradient.
                 * Was: only Half or Full — Small NEVER appeared as counterfactual. */
                int alt_mag;
                int cycle = (current_t / 3) % 3;
                if (cycle == 0)      alt_mag = (mag_idx > 0) ? mag_idx - 1 : 2;
                else if (cycle == 1) alt_mag = (mag_idx < 2) ? mag_idx + 1 : 0;
                else                 alt_mag = 2 - mag_idx;

                float alt_mag_frac = mag_fracs[alt_mag];
                cf_action = dir_idx * b1_size * b2_size * b3_size
                          + alt_mag * b2_size * b3_size
                          + orig_order * b3_size + orig_urgency;
                /* Undo do_flip before magnitude scaling, then re-apply.
                 * Without this, flipped reward gets scaled as if it were
                 * the original direction, producing wrong-sign gradients. */
                float base_reward = do_flip ? -reward : reward;
                cf_reward = base_reward * (alt_mag_frac / actual_mag_frac);
                if (do_flip) cf_reward = -cf_reward;
                cf_reward = fminf(fmaxf(cf_reward, -10.0f), 10.0f);
            } else {
                /* Near-zero reward: magnitude scaling uninformative, use directional mirror */
                int cf_dir = (dir_idx == DIR_SHORT) ? DIR_LONG : (dir_idx == DIR_LONG) ? DIR_SHORT : dir_idx; /* Short↔Long, Hold/Flat stay */
                cf_action = cf_dir * b1_size * b2_size * b3_size
                          + mag_idx * b2_size * b3_size
                          + orig_order * b3_size + orig_urgency;
                cf_reward = -reward;
            }
        } else {
            /* ── Step 2: Order-type counterfactual ──
             * Cycle through alternative order types. Compute execution cost
             * difference and amplify to match directional signal magnitude. */
            int alt_order = (orig_order + 1 + ((current_t / 3) & 1)) % b2_size;

            if (fabsf(position) > 0.001f) {
                float taken_cost = compute_tx_cost(
                    position - pre_trade_position, raw_close, tx_cost_multiplier,
                    spread_cost, max_position, orig_order, spread_scale);
                float alt_cost = compute_tx_cost(
                    position - pre_trade_position, raw_close, tx_cost_multiplier,
                    spread_cost, max_position, alt_order, spread_scale);
                float cost_delta = (taken_cost - alt_cost)
                                 / fmaxf(prev_equity, 1.0f);

                cf_action = dir_idx * b1_size * b2_size * b3_size
                          + mag_idx * b2_size * b3_size
                          + alt_order * b3_size + orig_urgency;
                /* Undo do_flip before adding cost delta, then re-apply.
                 * Same fix as magnitude branch — cost delta is direction-invariant
                 * but the base reward sign must match the original trade direction. */
                float base_reward_ord = do_flip ? -reward : reward;
                cf_reward = base_reward_ord + 50.0f * cost_delta;
                if (do_flip) cf_reward = -cf_reward;
                cf_reward = fminf(fmaxf(cf_reward, -10.0f), 10.0f);
            } else {
                /* Flat position: order irrelevant, directional mirror fallback */
                int cf_dir = (dir_idx == DIR_SHORT) ? DIR_LONG : (dir_idx == DIR_LONG) ? DIR_SHORT : dir_idx; /* Short↔Long, Hold/Flat stay */
                cf_action = cf_dir * b1_size * b2_size * b3_size
                          + mag_idx * b2_size * b3_size
                          + orig_order * b3_size + orig_urgency;
                cf_reward = -reward;
            }
        }

        if (isnan(cf_reward) || isinf(cf_reward)) cf_reward = 0.0f;

        /* SP11 B1b §3.5.0/§3.5: apply w_cf to the cf-tuple reward written
         * to the replay buffer. Loss kernels (mse, c51, IQN target, CQL,
         * Bellman) read this buffer slot and consume the controller-weighted
         * value directly. Same Invariant-1 hard floor as the on-policy
         * weights — pre-emit sentinel 0 → 0.01 baseline scale.
         *
         * Note (spec §3.5 amendment): the `cf_weight = 0.3f` constants at
         * `mse_loss_kernel.cu:318` and `c51_loss_kernel.cu:789` are
         * STRUCTURAL Q-distribution mixing weights for Hold-sample magnitude
         * branch gradient — a different signal, NOT migrated here. They
         * stay at 0.3f untouched. The SP11 controller weight applies ONLY
         * to the cf-reward path in this kernel. */
        const float w_cf_raw = (isv_signals_ptr != NULL) ? isv_signals_ptr[341] : 0.0f;
        const float w_cf     = fmaxf(w_cf_raw, 0.01f);
        const float r_cf     = cf_reward;
        const float cf_reward_weighted = w_cf * r_cf;

        out_actions[cf_off] = cf_action;
        out_rewards[cf_off] = cf_reward_weighted;
        out_dones[cf_off]   = (float)done;
        /* C.2: component [1] = counterfactual reward magnitude for this sample.
         * Written at out_off (on-policy slot), not cf_off, so the EMA kernel's
         * [N*L, 6] reduction over out_off slots captures it alongside the other
         * on-policy components.
         *
         * Writes the RAW intrinsic cf_reward, NOT cf_reward_weighted. The
         * weighted value is still used at out_rewards[cf_off] above so the
         * loss kernels train on the controller-weighted signal — that's
         * correct. But this slot feeds the SP11 mag-ratio canary's cf axis
         * via SP4 reward_component_ema_kernel → ISV slot 64
         * (REWARD_CF_EMA_INDEX), and the canary must observe the
         * INTRINSIC component magnitude, not the controller's own output.
         * Otherwise: high w_cf → high cf_reward_weighted → high slot 64 →
         * high ratio[1] → controller raises w_cf → self-reinforcing loop
         * until mean=1 normalization saturates the other 5 components to
         * floor. Match the convention of the other component slots:
         * rc[+2..+5] all write raw r_<component> pre-Σ, and slot 360
         * (popart-component) is fed by raw r_popart via
         * popart_component_per_sample. Only rc[+0] is intentionally
         * post-composition (PopArt input). */
        reward_components_per_sample[out_off * 6 + 1] = cf_reward;
    }

    /* ---- Advance timestep counter ---- */
    current_timesteps[i] = t + 1;

    /* ---- Episode reset on done ---- */
    /* Position-gated episodes: three done reasons, two reset paths.
     *   - data_end / capital_breach: HARD reset (full restart with fresh capital)
     *   - trade_complete: SOFT reset (keep equity, clear trade-tracking only)
     * Soft reset lets the model accumulate gains/losses across trade cycles
     * within a single data window, preventing the 45x train/val gap caused by
     * resetting equity after every trade completion. */
    if (done) {
        if (data_end || capital_breach) {
            /* Hard reset: new data window or capital wipeout — full restart */
            float init_cap = (peak_equity);  /* use peak as starting capital for next ep */
            ps[PS_POSITION]  = 0.0f;           /* position = flat */
            ps[PS_CASH]  = init_cap;       /* cash */
            ps[PS_PORTFOLIO_VALUE]  = init_cap;       /* portfolio_value */
            ps[PS_PEAK_EQUITY]  = init_cap;       /* peak_equity */
            ps[PS_FLAT_COUNTER]  = 0.0f;           /* flat_counter */
            ps[PS_PREV_EQUITY]  = init_cap;       /* prev_equity */
            ps[PS_HOLD_TIME] = 0.0f;           /* hold_time */
            ps[PS_REALIZED_PNL] = 0.0f;           /* realized_pnl */
            ps[PS_ENTRY_PRICE] = 0.0f;           /* entry_price */
            ps[PS_TRADE_START_PNL] = 0.0f;           /* trade_start_pnl */
            ps[PS_KELLY_WIN_COUNT] = 0.0f;           /* win/loss/Kelly counters */
            ps[PS_KELLY_LOSS_COUNT] = 0.0f;
            ps[PS_KELLY_SUM_WINS] = 0.0f;
            ps[PS_KELLY_SUM_LOSSES] = 0.0f;
            ps[PS_KELLY_SUM_RETURNS] = 0.0f;
            ps[PS_KELLY_SUM_SQ_RETURNS] = 0.0f;
            ps[PS_INTRA_TRADE_MAX_DD] = 0.0f;           /* intra_trade_max_dd */
            ps[PS_INTRA_TRADE_MAX_PNL] = 0.0f;           /* intra_trade_max_pnl */
            ps[PS_PEAK_PNL_BAR] = 0.0f;                  /* Plan 3 C.4: peak-bar alongside MAX_PNL */
            ps[PS_INTRA_TRADE_MIN_PNL] = 0.0f;           /* Plan 3 D.4a: MIN_PNL alongside MAX_PNL */
            ps[PS_REGIME_SHIFT_BAR] = 0.0f;              /* Plan 3 D.4b: reset at trade lifecycle boundary */
            ps[PS_PRE_ENTRY_CONVICTION_EMA] = 0.0f;      /* Plan 3 D.4c: hard-reset Flat-epoch EMA */
            ps[PS_PRE_ENTRY_CONVICTION_VAR_EMA] = 0.0f;  /* Plan 3 D.4c: hard-reset Flat-epoch EMA */
            ps[PS_PLAN_TARGET_BARS] = 0.0f; ps[PS_PLAN_PROFIT_TARGET] = 0.0f; ps[PS_PLAN_STOP_LOSS] = 0.0f;
            ps[PS_PLAN_SCALE_AGGRESSION] = 0.0f; ps[PS_PLAN_CONVICTION] = 0.0f; ps[PS_PLAN_ASYMMETRY] = 0.0f;
            ps[PS_PLAN_ENTRY_REGIME] = 0.0f;           /* plan slots zeroed */
            current_timesteps[i] = 0;
        } else {
            /* Soft reset (trade_complete): keep equity, clear trade state only.
             * The model starts next segment with accumulated gains/losses.
             * Position is already 0 (model chose Flat). Cash, equity,
             * win/loss counters, DSR state — all preserved. */
            ps[PS_HOLD_TIME] = 0.0f;           /* hold_time — new trade starts fresh */
            ps[PS_ENTRY_PRICE] = 0.0f;           /* entry_price — no active trade */
            ps[PS_TRADE_START_PNL] = (ps[PS_REALIZED_PNL]);       /* trade_start_pnl = current realized_pnl */
            ps[PS_INTRA_TRADE_MAX_DD] = 0.0f;           /* intra_trade_max_dd — reset for next trade */
            ps[PS_INTRA_TRADE_MAX_PNL] = 0.0f;           /* intra_trade_max_pnl — reset for next trade */
            ps[PS_PEAK_PNL_BAR] = 0.0f;                  /* Plan 3 C.4: peak-bar reset for next trade */
            ps[PS_INTRA_TRADE_MIN_PNL] = 0.0f;           /* Plan 3 D.4a: MIN_PNL reset for next trade */
            ps[PS_REGIME_SHIFT_BAR] = 0.0f;              /* Plan 3 D.4b: reset at trade lifecycle boundary */
            ps[PS_PRE_ENTRY_CONVICTION_EMA] = 0.0f;      /* Plan 3 D.4c: soft-reset Flat-epoch EMA */
            ps[PS_PRE_ENTRY_CONVICTION_VAR_EMA] = 0.0f;  /* Plan 3 D.4c: soft-reset Flat-epoch EMA */
            ps[PS_PLAN_TARGET_BARS] = 0.0f; ps[PS_PLAN_PROFIT_TARGET] = 0.0f; ps[PS_PLAN_STOP_LOSS] = 0.0f;
            ps[PS_PLAN_SCALE_AGGRESSION] = 0.0f; ps[PS_PLAN_CONVICTION] = 0.0f; ps[PS_PLAN_ASYMMETRY] = 0.0f;
            ps[PS_PLAN_ENTRY_REGIME] = 0.0f;           /* plan slots zeroed on trade complete */
            current_timesteps[i] = 0;
        }
    }
}

/* ======================================================================
 * LEGACY: Portfolio simulation kernel (used by GpuPortfolioSimulator)
 *
 * Single-thread sequential processing for hyperopt/backtest paths.
 * NOT part of the timestep-loop experience collection — kept for
 * GpuPortfolioSimulator compatibility in gpu_portfolio.rs.
 *
 * NOTE: portfolio_state, portfolio_out, rewards_out are bf16 on Rust side.
 * Internal arithmetic uses float accumulators for precision, converts at
 * boundaries.
 * ====================================================================== */

/* action_to_exposure and action_to_tx_cost are provided by common_device_functions.cuh */

extern "C" __global__ void portfolio_sim_kernel(
    const float* __restrict__ targets,
    const int*   __restrict__ actions,
    float* portfolio_state,
    float* portfolio_out,
    float* rewards_out,
    int*         done_out,
    int          batch_start,
    int          batch_size,
    float        max_position,
    int          episode_length,
    int          total_bars
) {
    if (threadIdx.x != 0 || blockIdx.x != 0) return;

    float cash          = (portfolio_state[0]);
    float position      = (portfolio_state[1]);
    float entry_price   = (portfolio_state[2]);
    float initial_cap   = (portfolio_state[3]);
    float spread        = (portfolio_state[4]);
    float last_price    = (portfolio_state[5]);
    float reserve_pct   = (portfolio_state[6]);
    float cum_costs     = (portfolio_state[7]);

    for (int b = 0; b < batch_size; b++) {
        int global_idx = batch_start + b;
        int action_idx = actions[b];

        /* Targets stride per `crates/ml/src/fxcache.rs`
         * (TARGET_DIM=6): col 0=preproc_close, 1=preproc_next, 2=raw_close,
         * 3=raw_next (FUTURE), 4=raw_open, 5=mid_open. Pre-`063fd2716` the
         * stride was 4 with the same column semantics for cols 0-3; only
         * the stride bumps 4 → 6 here. */
        int t_offset = global_idx * 6;
        float current_close     = (targets[t_offset + 0]);
        float next_close        = (targets[t_offset + 1]);
        float current_close_raw = (targets[t_offset + 2]);
        (void)(targets[t_offset + 3]); /* raw_next: FUTURE — must NOT be used */

        float price = (current_close_raw != 0.0f) ? current_close_raw : current_close;
        if (price <= 0.0f) price = 1.0f;

        float current_value = cash + position * price;
        float current_norm = current_value / initial_cap;

        float target_exposure = action_to_exposure(action_idx);
        float target_position = target_exposure * max_position;
        float tx_rate = action_to_tx_cost(action_idx);

        bool is_reversal = (position > 0.0f && target_position < 0.0f) ||
                           (position < 0.0f && target_position > 0.0f);

        if (is_reversal) {
            float close_cash = position * price;
            float close_cost = fabsf(position) * price * tx_rate;
            cash += close_cash - close_cost;
            cum_costs += close_cost;

            float reserve = (reserve_pct > 0.0f) ? current_value * (reserve_pct / 100.0f) : 0.0f;
            float affordable = fmaxf(cash - reserve, 0.0f);
            float max_contracts = (price > 0.0f) ? affordable / (price * (1.0f + tx_rate)) : 0.0f;
            max_contracts = floorf(max_contracts);
            float actual = fminf(max_contracts, fabsf(target_position));

            if (actual > 0.0f) {
                float new_pos = (target_position > 0.0f) ? actual : -actual;
                float open_cost = actual * price * tx_rate;
                cash -= new_pos * price + open_cost;
                cum_costs += open_cost;
                position = new_pos;
                entry_price = price;
            } else {
                position = 0.0f;
                entry_price = 0.0f;
            }
        } else {
            float delta = target_position - position;
            if (fabsf(delta) > 0.0f) {
                float trade_cost = fabsf(delta) * price * tx_rate;
                cum_costs += trade_cost;
                cash -= trade_cost;

                if (delta > 0.0f && reserve_pct > 0.0f) {
                    float pv = cash + position * price;
                    float reserve = pv * (reserve_pct / 100.0f);
                    float buy_cost = delta * price;
                    if (cash - buy_cost < reserve) {
                        float affordable = fmaxf(cash - reserve, 0.0f);
                        delta = fminf(delta, floorf(affordable / price));
                    }
                }

                if (delta > 0.0f) {
                    entry_price = price;
                } else if (target_position == 0.0f) {
                    entry_price = 0.0f;
                }
                cash -= delta * price;
                position = position + delta;
            }
        }

        last_price = price;

        /* Use current price for mark-to-market — NOT next_close_raw (future info).
         * next_close_raw must NOT feed into reward/equity/P&L. */
        float mtm_value = cash + position * price;
        float next_norm = mtm_value / initial_cap;

        float max_pos_norm = (price > 0.0f) ? initial_cap / price : 1.0f;
        float pos_norm = position / max_pos_norm;

        portfolio_out[b * 3 + 0] = (next_norm);
        portfolio_out[b * 3 + 1] = (pos_norm);
        portfolio_out[b * 3 + 2] = (spread);

        float reward = 0.0f;
        if (current_norm > 0.0f) {
            reward = (next_norm - current_norm) / current_norm;
        }

        float abs_pos = fabsf(pos_norm);
        if (abs_pos > 0.8f) {
            reward -= (abs_pos - 0.8f) * 5.0f * 0.1f;
        }

        rewards_out[b] = reward;

        int step = global_idx + 1;
        int time_done = (step % episode_length == 0) ? 1 : 0;
        int data_done = (step >= total_bars) ? 1 : 0;
        done_out[b] = (time_done || data_done) ? 1 : 0;

        if (done_out[b]) {
            cash = initial_cap;
            position = 0.0f;
            entry_price = 0.0f;
            cum_costs = 0.0f;
        }
    }

    portfolio_state[0] = (cash);
    portfolio_state[1] = (position);
    portfolio_state[2] = (entry_price);
    portfolio_state[3] = (initial_cap);
    portfolio_state[4] = (spread);
    portfolio_state[5] = (last_price);
    portfolio_state[6] = (reserve_pct);
    portfolio_state[7] = (cum_costs);
}

/* ================================================================== */
/* Kernel 4: compute_expected_q                                        */
/* ================================================================== */

/**
 * Compute expected Q-values from C51 distributional logits.
 *
 * For each sample and each branch action, combines value + advantage
 * logits via dueling aggregation, applies softmax over atoms, and
 * computes expected Q = sum(softmax(logits) * z_support).
 *
 * When num_atoms == 1, v_logits and b_logits are already plain
 * Q-values and this kernel is not needed (pass them directly
 * to action_select).
 *
 * Grid: ceil(N / 256), Block: 256.  One thread per episode.
 *
 * @param v_logits    [N, NA]                    value head logits (bf16)
 * @param b_logits    [N, (B0+B1+B2+B3)*NA]      branch advantage logits (bf16)
 * @param q_values    [N, B0+B1+B2+B3]           output: expected Q per action (bf16)
 * @param N                                       number of samples
 * @param num_atoms                               C51 atom count (NA)
 * @param b0_size                                 direction branch size
 * @param b1_size                                 magnitude branch size
 * @param b2_size                                 order branch size
 * @param b3_size                                 urgency branch size
 * @param v_range_buf                             [2] adaptive C51 z-support: [v_min, v_max]
 */
extern "C" __global__ void compute_expected_q(
    const float* __restrict__ v_logits,
    const float* __restrict__ b_logits,
    float*                q_values,
    int N,
    int num_atoms,
    int b0_size,
    int b1_size,
    int b2_size,
    int b3_size,
    const float* __restrict__ per_sample_support, /* [N, 4, 3] stride-12: per-sample per-branch [v_min, v_max, delta_z] from IQL (Phase 2d) */
    float* __restrict__ atom_stats_block_sums, /* [num_blocks * 2]: per-block partial sums (phase 1).
                                                 * NULL to skip atom-stat collection entirely. */
    float* __restrict__ q_variance,   /* [N, total_actions] Var[Q] per action. NULL to skip. */
    const float* __restrict__ atom_positions  /* [4, num_atoms] adaptive positions. NULL = use linear. */
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    /* Phase 2d: per-branch (v_min, v_max, delta_z) read INSIDE the d-loop
     * below — direction/magnitude/order/urgency own independent ranges. */

    int total_actions = b0_size + b1_size + b2_size + b3_size;
    float util_threshold = 0.5f / (float)num_atoms; /* half of uniform */

    /* Per-sample value logits — sample-major [N, num_atoms] */
    const float* v_row = v_logits + (long long)i * num_atoms;

    /* Branch logits layout is BRANCH-MAJOR (cuBLAS writes per-branch blocks):
     *   [Branch0: N×b0×NA | Branch1: N×b1×NA | Branch2: N×b2×NA | Branch3: N×b3×NA]
     * Sample i, branch d, local action a, atom z is at:
     *   b_logits + sum(N × n_k × NA for k < d) + i × n_d × NA + a × NA + z
     */
    int branch_sizes[4] = { b0_size, b1_size, b2_size, b3_size };
    int branch_logit_offset = 0;
    int action_idx = 0;
    float sample_entropy = 0.0f;
    int sample_utilized = 0;

    for (int d = 0; d < 4; d++) {
        int n_d = branch_sizes[d];
        const float* branch_base = b_logits + (long long)branch_logit_offset
                                 + (long long)i * n_d * num_atoms;

        /* Phase 2d: per-branch support triple from the [N, 4, 3] tile. */
        int support_base = i * 12 + d * 3;
        float v_min = per_sample_support[support_base + 0];
        float dz    = per_sample_support[support_base + 2];
        const float* atom_pos_d = (atom_positions != NULL)
            ? atom_positions + (long long)d * num_atoms
            : NULL;

        for (int a = 0; a < n_d; a++) {
            const float* adv_a = branch_base + (long long)a * num_atoms;

            /* ── Online softmax: 2-pass instead of 3-pass ────────────────
             * Pass 1: streaming (running_max, running_sum_exp_shifted) plus
             *   running accumulators for E[Z], E[Z²], E[Z*(logit - max)] —
             *   the last gives entropy via:
             *     -sum(p log p) = log(S) - (1/S) * sum(exp(logit-M)*(logit-M))
             *   where S = sum_exp_shifted (reduces 3rd pass to a final
             *   normalisation step).
             *
             * Online rescale: when running_max updates by delta = old - new,
             * each accumulator multiplied by exp(delta) (delta <= 0 → factor
             * in [0,1], no overflow). Bit-stable when atoms are processed
             * in fixed order (z = 0..num_atoms-1).
             *
             * Pass 2: atom utilisation (need final S = sum_exp_shifted to
             *   compute prob_z = exp(logit-M)/S and threshold-test). One
             *   extra pass instead of folding into Pass 1 because util
             *   depends on S which is only known after Pass 1 completes.
             *
             * Saving: 3*num_atoms reads of v_row[z]+adv_a[z] → 2*num_atoms.
             * For num_atoms=51 the inner action loop drops by ~33% global
             * loads against the inner advantage logits (which dominate
             * because v_row may stay in L1 across the 4×n_d action loop;
             * adv_a flips per action — pure global). */
            float M = -1e30f;
            float S = 0.0f;          /* sum exp(logit - M) */
            float TZ = 0.0f;         /* sum exp(logit - M) * z_val */
            float TZ2 = 0.0f;        /* sum exp(logit - M) * z_val^2 */
            float TLM = 0.0f;        /* sum exp(logit - M) * (logit - M) */
            for (int z = 0; z < num_atoms; z++) {
                float logit = v_row[z] + adv_a[z];
                float z_val = (atom_pos_d != NULL)
                    ? atom_pos_d[z]
                    : v_min + (float)z * dz;
                if (logit > M) {
                    /* Rescale running accumulators down to the new max. */
                    float scale = expf(M - logit);
                    S   *= scale;
                    TZ  *= scale;
                    TZ2 *= scale;
                    TLM *= scale;
                    M = logit;
                }
                float w = expf(logit - M);  /* exp(logit_z - M_current) */
                S   += w;
                TZ  += w * z_val;
                TZ2 += w * z_val * z_val;
                TLM += w * (logit - M);
            }

            float inv_S = 1.0f / S;
            float expected_q = TZ * inv_S;
            float sum_z_sq   = TZ2 * inv_S;
            /* Entropy = -sum(p log p) = -sum(p (logit - M - log S))
             *         = log(S) - sum(p (logit - M))
             *         = log(S) - TLM / S                                  */
            float branch_entropy = logf(S) - TLM * inv_S;
            sample_entropy += branch_entropy;

            /* Pass 2: atom utilisation — count atoms with prob > threshold.
             * prob_z = exp(logit_z - M)/S > util_threshold
             *   ⇔ exp(logit_z - M) > util_threshold * S
             * The branch is identical to the prior 3-pass version. */
            float util_cutoff = util_threshold * S;
            for (int z = 0; z < num_atoms; z++) {
                float logit = v_row[z] + adv_a[z];
                if (expf(logit - M) > util_cutoff) sample_utilized++;
            }

            q_values[(long long)i * total_actions + action_idx] = expected_q;
            /* Var[Q] = E[Z²] - E[Z]² — distributional uncertainty per action */
            if (q_variance != NULL) {
                float var_q = fmaxf(sum_z_sq - expected_q * expected_q, 0.0f);
                q_variance[(long long)i * total_actions + action_idx] = var_q;
            }
            action_idx++;
        }
        branch_logit_offset += N * n_d * num_atoms;
    }

    /* Atom-stat accumulation across samples (monitoring + adaptive_atom_positions input).
     *
     * 2-phase deterministic reduction (replaces cross-block atomicAdd):
     *   Phase 1 (here): warp reduce → block reduce via shared mem → write
     *     one (entropy, util) pair per block to atom_stats_block_sums.
     *   Phase 2: `atom_stats_finalize` kernel sequentially sums block_sums
     *     into atom_stats[0..1]. Bit-stable across runs.
     *
     * atom_stats_block_sums layout: [num_blocks * 2], block b writes
     *   [b*2+0] = block's entropy sum, [b*2+1] = block's util sum.
     */
    if (atom_stats_block_sums != NULL) {
        float inv_actions = 1.0f / (float)total_actions;
        float my_entropy = sample_entropy * inv_actions;
        float my_util    = (float)sample_utilized * inv_actions;

        /* Warp-level reduction (shfl — deterministic across lanes) */
        for (int offset = 16; offset > 0; offset >>= 1) {
            my_entropy += __shfl_xor_sync(0xFFFFFFFF, my_entropy, offset);
            my_util    += __shfl_xor_sync(0xFFFFFFFF, my_util,    offset);
        }

        /* Block-level reduction via shared memory */
        __shared__ float s_entropy[8];
        __shared__ float s_util[8];
        int warp_id = threadIdx.x / 32;
        int lane    = threadIdx.x % 32;
        if (lane == 0) { s_entropy[warp_id] = my_entropy; s_util[warp_id] = my_util; }
        __syncthreads();
        if (warp_id == 0) {
            float ve = (lane < blockDim.x / 32) ? s_entropy[lane] : 0.0f;
            float vu = (lane < blockDim.x / 32) ? s_util[lane]    : 0.0f;
            for (int off = 16; off > 0; off >>= 1) {
                ve += __shfl_xor_sync(0xFFFFFFFF, ve, off);
                vu += __shfl_xor_sync(0xFFFFFFFF, vu, off);
            }
            if (lane == 0) {
                /* One write per block to unique scratch slot — no atomic. */
                atom_stats_block_sums[blockIdx.x * 2 + 0] = ve;
                atom_stats_block_sums[blockIdx.x * 2 + 1] = vu;
            }
        }
    }
}

/* ══════════════════════════════════════════════════════════════════════
 * atom_stats_finalize — phase 2 of the deterministic reduction.
 *
 * Sequentially sums atom_stats_block_sums[0..num_blocks*2] into
 * atom_stats[0..1]. Launch grid=(1,1,1) block=(1,1,1); deterministic order.
 * ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void atom_stats_finalize(
    const float* __restrict__ atom_stats_block_sums,  /* [num_blocks * 2] from phase 1 */
    float* __restrict__ atom_stats,                    /* [2] final (entropy_sum, util_sum) */
    int num_blocks
) {
    float sum_entropy = 0.0f;
    float sum_util    = 0.0f;
    for (int b = 0; b < num_blocks; b++) {
        sum_entropy += atom_stats_block_sums[b * 2 + 0];
        sum_util    += atom_stats_block_sums[b * 2 + 1];
    }
    atom_stats[0] = sum_entropy;
    atom_stats[1] = sum_util;
}

/* ================================================================== */
/* Kernel 5: expert_action_override                                    */
/* ================================================================== */

/**
 * GPU-native expert action override using inline MA crossover + ADX filter.
 *
 * For each episode, reads prices from `targets` and ADX from `features` at the
 * current bar position. Computes a simple fast/slow price crossover signal:
 *   - If close > open (bullish bar) AND ADX > 25 -> Long (a0 = b0-1)
 *   - If close < open (bearish bar) AND ADX > 25 -> Short (a0 = 0)
 *   - Otherwise -> no override
 *
 * With probability `expert_ratio`, overrides the Q-network's exposure action.
 * Order and urgency branches are preserved from the Q-network.
 *
 * Zero CPU involvement — all data read from GPU-resident targets/features buffers.
 *
 * Grid: ceil(N / 256), Block: 256.  One thread per episode.
 */
extern "C" __global__ void expert_action_override(
    int*                       out_actions,       /* [N] Q-network actions (overwritten) */
    const float* __restrict__  targets,           /* [total_bars, TARGET_DIM=6] fxcache targets */
    const float* __restrict__  features,          /* [total_bars, MARKET_DIM] market features */
    const float* __restrict__  portfolio_states,  /* [N, PORTFOLIO_STRIDE] per-episode state */
    const int*   __restrict__  episode_starts,    /* [N] bar offset per episode */
    const int*   __restrict__  current_timesteps, /* [N] current step in episode */
    float expert_ratio,                           /* override probability [0,1] */
    int N,
    int total_bars,
    int market_dim,
    int b0_size,                                  /* direction branch size (4) */
    int b1_size,                                  /* magnitude branch size (3) */
    int b2_size,                                  /* order branch size (3) */
    int b3_size                                   /* urgency branch size (3) */
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;
    if (expert_ratio <= 0.0f) return;

    float r = philox_uniform(i, 0, 5000);
    if (r >= expert_ratio) return;  /* Keep Q-network action */

    /* Look up current bar position */
    int bar = episode_starts[i] + current_timesteps[i];
    if (bar < 1 || bar >= total_bars) return;

    /* Read prices: compute multi-bar momentum as EMA proxy.
     * True EMA requires sequential scan (O(n) per thread). Instead, use
     * 5-bar price momentum + 20-bar momentum divergence as a crossover proxy.
     * When short-term momentum > long-term momentum AND ADX is strong -> trend.
     * This approximates fast/slow EMA crossover without sequential scan. */
    if (bar < 20) return;  /* Need 20 bars of history */

    float adx_bf = features[bar * market_dim + 40];
    float cusum_bf = features[bar * market_dim + 41];
    float adx = (adx_bf);
    float cusum = (cusum_bf);

    /* 5-bar return (fast proxy).
     * Targets layout per `crates/ml/src/fxcache.rs`
     * (TARGET_DIM=6): col 0=preproc_close, 1=preproc_next, 2=raw_close,
     * 3=raw_next (FUTURE), 4=raw_open, 5=mid_open. Read raw_close (col 2)
     * for momentum — pre-`063fd2716` this was `bar*4+3` (= col 3 = raw_next
     * in stride-4 = same value as raw_close[bar+1]); the stride bump made
     * that read slide into the wrong bar. Switch to raw_close at col 2,
     * stride 6. */
    float close_now = (targets[bar * 6 + 2]);
    float close_5   = (targets[(bar - 5) * 6 + 2]);
    float close_20  = (targets[(bar - 20) * 6 + 2]);
    float ret_5  = (close_now - close_5)  / (close_5  + 1e-8f);
    float ret_20 = (close_now - close_20) / (close_20 + 1e-8f);

    /* Expert signal: momentum crossover + ADX strength + CUSUM direction.
     * 4-branch: direction(4) = [Short(0), Hold(1), Long(2), Flat(3)],
     * magnitude always Full(2) for expert signals (strong conviction). */
    int expert_dir = -1;  /* -1 = no opinion */
    int expert_mag = 2;   /* Full magnitude for expert */
    if (adx > 25.0f) {
        /* Bullish: fast momentum > slow momentum AND CUSUM positive */
        if (ret_5 > ret_20 && ret_5 > 0.001f && cusum > 0.0f) {
            expert_dir = 2;  /* Long */
        }
        /* Bearish: fast momentum < slow momentum AND CUSUM negative */
        else if (ret_5 < ret_20 && ret_5 < -0.001f && cusum < 0.0f) {
            expert_dir = 0;  /* Short */
        }
    }

    if (expert_dir < 0) return;  /* No clear signal */

    /* Check if current position already matches expert signal — skip if redundant.
     * portfolio_states[i * PORTFOLIO_STRIDE + 0] = current position.
     * If expert says Long and we're already Long (position > 0.5), skip. */
    float current_pos = (portfolio_states[i * PORTFOLIO_STRIDE + 0]);
    if (expert_dir == DIR_LONG && current_pos > 0.5f) return;   /* Already Long */
    if (expert_dir == DIR_SHORT && current_pos < -0.5f) return;  /* Already Short */

    /* Override direction+magnitude, keep Q-network's order and urgency */
    int current = out_actions[i];
    int orig_order = decode_order_4b(current, b2_size, b3_size);
    int orig_urgency = decode_urgency_4b(current, b3_size);
    out_actions[i] = expert_dir * b1_size * b2_size * b3_size
                   + expert_mag * b2_size * b3_size
                   + orig_order * b3_size + orig_urgency;
}

/* ================================================================== */
/* Kernel: classify_regime_gpu                                         */
/* ================================================================== */

/**
 * Classify market regime from state features on GPU.
 *
 * ADX at index `adx_idx` (typically 40), CUSUM at index `cusum_idx`
 * (typically 41) of the state vector.  Classification logic mirrors
 * the CPU-side classify_regime_from_state() in experience.rs:
 *
 *   ADX > adx_threshold       => 0 (Trending)
 *   |CUSUM| > cusum_threshold => 2 (Volatile)
 *   else                      => 1 (Ranging)
 *
 * Grid: ceil(batch_size / 256), Block: 256.  One thread per sample.
 */
extern "C" __global__ void classify_regime_gpu(
    const float* __restrict__ states,     /* [batch_size, state_dim] */
    unsigned char* __restrict__ regimes,  /* [batch_size] output     */
    int batch_size,
    int state_dim,
    int adx_idx,            /* typically 40             */
    int cusum_idx,          /* typically 41             */
    float adx_threshold,    /* typically 0.25           */
    float cusum_threshold   /* typically 0.7            */
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= batch_size) return;

    float adx   = (adx_idx   < state_dim) ? states[idx * state_dim + adx_idx]   : 0.0f;
    float cusum = (cusum_idx < state_dim) ? states[idx * state_dim + cusum_idx] : 0.0f;

    unsigned char regime;
    if (adx > adx_threshold) {
        regime = 0;  /* Trending  */
    } else if (fabsf(cusum) > cusum_threshold) {
        regime = 2;  /* Volatile  */
    } else {
        regime = 1;  /* Ranging   */
    }
    regimes[idx] = regime;
}

/* ═════════════════════════════════════════════════════════════════════════ */
/* GPU-resident step counter advance                                        */
/* ═════════════════════════════════════════════════════════════════════════ */

/**
 * Advances the GPU-resident timestep counter by 1.
 *
 * Launched as a single-thread kernel (grid=1, block=1) after each
 * experience_env_step call.  Keeping the step counter on-device
 * eliminates per-timestep host→GPU parameter variation in the
 * experience collection loop, enabling future CUDA Graph capture
 * of the entire timestep sequence.
 *
 * @param counter [1] int — incremented in-place.
 */
extern "C" __global__ void step_counter_advance(int* counter) {
    counter[0] += 1;
}

/**
 * SP6 Pearl 3: Add per-branch Gaussian noise to Q-values for exploration.
 * Replaces dead NoisyLinear. Uses Philox PRNG seeded from (thread_id, step).
 * Each action slot receives noise scaled by its branch's sigma.
 * branch_sizes {b0, b1, b2, b3}: cumulative offsets derive branch index.
 * Sigma=0 for a branch disables noise for that branch entirely.
 *
 * Grid: ceil(N * total_actions / 256), Block: 256.
 */
extern "C" __global__ void add_advantage_noise(
    float* __restrict__ q_values,           /* [N, total_actions] — modified in-place */
    const float* __restrict__ noise_sigma,  /* [4] per-branch std dev */
    int N,                                  /* number of episodes */
    int total_actions,                      /* 13 = 4+3+3+3 */
    unsigned int seed,                      /* monotonic step counter for decorrelation */
    int b0_size,                            /* branch 0 action count (direction = 4) */
    int b1_size,                            /* branch 1 action count (magnitude = 3) */
    int b2_size,                            /* branch 2 action count (order = 3) */
    int b3_size                             /* branch 3 action count (urgency = 3) */
) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int total = N * total_actions;
    if (tid >= total) return;

    int action = tid % total_actions;

    /* Derive branch index from action offset */
    int branch_idx;
    int b01  = b0_size + b1_size;
    int b012 = b01 + b2_size;
    if      (action < b0_size) branch_idx = 0;
    else if (action < b01)     branch_idx = 1;
    else if (action < b012)    branch_idx = 2;
    else                       branch_idx = 3;

    float sigma = noise_sigma[branch_idx];
    if (sigma <= 0.0f) return;

    /* Stateless Philox hash — deterministic per (tid, seed) */
    int episode = tid / total_actions;
    float u1 = fmaxf(philox_uniform(episode, (int)seed, 4000 + action * 2), 1e-6f);
    float u2 = philox_uniform(episode, (int)seed, 4000 + action * 2 + 1);
    float gauss = sqrtf(-2.0f * logf(u1)) * cosf(6.2831853f * u2);

    q_values[tid] += sigma * gauss;
}

/* ══════════════════════════════════════════════════════════════════════
 * CURRICULUM DIFFICULTY SCORING KERNEL (v8 D3)
 *
 * Per-bar difficulty: inverse of 5-bar directional clarity.
 * Low score = strong trend (easy). High score = choppy (hard).
 * Used to order episode starts from easy → hard across training.
 *
 * Launch: grid=(ceil(total_bars/256)), block=(256).
 * ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void compute_difficulty_scores(
    const float* __restrict__ targets,
    float* __restrict__ scores,
    int total_bars
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= total_bars) return;
    if (i >= total_bars - 5) { scores[i] = 1e6f; return; }

    /* Per `crates/ml/src/fxcache.rs` (TARGET_DIM=6):
     * col 2 = raw_close. Pre-`063fd2716` stride was 4 with raw_close still
     * at col 2 — the `+ 2` column index is unchanged; only the stride bumps
     * 4 → 6. Reading at the old stride against the new layout slid every
     * lookup into the wrong bar's data. */
    float close_0 = (targets[i * 6 + 2]);
    float close_5 = (targets[(i + 5) * 6 + 2]);
    float ret_5 = fabsf(close_5 - close_0) / fmaxf(close_0, 1.0f);
    scores[i] = 1.0f / (ret_5 + 0.001f);
}

/* ══════════════════════════════════════════════════════════════════════
 * HINDSIGHT OPTIMAL EXIT RELABELING KERNEL (v8 D4)
 *
 * For a fraction of replay samples, replaces the reward with the
 * optimal exit PnL within a lookahead window. Teaches the model
 * "what was the best possible outcome for this entry?"
 *
 * Launch: grid=(ceil(batch_size/256)), block=(256).
 * ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void hindsight_relabel_kernel(
    const float* __restrict__ targets,
    const int*   __restrict__ actions,
    const int*   __restrict__ bar_indices,
    float*       __restrict__ rewards,
    int b0_size, int b1_size, int b2_size, int b3_size,
    float max_position, int lookahead,
    int total_bars, float hindsight_fraction,
    int batch_size
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= batch_size) return;

    /* Deterministic mask */
    unsigned int hash = (unsigned int)(i * 48271 + bar_indices[i] * 7919);
    hash ^= (hash >> 16); hash *= 0x45d9f3bu; hash ^= (hash >> 16);
    if ((float)(hash & 0xFFFF) / 65535.0f >= hindsight_fraction) return;

    int bar = bar_indices[i];
    if (bar < 0 || bar + lookahead >= total_bars) return;

    int dir_idx_hr = decode_direction_4b(actions[i], b1_size, b2_size, b3_size);
    int mag_idx_hr = decode_magnitude_4b(actions[i], b1_size, b2_size, b3_size);
    float pos = compute_target_position_4branch(dir_idx_hr, mag_idx_hr, max_position);
    if (fabsf(pos) < 0.001f) return;

    /* Per `crates/ml/src/fxcache.rs` (TARGET_DIM=6):
     * col 2 = raw_close. Stride 4 → 6 fix; column index unchanged. */
    float entry_price = (targets[bar * 6 + 2]);
    if (entry_price < 1.0f) return;

    float best_pnl = 0.0f;
    for (int k = 1; k <= lookahead && (bar + k) < total_bars; k++) {
        float future_price = (targets[(bar + k) * 6 + 2]);
        float pnl = pos * (future_price - entry_price);
        if (pnl > best_pnl) best_pnl = pnl;
    }

    if (best_pnl > 0.0f) {
        float opt_return = best_pnl / entry_price;
        /* Task 2.4: upper +10 cap only — negative-tail compression moved to
         * c51_loss_kernel.cu::block_bellman_project_f. best_pnl > 0 here so
         * opt_reward is always non-negative; the cap enforces numerical safety. */
        float opt_reward = fminf(10.0f * opt_return / 0.005f, 10.0f);
        rewards[i] = opt_reward;
    }
}

/* ================================================================== */
/* Kernel: mag_concat_qdir                                             */
/* ================================================================== */

/**
 * Concatenate h_s2 with direction E[Q] for magnitude branch conditioning.
 *
 * For each sample b:
 *   1. Copy h_s2[b, 0..SH2] → concat_out[b, 0..SH2]
 *   2. Compute E[Q_dir_a] for a=0..b0_size-1 from dir_logits via softmax × support
 *   3. Plan 4 Task 2c.3c.6: Q_dir is now adaptively scaled so its per-sample
 *      RMS over the b0_size action dimension matches the trunk-output RMS
 *      tracked by ISV[H_S2_RMS_EMA_INDEX] (producer kernel
 *      `h_s2_rms_ema_update` populates the slot each step). The earlier
 *      `eq / dz` normalisation was calibrated for the pre-GRN post-ReLU
 *      h_s2 scale and has been replaced. The scale falls back to
 *      `1 / max(dz, 1e-6)` when `q_rms ≈ 0` (uniform Q across actions —
 *      the rare degenerate case where RMS-match is undefined).
 *   4. Write scaled Q_dir → concat_out[b, SH2..SH2+b0_size]
 *
 * Grid: ceil(B/256), Block: 256. One thread per sample.
 *
 * Static bound: direction branch is exactly 4 actions per the project's
 * 4-direction layout (S/H/L/F). The kernel still takes `b0_size` at
 * runtime to keep the signature stable, but the per-thread eq stash is
 * a 4-element register array — production callers pass b0_size==4.
 */
#define MAG_CONCAT_MAX_DIR 4

extern "C" __global__ void mag_concat_qdir(
    const float* __restrict__ h_s2,           /* [B, SH2] trunk activation (or vsn_masked) */
    const float* __restrict__ dir_logits_v,   /* [B, NA] value logits (shared head) */
    const float* __restrict__ dir_logits_b,   /* [B*b0*NA] branch 0 logits (direction) */
    float* __restrict__ concat_out,           /* [B, SH2+b0_size] output */
    int B,
    int SH2,
    int NA,        /* num_atoms */
    int b0_size,   /* direction branch size (4) */
    const float* __restrict__ per_sample_support, /* [B, 4, 3] stride-12: v_min, v_max, delta_z per-branch (Phase 2d) */
    const float* __restrict__ isv,            /* Plan 4 Task 2c.3c.6: ISV bus base pointer */
    int isv_h_s2_rms_index                    /* Plan 4 Task 2c.3c.6: H_S2_RMS_EMA_INDEX = 96 */
) {
    int b = blockIdx.x * blockDim.x + threadIdx.x;
    if (b >= B) return;

    /* Plan 4 Task 2c.3c.6: read trunk-output RMS once into a register.
     * Producer (`h_s2_rms_ema_update`) writes ISV[96] each step; cold-start
     * is 1.0 (neutral) so the first batches see a meaningful target. */
    const float h_s2_rms_ema = isv[isv_h_s2_rms_index];

    /* Step 1: copy h_s2 row */
    int out_stride = SH2 + b0_size;
    for (int i = 0; i < SH2; i++) {
        concat_out[b * out_stride + i] = h_s2[b * SH2 + i];
    }

    /* Step 2: compute E[Q_dir] for each direction action.
     * Phase 2d: read direction-branch (d=0) slots from the [B, 4, 3] tile. */
    int support_base = b * 12 + 0 * 3;  /* direction = branch 0 */
    float v_min = per_sample_support[support_base + 0];
    float dz    = per_sample_support[support_base + 2];

    /* Pass 1: softmax → E[Q] per direction action; stash for the RMS-match pass. */
    float eq_local[MAG_CONCAT_MAX_DIR];
    for (int a = 0; a < b0_size; a++) {
        /* Combined value + advantage logits for direction action a.
         * Branch logits layout: BRANCH-MAJOR [N × b0 × NA], so action a
         * for sample b is at: dir_logits_b[b * b0_size * NA + a * NA + z] */
        float max_logit = -1e30f;
        for (int z = 0; z < NA; z++) {
            float logit = dir_logits_v[b * NA + z]
                        + dir_logits_b[b * b0_size * NA + a * NA + z];
            if (logit > max_logit) max_logit = logit;
        }
        float sum_exp = 0.0f;
        for (int z = 0; z < NA; z++) {
            float logit = dir_logits_v[b * NA + z]
                        + dir_logits_b[b * b0_size * NA + a * NA + z];
            sum_exp += expf(logit - max_logit);
        }
        float eq = 0.0f;
        for (int z = 0; z < NA; z++) {
            float logit = dir_logits_v[b * NA + z]
                        + dir_logits_b[b * b0_size * NA + a * NA + z];
            float prob = expf(logit - max_logit) / sum_exp;
            float z_val = v_min + (float)z * dz;
            eq += prob * z_val;
        }
        eq_local[a] = eq;
    }

    /* Pass 2: per-sample RMS of Q_dir over the b0_size action dim. */
    float q_sum_sq = 0.0f;
    for (int a = 0; a < b0_size; a++) {
        q_sum_sq += eq_local[a] * eq_local[a];
    }
    float q_rms = sqrtf(q_sum_sq / (float)b0_size);

    /* Pass 3: adaptive scale — write each eq_a so Q_dir's per-sample RMS
     * matches the trunk-output RMS tracked by ISV[H_S2_RMS_EMA_INDEX].
     *
     * Fallback: when q_rms ≈ 0 (uniform Q across actions ⇒ no spread to
     * scale to a target), there is no well-defined RMS-match. Falling
     * back to `1 / max(dz, 1e-6)` preserves the legacy 2c.3c.4 scale
     * specifically for that degenerate case so the magnitude-branch
     * input doesn't collapse to zero. The 1e-6 epsilon mirrors the
     * legacy `fmaxf(dz, 1e-6f)` tolerance.
     * domain: uniform Q across actions has no RMS to match — fallback is
     * mathematically required, not a stub return. */
    const float scale = (q_rms > 1e-6f)
        ? (h_s2_rms_ema / q_rms)
        : (1.0f / fmaxf(dz, 1e-6f));
    for (int a = 0; a < b0_size; a++) {
        concat_out[b * out_stride + SH2 + a] = eq_local[a] * scale;
    }
}

/* ================================================================== */
/* Kernel: strided_accumulate                                          */
/* ================================================================== */

/**
 * Accumulate first dst_stride columns of a wider src buffer into dst.
 * Used in backward to extract d_h_s2 from d_mag_concat [B, SH2+b0_size].
 *
 * dst[b*dst_stride+i] = beta * dst[b*dst_stride+i] + src[b*src_stride+i]
 * for i in [0, dst_stride).
 *
 * Grid: ceil(total/256), Block: 256. total = B * dst_stride.
 */
extern "C" __global__ void strided_accumulate(
    const float* __restrict__ src,  /* [B, src_stride] */
    float* __restrict__ dst,        /* [B, dst_stride] */
    int dst_stride,                 /* SH2 */
    int src_stride,                 /* SH2+b0_size */
    int total,                      /* B * dst_stride */
    float beta                      /* 0=overwrite, 1=add */
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= total) return;
    int b = idx / dst_stride;
    int i = idx % dst_stride;
    float val = src[b * src_stride + i];
    if (beta > 0.0f) {
        dst[idx] += val;
    } else {
        dst[idx] = val;
    }
}

/* ================================================================== */
/* Kernel: strided_scatter                                             */
/* ================================================================== */

/**
 * Scatter first src_stride columns FROM tight buffer TO wider buffer.
 * Inverse of strided_accumulate: reads tight [B, src_stride], writes
 * to first src_stride cols of wide [B, dst_stride].
 *
 * dst[b*dst_stride+i] = src[b*src_stride+i]  for i in [0, src_stride)
 *
 * Grid: ceil(total/256), Block: 256. total = B * src_stride.
 */
extern "C" __global__ void strided_scatter(
    const float* __restrict__ src,  /* [B, src_stride] tight */
    float* __restrict__ dst,        /* [B, dst_stride] wide */
    int src_stride,                 /* SH2 */
    int dst_stride,                 /* SH2+b0_size */
    int total                       /* B * src_stride */
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= total) return;
    int b = idx / src_stride;
    int i = idx % src_stride;
    dst[b * dst_stride + i] = src[idx];
}

/**
 * Plan 4 Task 1B-iv: gather a single column from a wide buffer into a tight one.
 *
 * Inverse of `strided_scatter` for the column-extraction case (src_stride=1):
 *   dst[b * dst_stride + i] = src[b * src_lda + col + i]   for i in [0, dst_stride)
 *
 * Used by `vsn_backward` to extract column `g` of `d_logits[B, num_groups]`
 * into a contiguous `[B, dst_stride]` buffer, so the per-group cuBLAS
 * `Linear_2` backward (which expects a tightly-packed `dY[B, out_dim]`) can
 * read it without a strided-leading-dim cuBLAS variant. Per-group `out_dim=1`
 * for VSN's scalar-per-group logit, but the kernel is generic in dst_stride.
 *
 * Grid: ceil(total/256), Block: 256. total = B * dst_stride. No atomicAdd.
 * One thread per output element, deterministic.
 */
extern "C" __global__ void strided_gather(
    const float* __restrict__ src,  /* [B, src_lda] wide */
    float* __restrict__ dst,        /* [B, dst_stride] tight */
    int src_lda,                    /* row stride of src */
    int col,                        /* column offset within each src row */
    int dst_stride,                 /* number of contiguous columns to read */
    int total                       /* B * dst_stride */
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= total) return;
    int b = idx / dst_stride;
    int i = idx % dst_stride;
    dst[idx] = src[b * src_lda + col + i];
}

/* ================================================================== */
/* Kernel: variable_select_bottleneck                                  */
/* ================================================================== */

/**
 * Per-branch feature gating with R=16 bottleneck.
 *
 * h_masked[b,i] = h_s2[b,i] * sigmoid(h_s2[b] @ W_vsn1 @ W_vsn2)[i]
 *
 * Sigmoid (not softmax): each feature is independently gated ∈ (0,1).
 * At W_vsn2=0 init: sigmoid(0) = 0.5 → h_masked = 0.5 * h_s2 (stable start).
 * Softmax would give 1/SH2 ≈ 0.004 per feature → 256× signal collapse → gradient explosion.
 *
 * Phase 1: proj[r]   = sum_k h_local[k] * W_vsn1[k*R + r]
 * Phase 2: mask[i]   = sigmoid(sum_r proj[r] * W_vsn2[r*SH2 + i])
 * Phase 3: h_masked[b,i] = h_local[i] * mask[i]
 *
 * Grid: (B, 1, 1), Block: (256, 1, 1)
 * Dynamic shared: (SH2 + R) * sizeof(float)
 */
extern "C" __global__ void variable_select_bottleneck(
    const float* __restrict__ h_s2,     /* [B, SH2] */
    float* __restrict__ h_masked,       /* [B, SH2] */
    const float* __restrict__ W_vsn1,   /* [SH2, R] */
    const float* __restrict__ W_vsn2,   /* [R, SH2] */
    int B, int SH2, int R)
{
    extern __shared__ float smem[];
    float* h_local = smem;              /* [SH2] */
    float* proj    = smem + SH2;        /* [R]   */

    int b = blockIdx.x;
    if (b >= B) return;

    /* Load h_s2[b] into shared memory */
    for (int i = threadIdx.x; i < SH2; i += blockDim.x) {
        h_local[i] = h_s2[b * SH2 + i];
    }
    __syncthreads();

    /* Phase 1: proj[r] = sum_k h_local[k] * W_vsn1[k*R + r] */
    for (int r = threadIdx.x; r < R; r += blockDim.x) {
        float acc = 0.0f;
        for (int k = 0; k < SH2; k++) {
            acc += h_local[k] * W_vsn1[k * R + r];
        }
        proj[r] = acc;
    }
    __syncthreads();

    /* Phase 2+3: mask[i] = sigmoid(proj @ W_vsn2[:,i]), h_masked = h_local * mask */
    for (int i = threadIdx.x; i < SH2; i += blockDim.x) {
        float acc = 0.0f;
        for (int r = 0; r < R; r++) {
            acc += proj[r] * W_vsn2[r * SH2 + i];
        }
        float gate = 1.0f / (1.0f + expf(-acc));  /* sigmoid per feature */
        h_masked[b * SH2 + i] = h_local[i] * gate;
    }
}

/* ================================================================== */
/* Kernel: glu_combine                                                 */
/* ================================================================== */

/**
 * Gated Linear Unit forward pass.
 *   output[i] = sigmoid(gate_pre[i]) * value[i]
 *
 * Grid: ceil(n/256), Block: 256
 */
extern "C" __global__ void glu_combine(
    float* __restrict__ output,
    const float* __restrict__ gate_pre,
    const float* __restrict__ value,
    int n)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= n) return;
    float g = 1.0f / (1.0f + expf(-gate_pre[i]));
    output[i] = g * value[i];
}

/* ================================================================== */
/* Kernel: glu_backward                                                */
/* ================================================================== */

/**
 * Gated Linear Unit backward pass.
 *   d_value[i]    = d_output[i] * sigmoid(gate_pre[i])
 *   d_gate_pre[i] = d_output[i] * value[i] * sigmoid(gate_pre[i]) * (1 - sigmoid(gate_pre[i]))
 *
 * Grid: ceil(n/256), Block: 256
 */
extern "C" __global__ void glu_backward(
    const float* __restrict__ d_output,
    const float* __restrict__ gate_pre,
    const float* __restrict__ value,
    float* __restrict__ d_gate_pre,
    float* __restrict__ d_value,
    int n)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= n) return;
    float g = 1.0f / (1.0f + expf(-gate_pre[i]));
    d_value[i]    = d_output[i] * g;
    d_gate_pre[i] = d_output[i] * value[i] * g * (1.0f - g);
}

/* ================================================================== */
/* Kernel: cross_branch_q_attention                                    */
/* ================================================================== */

/**
 * Cross-branch Q-value coordination via 2-head gated projection.
 *
 * NOT standard multi-head self-attention (seq_len=1 makes softmax trivial).
 * Instead: each head computes a learned gating weight via sigmoid(Q·K/√d)
 * that modulates the value projection V. This is a per-head feature gate
 * that lets branches coordinate — e.g. direction Q-values can suppress or
 * amplify magnitude Q-values through the learned W_Q/W_K/W_V projections.
 *
 * Forward:
 *   Q_proj = x @ W_Q + b_Q,  K_proj = x @ W_K + b_K,  V_proj = x @ W_V + b_V
 *   For each head h (dim 6):
 *     gate_h = sigmoid(dot(Q_h, K_h) / sqrt(6))     // learned scalar gate
 *     head_h = gate_h * V_h                           // gated value
 *   output = [head_0; head_1] @ W_O + b_O             // project back
 *   Q_coord = LayerNorm(x + output)                   // residual + normalize
 *
 * Weight layout in attn_params (624 floats):
 *   W_Q[12,12], W_K[12,12], W_V[12,12]   (3×144 = 432)
 *   b_Q[12], b_K[12], b_V[12]             (36)
 *   W_O[12,12]                             (144)
 *   b_O[12]                                (12)
 * Total: 432 + 36 + 144 + 12 = 624
 *
 * One thread per sample. Grid: ceil(B/256), Block: 256.
 */
extern "C" __global__ void cross_branch_q_attention(
    const float* __restrict__ Q_raw,      /* [B, 12] */
    float* __restrict__ Q_coord,          /* [B, 12] */
    const float* __restrict__ attn_params, /* [624] */
    int B)
{
    int b = blockIdx.x * blockDim.x + threadIdx.x;
    if (b >= B) return;

    const int D  = 12;
    const int NH = 2;
    const int HD = 6; /* D / NH */

    /* Unpack weight pointers */
    const float* W_Q = attn_params;               /* [D, D] */
    const float* W_K = attn_params + D * D;       /* [D, D] */
    const float* W_V = attn_params + 2 * D * D;   /* [D, D] */
    const float* b_Q = attn_params + 3 * D * D;   /* [D] */
    const float* b_K = attn_params + 3 * D * D + D;
    const float* b_V = attn_params + 3 * D * D + 2 * D;
    const float* W_O = attn_params + 3 * D * D + 3 * D; /* [D, D] */
    const float* b_O = attn_params + 3 * D * D + 3 * D + D * D; /* [D] */

    const float* x = Q_raw + b * D;

    float Q_proj[12], K_proj[12], V_proj[12];

    /* Linear projections */
    for (int i = 0; i < D; i++) {
        float qv = b_Q[i], kv = b_K[i], vv = b_V[i];
        for (int j = 0; j < D; j++) {
            qv += x[j] * W_Q[j * D + i];
            kv += x[j] * W_K[j * D + i];
            vv += x[j] * W_V[j * D + i];
        }
        Q_proj[i] = qv;
        K_proj[i] = kv;
        V_proj[i] = vv;
    }

    float attn_out[12] = {0};
    float scale = rsqrtf((float)HD);

    /* 2-head attention */
    for (int h = 0; h < NH; h++) {
        int off = h * HD;
        /* Per-head gating: score = dot(Q_h, K_h) / sqrt(head_dim).
         * Sigmoid gate ∈ (0,1) modulates the value projection.
         * High Q·K agreement → gate opens → V passes through.
         * Low Q·K agreement → gate closes → V suppressed. */
        float score = 0.0f;
        for (int d = 0; d < HD; d++) {
            score += Q_proj[off + d] * K_proj[off + d];
        }
        score *= scale;
        float attn_w = 1.0f / (1.0f + expf(-score)); /* sigmoid gate */
        for (int d = 0; d < HD; d++) {
            attn_out[off + d] = attn_w * V_proj[off + d];
        }
    }

    /* Output projection */
    float out[12];
    for (int i = 0; i < D; i++) {
        float v = b_O[i];
        for (int j = 0; j < D; j++) {
            v += attn_out[j] * W_O[j * D + i];
        }
        out[i] = v;
    }

    /* Residual */
    for (int i = 0; i < D; i++) {
        out[i] += x[i];
    }

    /* LayerNorm */
    float mean = 0.0f;
    for (int i = 0; i < D; i++) mean += out[i];
    mean /= (float)D;
    float var = 0.0f;
    for (int i = 0; i < D; i++) {
        float d = out[i] - mean;
        var += d * d;
    }
    var /= (float)D;
    float inv_std = rsqrtf(var + 1e-5f);
    for (int i = 0; i < D; i++) {
        Q_coord[b * D + i] = (out[i] - mean) * inv_std;
    }
}

/* ================================================================== */
/* Kernel: selectivity_forward                                         */
/* ================================================================== */

/**
 * Mamba-2 learned replay priority gate — forward pass.
 *   sel[b] = sigmoid(dot(W_sel, h_s2[b]) + b_sel)
 *
 * sel_params layout: [W_sel[SH2], b_sel]  (SH2+1 floats)
 *
 * Grid: ceil(B/256), Block: 256
 */
extern "C" __global__ void selectivity_forward(
    const float* __restrict__ h_s2,       /* [B, SH2] */
    float* __restrict__ sel_out,          /* [B] */
    const float* __restrict__ sel_params, /* [SH2 + 1] */
    int B, int SH2)
{
    int b = blockIdx.x * blockDim.x + threadIdx.x;
    if (b >= B) return;

    float dot = sel_params[SH2]; /* b_sel */
    const float* W = sel_params;
    const float* h = h_s2 + b * SH2;
    for (int k = 0; k < SH2; k++) {
        dot += W[k] * h[k];
    }
    sel_out[b] = 1.0f / (1.0f + expf(-dot));
}

/* ================================================================== */
/* Kernel: selectivity_backward                                        */
/* ================================================================== */

/**
 * Mamba-2 selectivity gate — backward pass (BCE gradient).
 * DETERMINISTIC: one thread per weight element, loops over batch.
 *
 *   target[b] = clamp(per_sample_loss[b] / mean_loss, 0, 1)
 *   d_z[b]    = sel[b] - target[b]
 *   dW[k]     = sum_b(d_z[b] * h_s2[b, k])
 *   db        = sum_b(d_z[b])
 *
 * d_sel_params layout: [dW[SH2], db]  (SH2+1 floats)
 *
 * Grid: ceil((SH2+1)/256), Block: 256
 */
/* selectivity_compute_dz — Compute per-sample BCE gradient d_z[b] = sel[b] - clamp(loss[b]/mean, 0, 1)
 * Grid: ceil(B/256), Block: 256
 */
extern "C" __global__ void selectivity_compute_dz(
    const float* __restrict__ sel_out,         /* [B] */
    const float* __restrict__ per_sample_loss, /* [B] */
    float* __restrict__ d_z,                   /* [B] output */
    float mean_loss, int B)
{
    int b = blockIdx.x * blockDim.x + threadIdx.x;
    if (b >= B) return;
    float safe_mean = fmaxf(mean_loss, 1e-8f);
    float target = fminf(fmaxf(per_sample_loss[b] / safe_mean, 0.0f), 1.0f);
    d_z[b] = sel_out[b] - target;
}

/* selectivity_dw_reduce_p1 — Phase 1: dW[k] = sum_b(d_z[b] * h_s2[b, k])
 * Block-level shared memory reduce over B.
 * Grid: (num_blocks, SH2+1), Block: 256, shared: 256*sizeof(float)
 * k=SH2 computes db = sum_b(d_z[b]) (h_s2 term is 1.0)
 */
extern "C" __global__ void selectivity_dw_reduce_p1(
    const float* __restrict__ d_z,    /* [B] */
    const float* __restrict__ h_s2,   /* [B, SH2] row-major */
    float* __restrict__ partials,     /* [num_blocks, SH2+1] */
    int B, int SH2)
{
    extern __shared__ float sdata[];
    int k   = blockIdx.y;             /* which weight element */
    int tid = threadIdx.x;
    int gid = blockIdx.x * blockDim.x + tid;

    float val = 0.0f;
    if (gid < B) {
        float dz = d_z[gid];
        val = (k < SH2) ? dz * h_s2[(long long)gid * SH2 + k] : dz;
    }
    sdata[tid] = val;
    __syncthreads();
    for (int s = blockDim.x / 2; s > 0; s >>= 1) {
        if (tid < s) sdata[tid] += sdata[tid + s];
        __syncthreads();
    }
    if (tid == 0) partials[blockIdx.x * (SH2 + 1) + k] = sdata[0];
}

/* selectivity_dw_reduce_p2 — Phase 2: final reduce across block partials
 * Grid: ceil((SH2+1)/256), Block: 256
 */
extern "C" __global__ void selectivity_dw_reduce_p2(
    const float* __restrict__ partials,  /* [num_blocks, SH2+1] */
    float* __restrict__ d_sel_params,    /* [SH2+1] output */
    int total_params, int num_blocks)
{
    int k = blockIdx.x * blockDim.x + threadIdx.x;
    if (k >= total_params) return;
    float sum = 0.0f;
    for (int i = 0; i < num_blocks; i++) sum += partials[i * total_params + k];
    d_sel_params[k] = sum;
}

/**
 * Phase 1: Compute mean of Q-values into a scratch scalar.
 * Grid: (1, 1, 1), Block: (256, 1, 1).
 */
extern "C" __global__ void q_mean_reduce(
    const float* __restrict__ q_values,
    float* __restrict__ mean_out,
    int total)
{
    __shared__ float sdata[256];
    float sum = 0.0f;
    for (int i = threadIdx.x; i < total; i += 256) {
        sum += q_values[i];
    }
    sdata[threadIdx.x] = sum;
    __syncthreads();
    for (int s = 128; s > 0; s >>= 1) {
        if (threadIdx.x < s) sdata[threadIdx.x] += sdata[threadIdx.x + s];
        __syncthreads();
    }
    if (threadIdx.x == 0) mean_out[0] = sdata[0] / (float)total;
}

/**
 * Phase 2: Subtract mean from all Q-values in-place.
 * Grid: ceil(total/256), Block: 256.
 */
extern "C" __global__ void q_mean_subtract(
    float* __restrict__ q_values,
    const float* __restrict__ mean_val,
    int total)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= total) return;
    q_values[i] -= mean_val[0];
}

/* ================================================================== */
/* Kernel: quantile_q_select                                           */
/* ================================================================== */

/**
 * Uncertainty-driven action selection from C51 atom distributions.
 *
 * Extracts Q_10th, Q_50th, Q_90th from the CDF of each action's atom
 * distribution, then blends based on exploration phase AND atom health:
 *
 *   quantile_blend = (1-alpha)*Q_90th + alpha*Q_10th   (OFU exploration)
 *   util_factor = clamp(util_ema / 0.5, 0, 1)          (atom health)
 *   Q_select = util_factor * quantile_blend + (1-util_factor) * Q_50th
 *
 * When atoms are healthy (util>50%): full quantile blend.
 * When atoms collapse (util<50%): graceful fallback to robust median.
 * Prevents entropy regularization from fighting quantile quality.
 *
 * Grid: ceil(N/256), Block: 256. One thread per episode.
 *
 * @param util_ema          atom utilization EMA [0,1] for graceful degradation
 */
extern "C" __global__ void quantile_q_select(
    const float* __restrict__ v_logits,
    const float* __restrict__ b_logits,
    float*                q_select,
    int N,
    int num_atoms,
    int b0_size,
    int b1_size,
    int b2_size,
    int b3_size,
    const float* __restrict__ per_sample_support,   /* [N, 4, 3] stride-12 per-branch (Phase 2d) */
    float iqn_readiness,
    float util_ema)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    /* Phase 2d: per-branch (v_min, delta_z) read INSIDE the d-loop below. */

    int total_actions = b0_size + b1_size + b2_size + b3_size;
    const float* v_row = v_logits + (long long)i * num_atoms;

    int branch_sizes[4] = { b0_size, b1_size, b2_size, b3_size };
    int branch_logit_offset = 0;
    int action_idx = 0;

    float alpha = fminf(fmaxf(iqn_readiness, 0.0f), 1.0f);
    /* Atom health factor: 1.0 when util>50%, decays to 0 when util→0.
     * Prevents entropy regularization from making quantile blend meaningless. */
    float util_factor = fminf(fmaxf(util_ema / 0.5f, 0.0f), 1.0f);

    for (int d = 0; d < 4; d++) {
        int n_d = branch_sizes[d];
        const float* branch_base = b_logits + (long long)branch_logit_offset
                                 + (long long)i * n_d * num_atoms;

        /* Phase 2d: per-branch (v_min, dz) from the [N, 4, 3] tile. */
        int support_base = i * 12 + d * 3;
        float v_min = per_sample_support[support_base + 0];
        float dz    = per_sample_support[support_base + 2];

        for (int a = 0; a < n_d; a++) {
            const float* adv_a = branch_base + (long long)a * num_atoms;

            /* Softmax over atoms */
            float max_logit = -1e30f;
            for (int z = 0; z < num_atoms; z++) {
                float logit = v_row[z] + adv_a[z];
                if (logit > max_logit) max_logit = logit;
            }
            float sum_exp = 0.0f;
            for (int z = 0; z < num_atoms; z++) {
                sum_exp += expf((v_row[z] + adv_a[z]) - max_logit);
            }

            /* Build CDF and extract quantiles */
            float cdf = 0.0f;
            float q10 = v_min;
            float q50 = v_min;
            float q90 = v_min;
            int got10 = 0, got50 = 0, got90 = 0;

            for (int z = 0; z < num_atoms; z++) {
                float prob = expf((v_row[z] + adv_a[z]) - max_logit) / sum_exp;
                cdf += prob;
                float z_val = v_min + (float)z * dz;
                if (!got10 && cdf >= 0.10f) { q10 = z_val; got10 = 1; }
                if (!got50 && cdf >= 0.50f) { q50 = z_val; got50 = 1; }
                if (!got90 && cdf >= 0.90f) { q90 = z_val; got90 = 1; }
            }

            /* Blend: exploring (alpha=0) → optimistic Q_90th,
             *        exploiting (alpha=1) → pessimistic Q_10th.
             * Graceful degradation: when atoms are sparse, shift to robust median. */
            float quantile_blend = (1.0f - alpha) * q90 + alpha * q10;
            float q_blended = util_factor * quantile_blend + (1.0f - util_factor) * q50;

            q_select[(long long)i * total_actions + action_idx] = q_blended;
            action_idx++;
        }
        branch_logit_offset += N * n_d * num_atoms;
    }
}

/* ================================================================== */
/* Kernel: branch_graph_message_pass                                   */
/* ================================================================== */

/**
 * Cross-branch graph message passing with domain-specific edges.
 *
 * 4 directed edges encode branch dependencies:
 *   Edge 0: direction(0) -> magnitude(1)   — position sizing depends on direction
 *   Edge 1: direction(0) -> order(2)        — order type depends on market direction
 *   Edge 2: magnitude(1) -> urgency(3)      — position size affects timing pressure
 *   Edge 3: order(2)     -> urgency(3)      — limit vs market affects urgency
 *
 * Per edge: gate = sigmoid(W_gate @ [Q_src; Q_dst]), message = gate * (W_msg @ Q_src)
 * Per node: Q_new = Q_old + mean(incoming messages)
 *
 * Runs after cross_branch_q_attention, before diffusion refinement.
 * In-place residual update preserves original Q-value ordering.
 *
 * Grid: ceil(B/256), Block: 256. One thread per sample.
 *
 * @param q_values      [B, 13]   Q-values (in-place update), stride = b0+b1+b2+b3
 * @param graph_params  [76]      edge parameters: variable per-edge sizes
 *                                Edge 0 (dir→mag): W_gate[7] + W_msg[3*4]=12 = 19
 *                                Edge 1 (dir→ord): W_gate[7] + W_msg[3*4]=12 = 19
 *                                Edge 2 (mag→urg): W_gate[6] + W_msg[3*3]= 9 = 15
 *                                Edge 3 (ord→urg): W_gate[6] + W_msg[3*3]= 9 = 15
 *                                Total: 19+19+15+15 = 68. Padded to 76 for alignment.
 * @param B                       batch size
 * @param b0_size                 direction branch size (4)
 */
extern "C" __global__ void branch_graph_message_pass(
    float* __restrict__ q_values,
    const float* __restrict__ graph_params,
    int B,
    int b0_size)
{
    int b = blockIdx.x * blockDim.x + threadIdx.x;
    if (b >= B) return;

    const int b1_size = 3, b2_size = 3, b3_size = 3;
    int total_q = b0_size + b1_size + b2_size + b3_size;
    float* q = q_values + b * total_q;

    /* Read current Q-values into local registers (max 13 = 4+3+3+3) */
    float q_local[13];
    for (int i = 0; i < total_q && i < 13; i++) q_local[i] = q[i];

    /* Branch boundaries: dir=[0,b0), mag=[b0,b0+3), ord=[b0+3,b0+6), urg=[b0+6,b0+9) */
    const int branch_start[4] = {0, b0_size, b0_size + 3, b0_size + 6};
    const int branch_size[4]  = {b0_size, b1_size, b2_size, b3_size};

    /* 4 edges: (src_branch, dst_branch) */
    const int edge_src[4] = {0, 0, 1, 2};
    const int edge_dst[4] = {1, 2, 3, 3};

    /* Accumulate incoming messages per node */
    float msg_accum[13];
    for (int i = 0; i < total_q; i++) msg_accum[i] = 0.0f;
    int msg_count[4] = {0, 0, 0, 0};

    int param_offset = 0;
    for (int e = 0; e < 4; e++) {
        int src_b = edge_src[e];
        int dst_b = edge_dst[e];
        int src_off = branch_start[src_b];
        int dst_off = branch_start[dst_b];
        int src_sz = branch_size[src_b];
        int dst_sz = branch_size[dst_b];

        /* W_gate[1, src_sz+dst_sz], W_msg[dst_sz, src_sz] */
        int gate_dim = src_sz + dst_sz;
        const float* W_gate = graph_params + param_offset;
        const float* W_msg  = graph_params + param_offset + gate_dim;

        /* gate = sigmoid(W_gate @ [Q_src; Q_dst]) */
        float gate_pre = 0.0f;
        for (int j = 0; j < src_sz; j++) gate_pre += W_gate[j]          * q_local[src_off + j];
        for (int j = 0; j < dst_sz; j++) gate_pre += W_gate[src_sz + j] * q_local[dst_off + j];
        float gate = 1.0f / (1.0f + expf(-gate_pre));

        /* message = gate * (W_msg @ Q_src) */
        for (int i = 0; i < dst_sz; i++) {
            float m = 0.0f;
            for (int j = 0; j < src_sz; j++) {
                m += W_msg[i * src_sz + j] * q_local[src_off + j];
            }
            msg_accum[dst_off + i] += gate * m;
        }
        msg_count[dst_b]++;
        param_offset += gate_dim + dst_sz * src_sz;
    }

    /* Residual update: Q_new = Q_old + mean(incoming messages) */
    for (int d = 0; d < 4; d++) {
        if (msg_count[d] > 0) {
            float inv = 1.0f / (float)msg_count[d];
            int off = branch_start[d];
            int sz = branch_size[d];
            for (int i = 0; i < sz; i++) {
                q[off + i] = q_local[off + i] + msg_accum[off + i] * inv;
            }
        }
    }
}

/* ================================================================== */
/* Kernel: kan_gate_combine                                            */
/* ================================================================== */

/**
 * KAN Spline Activation gate — replaces sigmoid in GLU.
 *
 *   gate = clamp(sum_k coeff[k] * B_k(pre) + residual_w * pre, 0, 1)
 *   output = gate * value
 *
 * 8 cubic B-splines on uniform grid [-4, 4] per neuron.
 * Coefficients are learnable; basis functions are FIXED.
 * Initialized to approximate sigmoid (see param init step).
 *
 * Grid: ceil(n/256), Block: 256. One thread per element.
 *
 * @param output      [n]       output = gate * value
 * @param gate_pre    [n]       pre-activation input to gate (from W_gate @ input)
 * @param value       [n]       value path (from W_bdf @ input)
 * @param spline_coeff [AH, 8] spline coefficients per neuron (8 per neuron, tiled over batch)
 * @param residual_w  [AH]     residual weight per neuron (tiled over batch)
 * @param n                     total elements (batch_size * adv_h)
 * @param adv_h                 neuron count per sample (for tiling coeff/residual)
 */
extern "C" __global__ void kan_gate_combine(
    float* __restrict__ output,
    const float* __restrict__ gate_pre,
    const float* __restrict__ value,
    const float* __restrict__ spline_coeff,
    const float* __restrict__ residual_w,
    int n,
    int adv_h)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= n) return;

    float x = gate_pre[i];
    int neuron = i % adv_h;

    /* 8 cubic B-splines on uniform knots [-4, -3, -2, ..., 4] (9 knots, 8 spans)
     * Each B-spline B_k(x) is nonzero on [knot[k], knot[k+4]).
     * For uniform cubic: we use the de Boor recursive formula simplified for
     * uniform spacing (h=1.0).
     *
     * Simplified: find the span, compute the 4 nonzero basis values. */
    const float GRID_MIN = -4.0f;
    const float GRID_STEP = 1.0f;
    const int NUM_BASES = 8;

    /* Clamp x to grid range for basis evaluation */
    float x_clamped = fminf(fmaxf(x, GRID_MIN), GRID_MIN + NUM_BASES * GRID_STEP - 0.001f);

    /* Find span index: which interval [knot_i, knot_{i+1}) contains x */
    float t_norm = (x_clamped - GRID_MIN) / GRID_STEP;
    int span = (int)floorf(t_norm);
    span = min(max(span, 0), NUM_BASES - 1);
    float t = t_norm - (float)span; /* local parameter in [0, 1) */

    /* Cubic B-spline basis values (de Boor, uniform knots, h=1):
     * B_{span-1}(t) = (1-t)^3 / 6
     * B_{span}(t)   = (3t^3 - 6t^2 + 4) / 6
     * B_{span+1}(t) = (-3t^3 + 3t^2 + 3t + 1) / 6
     * B_{span+2}(t) = t^3 / 6 */
    float t2 = t * t;
    float t3 = t2 * t;
    float omt = 1.0f - t;
    float b0 = omt * omt * omt / 6.0f;
    float b1 = (3.0f * t3 - 6.0f * t2 + 4.0f) / 6.0f;
    float b2 = (-3.0f * t3 + 3.0f * t2 + 3.0f * t + 1.0f) / 6.0f;
    float b3 = t3 / 6.0f;

    /* Accumulate: gate_raw = sum(coeff[k] * B_k(x)) over the 4 nonzero bases */
    const float* c = spline_coeff + neuron * NUM_BASES;
    float gate_raw = 0.0f;

    int k0 = span - 1;
    if (k0 >= 0 && k0 < NUM_BASES) gate_raw += c[k0] * b0;
    if (span >= 0 && span < NUM_BASES) gate_raw += c[span] * b1;
    int k2 = span + 1;
    if (k2 >= 0 && k2 < NUM_BASES) gate_raw += c[k2] * b2;
    int k3 = span + 2;
    if (k3 >= 0 && k3 < NUM_BASES) gate_raw += c[k3] * b3;

    /* Add residual connection */
    gate_raw += residual_w[neuron] * x;

    /* Clamp to [0, 1] — gate must be a probability */
    float gate = fminf(fmaxf(gate_raw, 0.0f), 1.0f);

    output[i] = gate * value[i];
}

/* ================================================================== */
/* Kernel: kan_gate_backward                                           */
/* ================================================================== */

/**
 * KAN Spline Activation backward pass — per-element scatter stage.
 *
 * Computes per-element outputs (no cross-element reduction):
 *   d_value[i]               = d_output[i] * gate
 *   d_gate_pre[i]            = d_output[i] * value[i] * clamp_mask * (dB/dx + resid_w)
 *   d_coeff_per_elem[i*4+0..3] = d_raw * {b0, b1, b2, b3}  (4 basis contributions)
 *   d_coeff_span[i]          = span index (int) for reduction routing
 *   d_residual_per_elem[i]   = d_raw * x  (residual_w contribution)
 *
 * The coefficient and residual gradients are NOT accumulated here. A separate
 * kan_grad_reduce kernel deterministically reduces them into d_spline_coeff
 * and d_residual_w without atomicAdd.
 *
 * Grid: ceil(n/256), Block: 256. One thread per element.
 */
extern "C" __global__ void kan_gate_backward(
    const float* __restrict__ d_output,
    const float* __restrict__ gate_pre,
    const float* __restrict__ value,
    const float* __restrict__ spline_coeff,
    const float* __restrict__ residual_w,
    float* __restrict__ d_gate_pre,
    float* __restrict__ d_value,
    float* __restrict__ d_coeff_per_elem,
    int*   __restrict__ d_coeff_span,
    float* __restrict__ d_residual_per_elem,
    int n,
    int adv_h)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= n) return;

    float x = gate_pre[i];
    int neuron = i % adv_h;

    const float GRID_MIN = -4.0f;
    const float GRID_STEP = 1.0f;
    const int NUM_BASES = 8;

    float x_clamped = fminf(fmaxf(x, GRID_MIN), GRID_MIN + NUM_BASES * GRID_STEP - 0.001f);
    float t_norm = (x_clamped - GRID_MIN) / GRID_STEP;
    int span = (int)floorf(t_norm);
    span = min(max(span, 0), NUM_BASES - 1);
    float t = t_norm - (float)span;

    float t2 = t * t;
    float t3 = t2 * t;
    float omt = 1.0f - t;
    float b0 = omt * omt * omt / 6.0f;
    float b1 = (3.0f * t3 - 6.0f * t2 + 4.0f) / 6.0f;
    float b2 = (-3.0f * t3 + 3.0f * t2 + 3.0f * t + 1.0f) / 6.0f;
    float b3 = t3 / 6.0f;

    /* Recompute gate_raw and gate */
    const float* c = spline_coeff + neuron * NUM_BASES;
    float gate_raw = 0.0f;
    int k0 = span - 1;
    if (k0 >= 0 && k0 < NUM_BASES) gate_raw += c[k0] * b0;
    if (span >= 0 && span < NUM_BASES) gate_raw += c[span] * b1;
    int k2 = span + 1;
    if (k2 >= 0 && k2 < NUM_BASES) gate_raw += c[k2] * b2;
    int k3_idx = span + 2;
    if (k3_idx >= 0 && k3_idx < NUM_BASES) gate_raw += c[k3_idx] * b3;
    gate_raw += residual_w[neuron] * x;
    float gate = fminf(fmaxf(gate_raw, 0.0f), 1.0f);

    /* Clamp derivative: d_gate/d_raw = 1 if raw in (0,1), else 0 */
    float clamp_mask = (gate_raw > 0.0f && gate_raw < 1.0f) ? 1.0f : 0.0f;

    float d_out = d_output[i];
    float val = value[i];

    /* d_value = d_output * gate */
    d_value[i] = d_out * gate;

    /* d_raw = d_output * value * clamp_mask */
    float d_raw = d_out * val * clamp_mask;

    /* d_gate_pre: chain through spline basis derivatives + residual */
    float db0_dt = -omt * omt / 2.0f;
    float db1_dt = (3.0f * t2 - 4.0f * t) / 2.0f;
    float db2_dt = (-3.0f * t2 + 2.0f * t + 0.5f);
    float db3_dt = t2 / 2.0f;

    float dgate_dx = 0.0f;
    if (k0 >= 0 && k0 < NUM_BASES) dgate_dx += c[k0] * db0_dt;
    if (span >= 0 && span < NUM_BASES) dgate_dx += c[span] * db1_dt;
    if (k2 >= 0 && k2 < NUM_BASES) dgate_dx += c[k2] * db2_dt;
    if (k3_idx >= 0 && k3_idx < NUM_BASES) dgate_dx += c[k3_idx] * db3_dt;
    /* Chain rule: dt/dx = 1/GRID_STEP = 1.0 for uniform grid */
    dgate_dx += residual_w[neuron];  /* residual contribution */

    d_gate_pre[i] = d_raw * dgate_dx;

    /* Write per-element basis contributions for deterministic reduction */
    d_coeff_per_elem[i * 4 + 0] = d_raw * b0;
    d_coeff_per_elem[i * 4 + 1] = d_raw * b1;
    d_coeff_per_elem[i * 4 + 2] = d_raw * b2;
    d_coeff_per_elem[i * 4 + 3] = d_raw * b3;
    d_coeff_span[i] = span;
    d_residual_per_elem[i] = d_raw * x;
}

/* ================================================================== */
/* Kernel: kan_grad_reduce (2-phase warp-parallel reduction)           */
/* ================================================================== */

/**
 * Phase 1: Partial block sums for KAN spline coefficient + residual gradients.
 *
 * Grid:  (ceil(batch_size/256), total_params, 1)
 * Block: (256, 1, 1), Shared: 256*sizeof(float)
 *
 * blockIdx.y = param_idx  in [0, total_params)
 * blockIdx.x = batch tile (256 samples per block)
 *
 * Each block reduces 256 batch samples for one param_idx and writes a
 * single partial sum to partials[blockIdx.x * total_params + param_idx].
 *
 * total_params = adv_h * NUM_BASES + adv_h = adv_h * 9.
 * n = batch_size * adv_h.
 */
extern "C" __global__ void kan_grad_reduce_p1(
    const float* __restrict__ d_coeff_per_elem,
    const int*   __restrict__ d_coeff_span,
    const float* __restrict__ d_residual_per_elem,
    float* __restrict__ partials,   /* [ceil(B/256), total_params] */
    int n,
    int adv_h,
    int total_params)
{
    const int NUM_BASES = 8;
    extern __shared__ float sdata[];

    int param_idx  = blockIdx.y;
    int tid        = threadIdx.x;
    int batch_size = n / adv_h;
    int s          = blockIdx.x * blockDim.x + tid; /* sample index */

    bool is_residual = (param_idx >= adv_h * NUM_BASES);
    int neuron, target_k;
    if (!is_residual) {
        neuron   = param_idx / NUM_BASES;
        target_k = param_idx % NUM_BASES;
    } else {
        neuron   = param_idx - adv_h * NUM_BASES;
        target_k = 0;
    }

    float val = 0.0f;
    if (s < batch_size) {
        int elem_idx = s * adv_h + neuron;
        if (is_residual) {
            val = d_residual_per_elem[elem_idx];
        } else {
            int span = d_coeff_span[elem_idx];
            int k0   = span - 1;
            if (target_k == k0 && k0 >= 0 && k0 < NUM_BASES)
                val = d_coeff_per_elem[elem_idx * 4 + 0];
            else if (target_k == span)
                val = d_coeff_per_elem[elem_idx * 4 + 1];
            else if (target_k == span + 1 && span + 1 < NUM_BASES)
                val = d_coeff_per_elem[elem_idx * 4 + 2];
            else if (target_k == span + 2 && span + 2 < NUM_BASES)
                val = d_coeff_per_elem[elem_idx * 4 + 3];
        }
    }

    sdata[tid] = val;
    __syncthreads();

    for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
        if (tid < stride) sdata[tid] += sdata[tid + stride];
        __syncthreads();
    }

    if (tid == 0)
        partials[blockIdx.x * total_params + param_idx] = sdata[0];
}

/**
 * Phase 2: Final reduction + write for KAN spline coefficient + residual gradients.
 *
 * Grid:  (total_params, 1, 1)
 * Block: (256, 1, 1)
 *
 * blockIdx.x = param_idx.  Threads stride over num_blocks partials, then
 * warp-shuffle reduce to lane 0, which writes the final gradient.
 */
extern "C" __global__ void kan_grad_reduce_p2(
    const float* __restrict__ partials,   /* [num_blocks, total_params] */
    float* __restrict__ d_spline_coeff,
    float* __restrict__ d_residual_w,
    int num_blocks,
    int adv_h,
    int total_params)
{
    const int NUM_BASES = 8;
    int param_idx = blockIdx.x;
    int tid       = threadIdx.x;

    float sum = 0.0f;
    for (int i = tid; i < num_blocks; i += blockDim.x)
        sum += partials[i * total_params + param_idx];

    /* Warp-level reduction */
    for (int offset = 16; offset > 0; offset >>= 1)
        sum += __shfl_down_sync(0xFFFFFFFF, sum, offset);

    if (tid == 0) {
        bool is_residual = (param_idx >= adv_h * NUM_BASES);
        if (is_residual) {
            int neuron = param_idx - adv_h * NUM_BASES;
            d_residual_w[neuron] = sum;
        } else {
            int neuron   = param_idx / NUM_BASES;
            int target_k = param_idx % NUM_BASES;
            d_spline_coeff[neuron * NUM_BASES + target_k] = sum;
        }
    }
}

/* ================================================================== */
/* Kernel: q_denoise_step                                              */
/* ================================================================== */

/**
 * Single diffusion denoising step for Q-value refinement.
 *
 *   noise_level = sqrt(var) * (1 - step_k / K)
 *   input = [Q_prev; noise_level]              // [B, 24]
 *   h = SiLU(W1 @ input + b1)                  // [B, 24]
 *   residual = W2 @ h + b2                      // [B, 12]
 *   Q_next = Q_prev + 0.1 * residual           // small step
 *
 * SiLU(x) = x * sigmoid(x).
 *
 * Grid: ceil(B/256), Block: 256. One thread per sample.
 *
 * @param q_prev       [B, 12]            Q-values from previous step (read)
 * @param q_next       [B, 12]            Q-values after this step (write; may alias q_prev)
 * @param q_variance   [B, var_stride]    Var[Q] per action from C51 distribution.
 *                                        var_stride is the buffer's per-sample width
 *                                        (== total Q-actions, e.g. 13 for the
 *                                        4+3+3+3 factored layout). The denoiser
 *                                        MLP only consumes the first D=12 slots
 *                                        (legacy exposure layout); the trailing
 *                                        slot is unused but the stride is needed
 *                                        so subsequent samples align correctly.
 * @param W1           [24, 24]           first FC weight
 * @param b1           [24]               first FC bias
 * @param W2           [12, 24]           second FC weight
 * @param b2           [12]               second FC bias
 * @param B                               batch size
 * @param var_stride                      per-sample stride of q_variance (>= D)
 * @param step_k                          current step (1, 2, or 3)
 * @param total_steps                     total steps K (3)
 */
extern "C" __global__ void q_denoise_step(
    const float* __restrict__ q_prev,
    float* __restrict__ q_next,
    const float* __restrict__ q_variance,
    const float* __restrict__ W1,
    const float* __restrict__ b1,
    const float* __restrict__ W2,
    const float* __restrict__ b2,
    int B,
    int var_stride,
    int step_k,
    int total_steps)
{
    int b = blockIdx.x * blockDim.x + threadIdx.x;
    if (b >= B) return;

    const int D = 12;
    const int H = 24; /* hidden = 2*D */

    const float* q_in = q_prev + b * D;
    const float* var_in = q_variance + b * var_stride;

    /* Build input[24] = [Q_prev[12]; noise_level[12]] */
    float input[24];
    float schedule = 1.0f - (float)step_k / (float)total_steps;
    for (int i = 0; i < D; i++) {
        input[i] = q_in[i];
        input[D + i] = sqrtf(fmaxf(var_in[i], 0.0f)) * schedule;
    }

    /* h = SiLU(W1 @ input + b1) */
    float h[24];
    for (int i = 0; i < H; i++) {
        float acc = b1[i];
        for (int j = 0; j < H; j++) {
            acc += W1[i * H + j] * input[j];
        }
        /* SiLU: x * sigmoid(x) */
        float sig = 1.0f / (1.0f + expf(-acc));
        h[i] = acc * sig;
    }

    /* residual = W2 @ h + b2 */
    float* q_out = q_next + b * D;
    for (int i = 0; i < D; i++) {
        float acc = b2[i];
        for (int j = 0; j < H; j++) {
            acc += W2[i * H + j] * h[j];
        }
        /* Q_next = Q_prev + 0.1 * residual */
        q_out[i] = q_in[i] + 0.1f * acc;
    }
}

/* ================================================================== */
/* Kernel: concat_ofi_features                                         */
/* ================================================================== */

/**
 * Concatenate VSN-masked trunk output with OFI microstructure features.
 *
 * For order branch (d=2):  out[b] = [vsn_masked[b, :SH2]; ofi_order[b, 3]]
 * For urgency branch (d=3): out[b] = [vsn_masked[b, :SH2]; ofi_urgency[b, 3]]
 *
 * OFI feature indices (from 50-dim feature vector, OFI at dims 42-49):
 *   order_extra:   [42=bid_ask_spread, 43=depth_imbalance_L1, 44=queue_pressure]
 *   urgency_extra: [45=spread_velocity, 46=depth_change_rate, 47=trade_arrival_rate]
 *
 * Grid: ceil((B * (SH2+3)) / 256), Block: 256.
 *
 * @param output        [B, SH2+3]  output concat buffer
 * @param vsn_masked    [B, SH2]    VSN-masked trunk output
 * @param features      [B, SD]     raw feature vector (contains OFI at fixed indices)
 * @param B                         batch size
 * @param SH2                       shared_h2 dimension
 * @param SD                        state_dim (feature vector width)
 * @param ofi_start_idx             first OFI index for this branch (66 for order, 69 for urgency)
 */
extern "C" __global__ void concat_ofi_features(
    float* __restrict__ output,
    const float* __restrict__ vsn_masked,
    const float* __restrict__ features,
    int B,
    int SH2,
    int SD,
    int ofi_start_idx)
{
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int total = B * (SH2 + 3);
    if (idx >= total) return;

    int b = idx / (SH2 + 3);
    int col = idx % (SH2 + 3);

    if (col < SH2) {
        output[idx] = vsn_masked[b * SH2 + col];
    } else {
        int ofi_col = col - SH2; /* 0, 1, or 2 */
        output[idx] = features[b * SD + ofi_start_idx + ofi_col];
    }
}

/* ================================================================== */
/* Kernel: qlstm_step                                                   */
/* ================================================================== */

/**
 * xLSTM matrix memory step — processes Q-stats into temporal context.
 *
 * Reads q_stats[7] + per_branch_q_gaps[4] = 11 inputs from device buffers.
 * Updates matrix memory C[8,8] and normalizer n[8] (persistent device state).
 * Writes context[8] output to device buffer.
 *
 * mLSTM update:
 *   k = W_k @ input, v = W_v @ input, q = W_q @ input
 *   i = exp(W_i @ input), f = sigmoid(W_f @ input), o = sigmoid(W_o @ input)
 *   C = f * C + i * outer(v, k)
 *   n = f * n + i * k
 *   h = o * (C @ q) / max(|n . q|, 1)
 *
 * One thread, one block. Runs every 50 training steps.
 * Grid: (1,1,1), Block: (1,1,1).
 */
extern "C" __global__ void qlstm_step(
    const float* __restrict__ q_stats,         /* [7] from q_stats_buf */
    const float* __restrict__ branch_q_gaps,   /* [4] per-branch Q-gaps */
    float* __restrict__ C_matrix,              /* [64] persistent 8x8 matrix memory */
    float* __restrict__ n_vec,                 /* [8] persistent normalizer */
    float* __restrict__ context_out,           /* [8] output context vector */
    const float* __restrict__ weights,         /* [528] 6 weight matrices each [11,8] */
    int input_dim,                             /* 11 */
    int head_dim                               /* 8 */
) {
    if (threadIdx.x != 0 || blockIdx.x != 0) return;

    const int ID = input_dim;  /* 11 */
    const int HD = head_dim;   /* 8 */

    /* Assemble input vector [11] */
    float input[11];
    for (int i = 0; i < 7; i++) input[i] = q_stats[i];
    for (int i = 0; i < 4; i++) input[7 + i] = branch_q_gaps[i];

    /* Weight layout: W_k[11,8], W_v[11,8], W_q[11,8], W_i[11,8], W_f[11,8], W_o[11,8] */
    const float* W_k = weights;
    const float* W_v = weights + ID * HD;
    const float* W_q = weights + 2 * ID * HD;
    const float* W_i = weights + 3 * ID * HD;
    const float* W_f = weights + 4 * ID * HD;
    const float* W_o = weights + 5 * ID * HD;

    /* Project: k, v, q, gates */
    float k[8], v[8], q[8], ig[8], fg[8], og[8];
    for (int h = 0; h < HD; h++) {
        float kv = 0, vv = 0, qv = 0, iv = 0, fv = 0, ov = 0;
        for (int j = 0; j < ID; j++) {
            float x = input[j];
            kv += W_k[j * HD + h] * x;
            vv += W_v[j * HD + h] * x;
            qv += W_q[j * HD + h] * x;
            iv += W_i[j * HD + h] * x;
            fv += W_f[j * HD + h] * x;
            ov += W_o[j * HD + h] * x;
        }
        k[h] = kv;
        v[h] = vv;
        q[h] = qv;
        ig[h] = expf(fminf(iv, 10.0f));               /* exp input gate (clamped to prevent overflow) */
        fg[h] = 1.0f / (1.0f + expf(-fv));            /* sigmoid forget gate */
        og[h] = 1.0f / (1.0f + expf(-ov));            /* sigmoid output gate */
    }

    /* Update C matrix: C = f * C + i * outer(v, k) */
    /* Use scalar f and i (average across heads for simplicity) */
    float f_avg = 0, i_avg = 0;
    for (int h = 0; h < HD; h++) { f_avg += fg[h]; i_avg += ig[h]; }
    f_avg /= HD; i_avg /= HD;

    for (int r = 0; r < HD; r++) {
        for (int c = 0; c < HD; c++) {
            C_matrix[r * HD + c] = f_avg * C_matrix[r * HD + c] + i_avg * v[r] * k[c];
        }
    }

    /* Update normalizer: n = f * n + i * k */
    for (int h = 0; h < HD; h++) {
        n_vec[h] = f_avg * n_vec[h] + i_avg * k[h];
    }

    /* Retrieve: h = o * (C @ q) / max(|n . q|, 1) */
    float n_dot_q = 0;
    for (int h = 0; h < HD; h++) n_dot_q += n_vec[h] * q[h];
    float denom = fmaxf(fabsf(n_dot_q), 1.0f);

    for (int h = 0; h < HD; h++) {
        float cq = 0;
        for (int c = 0; c < HD; c++) cq += C_matrix[h * HD + c] * q[c];
        context_out[h] = og[h] * cq / denom;
    }
}

/* ================================================================== */
/* ================================================================== */
/* Denoise cuBLAS helper kernels                                       */
/* ================================================================== */

/**
 * denoise_build_input — Build col-major input [H=24, B] from row-major Q [B, D=12]
 * and row-major var_q [B, var_stride]. First D rows = Q values, next D rows =
 * sqrt(var_q) * schedule. Only the first D=12 variance slots per sample are
 * consumed by the denoiser MLP; var_stride may exceed D when the upstream
 * variance buffer is sized per total Q-actions (b0+b1+b2+b3 = 13 with the
 * 4-direction layout) rather than the legacy 12-slot exposure layout.
 *
 * Col-major [H, B] layout: element (h, b) at offset h + b * H.
 * Grid: ceil(B/256), Block: 256. One thread per sample.
 */
extern "C" __global__ void denoise_build_input(
    const float* __restrict__ q_values,
    const float* __restrict__ var_q,
    float* __restrict__ input_buf,
    int B, int D, int var_stride, float schedule
) {
    int b = blockIdx.x * blockDim.x + threadIdx.x;
    if (b >= B) return;
    int H = 2 * D;
    for (int i = 0; i < D; i++) {
        input_buf[i + b * H] = q_values[b * D + i];
        input_buf[(D + i) + b * H] = sqrtf(fmaxf(var_q[b * var_stride + i], 0.0f)) * schedule;
    }
}

/**
 * denoise_bias_silu — Add bias + SiLU activation.
 * Operates on col-major [H, B] buffer from cuBLAS GEMM output.
 * Saves pre-activation (with bias) in pre_act for backward.
 * Writes SiLU output to h_out.
 *
 * Col-major [H, B]: element at linear index idx has row h = idx % H.
 * Grid: ceil(H*B/256), Block: 256.
 */
extern "C" __global__ void denoise_bias_silu(
    float* __restrict__ pre_act,
    float* __restrict__ h_out,
    const float* __restrict__ bias,
    int H, int N
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= N) return;
    int h = idx % H;
    float v = pre_act[idx] + bias[h];
    pre_act[idx] = v;
    float sig = 1.0f / (1.0f + expf(-v));
    h_out[idx] = v * sig;
}

/**
 * denoise_silu_bwd — SiLU backward: d_pre = d_h * SiLU'(pre_act).
 * SiLU'(x) = sigmoid(x) * (1 + x * (1 - sigmoid(x))).
 * Grid: ceil(N/256), Block: 256.
 */
extern "C" __global__ void denoise_silu_bwd(
    const float* __restrict__ d_h,
    const float* __restrict__ pre_act,
    float* __restrict__ d_pre,
    int N
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= N) return;
    float x = pre_act[idx];
    float sig = 1.0f / (1.0f + expf(-x));
    d_pre[idx] = d_h[idx] * sig * (1.0f + x * (1.0f - sig));
}

/**
 * denoise_loss_grad — MSE gradient with 0.1x residual scaling.
 * Reads row-major [B, D], writes col-major [D, B].
 * d_res[d, b] = 0.1 * 2.0 * (Q_refined[b*D+d] - Q_target[b*D+d]) * inv_B.
 *
 * Col-major [D, B] output: element (d, b) at offset d + b * D.
 * Grid: ceil(D*B/256), Block: 256.
 */
extern "C" __global__ void denoise_loss_grad(
    const float* __restrict__ Q_refined,
    const float* __restrict__ Q_target,
    float* __restrict__ d_res,
    int B, int D, float inv_B
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int total = D * B;
    if (idx >= total) return;
    int d = idx % D;
    int b = idx / D;
    d_res[idx] = 0.2f * (Q_refined[b * D + d] - Q_target[b * D + d]) * inv_B;
}

/**
 * denoise_skip_connection — q_out = q_in + scale * (mlp_out_colmaj + bias).
 * q_in/q_out: row-major [B, D]. mlp_out: col-major [D, B].
 * Grid: ceil(B*D/256), Block: 256.
 */
extern "C" __global__ void denoise_skip_connection(
    const float* __restrict__ q_in,
    const float* __restrict__ mlp_out,
    const float* __restrict__ bias,
    float* __restrict__ q_out,
    int B, int D, float scale
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= B * D) return;
    int b = idx / D;
    int d = idx % D;
    q_out[idx] = q_in[idx] + scale * (mlp_out[d + b * D] + bias[d]);
}

/**
 * denoise_bias_grad_p1 — Phase 1 block-level reduction for bias gradient.
 * Reduces col-major [out_dim, B] over B dimension.
 * Grid: (ceil(B/256), out_dim), Block: 256.
 */
extern "C" __global__ void denoise_bias_grad_p1(
    const float* __restrict__ d_pre,
    float* __restrict__ partials,
    int out_dim, int B
) {
    int j = blockIdx.y;
    __shared__ float sdata[256];
    float sum = 0.0f;
    for (int b = blockIdx.x * blockDim.x + threadIdx.x; b < B; b += gridDim.x * blockDim.x) {
        sum += d_pre[j + b * out_dim];
    }
    sdata[threadIdx.x] = sum;
    __syncthreads();
    for (int s = 128; s > 0; s >>= 1) {
        if (threadIdx.x < (unsigned)s) sdata[threadIdx.x] += sdata[threadIdx.x + s];
        __syncthreads();
    }
    if (threadIdx.x == 0) partials[blockIdx.x * out_dim + j] = sdata[0];
}

/**
 * denoise_bias_grad_p2 — Phase 2 final reduction across block partials.
 * Grid: ceil(out_dim/256), Block: 256.
 */
extern "C" __global__ void denoise_bias_grad_p2(
    const float* __restrict__ partials,
    float* __restrict__ d_bias,
    int out_dim, int num_blocks
) {
    int j = blockIdx.x * blockDim.x + threadIdx.x;
    if (j >= out_dim) return;
    float sum = 0.0f;
    for (int i = 0; i < num_blocks; i++) sum += partials[i * out_dim + j];
    d_bias[j] = sum;
}

/* ================================================================== */
/* Kernel: q_denoise_backward                                          */
/* ================================================================== */

/**
 * Backward through 2-step FC-SiLU-FC diffusion denoiser.
 * DETERMINISTIC: one thread per weight element, loops over batch.
 *
 * Loss: L = mean_b((Q_refined[b] - Q_target[b])^2) summed over 12 actions.
 * d_Q_refined = 2*(Q_refined - Q_target) / B per element.
 *
 * Then backprop through 2 FC-SiLU-FC steps (reverse order) to accumulate
 * d_params by summing over all samples with fixed iteration order.
 *
 * Total params: 1800 (2 steps × 900).
 * Grid: ceil(1800/256), Block: 256.
 */
/* var_q stride matches the variance buffer's per-sample width (>= D). The
 * denoiser only uses the first D entries per sample; pass var_stride from the
 * Rust launch site to keep alignment when q_var is sized per total Q-actions. */
extern "C" __global__ void q_denoise_backward(
    const float* __restrict__ Q_refined,
    const float* __restrict__ Q_target,
    const float* __restrict__ denoise_params,
    float* __restrict__ d_denoise_params,
    const float* __restrict__ Q_input,
    const float* __restrict__ var_q,
    int B,
    int var_stride)
{
    int widx = blockIdx.x * blockDim.x + threadIdx.x;
    const int D = 12;
    const int H = 24;
    const int STEP_PARAMS = 900; /* W1[24,24]+b1[24]+W2[12,24]+b2[12] */
    const int num_steps = 2;
    const int TOTAL_PARAMS = num_steps * STEP_PARAMS;
    if (widx >= TOTAL_PARAMS) return;

    float inv_B = 1.0f / (float)B;

    /* Determine which step and which sub-parameter this thread handles */
    int step = widx / STEP_PARAMS;
    int local = widx % STEP_PARAMS;

    /* Sub-parameter layout within one step (900 total):
     * W1: [H, H] = 576 elements (offsets 0..575)
     * b1: [H]    = 24  elements (offsets 576..599)
     * W2: [D, H] = 288 elements (offsets 600..887)
     * b2: [D]    = 12  elements (offsets 888..899) */
    int is_W1 = (local < 576);
    int is_b1 = (local >= 576 && local < 600);
    int is_W2 = (local >= 600 && local < 888);
    /* int is_b2 = (local >= 888); */

    float acc = 0.0f;

    for (int b = 0; b < B; b++) {
        /* Compute loss gradient for this sample */
        float d_out_b[12];
        for (int i = 0; i < D; i++) {
            d_out_b[i] = 2.0f * (Q_refined[b * D + i] - Q_target[b * D + i]) * inv_B;
        }

        /* Process steps in reverse. The gradient for the current step is needed. */
        /* For each step s from (num_steps-1) down to 0, d_out propagates through
         * the skip connection unchanged. We only need the gradient at `step`. */

        /* Reconstruct Q at start of `step` by running forward from Q_input
         * through steps 0..(step-1). */
        float q_curr[12];
        for (int i = 0; i < D; i++) q_curr[i] = Q_input[b * D + i];

        for (int s = 0; s < step; s++) {
            int soff = s * STEP_PARAMS;
            const float* sW1 = denoise_params + soff;
            const float* sb1 = denoise_params + soff + 576;
            const float* sW2 = denoise_params + soff + 600;
            const float* sb2 = denoise_params + soff + 888;

            float schedule_s = 1.0f - (float)(s + 1) / (float)num_steps;
            float inp_s[24];
            for (int i = 0; i < D; i++) {
                inp_s[i] = q_curr[i];
                inp_s[D + i] = sqrtf(fmaxf(var_q[b * var_stride + i], 0.0f)) * schedule_s;
            }
            float h_s[24];
            for (int i = 0; i < H; i++) {
                float v = sb1[i];
                for (int j = 0; j < H; j++) v += sW1[i * H + j] * inp_s[j];
                float sig = 1.0f / (1.0f + expf(-v));
                h_s[i] = v * sig;
            }
            for (int i = 0; i < D; i++) {
                float v = sb2[i];
                for (int j = 0; j < H; j++) v += sW2[i * H + j] * h_s[j];
                q_curr[i] += 0.1f * v;
            }
        }

        /* Build input for this step */
        float schedule = 1.0f - (float)(step + 1) / (float)num_steps;
        float input[24];
        for (int i = 0; i < D; i++) {
            input[i] = q_curr[i];
            input[D + i] = sqrtf(fmaxf(var_q[b * var_stride + i], 0.0f)) * schedule;
        }

        /* Forward through this step to get pre-activations */
        int off = step * STEP_PARAMS;
        const float* W1 = denoise_params + off;
        const float* b1_ptr = denoise_params + off + 576;
        const float* W2 = denoise_params + off + 600;

        float pre_act[24], h_silu[24];
        for (int i = 0; i < H; i++) {
            float v = b1_ptr[i];
            for (int j = 0; j < H; j++) v += W1[i * H + j] * input[j];
            pre_act[i] = v;
            float sig = 1.0f / (1.0f + expf(-v));
            h_silu[i] = v * sig;
        }

        /* d_residual = 0.1 * d_out */
        float d_res[12];
        for (int i = 0; i < D; i++) {
            d_res[i] = 0.1f * d_out_b[i];
        }

        /* Compute the gradient for this thread's specific parameter */
        if (is_W2) {
            /* dW2[i, j] += d_res[i] * h_silu[j] */
            int idx = local - 600;
            int row = idx / H;
            int col = idx % H;
            acc += d_res[row] * h_silu[col];
        } else if (local >= 888) {
            /* db2[i] += d_res[i] */
            int idx = local - 888;
            acc += d_res[idx];
        } else {
            /* Need d_pre for W1/b1 gradients */
            float d_h[24];
            for (int i = 0; i < H; i++) {
                float v = 0.0f;
                for (int j = 0; j < D; j++) v += W2[j * H + i] * d_res[j];
                d_h[i] = v;
            }
            float d_pre[24];
            for (int i = 0; i < H; i++) {
                float sig = 1.0f / (1.0f + expf(-pre_act[i]));
                float silu_grad = sig * (1.0f + pre_act[i] * (1.0f - sig));
                d_pre[i] = d_h[i] * silu_grad;
            }

            if (is_W1) {
                /* dW1[i, j] += d_pre[i] * input[j] */
                int idx = local;
                int row = idx / H;
                int col = idx % H;
                acc += d_pre[row] * input[col];
            } else if (is_b1) {
                /* db1[i] += d_pre[i] */
                int idx = local - 576;
                acc += d_pre[idx];
            }
        }
    }

    d_denoise_params[widx] = acc;  /* single deterministic write */
}

/* ================================================================== */
/* Kernel: qlstm_train_step                                            */
/* ================================================================== */

/**
 * xLSTM training: prediction loss on context output.
 *
 * L = (context[0] - prev_q_mean)^2
 * Backprop through output gate + retrieval to compute d_W_o and d_W_q,
 * then SGD update in-place (lr=1e-4).
 *
 * The output is: h[i] = o[i] * (C @ q)[i] / denom
 * Only h[0] has nonzero loss gradient.
 *
 * d_h[0] = 2*(h[0] - prev_q_mean)
 *
 * Output gate gradient (through sigmoid):
 *   d_o[0] = d_h[0] * (C @ q)[0] / denom
 *   d_pre_o = d_o * o * (1 - o)         (sigmoid derivative)
 *   d_W_o[j, 0] += d_pre_o[0] * input[j]
 *
 * Query gradient (through C @ q):
 *   d_Cq[0] = d_h[0] * o[0] / denom
 *   d_q[k] = sum_i(C[0,k] * d_Cq[0]) = C[0,k] * d_Cq[0]  (only i=0 has gradient)
 *   d_pre_q = d_q                        (linear — no activation on q)
 *   d_W_q[j, k] += d_pre_q[k] * input[j]
 *
 * Grid: (1,1,1), Block: (1,1,1). Single thread — 528 params, tiny.
 *
 * @param weights       [528]  updated in-place with SGD
 * @param context       [8]    current context output from qlstm_step
 * @param C_matrix      [64]   current 8x8 memory state
 * @param n_vec         [8]    current normalizer
 * @param q_stats       [7]    current q_stats (for input reconstruction)
 * @param branch_q_gaps [4]    current Q-gaps (for input reconstruction)
 * @param prev_q_mean          scalar: Q-mean from previous step
 * @param lr                   learning rate (1e-4)
 * @param input_dim            11
 * @param head_dim             8
 */
extern "C" __global__ void qlstm_train_step(
    float* __restrict__ weights,
    const float* __restrict__ context,
    const float* __restrict__ C_matrix,
    const float* __restrict__ n_vec,
    const float* __restrict__ q_stats,
    const float* __restrict__ branch_q_gaps,
    float prev_q_mean,
    float lr,
    int input_dim,
    int head_dim)
{
    if (threadIdx.x != 0 || blockIdx.x != 0) return;

    const int ID = input_dim;  /* 11 */
    const int HD = head_dim;   /* 8 */

    /* Reconstruct input vector [11] */
    float input[11];
    for (int i = 0; i < 7; i++) input[i] = q_stats[i];
    for (int i = 0; i < 4; i++) input[7 + i] = branch_q_gaps[i];

    /* Weight layout: W_k[11,8], W_v[11,8], W_q[11,8], W_i[11,8], W_f[11,8], W_o[11,8] */
    float* W_q = weights + 2 * ID * HD;  /* offset 176 */
    float* W_o = weights + 5 * ID * HD;  /* offset 440 */

    /* Reconstruct q and o projections for gradient computation */
    float q[8], pre_o[8], og[8];
    for (int h = 0; h < HD; h++) {
        float qv = 0.0f, ov = 0.0f;
        for (int j = 0; j < ID; j++) {
            float x = input[j];
            qv += W_q[j * HD + h] * x;
            ov += W_o[j * HD + h] * x;
        }
        q[h] = qv;
        og[h] = 1.0f / (1.0f + expf(-ov));
        pre_o[h] = ov;
    }

    /* Reconstruct retrieval: Cq[i] = sum_k C[i,k] * q[k] */
    float Cq[8];
    for (int i = 0; i < HD; i++) {
        float acc = 0.0f;
        for (int k = 0; k < HD; k++) acc += C_matrix[i * HD + k] * q[k];
        Cq[i] = acc;
    }

    /* Reconstruct normalizer denominator */
    float n_dot_q = 0.0f;
    for (int h = 0; h < HD; h++) n_dot_q += n_vec[h] * q[h];
    float denom = fmaxf(fabsf(n_dot_q), 1.0f);

    /* Loss gradient: d_h[0] = 2*(context[0] - prev_q_mean), rest = 0 */
    float d_h0 = 2.0f * (context[0] - prev_q_mean);

    /* Clamp gradient to prevent instability */
    d_h0 = fminf(fmaxf(d_h0, -10.0f), 10.0f);

    /* ── Gradient through output gate for dim 0 ── */
    /* h[0] = o[0] * Cq[0] / denom */
    float d_o0 = d_h0 * Cq[0] / denom;
    /* sigmoid'(pre_o) = o * (1 - o) */
    float d_pre_o0 = d_o0 * og[0] * (1.0f - og[0]);

    /* d_W_o[:, 0] -= lr * d_pre_o0 * input[:] */
    for (int j = 0; j < ID; j++) {
        W_o[j * HD + 0] -= lr * d_pre_o0 * input[j];
    }

    /* ── Gradient through query for dim 0 ── */
    /* d_Cq[0] = d_h[0] * o[0] / denom */
    float d_Cq0 = d_h0 * og[0] / denom;

    /* d_q[k] = C[0, k] * d_Cq[0] */
    float d_q[8];
    for (int k = 0; k < HD; k++) {
        d_q[k] = C_matrix[0 * HD + k] * d_Cq0;
    }

    /* d_W_q[:, k] -= lr * d_q[k] * input[:] */
    for (int k = 0; k < HD; k++) {
        float d_qk = d_q[k];
        for (int j = 0; j < ID; j++) {
            W_q[j * HD + k] -= lr * d_qk * input[j];
        }
    }
}

/* ================================================================== */
/* Kernel: regime_branch_gate                                          */
/* ================================================================== */

/**
 * Regime-conditioned branch gating: learned per-branch importance weights.
 *
 * regime_input = [ADX, CUSUM, Q_gap, atom_utilization]   (4 features)
 * branch_importance[4] = softmax(W_regime @ regime_input + b_regime)
 * Q_gated[b, a] = branch_importance[branch_of(a)] * Q_in[b, a]
 *
 * Grid: ceil(N / 256), Block: 256. One thread per sample.
 */
extern "C" __global__ void regime_branch_gate(
    float* __restrict__ q_values,
    const float* __restrict__ states,
    const float* __restrict__ q_gap,
    const float* __restrict__ atom_util,
    const float* __restrict__ w_regime,
    const float* __restrict__ b_regime,
    int N,
    int state_dim,
    int b0_size,
    int b1_size,
    int b2_size,
    int b3_size
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    int total_actions = b0_size + b1_size + b2_size + b3_size;

    /* Extract regime features from state vector */
    float adx    = states[(long long)i * state_dim + 40];
    float cusum  = states[(long long)i * state_dim + 41];
    float qgap_i = q_gap[i];
    float util_i = atom_util[0];

    float regime_input[4] = { adx, cusum, qgap_i, util_i };

    /* Compute W_regime @ regime_input + b_regime */
    float logits[4];
    for (int d = 0; d < 4; d++) {
        float val = b_regime[d];
        for (int k = 0; k < 4; k++) {
            val += w_regime[d * 4 + k] * regime_input[k];
        }
        logits[d] = val;
    }

    /* Softmax over 4 branches */
    float max_logit = logits[0];
    for (int d = 1; d < 4; d++)
        max_logit = fmaxf(max_logit, logits[d]);

    float sum_exp = 0.0f;
    float importance[4];
    for (int d = 0; d < 4; d++) {
        importance[d] = expf(logits[d] - max_logit);
        sum_exp += importance[d];
    }
    for (int d = 0; d < 4; d++)
        importance[d] /= sum_exp;

    /* Scale Q-values by branch importance */
    float* q_row = q_values + (long long)i * total_actions;
    int offset = 0;
    int branch_sizes[4] = { b0_size, b1_size, b2_size, b3_size };
    for (int d = 0; d < 4; d++) {
        for (int a = 0; a < branch_sizes[d]; a++) {
            q_row[offset + a] *= importance[d];
        }
        offset += branch_sizes[d];
    }
}

/* ================================================================== */
/* Kernel: adaptive_atom_positions                                      */
/* ================================================================== */

/**
 * Compute adaptive C51 atom positions from learnable spacing parameters.
 *
 * spacing = softmax(spacing_raw)
 * positions[j] = v_min + (v_max - v_min) * cumsum(spacing)[j]
 *
 * Per-branch bounds come from the ISV signal bus (slots 23 + 2*b for centre,
 * 24 + 2*b for half-width). Written by `update_eval_v_range`; bootstrap values
 * (centre=0, half=(config.v_max-config.v_min)/2) at construction produce the
 * same `[config.v_min, config.v_max]` positions as the pre-ISV behaviour.
 *
 * Single-block kernel: num_atoms=51 fits in one block.
 * Grid: (1,1,1), Block: (256,1,1). Shared memory: 2 * num_atoms * sizeof(float).
 */
extern "C" __global__ void adaptive_atom_positions(
    const float* __restrict__ spacing_raw,
    float* __restrict__ positions_out,
    int num_atoms,
    int branch_idx,
    const float* __restrict__ isv_signals
) {
    int tid = threadIdx.x;
    extern __shared__ float shmem[];
    float* s_softmax = shmem;
    float* s_cumsum  = shmem + num_atoms;

    /* Per-branch v-range from ISV slots. Layout: centre at 23+2*b, half at
     * 24+2*b for b in [0..4). Caller guarantees isv_signals is non-null and
     * branch_idx ∈ [0,4). */
    float v_center = isv_signals[23 + 2 * branch_idx];
    float v_half   = isv_signals[24 + 2 * branch_idx];
    float v_min = v_center - v_half;
    float v_max = v_center + v_half;

    /* Load spacing_raw into shared memory */
    if (tid < num_atoms) {
        s_softmax[tid] = spacing_raw[tid];
    }
    __syncthreads();

    /* Find max for numerical stability (single thread, num_atoms=51 is tiny) */
    float max_val;
    if (tid == 0) {
        max_val = s_softmax[0];
        for (int j = 1; j < num_atoms; j++)
            max_val = fmaxf(max_val, s_softmax[j]);
        s_cumsum[0] = max_val;  /* store max temporarily */
    }
    __syncthreads();
    max_val = s_cumsum[0];
    __syncthreads();

    /* Compute softmax */
    if (tid < num_atoms) {
        s_softmax[tid] = expf(s_softmax[tid] - max_val);
    }
    __syncthreads();

    /* Sum for normalization */
    if (tid == 0) {
        float sum = 0.0f;
        for (int j = 0; j < num_atoms; j++)
            sum += s_softmax[j];
        float inv_sum = 1.0f / fmaxf(sum, 1e-8f);
        for (int j = 0; j < num_atoms; j++)
            s_softmax[j] *= inv_sum;
    }
    __syncthreads();

    /* Inclusive prefix sum (sequential, num_atoms=51) */
    if (tid == 0) {
        s_cumsum[0] = s_softmax[0];
        for (int j = 1; j < num_atoms; j++)
            s_cumsum[j] = s_cumsum[j-1] + s_softmax[j];
    }
    __syncthreads();

    /* Write positions: positions[0] = v_min, positions[j>0] = v_min + range * cumsum[j-1] */
    if (tid < num_atoms) {
        float range = v_max - v_min;
        float frac = (tid == 0) ? 0.0f : s_cumsum[tid - 1];
        float new_pos = v_min + range * frac;

        /* SP4 Layer B (Mech 2): bound atom positions to ±ISV[ATOM_POS_BOUND[branch_idx]].
         * Per-branch p99(|atom_positions|) populated by SP4 producer
         * `launch_sp4_atom_pos_p99` (Layer A Task A6). Mirrors atoms_update_kernel.cu;
         * kept in lockstep with the active atoms_update kernel so this legacy
         * single-branch entry point stays bound-consistent if re-wired.
         * ε floor on the bound itself (`max(.., 1.0)`) per the SP1 cold-start pearl. */
        const float max_atom_abs = fmaxf(isv_signals[ATOM_POS_BOUND_BASE + branch_idx], 1.0f);
        new_pos = fminf(fmaxf(new_pos, -max_atom_abs), max_atom_abs);

        positions_out[tid] = new_pos;
    }
}

/* ================================================================== */
/* Kernel: atom_position_gradient                                       */
/* ================================================================== */

/**
 * Compute gradient of atom entropy w.r.t. spacing_raw via finite difference.
 * For each branch, perturb spacing_raw[j] by ±epsilon, recompute softmax+cumsum,
 * measure entropy of the C51 distribution under the perturbed positions.
 * Gradient = (entropy_plus - entropy_minus) / (2 * epsilon).
 *
 * We MAXIMIZE entropy (spread atoms evenly where mass exists), so SGD update is:
 *   spacing_raw[j] += lr * gradient  (gradient ascent on entropy)
 *
 * Grid: (1,1,1), Block: (256,1,1). Single block handles all 4 branches sequentially.
 * Called every 50 training steps.
 */
extern "C" __global__ void atom_position_gradient(
    const float* __restrict__ spacing_raw,   /* [4, NA] current spacing params in params_buf */
    float* __restrict__ d_spacing_raw,       /* [4, NA] output gradient */
    const float* __restrict__ atom_probs,    /* [4, NA] mean atom probabilities from Q-stats */
    int num_atoms,
    float epsilon                            /* finite difference step (0.01) */
) {
    int tid = threadIdx.x;

    for (int d = 0; d < 4; d++) {
        const float* sr = spacing_raw + d * num_atoms;
        const float* probs = atom_probs + d * num_atoms;
        float* grad = d_spacing_raw + d * num_atoms;

        if (tid < num_atoms) {
            /* Compute current entropy: -sum(p * log(p + eps)) */
            float base_entropy = 0.0f;
            for (int j = 0; j < num_atoms; j++) {
                float p = fmaxf(probs[j], 1e-8f);
                base_entropy -= p * logf(p);
            }

            /* Finite difference: perturb spacing_raw[tid] by +epsilon */
            /* We approximate: how does entropy change if we shift atom tid? */
            /* Since softmax is smooth, small perturbation → small entropy change */
            float p_tid = fmaxf(probs[tid], 1e-8f);
            /* Gradient approximation: atoms with low probability should attract neighbors */
            /* d_entropy/d_spacing ≈ -log(p_tid) - base_entropy (push toward uniform) */
            grad[tid] = (-logf(p_tid) - base_entropy) * epsilon;
        }
        __syncthreads();
    }
}

/* ================================================================== */
/* Kernel: q_anchor_to_flat (G11)                                      */
/* ================================================================== */

/**
 * Subtract the Flat action's Q-value from all actions in each branch.
 * Anchors Q-values to "excess return over doing nothing."
 *
 * For factored branches: anchor each branch to its neutral action.
 *   Branch 0 (direction): anchor to Flat(3) — index 3
 *   Branch 1 (magnitude): anchor to Small(0) — index 0
 *   Branch 2 (order): anchor to Market(0) — index 0
 *   Branch 3 (urgency): anchor to Normal(0) — index 0
 *
 * Grid: ceil(N / 256), Block: 256.
 */
extern "C" __global__ void q_anchor_to_flat(
    float* __restrict__ q_values,  /* [N, total_actions] modified in-place */
    int N,
    int b0_size,
    int b1_size,
    int b2_size,
    int b3_size
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    int total = b0_size + b1_size + b2_size + b3_size;
    float* q = q_values + (long long)i * total;

    /* Branch 0: anchor to Flat(3) */
    float anchor0 = q[3];  /* Flat is index 3 in direction branch: Short(0)/Hold(1)/Long(2)/Flat(3) */
    for (int a = 0; a < b0_size; a++) q[a] -= anchor0;

    /* Branch 1: anchor to Small(0) */
    float anchor1 = q[b0_size];  /* Small is index 0 in magnitude branch */
    for (int a = b0_size; a < b0_size + b1_size; a++) q[a] -= anchor1;

    /* Branch 2: anchor to Market(0) */
    float anchor2 = q[b0_size + b1_size];
    for (int a = b0_size + b1_size; a < b0_size + b1_size + b2_size; a++) q[a] -= anchor2;

    /* Branch 3: anchor to Normal(0) */
    float anchor3 = q[b0_size + b1_size + b2_size];
    for (int a = b0_size + b1_size + b2_size; a < total; a++) q[a] -= anchor3;
}

/* ================================================================== */
/* Kernel: regime_dropout (G9)                                         */
/* ================================================================== */

/**
 * Regime-aware dropout on shared trunk output h_s2 [N, sh2].
 * Drops hidden units conditioned on ADX/CUSUM regime quantiles,
 * forcing the network to generalise across market regimes.
 *
 * - Quantises ADX (idx 40) and CUSUM (idx 41) into 4×4 = 16 regime buckets.
 * - Uses Philox PRNG seeded by (regime_hash, feature_idx + epoch_seed*31)
 *   so dropout pattern changes per epoch but is deterministic within an epoch.
 * - Applies inverted dropout scaling (1/(1-p)) so expected value is preserved.
 * - No-op when is_training == 0 (inference path).
 *
 * Grid: ceil(N*sh2 / 256), Block: 256.
 */
extern "C" __global__ void regime_dropout(
    float* __restrict__ h_s2,
    const float* __restrict__ states,
    int N,
    int sh2,
    int state_dim,
    float drop_rate,
    int epoch_seed,
    int is_training
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= N * sh2 || !is_training) return;

    int i = idx / sh2;
    int j = idx % sh2;

    float adx = states[(long long)i * state_dim + 40];
    float cusum = states[(long long)i * state_dim + 41];
    int adx_q = (int)fminf(fmaxf(adx * 0.1f, 0.0f), 3.0f);
    int cusum_q = (int)fminf(fmaxf((cusum + 2.0f) * 0.5f, 0.0f), 3.0f);
    int regime_hash = adx_q * 7 + cusum_q;

    float u = philox_uniform(regime_hash, j + epoch_seed * 31, 9999);
    if (u < drop_rate) {
        h_s2[idx] = 0.0f;
    } else {
        h_s2[idx] *= 1.0f / (1.0f - drop_rate);
    }
}

/* ================================================================== */
/* Kernel: epistemic_gate_magnitude (G5)                               */
/* ================================================================== */

/**
 * Epistemic-gated magnitude: uses ensemble variance to scale magnitude
 * branch Q-values via sigmoid gate. High disagreement → forced Small
 * position (conservative). Low disagreement → full magnitude range.
 *
 * Gate: mag_scale = 1 / (1 + exp(5 * (var_mean - threshold)))
 * Applied to Q-values in the magnitude branch range [b0_size, b0_size + b1_size).
 *
 * Grid: ceil(N / 256), Block: 256.
 */
extern "C" __global__ void epistemic_gate_magnitude(
    float* __restrict__ q_values,
    const float* __restrict__ ensemble_var,
    const float* __restrict__ var_ema_ptr,
    int N,
    int total_actions,
    int b0_size,
    int b1_size
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    float var_sum = 0.0f;
    for (int a = 0; a < total_actions; a++)
        var_sum += ensemble_var[(long long)i * total_actions + a];
    float var_mean = var_sum / (float)total_actions;

    float threshold = var_ema_ptr[0];
    float mag_scale = 1.0f / (1.0f + expf(5.0f * (var_mean - threshold)));

    float* q = q_values + (long long)i * total_actions;
    for (int a = b0_size; a < b0_size + b1_size; a++) {
        q[a] *= mag_scale;
    }
}

/* REMOVED 2026-04-21: branch_independence_penalty (G6) +
 * temporal_consistency_penalty (G10) kernels deleted after V7-gem
 * measurement (commit 61ab27ff3) confirmed their penalty values were
 * below floating-point noise — spectral_norm (global Lipschitz) and
 * NoisyNets (per-layer branch diversity) already cover the signal.
 * See commit 1961857c2 for the unwiring + measurement data. */

/* ================================================================== */
/* Kernel: predictive_coding_loss (G12)                                */
/* ================================================================== */

/**
 * Self-supervised prediction: temporal smoothness on enriched trunk activations.
 * MSE loss between consecutive h_s2 samples enforces temporal coherence.
 *
 * For consecutive samples (i, i+1) in the batch:
 *   per_sample_loss[i] = lambda_pred * |h_s2[i] - h_s2[i+1]|^2 / SH2
 *
 * Writes per-sample loss to per_sample_out[i] for i in [0, N-2].
 * Unused slots (i >= N-1) are zeroed.
 * Total reduction is done by a separate c51_loss_reduce kernel launch.
 *
 * This eliminates both cuMemsetD8Async (not captured in CUDA Graph)
 * and atomicAdd (non-deterministic), matching the existing loss pipeline.
 *
 * Grid: ceil(N / 256), Block: 256.
 */
extern "C" __global__ void predictive_coding_loss(
    const float* __restrict__ h_s2,        /* [N, SH2] enriched trunk (after Mamba2) */
    float* __restrict__ per_sample_out,    /* [N] per-sample loss (only [0..N-2] used) */
    int N,
    int sh2,
    float lambda_pred                      /* loss weight (0.1) */
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    if (i < N - 1) {
        float mse = 0.0f;
        for (int j = 0; j < sh2; j++) {
            float current = h_s2[(long long)i * sh2 + j];
            float next    = h_s2[(long long)(i + 1) * sh2 + j];
            float diff = current - next;
            mse += diff * diff;
        }
        mse /= (float)sh2;
        per_sample_out[i] = lambda_pred * mse;
    } else {
        per_sample_out[i] = 0.0f;
    }
}

/**
 * predictive_coding_backward — gradient of the predictive_coding_loss above.
 *
 * Loss shape:
 *   L = sum_{i=0..N-2}  lambda_pred * (1/SH2) * sum_j (h[i,j] - h[i+1,j])^2
 *
 * Gradient w.r.t. h[i,j]:
 *   dL/dh[i,j] = (2*lambda_pred/SH2) * sum over loss terms that involve h[i,j]
 *
 *   Each h[i,j] appears in TWO loss terms (except boundaries):
 *     - as "current" in term i:    contribution +diff(i,j) =   (h[i,j]   - h[i+1,j])
 *     - as "next" in term i-1:     contribution -diff(i-1,j) = -(h[i-1,j] - h[i,j])
 *                                                            =  (h[i,j]   - h[i-1,j])
 *
 *   Combined for interior i in [1, N-2]:
 *     dL/dh[i,j] = (2*lambda/SH2) * ( (h[i,j] - h[i+1,j]) + (h[i,j] - h[i-1,j]) )
 *               = (2*lambda/SH2) * ( 2*h[i,j] - h[i-1,j] - h[i+1,j] )
 *
 *   Boundary i=0 (only "current" term): dL/dh[0,j] = (2*lambda/SH2) * (h[0,j] - h[1,j])
 *   Boundary i=N-1 (only "next" term):  dL/dh[N-1,j] = (2*lambda/SH2) * (h[N-1,j] - h[N-2,j])
 *
 * Grid: ceil(N*SH2 / 256), Block: 256. One thread per (i, j) cell.
 * Output: ADDS gradient into bw_d_h_s2[i, j] (does not overwrite — caller's
 * trunk backward path expects accumulation semantics).
 *
 * Determinism: each thread writes to a unique (i, j) slot — plain += is safe,
 * no atomicAdd needed.
 */
extern "C" __global__ void predictive_coding_backward(
    const float* __restrict__ h_s2,        /* [N, SH2] same buffer as forward */
    float* __restrict__ d_h_s2,            /* [N, SH2] accumulated trunk gradient (in/out) */
    int N,
    int sh2,
    float lambda_pred                      /* must match forward */
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int total = N * sh2;
    if (idx >= total) return;

    int i = idx / sh2;
    int j = idx % sh2;

    /* Pre-factor: 2 * lambda / SH2 */
    float k = (2.0f * lambda_pred) / (float)sh2;
    long long off = (long long)i * sh2 + j;

    float v_self = h_s2[off];
    float grad = 0.0f;

    /* "current" term i: diff = (h[i] - h[i+1]) */
    if (i < N - 1) {
        float v_next = h_s2[(long long)(i + 1) * sh2 + j];
        grad += k * (v_self - v_next);
    }
    /* "next" term i-1: -diff(i-1) = (h[i] - h[i-1]) */
    if (i > 0) {
        float v_prev = h_s2[(long long)(i - 1) * sh2 + j];
        grad += k * (v_self - v_prev);
    }

    /* Accumulate (caller's existing trunk backward expects += semantics) */
    d_h_s2[off] += grad;
}

/* ================================================================== */
/* Kernel: homeostatic_regularizer (G16)                               */
/* ================================================================== */

/**
 * Unified self-regulating penalty framework.
 *
 * For each observable k: penalty = lambda * error * |error| (quadratic, signed)
 * where error = (observed - target) / max(|target|, eps).
 * Budget-capped: total penalty gradient <= budget_fraction of C51 gradient.
 *
 * All pointers are pinned device-mapped — CPU writes observables/targets,
 * GPU reads via dev_ptr. Zero memcpy.
 *
 * Grid: (1,1,1), Block: (32,1,1). Single warp, n_obs <= 8.
 */
extern "C" __global__ void homeostatic_regularizer(
    const float* __restrict__ observables,   /* [N_OBS] pinned device-mapped */
    const float* __restrict__ targets,       /* [N_OBS] pinned device-mapped */
    float* __restrict__ penalties,           /* [N_OBS] output penalty gradients */
    float* __restrict__ total_penalty,       /* [1] sum of |penalty_k| */
    int n_obs,
    float lambda_base,                       /* 0.01 */
    float budget_max                         /* max total penalty magnitude */
) {
    int k = threadIdx.x;
    if (k >= n_obs) return;

    float obs = observables[k];
    float tgt = targets[k];

    /* Adaptive normalization: use max(|target|, |observed|, 1.0) as scale.
     * For zero targets (Q-mean): scale = max(1.0, |observed|) — absolute error, not relative.
     * For non-zero targets (atom_util=0.85): scale = 0.85 — relative error.
     * This prevents zero-target observables from producing infinite error. */
    float scale = fmaxf(fmaxf(fabsf(tgt), fabsf(obs)), 1.0f);
    float error = (obs - tgt) / scale;

    /* Quadratic signed penalty — small drift = tiny, large drift = hard correction */
    float pen = lambda_base * error * fabsf(error);

    /* PER-OBSERVABLE budget cap (not global — prevents one runaway stealing all budget).
     * Each observable gets budget_max / n_obs of the total budget. */
    float per_obs_budget = budget_max / (float)n_obs;
    if (fabsf(pen) > per_obs_budget) {
        pen = (pen > 0.0f) ? per_obs_budget : -per_obs_budget;
    }

    penalties[k] = pen;
}

/* ───────── Risk-budget branch (5th DQN head) ───────── */

/**
 * Risk-budget forward — parallel over hidden neurons.
 *
 * Grid:  (B, 1, 1)   — one block per sample
 * Block: (AH, 1, 1)  — one thread per hidden neuron (AH=128)
 * Shared: (SH2 + 13) * sizeof(float) — input vector cached once per block
 *
 * Each block:
 *   1. Cooperatively loads h_s2[i, :] + ISV[12] + predicted_error[i] into shmem.
 *   2. Each thread j computes dot(w_risk_fc[j, :], input) + bias → ReLU → h_risk[i,j].
 *   3. Thread 0 reads risk_hidden[i,:] and computes sigmoid output.
 */
extern "C" __global__ void risk_budget_forward(
    const float* __restrict__ h_s2,
    const float* __restrict__ isv_signals_dev_ptr,  /* [12] pinned — raw ISV */
    const float* __restrict__ predicted_error,      /* [B] recursive confidence */
    const float* __restrict__ w_risk_fc,
    const float* __restrict__ b_risk_fc,
    const float* __restrict__ w_risk_out,
    const float* __restrict__ b_risk_out,
    float* __restrict__ risk_hidden,
    float* __restrict__ risk_budget_out,
    int B, int SH2, int AH
) {
    extern __shared__ float sinput[];  /* [SH2 + 13] */

    int i   = blockIdx.x;
    int j   = threadIdx.x;  /* hidden neuron index */
    int tid = threadIdx.x;
    if (i >= B) return;

    int input_dim = SH2 + 13;

    /* Cooperatively load input vector into shared memory */
    /* h_s2 portion */
    for (int k = tid; k < SH2; k += blockDim.x)
        sinput[k] = h_s2[(long long)i * SH2 + k];
    /* ISV portion */
    if (tid < 12)
        sinput[SH2 + tid] = isv_signals_dev_ptr[tid];
    /* predicted_error scalar */
    if (tid == 0)
        sinput[SH2 + 12] = predicted_error[i];
    __syncthreads();

    /* Each thread computes one hidden neuron */
    float val = b_risk_fc[j];
    const float* w_row = w_risk_fc + (long long)j * input_dim;
    for (int k = 0; k < input_dim; k++)
        val += w_row[k] * sinput[k];
    val = fmaxf(val, 0.0f);
    risk_hidden[(long long)i * AH + j] = val;
    __syncthreads();

    /* Thread 0 computes the scalar output */
    if (j == 0) {
        const float* h_risk = risk_hidden + (long long)i * AH;
        float raw = b_risk_out[0];
        for (int k = 0; k < AH; k++)
            raw += w_risk_out[k] * h_risk[k];
        risk_budget_out[i] = 1.0f / (1.0f + expf(-raw));
    }
}

extern "C" __global__ void apply_risk_budget(
    const float* __restrict__ risk_budget,
    float* __restrict__ q_values,
    float* __restrict__ cvar_alpha_buf,
    float* __restrict__ commit_lambda_buf,
    int B,
    int b0_size,
    int b1_size,
    int b2_size,
    int b3_size
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= B) return;

    float R = risk_budget[i];
    int total_actions = b0_size + b1_size + b2_size + b3_size;

    /* Scale magnitude Q-values: Small×1, Half×sqrt(R), Full×R */
    float* q_mag = q_values + (long long)i * total_actions + b0_size;
    /* q_mag[0] = Small: unchanged */
    if (b1_size > 1) q_mag[1] *= sqrtf(fmaxf(R, 1e-6f));
    if (b1_size > 2) q_mag[2] *= fmaxf(R, 1e-6f);

    cvar_alpha_buf[i] = 0.1f + 0.4f * R;
    commit_lambda_buf[i] = 0.01f * (1.0f - R);
}

/**
 * Risk budget backward — DETERMINISTIC: one thread per weight element.
 *
 * Weight layout (flat): [d_w_risk_fc: AH*(SH2+13) | d_b_risk_fc: AH | d_w_risk_out: AH | d_b_risk_out: 1]
 * Total: AH*(SH2+13) + AH + AH + 1 = AH*(SH2+15) + 1
 *
 * Grid: ceil(total_risk_params / 256), Block: 256.
 */
extern "C" __global__ void risk_budget_backward(
    const float* __restrict__ h_s2,
    const float* __restrict__ isv_signals_dev_ptr,  /* [12] raw ISV */
    const float* __restrict__ predicted_error,      /* [B] recursive confidence */
    const float* __restrict__ risk_hidden,
    const float* __restrict__ risk_budget,
    const float* __restrict__ d_q_mag,
    const float* __restrict__ q_mag_pre,
    const float* __restrict__ w_risk_out,
    float* __restrict__ d_w_risk_fc,
    float* __restrict__ d_b_risk_fc,
    float* __restrict__ d_w_risk_out,
    float* __restrict__ d_b_risk_out,
    int B, int SH2, int AH, int b1_size
) {
    int input_dim = SH2 + 13;
    int wfc_size = AH * input_dim;
    int bfc_size = AH;
    int wout_size = AH;
    int bout_size = 1;
    int total_params = wfc_size + bfc_size + wout_size + bout_size;

    int widx = blockIdx.x * blockDim.x + threadIdx.x;
    if (widx >= total_params) return;

    float acc = 0.0f;

    for (int i = 0; i < B; i++) {
        float R = risk_budget[i];

        float d_R = 0.0f;
        const float* dq = d_q_mag + (long long)i * b1_size;
        const float* qp = q_mag_pre + (long long)i * b1_size;
        if (b1_size > 1) d_R += dq[1] * qp[1] * 0.5f / fmaxf(sqrtf(fmaxf(R, 1e-6f)), 1e-6f);
        if (b1_size > 2) d_R += dq[2] * qp[2];

        float d_raw = d_R * R * (1.0f - R);

        if (widx < wfc_size) {
            /* d_w_risk_fc[j * input_dim + k] */
            int j = widx / input_dim;
            int k = widx % input_dim;

            float d_hj = d_raw * w_risk_out[j];
            const float* h_risk = risk_hidden + (long long)i * AH;
            if (h_risk[j] <= 0.0f) d_hj = 0.0f;

            float input_val;
            if (k < SH2) {
                input_val = h_s2[(long long)i * SH2 + k];
            } else if (k < SH2 + 12) {
                input_val = isv_signals_dev_ptr[k - SH2];
            } else {
                input_val = predicted_error[i];
            }
            acc += d_hj * input_val;
        } else if (widx < wfc_size + bfc_size) {
            /* d_b_risk_fc[j] */
            int j = widx - wfc_size;
            float d_hj = d_raw * w_risk_out[j];
            const float* h_risk = risk_hidden + (long long)i * AH;
            if (h_risk[j] <= 0.0f) d_hj = 0.0f;
            acc += d_hj;
        } else if (widx < wfc_size + bfc_size + wout_size) {
            /* d_w_risk_out[j] */
            int j = widx - wfc_size - bfc_size;
            const float* h_risk = risk_hidden + (long long)i * AH;
            acc += d_raw * h_risk[j];
        } else {
            /* d_b_risk_out */
            acc += d_raw;
        }
    }

    /* Single deterministic write per weight element */
    if (widx < wfc_size)
        d_w_risk_fc[widx] = acc;
    else if (widx < wfc_size + bfc_size)
        d_b_risk_fc[widx - wfc_size] = acc;
    else if (widx < wfc_size + bfc_size + wout_size)
        d_w_risk_out[widx - wfc_size - bfc_size] = acc;
    else
        d_b_risk_out[0] = acc;
}

/* ================================================================== */
/* Kernel: isv_signal_update — pure GPU ISV signal computation        */
/* ================================================================== */

extern "C" __global__ void isv_signal_update(
    float* __restrict__ isv_signals,          /* [ISV_DIM=23] pinned device-mapped */
    float* __restrict__ isv_history,          /* [K*12] pinned — history only rotates slots [0..11] */
    float* __restrict__ lagged_td_error,      /* [1] pinned — for recursive confidence target */
    const float* __restrict__ q_mean_ptr,     /* [1] pinned — batch Q-mean */
    const float* __restrict__ q_mean_ema_ptr, /* [1] pinned — Q-mean EMA */
    const float* __restrict__ grad_norm_ptr,  /* [1] pinned — gradient norm² */
    const float* __restrict__ td_error_ptr,   /* [1] pinned — batch mean |TD-error| */
    const float* __restrict__ q_var_ptr,      /* [1] pinned — batch mean C51 Q-distribution variance */
    const float* __restrict__ reward_ptr,     /* [1] pinned — reward proxy */
    const float* __restrict__ atom_util_ptr,  /* [1] pinned — atom utilization */
    const float* __restrict__ loss_ptr,       /* [1] pinned — total loss */
    int K,                                    /* temporal history length (4) */
    const float* __restrict__ states_ptr,     /* [B, state_dim_padded] — for ADX/CUSUM regime signals. NULL = skip regime. */
    int state_dim,                             /* stride of states_ptr — use STATE_DIM_PADDED, NOT logical STATE_DIM. ADX at [40], CUSUM at [41]. */
    int batch_size,                            /* B — used to batch-aggregate regime signals across all samples, not just sample 0. */
    /* Task 2.X "make Full useful" — per-magnitude-bin Q-mean array +
     * absolute-scale reference. Produced by q_mag_bin_means_reduce on
     * the same stream before isv_signal_update. NULL = freeze slots. */
    const float* __restrict__ q_mag_means_ptr,  /* [mag_size] per-bin Q mean */
    const float* __restrict__ q_abs_ref_ptr,    /* [1] max(|Q_mean|) across mag bins */
    int mag_size,                               /* typically 3 (Quarter/Half/Full) */
    /* Task 2.Y "make direction branch useful at eval" — per-direction Q-mean
     * array + absolute-scale reference. Produced by q_dir_bin_means_reduce
     * on the same stream before isv_signal_update. NULL = freeze slots. */
    const float* __restrict__ q_dir_means_ptr,  /* [dir_size] per-bin Q mean */
    const float* __restrict__ q_dir_abs_ref_ptr,/* [1] max(|Q_mean|) across dir bins */
    int dir_size                                /* typically 4 (Short/Hold/Long/Flat) */
) {
    if (threadIdx.x != 0) return;

    float alpha = 0.05f;

    /* Save current td_error_ema as lagged target for recursive confidence */
    lagged_td_error[0] = isv_signals[2];

    /* Shift temporal history: newest at [0], oldest at [K-1] */
    for (int t = K - 1; t > 0; t--)
        for (int k = 0; k < 12; k++)
            isv_history[t * 12 + k] = isv_history[(t - 1) * 12 + k];
    for (int k = 0; k < 12; k++)
        isv_history[k] = isv_signals[k];

    /* [0] Q-drift: normalized deviation from EMA */
    float q_mean = q_mean_ptr[0];
    float q_ema  = q_mean_ema_ptr[0];
    isv_signals[0] = (q_mean - q_ema) / fmaxf(fabsf(q_ema), 0.1f);

    /* [1] Gradient norm EMA (ptr stores norm², take sqrt) */
    float gn = sqrtf(fmaxf(grad_norm_ptr[0], 0.0f));
    isv_signals[1] = (1.0f - alpha) * isv_signals[1] + alpha * gn;

    /* [2] TD-error EMA */
    isv_signals[2] = (1.0f - alpha) * isv_signals[2] + alpha * td_error_ptr[0];

    /* [3] C51 Q-distribution variance EMA (save old for delta)
     *
     * Semantics: batch-mean of the atom-spread variance computed by the C51
     * distributional head — NOT multi-head ensemble variance. The source is
     * `q_var_buf_trainer` (size b*12, reduced by a dedicated c51_loss_reduce
     * call that scales by b*12). Slot 3's consumers (ISV encoder MLP only)
     * want a scale signal for training uncertainty, which the C51 Q-variance
     * proxies correctly. True multi-head ensemble disagreement would be a
     * separate scratch path if/when a per-head ensemble is wired. */
    float old_qvar_ema = isv_signals[3];
    isv_signals[3] = (1.0f - alpha) * old_qvar_ema + alpha * q_var_ptr[0];

    /* [4] C51 Q-distribution variance velocity (derivative of slot 3) */
    isv_signals[4] = (isv_signals[3] - old_qvar_ema) / fmaxf(old_qvar_ema, 1e-6f);

    /* [5] Reward EMA */
    isv_signals[5] = (1.0f - alpha) * isv_signals[5] + alpha * reward_ptr[0];

    /* [6] Atom utilization EMA */
    isv_signals[6] = (1.0f - alpha) * isv_signals[6] + alpha * atom_util_ptr[0];

    /* [7] Loss EMA */
    isv_signals[7] = (1.0f - alpha) * isv_signals[7] + alpha * loss_ptr[0];

    /* ── Regime awareness signals [8]-[11] from ADX/CUSUM ──────────── *
     *
     * Batch-aggregated across all B samples (ISV audit 2026-04-23): the
     * prior implementation read only sample 0 and threw away 99.994% of the
     * regime information, making the signal noisy at the batch-shuffle
     * level (sample 0 is effectively random after per-epoch shuffles).
     *
     * The launch passes `state_dim = STATE_DIM_PADDED = 128` (the true
     * stride of `states_buf`). The prior code passed the logical
     * `STATE_DIM = 104`, which only produced correct values for row 0 by
     * accident (both strides land at the same offset for sample 0). */
    if (states_ptr != 0 && state_dim > 41 && batch_size > 0) {
        /* Batch-mean ADX and CUSUM across all B rows (single-thread loop —
         * kernel already runs on 1 thread, microsecond cost at B=16384). */
        float adx_sum   = 0.0f;
        float cusum_sum = 0.0f;
        for (int b = 0; b < batch_size; b++) {
            adx_sum   += states_ptr[(long long)b * state_dim + 40];
            cusum_sum += states_ptr[(long long)b * state_dim + 41];
        }
        float inv_b = 1.0f / (float)batch_size;
        float adx   = adx_sum   * inv_b;
        float cusum = cusum_sum * inv_b;

        /* [8] Regime velocity: EMA of ADX (used for delta computation next step) */
        float prev_adx = isv_signals[8];
        float adx_delta = fabsf(adx - prev_adx);
        float prev_cusum_ema = isv_signals[10];
        float cusum_delta = fabsf(cusum - prev_cusum_ema);
        float regime_vel = adx_delta + cusum_delta;

        isv_signals[8] = (1.0f - alpha) * isv_signals[8] + alpha * adx;
        float velocity_ema = (1.0f - alpha) * prev_cusum_ema + alpha * regime_vel;

        /* [9] Regime disagreement: |norm(ADX) - norm(CUSUM)| */
        float sum_abs = fabsf(adx) + fabsf(cusum) + 1e-6f;
        isv_signals[9] = fabsf(adx / sum_abs - cusum / sum_abs);

        /* [10] Regime transition EMA */
        isv_signals[10] = velocity_ema;

        /* [11] Regime stability: 1 - sigmoid(5 * velocity) */
        isv_signals[11] = 1.0f - 1.0f / (1.0f + expf(-5.0f * regime_vel));
    } else {
        /* No states available — neutral defaults */
        isv_signals[8]  = 0.0f;
        isv_signals[9]  = 0.0f;
        isv_signals[10] = 0.0f;
        isv_signals[11] = 0.5f;  /* moderate stability */
    }

    /* ── [12] Learning health EMA driven by sharpe_ema (slot 22) ──────
     *
     * ISV audit 2026-04-23: the prior Rust-side HealthEma aggregator
     * fed slot 12 with seven-component health (q_gap, q_var, atom_util,
     * grad_norm, ens_disagreement, grad_consistency, spectral_gap).
     * Observed on train-mdh86 (20 epochs): health climbed monotonically
     * 0.50 → 0.64 while Sharpe cratered +34 → −67 (Pearson r = −0.765,
     * strongly ANTI-correlated). Components q_gap=1.0 and q_var=1.0 were
     * saturated at the upper bound for 19/20 epochs; grad_stable was
     * stuck at 0.0 for 20/20 epochs. The aggregate could not track
     * outcomes because its inputs were clipped / dead.
     *
     * Replacement: sigmoid(0.1 × sharpe_ema) EMA-blended with the
     * previous slot 12 value. The 0.1 coefficient places the sigmoid's
     * sensitive region in the [-10, +10] Sharpe range, which matches
     * the typical magnitudes observed in this system (Sharpe=0 →
     * health=0.5, Sharpe=±10 → health≈0.27/0.73). No external scale
     * reference needed — sigmoid saturates gracefully at extremes.
     *
     * Host Rust writes `training_sharpe_ema` into slot 22
     * (SHARPE_EMA_INDEX) every epoch via `write_isv_signal_at`; this
     * kernel reads it here and couples it into slot 12 which is the
     * heaviest ISV consumer in the codebase (C51 bin weighting, reward-
     * bias damping, CQL gate, distillation alpha, Kelly warmup floor).
     *
     * PRIOR HEALTH FORMULA (replaced 2026-04-23 per ISV audit):
     *   Rust-side training_loop.rs:1954:
     *     let health_value = self.learning_health.update(&raw);
     *     fused.write_isv_signal_at(LEARNING_HEALTH_INDEX, health_value);
     *   where raw contained 7 saturating components. See HealthEma in
     *   crates/ml/src/cuda_pipeline/learning_health.rs for the old
     *   aggregation; the Rust-side writer is preserved but is now
     *   overwritten on-GPU every epoch by this block. */
    {
        float sharpe_ema_val = isv_signals[22];
        float s_scaled = 0.1f * sharpe_ema_val;
        float new_health_target = 1.0f / (1.0f + expf(-s_scaled));
        isv_signals[12] = (1.0f - alpha) * isv_signals[12]
                        + alpha * new_health_target;
    }

    /* ── Task 2.X "make Full useful": per-magnitude-bin Q-mean EMAs [13..15]
     *    and absolute-Q-scale reference EMA [16] ───────────────────────
     *
     * Signal-driven adaptive inputs for the C51 magnitude bin weight.
     *   [13] Q_mean_ema(Quarter)  — persistent preference per bin.
     *   [14] Q_mean_ema(Half)
     *   [15] Q_mean_ema(Full)
     *   [16] |Q|-scale reference EMA (max over bins of |Q_mean|).
     *
     * Consumed by c51_loss_batched / c51_grad_kernel to derive
     *   max_mean      = max(Q_mean(Quarter), Q_mean(Half), Q_mean(Full))
     *   bias_gap      = max(0, max_mean − Q_mean(a1))
     *   collapse_frac = min(1, bias_gap / max(|Q|-scale, eps))
     *   bin_weight    = 1 + collapse_frac * mag_bias_signal(a1)
     *
     * Directly targets C51's "high-variance bins under-priced" signature
     * (experience_kernels.cu:~904). Self-disables when Full is the argmax
     * — bias_gap becomes 0 for Full samples, bin_weight → 1.
     *
     * EMA tau=0.05 matches the existing alpha=0.05 pattern (20-step window).
     * NULL-guarded — missing inputs leave the slots unchanged (no regression
     * against callers that do not supply the pointers).
     *
     * NOT rotated through isv_history — already EMA-smoothed. isv_forward
     * reads only the first 12 slots (the encoder/gate path is unchanged). */
    if (q_mag_means_ptr != 0) {
        int ub = (mag_size < 3) ? mag_size : 3;   /* clamp to the 3 reserved slots */
        for (int k = 0; k < ub; k++) {
            int slot = 13 + k;
            isv_signals[slot] = (1.0f - alpha) * isv_signals[slot]
                              + alpha * q_mag_means_ptr[k];
        }
    }
    if (q_abs_ref_ptr != 0) {
        /* Outlier clamp (ISV audit 2026-04-23): a single Q-scale spike of
         * ±10^5 at α=0.05 previously took ~20 epochs to decay, silently
         * inflating the C51 bin_weight denominator and disabling the
         * adaptive mechanism for the entire run. Clamp the raw input to
         * 10×current EMA + 1 so one bad epoch's pollution is bounded to
         * 10× its own magnitude rather than leaking for 20+ epochs.
         * +1 floor handles the cold-start case where EMA is near 0. */
        float cur = isv_signals[16];
        float raw = fmaxf(q_abs_ref_ptr[0], 0.0f);
        float cap = 10.0f * cur + 1.0f;
        float clamped = (raw < cap) ? raw : cap;
        isv_signals[16] = (1.0f - alpha) * cur + alpha * clamped;
    }

    /* ── Task 2.Y "make direction branch useful at eval": per-direction Q-mean
     *    EMAs [17..20] and absolute-Q-scale reference EMA [21] ───────────
     *
     * Direct structural mirror of Task 2.X's magnitude fix above, applied to
     * branch 0 (direction). Consumed by c51_loss_batched / c51_grad_kernel to
     * derive
     *   max_mean      = max(Q_mean(Short), Q_mean(Hold), Q_mean(Long), Q_mean(Flat))
     *   bias_gap      = max(0, max_mean − Q_mean(a0))
     *   collapse_frac = min(1, bias_gap / max(|Q|-scale, eps) + (1 - learning_health))
     *   bin_weight    = 1 + collapse_frac * dir_bias_signal(a0)
     *
     * where dir_bias_signal[k] = {1.0, 0.5, 0.0, 1.0} for Short/Hold/Long/Flat
     * — architectural shape that amplifies tradable directions (Short/Long)
     * and never amplifies Flat (the already-over-estimated low-variance bin
     * that C51's expected-Q structurally favours).
     *
     * EMA tau=0.05 matches the existing alpha=0.05 pattern used for slots
     * [13..16] (Task 2.X) and the rest of the ISV bus.
     *
     * NULL-guarded — missing inputs leave the slots unchanged, preserving
     * backward compatibility during staged build-outs.
     *
     * NOT rotated through isv_history — already EMA-smoothed. isv_forward
     * reads only the first 12 slots. */
    if (q_dir_means_ptr != 0) {
        int ub = (dir_size < 4) ? dir_size : 4;   /* clamp to the 4 reserved slots */
        for (int k = 0; k < ub; k++) {
            int slot = 17 + k;
            isv_signals[slot] = (1.0f - alpha) * isv_signals[slot]
                              + alpha * q_dir_means_ptr[k];
        }
    }
    if (q_dir_abs_ref_ptr != 0) {
        /* Outlier clamp (ISV audit 2026-04-23): same rationale as slot 16
         * above. Slot 21 is especially load-bearing because it feeds the
         * Kelly conviction denominator (`conviction = q_range /
         * q_dir_abs_ref` at experience_kernels.cu:1189), so a single
         * ±10^5 direction-Q excursion previously pushed conviction → ~0
         * for ~20 epochs, triggering the Kelly warmup-floor collapse
         * path and shrinking positions exactly when the policy was
         * diverging. */
        float cur = isv_signals[21];
        float raw = fmaxf(q_dir_abs_ref_ptr[0], 0.0f);
        float cap = 10.0f * cur + 1.0f;
        float clamped = (raw < cap) ? raw : cap;
        isv_signals[21] = (1.0f - alpha) * cur + alpha * clamped;
    }
}

/* ================================================================== */
/* Kernel: isv_forward — ISV encoder MLP + branch gate + gamma mod    */
/* ================================================================== */

extern "C" __global__ void isv_forward(
    const float* __restrict__ isv_signals,    /* [12] pinned */
    const float* __restrict__ isv_history,    /* [K*12] pinned */
    const float* __restrict__ isv_decay,      /* [12] pinned learned decay */
    const float* __restrict__ w_fc1,          /* [16, 12] */
    const float* __restrict__ b_fc1,          /* [16] */
    const float* __restrict__ w_fc2,          /* [8, 16] */
    const float* __restrict__ b_fc2,          /* [8] */
    const float* __restrict__ w_gate,         /* [4, 8] */
    const float* __restrict__ b_gate,         /* [4] */
    const float* __restrict__ w_gamma,        /* [8] (flat) */
    const float* __restrict__ b_gamma,        /* [1] */
    float* __restrict__ isv_embedding_out,    /* [8] */
    float* __restrict__ branch_gate_out,      /* [4] */
    float* __restrict__ gamma_mod_out,        /* [1] */
    int K
) {
    if (threadIdx.x != 0) return;

    /* Step 1: Temporal ISV — decay-weighted average over 12 signals */
    float isv_temporal[12];
    for (int k = 0; k < 12; k++) {
        float decay = 1.0f / (1.0f + expf(-isv_decay[k]));
        float weighted_sum = isv_signals[k];
        float weight_sum = 1.0f;
        for (int t = 0; t < K; t++) {
            float w = powf(decay, (float)(t + 1));
            weighted_sum += w * isv_history[t * 12 + k];
            weight_sum += w;
        }
        isv_temporal[k] = weighted_sum / weight_sum;
    }

    /* Step 2: MLP — FC1 (12→16, ReLU) → FC2 (16→8) */
    float hidden[16];
    for (int j = 0; j < 16; j++) {
        float val = b_fc1[j];
        for (int k = 0; k < 12; k++)
            val += w_fc1[j * 12 + k] * isv_temporal[k];
        hidden[j] = fmaxf(val, 0.0f);
    }
    float embedding[8];
    for (int k = 0; k < 8; k++) {
        float val = b_fc2[k];
        for (int j = 0; j < 16; j++)
            val += w_fc2[k * 16 + j] * hidden[j];
        embedding[k] = val;
        isv_embedding_out[k] = val;
    }

    /* Step 3: Branch gating — softmax(W_gate @ embedding + b_gate) */
    float logits[4];
    float max_logit = -1e9f;
    for (int d = 0; d < 4; d++) {
        float val = b_gate[d];
        for (int k = 0; k < 8; k++)
            val += w_gate[d * 8 + k] * embedding[k];
        logits[d] = val;
        max_logit = fmaxf(max_logit, logits[d]);
    }
    float sum_exp = 0.0f;
    for (int d = 0; d < 4; d++) {
        logits[d] = expf(logits[d] - max_logit);
        sum_exp += logits[d];
    }
    for (int d = 0; d < 4; d++)
        branch_gate_out[d] = logits[d] / sum_exp;

    /* Step 4: Drift-conditioned gamma — 0.5 + 0.5*sigmoid(w@emb+b) → [0.5, 1.0] */
    float gamma_raw = b_gamma[0];
    for (int k = 0; k < 8; k++)
        gamma_raw += w_gamma[k] * embedding[k];
    gamma_mod_out[0] = 0.5f + 0.5f / (1.0f + expf(-gamma_raw));
}

/* ================================================================== */
/* Kernel: fill_gamma_buf — broadcast scalar gamma to per-sample buf  */
/* ================================================================== */
extern "C" __global__ void fill_gamma_buf(
    float* __restrict__ gamma_buf,          /* [B] output */
    float base_gamma,
    const float* __restrict__ gamma_mod,    /* [1] from isv_forward */
    int B
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= B) return;
    gamma_buf[i] = base_gamma * gamma_mod[0];
}

/* ================================================================== */
/* Kernel: branch_confidence_routing — ISV gate × Q-value confidence  */
/* ================================================================== */
extern "C" __global__ void branch_confidence_routing(
    float* __restrict__ q_values,           /* [B, total_actions] in-place */
    const float* __restrict__ branch_gate,  /* [4] shared from isv_forward */
    int B,
    int b0_size, int b1_size, int b2_size, int b3_size
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= B) return;

    int branch_sizes[4] = { b0_size, b1_size, b2_size, b3_size };
    int total_actions = b0_size + b1_size + b2_size + b3_size;
    int branch_offset = 0;

    for (int d = 0; d < 4; d++) {
        int A_d = branch_sizes[d];

        float q_max = -1e9f;
        float q_sum = 0.0f;
        for (int a = 0; a < A_d; a++) {
            float q = q_values[(long long)i * total_actions + branch_offset + a];
            q_max = fmaxf(q_max, q);
            q_sum += q;
        }
        float q_mean_branch = q_sum / (float)A_d;
        float separation = q_max - q_mean_branch;
        float confidence = 1.0f / (1.0f + expf(-5.0f * separation));

        float isv_gate = branch_gate[d];
        float effective_weight = isv_gate * fmaxf(confidence, 0.3f);

        for (int a = 0; a < A_d; a++) {
            q_values[(long long)i * total_actions + branch_offset + a] *= effective_weight;
        }
        branch_offset += A_d;
    }
}

/* ================================================================== */
/* Kernel: recursive_confidence_forward — predict own TD-error        */
/* ================================================================== */
extern "C" __global__ void recursive_confidence_forward(
    const float* __restrict__ h_s2,       /* [B, SH2] */
    const float* __restrict__ w_conf,     /* [SH2] */
    const float* __restrict__ b_conf,     /* [1] */
    float* __restrict__ predicted_error,  /* [B] */
    int B, int SH2
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= B) return;

    const float* h = h_s2 + (long long)i * SH2;
    float val = b_conf[0];
    for (int k = 0; k < SH2; k++)
        val += w_conf[k] * h[k];
    predicted_error[i] = 1.0f / (1.0f + expf(-val));
}

/* ================================================================== */
/* Kernel: recursive_confidence_backward — MSE grad into trunk        */
/* ================================================================== */
/**
 * Per-sample trunk gradient + per-block weight gradient partials.
 * d_h_s2[i, k] += d_sigmoid * w_conf[k]  (plain write — single writer per (i,k)).
 * Weight gradients are reduced within each block (warp shuffle + shared mem)
 * and written to per-block partial buffers. A separate reduce kernel sums
 * the partials deterministically — zero atomicAdd.
 *
 * Grid: ceil(B/256), Block: 256.
 */
extern "C" __global__ void recursive_confidence_backward(
    const float* __restrict__ h_s2,             /* [B, SH2] */
    const float* __restrict__ predicted_error,  /* [B] */
    const float* __restrict__ lagged_td_error,  /* [1] pinned — target */
    const float* __restrict__ w_conf,           /* [SH2] */
    float* __restrict__ d_conf_partials,        /* [num_blocks, SH2 + 1] block partials */
    float* __restrict__ d_h_s2,                 /* [B, SH2] trunk gradient (accumulate) */
    int B, int SH2,
    float loss_weight                            /* 0.01 */
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    int stride = SH2 + 1;  /* per-block output: SH2 weight grads + 1 bias grad */

    float d_sigmoid = 0.0f;
    if (i < B) {
        float pred = predicted_error[i];
        float target = lagged_td_error[0];
        float d_loss = loss_weight * 2.0f * (pred - target) / (float)B;
        d_sigmoid = d_loss * pred * (1.0f - pred);

        /* Per-sample trunk gradient — no cross-sample contention, plain write */
        for (int k = 0; k < SH2; k++) {
            d_h_s2[(long long)i * SH2 + k] += d_sigmoid * w_conf[k];
        }
    }

    int warp_id = threadIdx.x / 32;
    int lane    = threadIdx.x % 32;

    /* Bias gradient: block-reduced partial → d_conf_partials[block, SH2] */
    {
        float my_db = d_sigmoid;
        for (int offset = 16; offset > 0; offset >>= 1)
            my_db += __shfl_xor_sync(0xFFFFFFFF, my_db, offset);

        __shared__ float ws_db[8];
        if (lane == 0) ws_db[warp_id] = my_db;
        __syncthreads();

        if (warp_id == 0) {
            float val = (lane < blockDim.x / 32) ? ws_db[lane] : 0.0f;
            for (int off = 16; off > 0; off >>= 1)
                val += __shfl_xor_sync(0xFFFFFFFF, val, off);
            if (lane == 0)
                d_conf_partials[blockIdx.x * stride + SH2] = val;
        }
    }

    /* Weight gradient: block-reduced partial → d_conf_partials[block, k] */
    for (int k = 0; k < SH2; k++) {
        float my_dw = (i < B) ? d_sigmoid * h_s2[(long long)i * SH2 + k] : 0.0f;

        for (int offset = 16; offset > 0; offset >>= 1)
            my_dw += __shfl_xor_sync(0xFFFFFFFF, my_dw, offset);

        __shared__ float ws_dw[8];
        if (lane == 0) ws_dw[warp_id] = my_dw;
        __syncthreads();

        if (warp_id == 0) {
            float val = (lane < blockDim.x / 32) ? ws_dw[lane] : 0.0f;
            for (int off = 16; off > 0; off >>= 1)
                val += __shfl_xor_sync(0xFFFFFFFF, val, off);
            if (lane == 0)
                d_conf_partials[blockIdx.x * stride + k] = val;
        }
        __syncthreads();
    }
}

/* ================================================================== */
/* Kernel: recursive_confidence_reduce — deterministic block partial   */
/*         reduction for weight gradients. Zero atomicAdd.             */
/* ================================================================== */
/**
 * One thread per output parameter (SH2 + 1 total).
 * Loops over block partials in fixed order → fully deterministic.
 * Grid: ceil((SH2+1)/256), Block: 256.
 */
extern "C" __global__ void recursive_confidence_reduce(
    const float* __restrict__ d_conf_partials,  /* [num_blocks, SH2 + 1] */
    float* __restrict__ d_w_conf,               /* [SH2] output */
    float* __restrict__ d_b_conf,               /* [1] output */
    int num_blocks, int SH2)
{
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int total = SH2 + 1;
    if (idx >= total) return;

    int stride = SH2 + 1;
    float sum = 0.0f;
    for (int blk = 0; blk < num_blocks; blk++) {
        sum += d_conf_partials[blk * stride + idx];
    }

    if (idx < SH2)
        d_w_conf[idx] += sum;
    else
        *d_b_conf += sum;
}

/* ================================================================== */
/* Kernel: isv_feature_gate — ISV embedding modulates h_s2 features   */
/* ================================================================== */
extern "C" __global__ void isv_feature_gate(
    float* __restrict__ h_s2,                    /* [B, SH2] in-place gating */
    const float* __restrict__ isv_embedding,     /* [8] shared (from isv_forward) */
    const float* __restrict__ w_feature_gate,    /* [SH2, 8] */
    const float* __restrict__ b_feature_gate,    /* [SH2] */
    int B, int SH2
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= B) return;

    for (int j = 0; j < SH2; j++) {
        float gate_val = b_feature_gate[j];
        for (int k = 0; k < 8; k++)
            gate_val += w_feature_gate[(long long)j * 8 + k] * isv_embedding[k];
        float gate = 1.0f / (1.0f + expf(-gate_val));
        /* In-place: creates WAW dependency on save_h_s2 between cuBLAS forward
         * and this kernel, which cuBLAS's internal TMA async ops on Hopper (sm_90)
         * require to maintain correct CUDA Graph replay ordering. */
        h_s2[(long long)i * SH2 + j] *= gate;
    }
}

/* ================================================================== */
/* cuBLAS decomposition: trade_plan_activate — bias + per-output      */
/* sigmoid/scaling activations on cuBLAS GEMM output.                 */
/* Reads row-major [B, 6], writes row-major [B, 6].                   */
/* Grid: ceil(B/256), Block: 256                                      */
/* ================================================================== */
extern "C" __global__ void trade_plan_activate(
    const float* __restrict__ pre_out,   /* [B, 6] row-major (cuBLAS output) */
    const float* __restrict__ bias,      /* [6] */
    float* __restrict__ plan_params,     /* [B, 6] row-major output */
    int B
) {
    int b = blockIdx.x * blockDim.x + threadIdx.x;
    if (b >= B) return;
    float out[6];
    for (int p = 0; p < 6; p++) {
        out[p] = pre_out[b * 6 + p] + bias[p];
    }
    float* dst = plan_params + b * 6;
    dst[0] = 3.0f + 22.0f / (1.0f + expf(-out[0]));
    dst[1] = 0.0005f + 0.0145f / (1.0f + expf(-out[1]));
    dst[2] = 0.0002f + 0.0048f / (1.0f + expf(-out[2]));
    dst[3] = 1.0f / (1.0f + expf(-out[3]));
    dst[4] = 1.0f / (1.0f + expf(-out[4]));
    dst[5] = 0.5f + 2.5f / (1.0f + expf(-out[5]));
}

/* ─── Plan noise injection for temporal ensemble diversity ────────────
 * Adds ±noise_scale multiplicative Gaussian-like noise to plan_params
 * after the forward pass.  Deterministic via Philox-style hash seeded
 * by (sample_index, step).  Creates temporal diversity — the model sees
 * slightly different plans each step, learning robust behaviour.
 */
extern "C" __global__ void plan_noise_inject(
    float* __restrict__ plan_params,  /* [B, 6] in-place */
    int B,
    int step,         /* adam_step — Philox RNG seed */
    float noise_scale /* 0.05 = ±5% */
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= B) return;

    /* Philox-like hash: deterministic, low-correlation across (i, step) */
    unsigned int h = (unsigned int)((unsigned long long)i * 6364136223846793005ULL
                                    + (unsigned long long)step * 1442695040888963407ULL);
    h ^= h >> 16; h *= 0x45d9f3bU; h ^= h >> 16;

    for (int p = 0; p < 6; p++) {
        /* Extract 5-bit sub-field per plan param → uniform in [-1, 1] */
        float u = ((float)((h >> (p * 5)) & 0x1FU) / 15.5f) - 1.0f;
        float noise = u * noise_scale;
        plan_params[i * 6 + p] *= (1.0f + noise);
    }
}

/* ── OFI embedding MLP input construction ─────────────────────────────────
 *
 * ofi_embed_build_input — Extract the full SL_OFI_DIM OFI input from states_buf.
 *   Canonical layout via state_layout.cuh — see the OFI slot map in
 *   crates/ml-core/src/state_layout.rs for the current assignment.
 *   Previously this kernel read only the first 18 slots (raw 8 + delta 8 +
 *   book_aggression + log_duration) and silently discarded ofi_acceleration
 *   (slot 18) and toxicity_gradient (slot 19) even though they were persisted.
 *   It now reads the full SL_OFI_DIM range, feeding every persisted OFI slot
 *   into the embedding MLP.
 *   Output: row-major [B, SL_OFI_DIM] for cuBLAS sgemm_f32 (row-major trick).
 *   Grid: ceil(B/256), Block: 256.
 */
extern "C" __global__ void ofi_embed_build_input(
    const float* __restrict__ states,
    float* __restrict__ output,
    int B, int state_dim
) {
    int b = blockIdx.x * blockDim.x + threadIdx.x;
    if (b >= B) return;
    const float* s = states + (long long)b * state_dim;
    /* Full OFI [0..SL_OFI_DIM) */
    for (int k = 0; k < SL_OFI_DIM; k++) {
        output[b * SL_OFI_DIM + k] =
            (state_dim > SL_OFI_START + k) ? s[SL_OFI_START + k] : 0.0f;
    }
}

/* ================================================================== */
/* Kernel: backtest_state_gather                                      */
/* Uses same assemble_state() as training — canonical layout.         */
/* Outer loop: one thread per window (simpler than episode-based).    */
/* ================================================================== */
extern "C" __global__ void backtest_state_gather(
    const float* __restrict__ features,      /* [n_windows * max_len * feat_dim] */
    const float* __restrict__ portfolio,     /* [n_windows * 8] live portfolio state */
    const float* __restrict__ plan_isv,      /* [n_windows * SL_PORTFOLIO_PLAN_DIM=7] plan/ISV features */
    float*       __restrict__ states_out,    /* [n_windows * padded_sd] */
    int n_windows,
    int max_len,
    int feat_dim,                             /* = market_dim + ofi_dim = 62 */
    int current_step,
    int padded_sd
) {
    int w = blockIdx.x * blockDim.x + threadIdx.x;
    if (w >= n_windows) return;

    int feat_base = (w * max_len + current_step) * feat_dim;
    int out_base  = w * padded_sd;
    float* out = states_out + out_base;

    /* Market features [0..42) */
    float market[SL_MARKET_DIM];
    for (int k = 0; k < SL_MARKET_DIM; k++)
        market[k] = features[feat_base + k];

    /* OFI [42..62) — appended after market features in feature buffer */
    int market_dim = feat_dim - SL_OFI_DIM;
    float ofi[SL_OFI_DIM];
    for (int k = 0; k < SL_OFI_DIM; k++)
        ofi[k] = features[feat_base + market_dim + k];

    /* MTF [62..78) — compute from price history with 4 lookbacks */
    float mtf[SL_MTF_DIM];
    const int lookbacks[4] = {5, 15, 60, 240};
    int window_bar_base = w * max_len;

    for (int lb = 0; lb < 4; lb++) {
        int N_lb = lookbacks[lb];
        int past_step = current_step - N_lb;
        int slot = lb * 4;

        if (past_step >= 0) {
            int now_off  = (window_bar_base + current_step) * feat_dim;
            int past_off = (window_bar_base + past_step) * feat_dim;
            float close_cur  = features[now_off];
            float close_past = features[past_off];

            /* Return over N bars */
            float ret = (close_past > 0.0f) ? (close_cur - close_past) / close_past : 0.0f;
            mtf[slot + 0] = fmaxf(-10.0f, fminf(10.0f, ret * 100.0f));

            /* Volatility range + volume */
            float max_val = close_cur, min_val = close_cur;
            float vol_sum = 0.0f;
            int vol_count = 0;
            for (int j = past_step; j <= current_step; j++) {
                int j_off = (window_bar_base + j) * feat_dim;
                float v = features[j_off];
                if (v > max_val) max_val = v;
                if (v < min_val) min_val = v;
                if (feat_dim > 4) { vol_sum += features[j_off + 4]; vol_count++; }
            }
            float range = (close_cur > 0.0f) ? (max_val - min_val) / close_cur : 0.0f;
            mtf[slot + 1] = fmaxf(0.0f, fminf(10.0f, range * 100.0f));

            float avg_vol = (vol_count > 0) ? vol_sum / (float)vol_count : 1.0f;
            float cur_vol = (feat_dim > 4) ? features[now_off + 4] : 1.0f;
            mtf[slot + 2] = fmaxf(0.0f, fminf(5.0f, (avg_vol > 0.0f) ? cur_vol / avg_vol : 1.0f));

            float range_size = max_val - min_val;
            mtf[slot + 3] = (range_size > 0.0f) ? (close_cur - min_val) / range_size : 0.5f;
        } else {
            mtf[slot + 0] = 0.0f; mtf[slot + 1] = 0.0f;
            mtf[slot + 2] = 0.0f; mtf[slot + 3] = 0.0f;
        }
    }

    /* Portfolio base [78..86) — read pre-computed from buffer */
    float pf[SL_PORTFOLIO_BASE_DIM];
    for (int k = 0; k < SL_PORTFOLIO_BASE_DIM; k++)
        pf[k] = portfolio[w * SL_PORTFOLIO_BASE_DIM + k];

    /* Plan/ISV [86..92) — read pre-computed from buffer */
    float pisv[SL_PORTFOLIO_PLAN_DIM];
    for (int k = 0; k < SL_PORTFOLIO_PLAN_DIM; k++)
        pisv[k] = plan_isv[w * SL_PORTFOLIO_PLAN_DIM + k];

    /* Single shared assembly call — produces identical layout to training */
    assemble_state(out, market, ofi, mtf, pf, pisv);

    /* Zero-pad [SL_STATE_DIM .. padded_sd) for cuBLAS K-tile alignment */
    for (int k = SL_STATE_DIM; k < padded_sd; k++)
        out[k] = 0.0f;
}

/* ================================================================== */
/* Kernel: backtest_state_gather_chunk                                */
/*                                                                    */
/* Chunked variant of backtest_state_gather: writes [chunk_len, N, PD]*/
/* states directly into chunked_states_buf in a single launch.        */
/*                                                                    */
/* Within a chunk, portfolio_buf and plan_isv_buf are CONSTANT (env   */
/* updates them only at chunk boundary via env_batch_kernel + plan    */
/* state_isv kernel). So each (window, step_offset) thread reads the  */
/* same per-window portfolio + plan_isv and writes a unique state row.*/
/*                                                                    */
/* Layout match: chunked_states_buf[step_offset * N + w] * PD.        */
/* This matches the per-step DtoD copy pattern that this kernel       */
/* replaces (each step's per-window gather copied row-major into the  */
/* chunked buffer at offset step_offset * N * PD).                    */
/*                                                                    */
/* Thread mapping: 1D grid with tid = step_offset * N + w.            */
/*   step_offset = tid / N    (chunk-local step index)                */
/*   w           = tid % N    (window index)                          */
/*   actual_step = start_step + step_offset                           */
/*                                                                    */
/* Total threads = chunk_len * N. Block size 256 (matches single-step */
/* kernel for occupancy parity). Per `feedback_no_atomicadd.md` no    */
/* atomics are used; each thread writes its own row independently.    */
/* ================================================================== */
extern "C" __global__ void backtest_state_gather_chunk(
    const float* __restrict__ features,      /* [n_windows * max_len * feat_dim] */
    const float* __restrict__ portfolio,     /* [n_windows * 8] live portfolio state */
    const float* __restrict__ plan_isv,      /* [n_windows * SL_PORTFOLIO_PLAN_DIM=7] plan/ISV features */
    float*       __restrict__ chunked_states_out, /* [chunk_len * n_windows * padded_sd] */
    int n_windows,
    int max_len,
    int feat_dim,                             /* = market_dim + ofi_dim */
    int start_step,
    int chunk_len,
    int padded_sd
) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int total_threads = n_windows * chunk_len;
    if (tid >= total_threads) return;

    int step_offset = tid / n_windows;
    int w           = tid - step_offset * n_windows;
    int current_step = start_step + step_offset;

    /* Defensive bound: caller guarantees chunk_end <= max_len, but keep
     * a guard so an over-sized launch can never read OOB on an early-
     * terminating window. The env-batch kernel handles per-window
     * window_lens[w] termination — gather can produce stale reads for
     * past-end steps without affecting downstream correctness (env step
     * no-ops once done_buf[w] is set). */
    if (current_step >= max_len) return;

    int feat_base = (w * max_len + current_step) * feat_dim;
    /* Output row = chunked_states_out[(step_offset * n_windows + w) * padded_sd] */
    int out_row   = step_offset * n_windows + w;
    int out_base  = out_row * padded_sd;
    float* out = chunked_states_out + out_base;

    /* Market features [0..42) */
    float market[SL_MARKET_DIM];
    for (int k = 0; k < SL_MARKET_DIM; k++)
        market[k] = features[feat_base + k];

    /* OFI [42..62) — appended after market features in feature buffer */
    int market_dim = feat_dim - SL_OFI_DIM;
    float ofi[SL_OFI_DIM];
    for (int k = 0; k < SL_OFI_DIM; k++)
        ofi[k] = features[feat_base + market_dim + k];

    /* MTF [62..78) — compute from price history with 4 lookbacks */
    float mtf[SL_MTF_DIM];
    const int lookbacks[4] = {5, 15, 60, 240};
    int window_bar_base = w * max_len;

    for (int lb = 0; lb < 4; lb++) {
        int N_lb = lookbacks[lb];
        int past_step = current_step - N_lb;
        int slot = lb * 4;

        if (past_step >= 0) {
            int now_off  = (window_bar_base + current_step) * feat_dim;
            int past_off = (window_bar_base + past_step) * feat_dim;
            float close_cur  = features[now_off];
            float close_past = features[past_off];

            float ret = (close_past > 0.0f) ? (close_cur - close_past) / close_past : 0.0f;
            mtf[slot + 0] = fmaxf(-10.0f, fminf(10.0f, ret * 100.0f));

            float max_val = close_cur, min_val = close_cur;
            float vol_sum = 0.0f;
            int vol_count = 0;
            for (int j = past_step; j <= current_step; j++) {
                int j_off = (window_bar_base + j) * feat_dim;
                float v = features[j_off];
                if (v > max_val) max_val = v;
                if (v < min_val) min_val = v;
                if (feat_dim > 4) { vol_sum += features[j_off + 4]; vol_count++; }
            }
            float range = (close_cur > 0.0f) ? (max_val - min_val) / close_cur : 0.0f;
            mtf[slot + 1] = fmaxf(0.0f, fminf(10.0f, range * 100.0f));

            float avg_vol = (vol_count > 0) ? vol_sum / (float)vol_count : 1.0f;
            float cur_vol = (feat_dim > 4) ? features[now_off + 4] : 1.0f;
            mtf[slot + 2] = fmaxf(0.0f, fminf(5.0f, (avg_vol > 0.0f) ? cur_vol / avg_vol : 1.0f));

            float range_size = max_val - min_val;
            mtf[slot + 3] = (range_size > 0.0f) ? (close_cur - min_val) / range_size : 0.5f;
        } else {
            mtf[slot + 0] = 0.0f; mtf[slot + 1] = 0.0f;
            mtf[slot + 2] = 0.0f; mtf[slot + 3] = 0.0f;
        }
    }

    /* Portfolio base [78..86) — read pre-computed from buffer (constant within chunk) */
    float pf[SL_PORTFOLIO_BASE_DIM];
    for (int k = 0; k < SL_PORTFOLIO_BASE_DIM; k++)
        pf[k] = portfolio[w * SL_PORTFOLIO_BASE_DIM + k];

    /* Plan/ISV [86..92) — read pre-computed from buffer (constant within chunk) */
    float pisv[SL_PORTFOLIO_PLAN_DIM];
    for (int k = 0; k < SL_PORTFOLIO_PLAN_DIM; k++)
        pisv[k] = plan_isv[w * SL_PORTFOLIO_PLAN_DIM + k];

    /* Single shared assembly call — produces identical layout to training */
    assemble_state(out, market, ofi, mtf, pf, pisv);

    /* Zero-pad [SL_STATE_DIM .. padded_sd) for cuBLAS K-tile alignment */
    for (int k = SL_STATE_DIM; k < padded_sd; k++)
        out[k] = 0.0f;
}

/* ================================================================== */
/* Kernel: scatter_intent_chunk                                       */
/* ================================================================== */

/**
 * Scatter per-window, per-step intent magnitude values from the chunked
 * action-select output buffer into a window-major history buffer matching
 * the layout of actions_history_buf.
 *
 * Input layout  (chunk_intent): [chunk_len, n_windows]  — step-major, same
 *                               as ch_actions (written by experience_action_select
 *                               with batch = n_windows * chunk_len and thread
 *                               index i = s * n_windows + w).
 * Output layout (intent_history): [n_windows, max_len] — window-major, mirrors
 *                                 actions_history[w * max_len + current_step].
 *
 * Each thread handles one (window, step_offset) pair. Thread index
 *   t = s * n_windows + w   with w in [0, n), s in [0, chunk_len).
 *
 * Diagnostic-only kernel. Writes within [start_step, start_step + chunk_len);
 * all other positions in intent_history remain zero from reset_evaluation_state.
 */
extern "C" __global__ void scatter_intent_chunk(
    const int* __restrict__ chunk_intent,   /* [chunk_len, n_windows] step-major */
    int*       __restrict__ intent_history, /* [n_windows, max_len]   window-major */
    int n_windows,
    int max_len,
    int start_step,
    int chunk_len
) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int total = n_windows * chunk_len;
    if (tid >= total) return;

    int s = tid / n_windows;   /* step offset within chunk */
    int w = tid - s * n_windows;
    int step = start_step + s;
    if (step < 0 || step >= max_len) return;

    intent_history[w * max_len + step] = chunk_intent[s * n_windows + w];
}
