//! SP18 v2 Phase 0 — D-leg + B-leg diagnostic-emit oracle tests.
//!
//! Plan: docs/superpowers/plans/2026-05-08-sp18-reward-shape-hold-attractor.md
//!
//! Phase 0 is observability-only: kernels write to new ISV slots
//! [493..505) and to a 20-float mapped-pinned diagnostic buffer; no
//! production-path consumer is wired. Tests therefore lock the
//! per-action reward decomposition kernel's bit-exact output against a
//! synthetic CPU oracle and confirm the host-side V_SHARE-trend +
//! TD-error-magnitude diagnostics emit the expected HEALTH_DIAG line
//! formats.
//!
//! Run on a GPU host:
//!
//!     SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//!       cargo test -p ml --test sp18_hold_reward_oracle_tests \
//!         --features cuda -- --ignored --nocapture
//!
//! All B-tests (`#[ignore = "requires GPU"]`) load cubins via the
//! workspace `OUT_DIR` and use `MappedF32Buffer` per
//! `feedback_no_htod_htoh_only_mapped_pinned`.

// ── Layer A (CPU oracle): per-action reward decomposition math ───────────

/// CPU oracle for the SP18 D-leg per-action reward decomposition.
///
/// Construction (4 samples, 4 directions):
///
///   sample 0: dir=Hold,  components=[popart=0.10, cf=0.0, trail=0.0,
///                                    micro=0.20, opp=0.05, bonus=0.0]
///   sample 1: dir=Long,  components=[popart=0.50, cf=0.0, trail=0.0,
///                                    micro=0.30, opp=0.0,  bonus=0.0]
///   sample 2: dir=Short, components=[popart=-0.40, cf=0.0, trail=0.0,
///                                    micro=-0.20, opp=0.0,  bonus=0.0]
///   sample 3: dir=Flat,  components=[popart=0.0,  cf=0.0, trail=0.0,
///                                    micro=0.0,   opp=0.10, bonus=0.0]
///
/// Expected per-bin output (bin order Hold, Long, Short, Flat):
///
///   Hold:  micro=0.20, opp=0.05, popart= 0.10, |abs|=0.35, fire=1.0
///   Long:  micro=0.30, opp=0.0,  popart= 0.50, |abs|=0.80, fire=1.0
///   Short: micro=-0.20, opp=0.0, popart=-0.40, |abs|=0.60, fire=1.0
///   Flat:  micro=0.0,  opp=0.10, popart= 0.0,  |abs|=0.10, fire=1.0
///
/// Pins the per-bin reduction math independent of GPU implementation.
#[test]
fn reward_decomp_per_action_cpu_oracle() {
    // Components: [popart, cf, trail, micro, opp_cost, bonus]
    let rc: Vec<[f32; 6]> = vec![
        [ 0.10, 0.0, 0.0,  0.20,  0.05, 0.0],   // Hold
        [ 0.50, 0.0, 0.0,  0.30,  0.0,  0.0],   // Long
        [-0.40, 0.0, 0.0, -0.20,  0.0,  0.0],   // Short
        [ 0.0,  0.0, 0.0,  0.0,   0.10, 0.0],   // Flat
    ];
    // Logical bin → DIR_*: 0=Hold(1), 1=Long(2), 2=Short(0), 3=Flat(3)
    const BIN_DIRS: [i32; 4] = [1, 2, 0, 3];
    const N: usize = 4;

    // Per-sample dir assignment matches the construction above.
    let dirs: [i32; N] = [1, 2, 0, 3];

    // CPU oracle: row-major 4×5 (bin × col).
    let mut out = [0.0_f32; 20];
    for (bin, target_dir) in BIN_DIRS.iter().enumerate() {
        let mut s_micro  = 0.0f32;
        let mut s_opp    = 0.0f32;
        let mut s_popart = 0.0f32;
        let mut s_abs    = 0.0f32;
        let mut s_count  = 0.0f32;
        let mut s_fire   = 0.0f32;
        for i in 0..N {
            if dirs[i] != *target_dir { continue; }
            let row = rc[i];
            let abs_total: f32 = row.iter().map(|x| x.abs()).sum();
            s_popart += row[0];
            s_micro  += row[3];
            s_opp    += row[4];
            s_abs    += abs_total;
            s_count  += 1.0;
            if abs_total > 0.0 { s_fire += 1.0; }
        }
        let denom = if s_count > 0.0 { s_count } else { 1.0 };
        let o = bin * 5;
        out[o + 0] = if s_count > 0.0 { s_micro  / denom } else { 0.0 };
        out[o + 1] = if s_count > 0.0 { s_opp    / denom } else { 0.0 };
        out[o + 2] = if s_count > 0.0 { s_popart / denom } else { 0.0 };
        out[o + 3] = if s_count > 0.0 { s_abs    / denom } else { 0.0 };
        out[o + 4] = if s_count > 0.0 { s_fire   / denom } else { 0.0 };
    }

    // Hold (bin 0).
    assert!((out[0]  - 0.20).abs() < 1e-6, "hold micro: got {}", out[0]);
    assert!((out[1]  - 0.05).abs() < 1e-6, "hold opp:   got {}", out[1]);
    assert!((out[2]  - 0.10).abs() < 1e-6, "hold popart: got {}", out[2]);
    assert!((out[3]  - 0.35).abs() < 1e-6, "hold abs:   got {}", out[3]);
    assert!((out[4]  - 1.00).abs() < 1e-6, "hold fire:  got {}", out[4]);
    // Long (bin 1).
    assert!((out[5]  - 0.30).abs() < 1e-6, "long micro: got {}", out[5]);
    assert!((out[6]  - 0.00).abs() < 1e-6, "long opp:   got {}", out[6]);
    assert!((out[7]  - 0.50).abs() < 1e-6, "long popart: got {}", out[7]);
    assert!((out[8]  - 0.80).abs() < 1e-6, "long abs:   got {}", out[8]);
    assert!((out[9]  - 1.00).abs() < 1e-6, "long fire:  got {}", out[9]);
    // Short (bin 2).
    assert!((out[10] - (-0.20)).abs() < 1e-6, "short micro: got {}", out[10]);
    assert!((out[11] -   0.00 ).abs() < 1e-6, "short opp:   got {}", out[11]);
    assert!((out[12] - (-0.40)).abs() < 1e-6, "short popart: got {}", out[12]);
    assert!((out[13] -   0.60 ).abs() < 1e-6, "short abs:   got {}", out[13]);
    assert!((out[14] -   1.00 ).abs() < 1e-6, "short fire:  got {}", out[14]);
    // Flat (bin 3).
    assert!((out[15] - 0.00).abs() < 1e-6, "flat micro: got {}", out[15]);
    assert!((out[16] - 0.10).abs() < 1e-6, "flat opp:   got {}", out[16]);
    assert!((out[17] - 0.00).abs() < 1e-6, "flat popart: got {}", out[17]);
    assert!((out[18] - 0.10).abs() < 1e-6, "flat abs:   got {}", out[18]);
    assert!((out[19] - 1.00).abs() < 1e-6, "flat fire:  got {}", out[19]);
}

// ── SP18 v2 Phase 2 Task 2.1 (T1): CPU oracle for `compute_sp18_hold_opportunity_cost` ──

/// CPU-side spec mirror of the `compute_sp18_hold_opportunity_cost` device
/// function in `crates/ml/src/cuda_pipeline/trade_physics.cuh`.
///
/// This is the math contract — the device function must be bit-equivalent
/// to within an f32 rounding budget (ε=1e-5; see Task 2.3 GPU oracle).
/// Per `feedback_no_cpu_test_fallbacks.md` this is **not** a production
/// reference implementation; production runs only the GPU device fn. The
/// CPU oracle exists solely to lock the closed-form math contract during
/// TDD.
///
/// Inputs / outputs match the device fn signature exactly:
///   - `dir_idx != DIR_HOLD` ⇒ returns 0.0 exactly (early exit gate).
///   - DD5(b) MIRRORED sign: positive `next_log_return` ⇒ negative result
///     (opp cost paid for sitting out a winning Long).
///   - `compute_asymmetric_capped_pnl(raw, neg_cap, pos_cap)
///        = fmaxf(neg_cap, fminf(raw, pos_cap))`.
///   - Multiplied by `shaping_scale` — validation mode (=0) ⇒ returns 0.
///
/// EPS=1e-12 mirrors the device fn's vol_proxy guard (numerical safety,
/// not behavioural — production caller's `fmaxf(atr_pct, 0.0001f)` makes
/// this floor a no-op in practice).
fn compute_sp18_hold_opp_cpu(
    dir_idx: i32,
    dir_hold: i32,
    next_log_return: f32,
    vol_proxy: f32,
    isv_neg_cap: f32,
    isv_pos_cap: f32,
    shaping_scale: f32,
) -> f32 {
    if dir_idx != dir_hold {
        return 0.0;
    }
    let vol_normalized_return = next_log_return / (vol_proxy + 1e-12_f32).sqrt();
    let base = 2.0_f32 * vol_normalized_return;
    let raw_opp = -base;
    let capped = isv_neg_cap.max(raw_opp.min(isv_pos_cap));
    capped * shaping_scale
}

const DIR_HOLD_CPU: i32 = 1;   // matches `state_layout.cuh:124`

/// T1 (spec §"Behavioral Test Predictions"): CPU oracle for
/// `compute_sp18_hold_opportunity_cost`.
///
/// Locks the closed-form math contract before the device fn lands. With
/// `next_log_return = +0.005`, `vol_proxy = 0.0001`, `pos_cap = +5.0`,
/// `neg_cap = -10.0`, `shaping_scale = 1.0`:
///
///   - `vol_normalized_return = 0.005 / sqrt(0.0001 + 1e-12) ≈ 0.5`
///   - `base = 2.0 × 0.5 = 1.0`
///   - `raw_opp = -1.0`
///   - `capped = max(-10, min(-1, +5)) = -1.0`
///   - `result = -1.0 × 1.0 = -1.0`
///
/// Verifies: returns negative scalar bounded in [-10, 0] for positive
/// `next_log_return` (opp cost paid for sitting out a winning Long); a
/// flipped-sign positive scalar bounded in [0, +5] for negative
/// `next_log_return` (correctly avoided a losing Long); and 0.0 exactly
/// for `dir_idx != DIR_HOLD`.
#[test]
fn compute_sp18_hold_opportunity_cost_cpu_oracle() {
    // Case A: positive next_log_return, dir=Hold → negative result in [-10, 0].
    let r_pos = compute_sp18_hold_opp_cpu(
        DIR_HOLD_CPU, DIR_HOLD_CPU, 0.005, 0.0001, -10.0, 5.0, 1.0,
    );
    assert!((r_pos - (-1.0)).abs() < 1e-5,
        "pos log-return: expected -1.0, got {r_pos}");
    assert!(r_pos >= -10.0 && r_pos <= 0.0,
        "pos log-return result must be in [-10, 0], got {r_pos}");

    // Case B: negative next_log_return → positive result in [0, +5].
    let r_neg = compute_sp18_hold_opp_cpu(
        DIR_HOLD_CPU, DIR_HOLD_CPU, -0.005, 0.0001, -10.0, 5.0, 1.0,
    );
    assert!((r_neg - 1.0).abs() < 1e-5,
        "neg log-return: expected +1.0, got {r_neg}");
    assert!(r_neg >= 0.0 && r_neg <= 5.0,
        "neg log-return result must be in [0, +5], got {r_neg}");

    // Case C: dir != HOLD → 0.0 exactly (early-exit gate).
    for dir in [0_i32, 2, 3] {  // SHORT, LONG, FLAT
        let r = compute_sp18_hold_opp_cpu(
            dir, DIR_HOLD_CPU, 0.005, 0.0001, -10.0, 5.0, 1.0,
        );
        assert_eq!(r, 0.0,
            "dir={dir} (non-HOLD): expected 0.0 exactly, got {r}");
    }

    // Case D: positive saturation — large negative log-return floods +pos_cap.
    // vol_norm = -0.5 / sqrt(0.0001) = -50; base = -100; raw_opp = +100;
    // capped = min(100, +5) = +5; final = +5.0.
    let r_sat_pos = compute_sp18_hold_opp_cpu(
        DIR_HOLD_CPU, DIR_HOLD_CPU, -0.5, 0.0001, -10.0, 5.0, 1.0,
    );
    assert!((r_sat_pos - 5.0).abs() < 1e-5,
        "+pos saturation: expected +5.0, got {r_sat_pos}");

    // Case E: negative saturation — large positive log-return floods -neg_cap.
    // vol_norm = 0.05 / sqrt(0.0001) = 5; base = 10; raw_opp = -10;
    // capped = max(-10, -10) = -10; final = -10.0.
    let r_sat_neg = compute_sp18_hold_opp_cpu(
        DIR_HOLD_CPU, DIR_HOLD_CPU, 0.05, 0.0001, -10.0, 5.0, 1.0,
    );
    assert!((r_sat_neg - (-10.0)).abs() < 1e-5,
        "-neg saturation: expected -10.0, got {r_sat_neg}");

    // Case F: shaping_scale = 0.0 (validation mode) → result = 0.0
    // regardless of inputs, mirroring the SP12 lump-sum opp-cost contract.
    let r_validation = compute_sp18_hold_opp_cpu(
        DIR_HOLD_CPU, DIR_HOLD_CPU, 0.005, 0.0001, -10.0, 5.0, 0.0,
    );
    assert_eq!(r_validation, 0.0,
        "shaping_scale=0 (validation): expected 0.0 exactly, got {r_validation}");
}

// ── Layer B (GPU oracle): kernel matches CPU oracle bit-for-bit ──────────

#[cfg(feature = "cuda")]
#[allow(unsafe_code)]
mod gpu {
    use std::sync::Arc;

    use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
    use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};

    /// Cubin handle for the SP18 D-leg per-action reward decomposition
    /// diagnostic kernel.
    const SP18_REWARD_DECOMP_DIAG_CUBIN: &[u8] =
        include_bytes!(concat!(env!("OUT_DIR"), "/reward_decomp_diag_kernel.cubin"));

    fn make_test_stream() -> Arc<CudaStream> {
        let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
        ctx.default_stream()
    }

    fn load_reward_decomp_diag(stream: &Arc<CudaStream>) -> CudaFunction {
        let module = stream
            .context()
            .load_cubin(SP18_REWARD_DECOMP_DIAG_CUBIN.to_vec())
            .expect("load reward_decomp_diag_kernel cubin");
        module
            .load_function("reward_decomp_diag")
            .expect("load reward_decomp_diag function")
    }

    /// SP18 v2 Phase 0 Task 0.1 — GPU oracle for the per-action reward
    /// decomposition diagnostic kernel.
    ///
    /// Builds a 4-sample synthetic batch matching the CPU oracle above
    /// (one sample per direction-axis bin), launches the kernel, and
    /// asserts the 20-float mapped-pinned output matches the closed-form
    /// expected values bit-for-bit (within f32 rounding budget).
    ///
    /// Branch sizes (b1=b2=b3=3) match production; the factored-action
    /// encoding is `dir * (b1*b2*b3) + ...` so DIR_HOLD=1 → action=27,
    /// DIR_LONG=2 → action=54, DIR_SHORT=0 → action=0, DIR_FLAT=3 →
    /// action=81.
    #[test]
    #[ignore = "requires GPU"]
    fn reward_decomp_per_action_gpu_oracle() {
        let stream = make_test_stream();
        let kernel = load_reward_decomp_diag(&stream);

        const N: usize = 4;
        const NUM_COMPS: usize = 6;
        const NUM_BINS: usize = 4;
        const NUM_COLS: usize = 5;
        const OUT_LEN: usize = NUM_BINS * NUM_COLS;

        // ── Inputs ───────────────────────────────────────────────────
        // reward_components_per_sample[N × 6], row-major.
        let rc_host: Vec<f32> = vec![
            // sample 0 — Hold
            0.10, 0.0, 0.0,  0.20, 0.05, 0.0,
            // sample 1 — Long
            0.50, 0.0, 0.0,  0.30, 0.0,  0.0,
            // sample 2 — Short
           -0.40, 0.0, 0.0, -0.20, 0.0,  0.0,
            // sample 3 — Flat
            0.0,  0.0, 0.0,  0.0,  0.10, 0.0,
        ];
        assert_eq!(rc_host.len(), N * NUM_COMPS);

        let rc_buf = unsafe { MappedF32Buffer::new(N * NUM_COMPS) }
            .expect("alloc rc buf");
        rc_buf.write_from_slice(&rc_host);

        // actions_out[N], factored-action ints. dir = action / (b1*b2*b3).
        const B1: i32 = 3;
        const B2: i32 = 3;
        const B3: i32 = 3;
        const DIR_DIV: i32 = B1 * B2 * B3;   // 27
        // DIR_SHORT=0, DIR_HOLD=1, DIR_LONG=2, DIR_FLAT=3.
        let actions_host: [i32; N] = [
            1 * DIR_DIV,   // Hold
            2 * DIR_DIV,   // Long
            0 * DIR_DIV,   // Short
            3 * DIR_DIV,   // Flat
        ];
        let actions_buf = unsafe { MappedI32Buffer::new(N) }
            .expect("alloc actions buf");
        actions_buf.write_from_slice(&actions_host);

        // Output buffer — 20 floats, mapped-pinned. Pre-fill with NaN
        // so any unwritten lane shows up as a test failure.
        let out_buf = unsafe { MappedF32Buffer::new(OUT_LEN) }
            .expect("alloc out buf");
        out_buf.write_from_slice(&vec![f32::NAN; OUT_LEN]);

        // ── Launch ───────────────────────────────────────────────────
        let n_i32 = N as i32;
        const BLOCK_DIM: u32 = 256;
        const NUM_BIN_BLOCKS: u32 = NUM_BINS as u32;
        // Dynamic shmem = 5 × BLOCK_DIM × sizeof(f32) — matches the
        // kernel's `s_buf` carve-up (5 reduction tiles).
        let shmem_bytes: u32 = 5 * BLOCK_DIM * std::mem::size_of::<f32>() as u32;

        unsafe {
            stream
                .launch_builder(&kernel)
                .arg(&rc_buf.dev_ptr)
                .arg(&actions_buf.dev_ptr)
                .arg(&n_i32)
                .arg(&B1)
                .arg(&B2)
                .arg(&B3)
                .arg(&out_buf.dev_ptr)
                .launch(LaunchConfig {
                    grid_dim: (NUM_BIN_BLOCKS, 1, 1),
                    block_dim: (BLOCK_DIM, 1, 1),
                    shared_mem_bytes: shmem_bytes,
                })
                .expect("launch reward_decomp_diag");
        }
        stream.synchronize().expect("sync after reward_decomp_diag");

        // ── Verify ───────────────────────────────────────────────────
        let out = out_buf.read_all();
        for (i, x) in out.iter().enumerate() {
            assert!(x.is_finite(), "out[{i}] is non-finite ({x}) — \
                    kernel did not write this lane");
        }

        // Closed-form expected values (matches the CPU oracle above).
        let expected: [f32; OUT_LEN] = [
            // Hold
             0.20,  0.05,  0.10, 0.35, 1.00,
            // Long
             0.30,  0.00,  0.50, 0.80, 1.00,
            // Short
            -0.20,  0.00, -0.40, 0.60, 1.00,
            // Flat
             0.00,  0.10,  0.00, 0.10, 1.00,
        ];

        let eps = 1e-6_f32;
        for i in 0..OUT_LEN {
            let bin = i / NUM_COLS;
            let col = i % NUM_COLS;
            let bin_name = match bin { 0 => "Hold", 1 => "Long", 2 => "Short", 3 => "Flat", _ => "?" };
            let col_name = match col { 0 => "micro", 1 => "opp", 2 => "popart", 3 => "abs", 4 => "fire", _ => "?" };
            assert!(
                (out[i] - expected[i]).abs() < eps,
                "out[{bin_name}.{col_name}] expected {:.6}, got {:.6} (diff {:.3e})",
                expected[i], out[i], (out[i] - expected[i]).abs(),
            );
        }
    }

    /// Cubin handle for the SP18 B-leg TD-error magnitude EMA producer.
    const SP18_TD_ERROR_MAG_EMA_CUBIN: &[u8] =
        include_bytes!(concat!(env!("OUT_DIR"), "/td_error_mag_ema_kernel.cubin"));

    fn load_td_error_mag_ema(stream: &Arc<CudaStream>) -> CudaFunction {
        let module = stream
            .context()
            .load_cubin(SP18_TD_ERROR_MAG_EMA_CUBIN.to_vec())
            .expect("load td_error_mag_ema_kernel cubin");
        module
            .load_function("td_error_mag_ema_update")
            .expect("load td_error_mag_ema_update function")
    }

    /// SP18 v2 Phase 0 Task 0.2 — TD-error magnitude EMA Pearl-A
    /// bootstrap test.
    ///
    /// Construct a synthetic td_errors buffer with known magnitudes,
    /// pre-populate the ISV slot with the sentinel (0.0), launch the
    /// kernel, and assert the slot equals the closed-form mean(|td|)
    /// (Pearl-A direct-replace on first observation).
    ///
    /// td_errors = [-1.0, +2.0, -0.5, +0.5, -1.0, +1.0, 0.0, +3.0]
    /// mean(|td|) = (1.0+2.0+0.5+0.5+1.0+1.0+0.0+3.0) / 8 = 9.0/8 = 1.125
    #[test]
    #[ignore = "requires GPU"]
    fn td_error_mag_ema_pearl_a_bootstrap() {
        use ml::cuda_pipeline::sp14_isv_slots::{
            TD_ERROR_MAG_EMA_INDEX, SENTINEL_TD_ERROR_MAG_EMA, WELFORD_ALPHA_MIN,
        };

        let stream = make_test_stream();
        let kernel = load_td_error_mag_ema(&stream);

        const B: usize = 8;
        let td_errors_host: [f32; B] = [-1.0, 2.0, -0.5, 0.5, -1.0, 1.0, 0.0, 3.0];
        let expected_mean: f32 = (1.0 + 2.0 + 0.5 + 0.5 + 1.0 + 1.0 + 0.0 + 3.0) / B as f32;

        let td_buf = unsafe { MappedF32Buffer::new(B) }.expect("alloc td buf");
        td_buf.write_from_slice(&td_errors_host);

        // ISV bus: pre-populate slot 493 with sentinel 0.0 so Pearl-A
        // bootstrap fires (first observation REPLACES directly).
        const ISV_SPAN: usize = 505;   // matches ISV_TOTAL_DIM post-PP.2
        let mut isv_host = vec![0.0_f32; ISV_SPAN];
        isv_host[TD_ERROR_MAG_EMA_INDEX] = SENTINEL_TD_ERROR_MAG_EMA;
        let isv_buf = unsafe { MappedF32Buffer::new(ISV_SPAN) }.expect("alloc isv buf");
        isv_buf.write_from_slice(&isv_host);

        let b_i32 = B as i32;
        let ema_idx_i32: i32 = TD_ERROR_MAG_EMA_INDEX as i32;
        let alpha: f32 = WELFORD_ALPHA_MIN;
        let sentinel: f32 = SENTINEL_TD_ERROR_MAG_EMA;
        const BLOCK_DIM: u32 = 256;
        let shmem_bytes: u32 = BLOCK_DIM * std::mem::size_of::<f32>() as u32;

        unsafe {
            stream
                .launch_builder(&kernel)
                .arg(&td_buf.dev_ptr)
                .arg(&b_i32)
                .arg(&isv_buf.dev_ptr)
                .arg(&ema_idx_i32)
                .arg(&alpha)
                .arg(&sentinel)
                .launch(LaunchConfig {
                    grid_dim: (1, 1, 1),
                    block_dim: (BLOCK_DIM, 1, 1),
                    shared_mem_bytes: shmem_bytes,
                })
                .expect("launch td_error_mag_ema_update");
        }
        stream.synchronize().expect("sync");

        let isv_after = isv_buf.read_all();
        let got = isv_after[TD_ERROR_MAG_EMA_INDEX];
        let eps = 1e-6_f32;
        assert!(
            (got - expected_mean).abs() < eps,
            "TD-error mag EMA Pearl-A bootstrap: expected {expected_mean:.6} \
             (== mean|td| since slot was at sentinel), got {got:.6}"
        );
    }

    /// SP18 v2 Phase 0 Task 0.2 — TD-error magnitude EMA blend math.
    ///
    /// Pre-populate the ISV slot with a non-sentinel value (1.0); launch
    /// the kernel against `td_errors = [+0.5, -0.5, +0.5, -0.5]` so
    /// `mean(|td|) = 0.5`; assert the result equals the closed-form
    /// blend `(1 - α) × 1.0 + α × 0.5` with α=0.4.
    #[test]
    #[ignore = "requires GPU"]
    fn td_error_mag_ema_blend_post_bootstrap() {
        use ml::cuda_pipeline::sp14_isv_slots::{
            TD_ERROR_MAG_EMA_INDEX, SENTINEL_TD_ERROR_MAG_EMA, WELFORD_ALPHA_MIN,
        };

        let stream = make_test_stream();
        let kernel = load_td_error_mag_ema(&stream);

        const B: usize = 4;
        let td_errors_host: [f32; B] = [0.5, -0.5, 0.5, -0.5];
        let mean_abs: f32 = 0.5;
        let prev: f32 = 1.0;
        let alpha = WELFORD_ALPHA_MIN;
        let expected = (1.0 - alpha) * prev + alpha * mean_abs;

        let td_buf = unsafe { MappedF32Buffer::new(B) }.expect("alloc td buf");
        td_buf.write_from_slice(&td_errors_host);

        const ISV_SPAN: usize = 505;
        let mut isv_host = vec![0.0_f32; ISV_SPAN];
        isv_host[TD_ERROR_MAG_EMA_INDEX] = prev;   // NOT sentinel — blend path
        let isv_buf = unsafe { MappedF32Buffer::new(ISV_SPAN) }.expect("alloc isv buf");
        isv_buf.write_from_slice(&isv_host);

        let b_i32 = B as i32;
        let ema_idx_i32: i32 = TD_ERROR_MAG_EMA_INDEX as i32;
        let sentinel: f32 = SENTINEL_TD_ERROR_MAG_EMA;
        const BLOCK_DIM: u32 = 256;
        let shmem_bytes: u32 = BLOCK_DIM * std::mem::size_of::<f32>() as u32;

        unsafe {
            stream
                .launch_builder(&kernel)
                .arg(&td_buf.dev_ptr)
                .arg(&b_i32)
                .arg(&isv_buf.dev_ptr)
                .arg(&ema_idx_i32)
                .arg(&alpha)
                .arg(&sentinel)
                .launch(LaunchConfig {
                    grid_dim: (1, 1, 1),
                    block_dim: (BLOCK_DIM, 1, 1),
                    shared_mem_bytes: shmem_bytes,
                })
                .expect("launch td_error_mag_ema_update");
        }
        stream.synchronize().expect("sync");

        let isv_after = isv_buf.read_all();
        let got = isv_after[TD_ERROR_MAG_EMA_INDEX];
        let eps = 1e-6_f32;
        assert!(
            (got - expected).abs() < eps,
            "TD-error mag EMA post-bootstrap blend: expected {expected:.6} \
             (= (1 - {alpha}) × {prev} + {alpha} × {mean_abs}), got {got:.6}"
        );
    }

    /// Empty-bin guard test — when no samples land in a given bin,
    /// the kernel must emit 0.0 for every column (NOT NaN, which would
    /// be the natural division-by-zero default). The HEALTH_DIAG
    /// consumer relies on this contract: a zero block is interpreted as
    /// "bin saw no traffic this epoch", whereas NaN would cascade
    /// downstream into the KILL CRITERION arithmetic.
    #[test]
    #[ignore = "requires GPU"]
    fn reward_decomp_empty_bin_emits_zero_not_nan() {
        let stream = make_test_stream();
        let kernel = load_reward_decomp_diag(&stream);

        const N: usize = 1;
        const NUM_BINS: usize = 4;
        const NUM_COLS: usize = 5;
        const OUT_LEN: usize = NUM_BINS * NUM_COLS;

        // Single Long-only sample → bins Hold/Short/Flat are all empty.
        let rc_host: Vec<f32> = vec![0.5, 0.0, 0.0, 0.3, 0.0, 0.0];
        let rc_buf = unsafe { MappedF32Buffer::new(rc_host.len()) }
            .expect("alloc rc buf");
        rc_buf.write_from_slice(&rc_host);

        const B1: i32 = 3; const B2: i32 = 3; const B3: i32 = 3;
        const DIR_DIV: i32 = B1 * B2 * B3;
        let actions_host: [i32; N] = [2 * DIR_DIV];   // Long
        let actions_buf = unsafe { MappedI32Buffer::new(N) }
            .expect("alloc actions buf");
        actions_buf.write_from_slice(&actions_host);

        let out_buf = unsafe { MappedF32Buffer::new(OUT_LEN) }
            .expect("alloc out buf");
        out_buf.write_from_slice(&vec![f32::NAN; OUT_LEN]);

        let n_i32 = N as i32;
        const BLOCK_DIM: u32 = 256;
        let shmem_bytes: u32 = 5 * BLOCK_DIM * std::mem::size_of::<f32>() as u32;
        unsafe {
            stream
                .launch_builder(&kernel)
                .arg(&rc_buf.dev_ptr)
                .arg(&actions_buf.dev_ptr)
                .arg(&n_i32)
                .arg(&B1)
                .arg(&B2)
                .arg(&B3)
                .arg(&out_buf.dev_ptr)
                .launch(LaunchConfig {
                    grid_dim: (NUM_BINS as u32, 1, 1),
                    block_dim: (BLOCK_DIM, 1, 1),
                    shared_mem_bytes: shmem_bytes,
                })
                .expect("launch reward_decomp_diag");
        }
        stream.synchronize().expect("sync");

        let out = out_buf.read_all();
        // Hold (bin 0), Short (bin 2), Flat (bin 3) — all 5 cols must be 0.0.
        for &bin in &[0_usize, 2, 3] {
            for col in 0..NUM_COLS {
                let v = out[bin * NUM_COLS + col];
                assert!(
                    v == 0.0,
                    "empty-bin guard: bin={bin} col={col} expected 0.0, got {v}"
                );
            }
        }
        // Long (bin 1) — non-zero (sanity).
        assert!(out[1 * NUM_COLS + 0] > 0.0, "long micro should be > 0");
        assert!(out[1 * NUM_COLS + 4] > 0.0, "long fire  should be > 0");
    }

    // ── SP18 v2 Phase 2 Task 2.3 (T2): GPU oracle for `compute_sp18_hold_opportunity_cost` ──

    /// Cubin handle for the SP18 D-leg structural Hold opportunity-cost
    /// device-function GPU oracle test wrapper.
    const SP18_HOLD_OPP_TEST_CUBIN: &[u8] =
        include_bytes!(concat!(env!("OUT_DIR"), "/sp18_hold_opp_test_kernel.cubin"));

    fn load_sp18_hold_opp_test(stream: &Arc<CudaStream>) -> CudaFunction {
        let module = stream
            .context()
            .load_cubin(SP18_HOLD_OPP_TEST_CUBIN.to_vec())
            .expect("load sp18_hold_opp_test_kernel cubin");
        module
            .load_function("sp18_hold_opp_test_kernel")
            .expect("load sp18_hold_opp_test_kernel function")
    }

    const DIR_HOLD_GPU: i32 = 1;   // matches `state_layout.cuh:124`

    /// CPU mirror for the GPU oracle assertions — duplicates the
    /// production CPU oracle helper at the top of this file (which lives
    /// outside the `gpu` mod due to `#[cfg(feature = "cuda")]` gating).
    /// Keeping the math inline preserves the per-test readability without
    /// re-exporting helpers across module boundaries.
    fn cpu_oracle(
        dir_idx: i32,
        next_log_return: f32,
        vol_proxy: f32,
        isv_neg_cap: f32,
        isv_pos_cap: f32,
        shaping_scale: f32,
    ) -> f32 {
        if dir_idx != DIR_HOLD_GPU {
            return 0.0;
        }
        let vnr = next_log_return / (vol_proxy + 1e-12_f32).sqrt();
        let base = 2.0_f32 * vnr;
        let raw = -base;
        let capped = isv_neg_cap.max(raw.min(isv_pos_cap));
        capped * shaping_scale
    }

    /// Single-shot GPU oracle launch: writes one f32 to a 1-element
    /// mapped-pinned output buffer; reads back via `read_volatile` after
    /// stream sync.
    fn launch_sp18_hold_opp(
        stream: &Arc<CudaStream>,
        kernel: &CudaFunction,
        dir_idx: i32,
        next_log_return: f32,
        vol_proxy: f32,
        isv_neg_cap: f32,
        isv_pos_cap: f32,
        shaping_scale: f32,
    ) -> f32 {
        let out_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc out buf");
        // Pre-fill with NaN so any unwritten output trips a finite-check
        // failure in the caller (locks the kernel actually wrote).
        out_buf.write_from_slice(&[f32::NAN]);

        let dir_hold = DIR_HOLD_GPU;
        unsafe {
            stream
                .launch_builder(kernel)
                .arg(&dir_idx)
                .arg(&dir_hold)
                .arg(&next_log_return)
                .arg(&vol_proxy)
                .arg(&isv_neg_cap)
                .arg(&isv_pos_cap)
                .arg(&shaping_scale)
                .arg(&out_buf.dev_ptr)
                .launch(LaunchConfig {
                    grid_dim: (1, 1, 1),
                    block_dim: (1, 1, 1),
                    shared_mem_bytes: 0,
                })
                .expect("launch sp18_hold_opp_test_kernel");
        }
        stream.synchronize().expect("sync after sp18_hold_opp_test_kernel");

        out_buf.read_all()[0]
    }

    /// T2 (spec §"Behavioral Test Predictions"): GPU oracle parity with
    /// CPU oracle. Drives every Case A-F from the CPU oracle test through
    /// the production device function via the test wrapper kernel and
    /// asserts bit-equality to ε=1e-5 (f32 rounding budget — `expf`,
    /// `sqrtf`, and the asymmetric clamp are deterministic to within a
    /// few ulps on Ampere/Ada).
    #[test]
    #[ignore = "requires GPU"]
    fn compute_sp18_hold_opportunity_cost_gpu_oracle() {
        let stream = make_test_stream();
        let kernel = load_sp18_hold_opp_test(&stream);

        let eps = 1e-5_f32;

        // Case A: positive next_log_return, dir=Hold → ~-1.0 (in [-10, 0]).
        let inputs_a = (DIR_HOLD_GPU, 0.005_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 1.0_f32);
        let cpu_a = cpu_oracle(inputs_a.0, inputs_a.1, inputs_a.2, inputs_a.3, inputs_a.4, inputs_a.5);
        let gpu_a = launch_sp18_hold_opp(
            &stream, &kernel, inputs_a.0, inputs_a.1, inputs_a.2, inputs_a.3, inputs_a.4, inputs_a.5,
        );
        assert!(gpu_a.is_finite(), "Case A: GPU output non-finite ({gpu_a})");
        assert!((gpu_a - cpu_a).abs() < eps,
            "Case A (pos log-return): GPU {gpu_a} vs CPU {cpu_a}");

        // Case B: negative next_log_return → +1.0 (in [0, +5]).
        let inputs_b = (DIR_HOLD_GPU, -0.005_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 1.0_f32);
        let cpu_b = cpu_oracle(inputs_b.0, inputs_b.1, inputs_b.2, inputs_b.3, inputs_b.4, inputs_b.5);
        let gpu_b = launch_sp18_hold_opp(
            &stream, &kernel, inputs_b.0, inputs_b.1, inputs_b.2, inputs_b.3, inputs_b.4, inputs_b.5,
        );
        assert!(gpu_b.is_finite(), "Case B: GPU output non-finite ({gpu_b})");
        assert!((gpu_b - cpu_b).abs() < eps,
            "Case B (neg log-return): GPU {gpu_b} vs CPU {cpu_b}");

        // Case C: dir != HOLD → 0.0 exactly (early-exit gate). Test all
        // three non-HOLD directions.
        for dir in [0_i32, 2, 3] {
            let cpu_c = cpu_oracle(dir, 0.005, 0.0001, -10.0, 5.0, 1.0);
            let gpu_c = launch_sp18_hold_opp(
                &stream, &kernel, dir, 0.005, 0.0001, -10.0, 5.0, 1.0,
            );
            assert_eq!(gpu_c, 0.0,
                "Case C dir={dir} (non-HOLD): GPU expected 0.0 exactly, got {gpu_c}");
            assert_eq!(cpu_c, 0.0,
                "Case C dir={dir} (non-HOLD): CPU expected 0.0 exactly, got {cpu_c}");
        }

        // Case D: positive saturation (+pos_cap).
        let inputs_d = (DIR_HOLD_GPU, -0.5_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 1.0_f32);
        let cpu_d = cpu_oracle(inputs_d.0, inputs_d.1, inputs_d.2, inputs_d.3, inputs_d.4, inputs_d.5);
        let gpu_d = launch_sp18_hold_opp(
            &stream, &kernel, inputs_d.0, inputs_d.1, inputs_d.2, inputs_d.3, inputs_d.4, inputs_d.5,
        );
        assert!((gpu_d - cpu_d).abs() < eps,
            "Case D (+pos saturation): GPU {gpu_d} vs CPU {cpu_d}");
        assert!((gpu_d - 5.0).abs() < eps,
            "Case D: expected exact +pos_cap clamp at +5.0, got {gpu_d}");

        // Case E: negative saturation (-neg_cap).
        let inputs_e = (DIR_HOLD_GPU, 0.05_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 1.0_f32);
        let cpu_e = cpu_oracle(inputs_e.0, inputs_e.1, inputs_e.2, inputs_e.3, inputs_e.4, inputs_e.5);
        let gpu_e = launch_sp18_hold_opp(
            &stream, &kernel, inputs_e.0, inputs_e.1, inputs_e.2, inputs_e.3, inputs_e.4, inputs_e.5,
        );
        assert!((gpu_e - cpu_e).abs() < eps,
            "Case E (-neg saturation): GPU {gpu_e} vs CPU {cpu_e}");
        assert!((gpu_e - (-10.0)).abs() < eps,
            "Case E: expected exact -neg_cap clamp at -10.0, got {gpu_e}");

        // Case F: shaping_scale=0 (validation mode) → 0.0 regardless.
        let inputs_f = (DIR_HOLD_GPU, 0.005_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 0.0_f32);
        let gpu_f = launch_sp18_hold_opp(
            &stream, &kernel, inputs_f.0, inputs_f.1, inputs_f.2, inputs_f.3, inputs_f.4, inputs_f.5,
        );
        assert_eq!(gpu_f, 0.0,
            "Case F (shaping_scale=0): expected 0.0 exactly, got {gpu_f}");
    }

    // ── SP18 Phase 3 — D-leg adaptive HOLD_REWARD_POS/NEG_CAP producer ──
    //
    // Tests for `hold_reward_cap_update_kernel.cu`. Mirrors the SP14 P0-A
    // `reward_cap_update_kernel` test pattern (sp14_oracle_tests.rs) with
    // three structural differences:
    //   1. Filter: (is_close && step_ret != 0) — both winners AND losers
    //      contribute, since the consumer needs the magnitude scale of
    //      *all* Long/Short closes.
    //   2. Welford accumulators in slots [487..493) feed Wiener-optimal α
    //      — sample_count < 3 → α=1.0 (REPLACE), else
    //      α ∈ [WELFORD_ALPHA_MIN=0.4, WELFORD_ALPHA_MAX=0.95].
    //   3. Bounds [HOLD_REWARD_POS_CAP_MIN_BOUND=0.5,
    //      HOLD_REWARD_POS_CAP_MAX_BOUND=50.0] (vs. [1.0, 50.0] for the
    //      position-side cap).
    //
    // All tests are #[ignore = "requires GPU"]; gated under
    // `#[cfg(feature = "cuda")]` (we are already inside that gate).

    /// Cubin handle for the SP18 D-leg adaptive Hold-reward-cap producer.
    const SP18_HOLD_REWARD_CAP_UPDATE_CUBIN: &[u8] =
        include_bytes!(concat!(env!("OUT_DIR"), "/hold_reward_cap_update_kernel.cubin"));

    fn load_hold_reward_cap_update(stream: &Arc<CudaStream>) -> CudaFunction {
        let module = stream
            .context()
            .load_cubin(SP18_HOLD_REWARD_CAP_UPDATE_CUBIN.to_vec())
            .expect("load hold_reward_cap_update_kernel cubin");
        module
            .load_function("hold_reward_cap_update")
            .expect("load hold_reward_cap_update function")
    }

    /// 4 arrays × 256 × 4 bytes = 4096 bytes shmem (matches kernel).
    const HRC_BLK_DIM: u32 = 256;
    const HRC_SMEM_BYTES: u32 = 4 * HRC_BLK_DIM * std::mem::size_of::<f32>() as u32;

    /// Helper: launch the SP18 hold-reward-cap producer with a fixed
    /// config. Mirrors the position-side `launch_reward_cap` helper but
    /// passes the 6 Welford slot indices instead of a single fixed α.
    #[allow(clippy::too_many_arguments)]
    fn launch_hold_reward_cap(
        stream: &Arc<CudaStream>,
        kernel: &CudaFunction,
        step_ret_ptr: u64,
        trade_close_ptr: u64,
        total_samples: i32,
        isv_ptr: u64,
        pos_idx: i32,
        neg_idx: i32,
        target_mean_idx: i32,
        target_m2_idx: i32,
        diff_mean_idx: i32,
        diff_m2_idx: i32,
        prev_target_idx: i32,
        sample_count_idx: i32,
        sentinel_pos: f32,
        sentinel_neg: f32,
        safety_factor: f32,
        neg_to_pos_ratio: f32,
        pos_min: f32,
        pos_max: f32,
    ) {
        unsafe {
            stream
                .launch_builder(kernel)
                .arg(&step_ret_ptr)
                .arg(&trade_close_ptr)
                .arg(&total_samples)
                .arg(&isv_ptr)
                .arg(&pos_idx)
                .arg(&neg_idx)
                .arg(&target_mean_idx)
                .arg(&target_m2_idx)
                .arg(&diff_mean_idx)
                .arg(&diff_m2_idx)
                .arg(&prev_target_idx)
                .arg(&sample_count_idx)
                .arg(&sentinel_pos)
                .arg(&sentinel_neg)
                .arg(&safety_factor)
                .arg(&neg_to_pos_ratio)
                .arg(&pos_min)
                .arg(&pos_max)
                .launch(LaunchConfig {
                    grid_dim: (1, 1, 1),
                    block_dim: (HRC_BLK_DIM, 1, 1),
                    shared_mem_bytes: HRC_SMEM_BYTES,
                })
                .expect("launch hold_reward_cap_update");
        }
        stream.synchronize().expect("sync after hold_reward_cap_update");
    }

    /// SP18 Phase 3 T5 — adaptive Hold-reward-cap producer drives slots
    /// [483]/[484] from Long/Short trade-close p99.
    ///
    /// Construct 100 simulated Long/Short close events with `step_ret`
    /// drawn from a centred Gaussian-ish distribution scaled to ~0.01
    /// (per the spec's T5 anchor: `step_ret ∈ N(0, 0.01)`). Mix winners
    /// (positive) and losers (negative) so the kernel's
    /// `(is_close && step_ret != 0)` filter takes both. Also include
    /// non-close samples (trade_close=0) with non-zero step_ret to
    /// verify they're FILTERED OUT (close events only contribute).
    ///
    /// Per the spec's T5 acceptance: ISV[483] ≈ p99(|step_ret|) × 1.5
    /// (small, ~0.04 for σ ≈ 0.01); ISV[484] = −2 × ISV[483]
    /// (mirrored); both within [HOLD_REWARD_POS_CAP_MIN_BOUND=0.5,
    /// HOLD_REWARD_POS_CAP_MAX_BOUND=50.0]. The lower-bound clamp
    /// at 0.5 is the dominating constraint at this magnitude scale —
    /// verifying the bilateral clamp activation per
    /// `pearl_symmetric_clamp_audit.md`.
    ///
    /// Jitter: per `pearl_sp4_histogram_warp_tile_undercount`, synthetic
    /// uniform test data can trip warp-tile undercount in
    /// `sp4_histogram_p99`. This producer doesn't use `sp4_histogram_p99`
    /// (it uses Welford `mean + Z_99 × sigma` like the position-side
    /// kernel), but the input is jittered anyway as defensive practice.
    #[test]
    #[ignore = "requires GPU"]
    fn hold_reward_cap_producer_drives_slots() {
        use ml::cuda_pipeline::sp14_isv_slots::{
            HOLD_REWARD_CAP_SAFETY_FACTOR, HOLD_REWARD_NEG_CAP_INDEX, HOLD_REWARD_POS_CAP_INDEX,
            HOLD_REWARD_POS_CAP_MAX_BOUND, HOLD_REWARD_POS_CAP_MIN_BOUND, HRC_DIFF_M2_INDEX,
            HRC_DIFF_MEAN_INDEX, HRC_PREV_TARGET_INDEX, HRC_SAMPLE_COUNT_INDEX,
            HRC_TARGET_M2_INDEX, HRC_TARGET_MEAN_INDEX, REWARD_NEG_TO_POS_RATIO,
            SENTINEL_HOLD_REWARD_NEG_CAP, SENTINEL_HOLD_REWARD_POS_CAP, SENTINEL_WELFORD_ZERO,
        };

        let stream = make_test_stream();
        let kernel = load_hold_reward_cap_update(&stream);

        const ISV_DIM: usize = 1024;

        // Build 100 Long/Short close events with jittered ~N(0, 0.01)
        // step_ret. Mix winners and losers; sprinkle in non-close
        // samples that should be filtered out.
        let n_close = 100usize;
        let n_filler = 50usize;
        let total = n_close + n_filler;
        let mut step_ret = vec![0.0_f32; total];
        let mut trade_close = vec![0_i32; total];

        // Deterministic pseudo-random: alternating signs + slight phase
        // jitter so the histogram (had we used one) wouldn't undercount.
        for i in 0..n_close {
            let sign = if i % 2 == 0 { 1.0_f32 } else { -1.0_f32 };
            // Stretch the magnitude across [0.001, 0.025] so p99 ≈ 0.025.
            let mag = 0.001 + (i as f32 / n_close as f32) * 0.024 + (i as f32 * 0.0001);
            step_ret[i] = sign * mag;
            trade_close[i] = 1;
        }
        // Filler: non-close samples with non-zero step_ret. The kernel
        // MUST filter these out via `is_close == 0`.
        for i in 0..n_filler {
            step_ret[n_close + i] = 1000.0; // outlier magnitude
            trade_close[n_close + i] = 0;   // not a close
        }

        let mut isv = vec![0.0_f32; ISV_DIM];
        // Cold-start: sentinels match SP18 PP.2 sentinel allocations.
        isv[HOLD_REWARD_POS_CAP_INDEX] = SENTINEL_HOLD_REWARD_POS_CAP;
        isv[HOLD_REWARD_NEG_CAP_INDEX] = SENTINEL_HOLD_REWARD_NEG_CAP;
        isv[HRC_TARGET_MEAN_INDEX]     = SENTINEL_WELFORD_ZERO;
        isv[HRC_TARGET_M2_INDEX]       = SENTINEL_WELFORD_ZERO;
        isv[HRC_DIFF_MEAN_INDEX]       = SENTINEL_WELFORD_ZERO;
        isv[HRC_DIFF_M2_INDEX]         = SENTINEL_WELFORD_ZERO;
        isv[HRC_PREV_TARGET_INDEX]     = SENTINEL_WELFORD_ZERO;
        isv[HRC_SAMPLE_COUNT_INDEX]    = SENTINEL_WELFORD_ZERO;

        let n = total as i32;
        let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
            .expect("alloc step_ret");
        ret_buf.write_from_slice(&step_ret);
        let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
            .expect("alloc trade_close");
        close_buf.write_from_slice(&trade_close);
        let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
        isv_buf.write_from_slice(&isv);

        launch_hold_reward_cap(
            &stream, &kernel,
            ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
            HOLD_REWARD_POS_CAP_INDEX as i32,
            HOLD_REWARD_NEG_CAP_INDEX as i32,
            HRC_TARGET_MEAN_INDEX as i32,
            HRC_TARGET_M2_INDEX as i32,
            HRC_DIFF_MEAN_INDEX as i32,
            HRC_DIFF_M2_INDEX as i32,
            HRC_PREV_TARGET_INDEX as i32,
            HRC_SAMPLE_COUNT_INDEX as i32,
            SENTINEL_HOLD_REWARD_POS_CAP,
            SENTINEL_HOLD_REWARD_NEG_CAP,
            HOLD_REWARD_CAP_SAFETY_FACTOR,
            REWARD_NEG_TO_POS_RATIO,
            HOLD_REWARD_POS_CAP_MIN_BOUND,
            HOLD_REWARD_POS_CAP_MAX_BOUND,
        );

        let result = isv_buf.read_all();
        let pos = result[HOLD_REWARD_POS_CAP_INDEX];
        let neg = result[HOLD_REWARD_NEG_CAP_INDEX];

        // The unclamped target_cap at this scale is ~p99 × 1.5 ≈
        // 0.025 × 1.5 ≈ 0.04, well below HOLD_REWARD_POS_CAP_MIN_BOUND
        // = 0.5. The bilateral clamp pre-blend pulls it up to the
        // floor. Pearl-A then REPLACES the sentinel with the clamped
        // value → POS = 0.5 exactly.
        assert!(
            (pos - HOLD_REWARD_POS_CAP_MIN_BOUND).abs() < 1e-5,
            "Pearl-A bootstrap: small p99 → bilateral clamp pulls POS to MIN_BOUND. \
             Expected POS={HOLD_REWARD_POS_CAP_MIN_BOUND}, got {pos}",
        );

        // POS must be within bounds.
        assert!(
            pos >= HOLD_REWARD_POS_CAP_MIN_BOUND && pos <= HOLD_REWARD_POS_CAP_MAX_BOUND,
            "POS out of bounds: {pos} not in [{HOLD_REWARD_POS_CAP_MIN_BOUND}, {HOLD_REWARD_POS_CAP_MAX_BOUND}]",
        );

        // NEG = −2 × POS (DD5=b mirrored asymmetry, single source of
        // truth at producer time).
        assert!(
            (neg - (-REWARD_NEG_TO_POS_RATIO * pos)).abs() < 1e-4,
            "NEG must equal −{REWARD_NEG_TO_POS_RATIO} × POS at producer-time \
             (Kahneman 2:1 single source of truth); got POS={pos}, NEG={neg}",
        );

        // NEG within derived range [−ratio × pos_max, −ratio × pos_min] = [−100, −1].
        let neg_lo = -REWARD_NEG_TO_POS_RATIO * HOLD_REWARD_POS_CAP_MAX_BOUND;
        let neg_hi = -REWARD_NEG_TO_POS_RATIO * HOLD_REWARD_POS_CAP_MIN_BOUND;
        assert!(
            neg >= neg_lo && neg <= neg_hi,
            "NEG out of bounds: {neg} not in [{neg_lo}, {neg_hi}]",
        );

        // Welford state updated: sample_count == 1 (first observation).
        let count = result[HRC_SAMPLE_COUNT_INDEX];
        assert!((count - 1.0).abs() < 1e-5,
            "First-observation Welford: sample_count expected 1.0, got {count}");

        // Filler outliers MUST NOT have contributed (they were
        // is_close=0). We can't read max directly from ISV, but we
        // can verify the cap stayed clamped to 0.5 — if the filler
        // outliers had leaked, target_cap would be ≈ 1000 × 1.5 = 1500,
        // clamped to MAX_BOUND=50.0. The fact POS == 0.5 proves the
        // filter worked.
        assert!(
            pos < 1.0,
            "Filter regression: filler outliers (close=0) leaked into accumulator; \
             cap should be clamped to MIN_BOUND=0.5, got {pos}",
        );
    }

    /// SP18 Phase 3 — Pearl-A first-observation bootstrap test.
    ///
    /// Sentinel state → first observation REPLACES (no blend). Verify
    /// ISV[483] equals `target_cap × 1.5` post-clamp exactly, NOT
    /// blended with the sentinel 5.0. Per
    /// `pearl_first_observation_bootstrap.md` the kernel must detect
    /// `|isv[pos_idx] − sentinel_pos| < EPS_F` and route through
    /// REPLACE (α=1.0), not the standard EMA blend.
    ///
    /// Construction: 4 closes with `step_ret = [+10, +20, +30, +40]`
    /// (large magnitudes — ensures the unclamped target_cap is well
    /// above MIN_BOUND so Pearl-A's REPLACE is observable). Welford
    /// rule on |step_ret|: mean=25, var = (100+400+900+1600)/4 − 625
    /// = 750 − 625 = 125, sigma ≈ 11.18, p99 ≈ 25 + 2.326 × 11.18 ≈
    /// 51.0. max=40. Estimator picks `max(p99, max) = 51.0`. ×1.5 =
    /// 76.5. Clamped to MAX_BOUND=50.0 → POS = 50.0.
    ///
    /// With sample_count < WELFORD_MIN_SAMPLE_COUNT_F=3, α=1.0
    /// (cold-start REPLACE per `pearl_wiener_alpha_floor_for_nonstationary`
    /// — Welford variance not yet defined). Combined with Pearl-A
    /// sentinel detection, the result is bit-identical to a direct
    /// REPLACE: blended = 50.0, NOT (1−α)×5.0 + α×50.0 if α had been
    /// some EMA value.
    #[test]
    #[ignore = "requires GPU"]
    fn hold_reward_cap_pearl_a_first_observation_replace() {
        use ml::cuda_pipeline::sp14_isv_slots::{
            HOLD_REWARD_CAP_SAFETY_FACTOR, HOLD_REWARD_NEG_CAP_INDEX, HOLD_REWARD_POS_CAP_INDEX,
            HOLD_REWARD_POS_CAP_MAX_BOUND, HOLD_REWARD_POS_CAP_MIN_BOUND, HRC_DIFF_M2_INDEX,
            HRC_DIFF_MEAN_INDEX, HRC_PREV_TARGET_INDEX, HRC_SAMPLE_COUNT_INDEX,
            HRC_TARGET_M2_INDEX, HRC_TARGET_MEAN_INDEX, REWARD_NEG_TO_POS_RATIO,
            SENTINEL_HOLD_REWARD_NEG_CAP, SENTINEL_HOLD_REWARD_POS_CAP, SENTINEL_WELFORD_ZERO,
        };

        let stream = make_test_stream();
        let kernel = load_hold_reward_cap_update(&stream);

        const ISV_DIM: usize = 1024;

        // 4 closes with mixed-sign large step_ret values. The kernel's
        // |step_ret| filter takes both winners and losers.
        let step_ret: Vec<f32> = vec![10.0, -20.0, 30.0, -40.0];
        let trade_close: Vec<i32> = vec![1, 1, 1, 1];

        let mut isv = vec![0.0_f32; ISV_DIM];
        isv[HOLD_REWARD_POS_CAP_INDEX] = SENTINEL_HOLD_REWARD_POS_CAP;   // 5.0
        isv[HOLD_REWARD_NEG_CAP_INDEX] = SENTINEL_HOLD_REWARD_NEG_CAP;   // -10.0
        isv[HRC_TARGET_MEAN_INDEX]     = SENTINEL_WELFORD_ZERO;
        isv[HRC_TARGET_M2_INDEX]       = SENTINEL_WELFORD_ZERO;
        isv[HRC_DIFF_MEAN_INDEX]       = SENTINEL_WELFORD_ZERO;
        isv[HRC_DIFF_M2_INDEX]         = SENTINEL_WELFORD_ZERO;
        isv[HRC_PREV_TARGET_INDEX]     = SENTINEL_WELFORD_ZERO;
        isv[HRC_SAMPLE_COUNT_INDEX]    = SENTINEL_WELFORD_ZERO;

        let n = step_ret.len() as i32;
        let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
            .expect("alloc step_ret");
        ret_buf.write_from_slice(&step_ret);
        let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
            .expect("alloc trade_close");
        close_buf.write_from_slice(&trade_close);
        let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
        isv_buf.write_from_slice(&isv);

        launch_hold_reward_cap(
            &stream, &kernel,
            ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
            HOLD_REWARD_POS_CAP_INDEX as i32,
            HOLD_REWARD_NEG_CAP_INDEX as i32,
            HRC_TARGET_MEAN_INDEX as i32,
            HRC_TARGET_M2_INDEX as i32,
            HRC_DIFF_MEAN_INDEX as i32,
            HRC_DIFF_M2_INDEX as i32,
            HRC_PREV_TARGET_INDEX as i32,
            HRC_SAMPLE_COUNT_INDEX as i32,
            SENTINEL_HOLD_REWARD_POS_CAP,
            SENTINEL_HOLD_REWARD_NEG_CAP,
            HOLD_REWARD_CAP_SAFETY_FACTOR,
            REWARD_NEG_TO_POS_RATIO,
            HOLD_REWARD_POS_CAP_MIN_BOUND,
            HOLD_REWARD_POS_CAP_MAX_BOUND,
        );

        let result = isv_buf.read_all();
        let pos = result[HOLD_REWARD_POS_CAP_INDEX];
        let neg = result[HOLD_REWARD_NEG_CAP_INDEX];

        // Pearl-A REPLACE: target_cap = max(p99, max) × 1.5 ≈ max(51, 40)
        // × 1.5 = 76.5. Bilateral clamp to [0.5, 50.0] → 50.0. The
        // sentinel-detection branch in the kernel writes blended = 50.0
        // directly (not (1−α)×5.0 + α×50.0). With α=1.0 these would
        // collapse to the same value, but the test pins behaviour:
        // the kernel MUST take the `if (|current − sentinel| < EPS)`
        // branch and write target_cap unblended.
        assert!(
            (pos - HOLD_REWARD_POS_CAP_MAX_BOUND).abs() < 1e-4,
            "Pearl-A REPLACE on sentinel: expected POS exactly at MAX_BOUND={HOLD_REWARD_POS_CAP_MAX_BOUND}, \
             got {pos}",
        );

        // Confirm we did NOT blend with sentinel: a blended result with
        // any α < 1 would land between 5.0 and 50.0, never exactly at
        // the clamp boundary.
        assert!(pos > 49.99 && pos <= 50.01,
            "Pearl-A signature: POS ≈ MAX_BOUND exactly (no sentinel blend), got {pos}");

        // NEG mirrors at producer time.
        assert!(
            (neg - (-REWARD_NEG_TO_POS_RATIO * pos)).abs() < 1e-4,
            "NEG must equal −{REWARD_NEG_TO_POS_RATIO} × POS; got POS={pos}, NEG={neg}",
        );
        assert!(
            (neg - (-100.0)).abs() < 1e-3,
            "NEG = −2 × 50.0 = −100; got {neg}",
        );

        // sample_count == 1 (first observation).
        let count = result[HRC_SAMPLE_COUNT_INDEX];
        assert!((count - 1.0).abs() < 1e-5,
            "Welford sample_count after 1 observation: expected 1.0, got {count}");

        // prev_target stores the unclamped target_cap (= max(p99, max)
        // × safety_factor) for the next launch's diff. Useful sanity:
        // proves the Welford state is being updated even though the
        // CAP write was REPLACED.
        let prev = result[HRC_PREV_TARGET_INDEX];
        assert!(prev > 1.0,
            "prev_target should be the unclamped target_cap (~76.5), got {prev}");
    }

    /// SP18 Phase 3 — no Long/Short closes ⇒ ISV slots preserved bit-exactly.
    ///
    /// All trade_close=0 (no closes) AND all step_ret=0. The kernel
    /// must skip the EMA update (count==0 guard) and leave POS, NEG,
    /// AND every Welford accumulator at their seeded values.
    ///
    /// Models the cold-start "Hold-heavy phase with no real trade
    /// activity" regime — exactly the case where the producer SHOULD
    /// keep the cap at sentinel and let the consumer fall back to the
    /// structural cold-start sentinel value (5.0/-10.0).
    #[test]
    #[ignore = "requires GPU"]
    fn hold_reward_cap_no_closes_preserves_isv() {
        use ml::cuda_pipeline::sp14_isv_slots::{
            HOLD_REWARD_CAP_SAFETY_FACTOR, HOLD_REWARD_NEG_CAP_INDEX, HOLD_REWARD_POS_CAP_INDEX,
            HOLD_REWARD_POS_CAP_MAX_BOUND, HOLD_REWARD_POS_CAP_MIN_BOUND, HRC_DIFF_M2_INDEX,
            HRC_DIFF_MEAN_INDEX, HRC_PREV_TARGET_INDEX, HRC_SAMPLE_COUNT_INDEX,
            HRC_TARGET_M2_INDEX, HRC_TARGET_MEAN_INDEX, REWARD_NEG_TO_POS_RATIO,
            SENTINEL_HOLD_REWARD_NEG_CAP, SENTINEL_HOLD_REWARD_POS_CAP,
        };

        let stream = make_test_stream();
        let kernel = load_hold_reward_cap_update(&stream);

        const ISV_DIM: usize = 1024;
        // Seed with deliberately non-sentinel values to verify
        // bit-exact preservation (NOT just "sentinel was preserved").
        const SEED_POS: f32 = 12.5;
        const SEED_NEG: f32 = -25.0;
        const SEED_TARGET_MEAN: f32 = 7.7;
        const SEED_TARGET_M2: f32   = 0.123;
        const SEED_DIFF_MEAN: f32   = 0.05;
        const SEED_DIFF_M2: f32     = 0.0001;
        const SEED_PREV_TARGET: f32 = 7.6;
        const SEED_SAMPLE_COUNT: f32 = 5.0;

        let mut isv = vec![0.0_f32; ISV_DIM];
        isv[HOLD_REWARD_POS_CAP_INDEX] = SEED_POS;
        isv[HOLD_REWARD_NEG_CAP_INDEX] = SEED_NEG;
        isv[HRC_TARGET_MEAN_INDEX]     = SEED_TARGET_MEAN;
        isv[HRC_TARGET_M2_INDEX]       = SEED_TARGET_M2;
        isv[HRC_DIFF_MEAN_INDEX]       = SEED_DIFF_MEAN;
        isv[HRC_DIFF_M2_INDEX]         = SEED_DIFF_M2;
        isv[HRC_PREV_TARGET_INDEX]     = SEED_PREV_TARGET;
        isv[HRC_SAMPLE_COUNT_INDEX]    = SEED_SAMPLE_COUNT;

        // Step returns present but no closes — none should contribute.
        let step_ret: Vec<f32> = vec![3.0, 5.0, -2.0];
        let trade_close: Vec<i32> = vec![0, 0, 0];

        let n = step_ret.len() as i32;
        let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
            .expect("alloc step_ret");
        ret_buf.write_from_slice(&step_ret);
        let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
            .expect("alloc trade_close");
        close_buf.write_from_slice(&trade_close);
        let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
        isv_buf.write_from_slice(&isv);

        launch_hold_reward_cap(
            &stream, &kernel,
            ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
            HOLD_REWARD_POS_CAP_INDEX as i32,
            HOLD_REWARD_NEG_CAP_INDEX as i32,
            HRC_TARGET_MEAN_INDEX as i32,
            HRC_TARGET_M2_INDEX as i32,
            HRC_DIFF_MEAN_INDEX as i32,
            HRC_DIFF_M2_INDEX as i32,
            HRC_PREV_TARGET_INDEX as i32,
            HRC_SAMPLE_COUNT_INDEX as i32,
            SENTINEL_HOLD_REWARD_POS_CAP,
            SENTINEL_HOLD_REWARD_NEG_CAP,
            HOLD_REWARD_CAP_SAFETY_FACTOR,
            REWARD_NEG_TO_POS_RATIO,
            HOLD_REWARD_POS_CAP_MIN_BOUND,
            HOLD_REWARD_POS_CAP_MAX_BOUND,
        );

        let result = isv_buf.read_all();
        // Every slot must be preserved bit-exactly.
        assert_eq!(result[HOLD_REWARD_POS_CAP_INDEX], SEED_POS,
            "POS preserved bit-exactly when no closes");
        assert_eq!(result[HOLD_REWARD_NEG_CAP_INDEX], SEED_NEG,
            "NEG preserved bit-exactly when no closes");
        assert_eq!(result[HRC_TARGET_MEAN_INDEX], SEED_TARGET_MEAN,
            "target_mean preserved");
        assert_eq!(result[HRC_TARGET_M2_INDEX], SEED_TARGET_M2,
            "target_M2 preserved");
        assert_eq!(result[HRC_DIFF_MEAN_INDEX], SEED_DIFF_MEAN,
            "diff_mean preserved");
        assert_eq!(result[HRC_DIFF_M2_INDEX], SEED_DIFF_M2,
            "diff_M2 preserved");
        assert_eq!(result[HRC_PREV_TARGET_INDEX], SEED_PREV_TARGET,
            "prev_target preserved");
        assert_eq!(result[HRC_SAMPLE_COUNT_INDEX], SEED_SAMPLE_COUNT,
            "sample_count preserved");
    }
}
