#![allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.

//! SP13 Layer B Commit B1.1a (2026-05-05) GPU oracle tests.
//!
//! Validates the kernel rewrites that take the aux next-bar head from
//! K=1 MSE regression to K=2 softmax CE classification:
//!   - `aux_next_bar_loss_reduce` (softmax + i32 labels → mean CE +
//!     `B_valid` count; `-1` label = mask/skip)
//!   - `aux_next_bar_backward`    (softmax + i32 labels +
//!     `B_valid` → per-sample dW/dh partials; masked rows zero)
//!   - `aux_dir_acc_reduce_kernel` argmax over softmax-vs-i32-labels
//!     (B1.1a-flipped — was f32 pred + f32 sign label)
//!   - `aux_pred_to_isv_tanh_kernel` `mean(softmax[1] - softmax[0])`
//!     into the SHARED ISV slot 375 (B1.1a-flipped — runtime tanh
//!     retired; bound is structural)
//!   - `LAYOUT_FINGERPRINT_CURRENT` regression — verifies the
//!     fingerprint bumped from the pre-B1.1a value because the seed
//!     names `PARAM_AUX_NB_W2 → PARAM_AUX_NB_W2_K2` (and `_B2`
//!     equivalent) flipped.
//!
//! Per the B1.1a brief, this file covers tests 7-11 (CE loss
//! correctness), 12-13 (dir_acc B1.1a additions), 14-15 (isv_tanh
//! B1.1a additions), and 16+18 (fingerprint regression + HEALTH_DIAG
//! snap stability). Tests 1-6 (producer kernel) and 17 (end-to-end
//! round-trip) defer to B1.1b — they require the producer kernel +
//! replay direct path which aren't wired in B1.1a.
//!
//! Per `feedback_no_cpu_test_fallbacks.md`: GPU oracle only, no CPU
//! reference impl. Per `feedback_no_htod_htoh_only_mapped_pinned.md`:
//! every CPU↔GPU buffer is a `MappedF32Buffer` / `MappedI32Buffer`
//! (cuMemHostAlloc with DEVICEMAP|PORTABLE); zero `htod_copy`, zero
//! `dtoh_sync_copy`. Tests are NOT exempt from this rule.
//!
//! All GPU tests are `#[ignore = "requires GPU"]`-gated to match every
//! other GPU oracle test in this crate (sp4/sp5/sp11/sp12/sp13_phase0).
//! Run on a GPU host:
//!
//!     SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//!       cargo test -p ml --test sp13_layer_b_oracle_tests --features cuda \
//!         -- --ignored --nocapture
//!
//! The fingerprint regression test (test 16) is a pure-Rust const-eval
//! test — does NOT require a GPU and runs without `--ignored`.

#![cfg(feature = "cuda")]

use std::sync::Arc;

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

// ── Test-only cubin handles ───────────────────────────────────────────────
//
// All three kernels live in the same `aux_heads_kernel.cubin` (loss +
// backward) plus the dedicated `aux_dir_acc_reduce_kernel.cubin` and
// `aux_pred_to_isv_tanh_kernel.cubin`. The cubins are emitted by
// `crates/ml/build.rs` into `OUT_DIR`; including them here mirrors the
// production loaders' include_bytes! pattern.

const SP13_AUX_HEADS_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/aux_heads_kernel.cubin"));
const SP13_AUX_DIR_ACC_REDUCE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/aux_dir_acc_reduce_kernel.cubin"));
const SP13_AUX_PRED_TO_ISV_TANH_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/aux_pred_to_isv_tanh_kernel.cubin"));
/// SP13 Layer B Commit B1.1b: producer kernel cubin for the aux next-bar
/// sign label `[total] i32` ring column. Loaded by tests 1-6 below.
const SP13_AUX_SIGN_LABEL_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/aux_sign_label_kernel.cubin"));

// ── Stream + kernel loaders ──────────────────────────────────────────────

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

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

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

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

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

// ── CE loss reduce scaffold ──────────────────────────────────────────────

const AUX_BLOCK: u32 = 256;

/// Drive one launch of `aux_next_bar_loss_reduce` with the given softmax
/// tile + i32 labels. Returns `(mean_ce, b_valid)`.
fn run_loss_reduce(
    stream: &Arc<CudaStream>,
    f: &CudaFunction,
    softmax: &[f32],
    labels: &[i32],
    k: i32,
) -> (f32, f32) {
    let n = labels.len();
    assert_eq!(
        softmax.len(),
        n * k as usize,
        "softmax must be [B, K]; got {} for B={} K={}",
        softmax.len(),
        n,
        k
    );

    let alloc_smx = (n * k as usize).max(1);
    let alloc_lbl = n.max(1);

    let softmax_buf = unsafe { MappedF32Buffer::new(alloc_smx) }
        .expect("alloc softmax buffer (mapped-pinned)");
    let labels_buf = unsafe { MappedI32Buffer::new(alloc_lbl) }
        .expect("alloc labels buffer (mapped-pinned)");
    if n > 0 {
        softmax_buf.write_from_slice(softmax);
        labels_buf.write_from_slice(labels);
    }

    let loss_buf = unsafe { MappedF32Buffer::new(1) }
        .expect("alloc loss buffer (mapped-pinned)");
    let valid_buf = unsafe { MappedF32Buffer::new(1) }
        .expect("alloc valid_count buffer (mapped-pinned)");

    let softmax_dev = softmax_buf.dev_ptr;
    let labels_dev  = labels_buf.dev_ptr;
    let loss_dev    = loss_buf.dev_ptr;
    let valid_dev   = valid_buf.dev_ptr;
    let b_i32: i32  = n as i32;

    unsafe {
        stream
            .launch_builder(f)
            .arg(&softmax_dev)
            .arg(&labels_dev)
            .arg(&b_i32)
            .arg(&k)
            .arg(&loss_dev)
            .arg(&valid_dev)
            .launch(LaunchConfig {
                grid_dim:         (1, 1, 1),
                block_dim:        (AUX_BLOCK, 1, 1),
                shared_mem_bytes: 2 * AUX_BLOCK * std::mem::size_of::<f32>() as u32,
            })
            .expect("launch aux_next_bar_loss_reduce");
    }
    stream.synchronize().expect("sync after aux_next_bar_loss_reduce");

    (loss_buf.read_all()[0], valid_buf.read_all()[0])
}

// ── CE backward scaffold (test 10/11) ────────────────────────────────────

/// Drive one launch of `aux_next_bar_backward` with hand-crafted forward
/// state (uniform identity-ish weights so the test focuses on the
/// CE backward arithmetic, not the matmul). Returns the per-sample
/// `db2_partial [B, K]` slice (which equals `d_logits[b, kc]` directly
/// per the kernel's `db2_partial[b, kc] = sh_dlogits[kc]` write).
///
/// Hand-crafted forward state:
///   * h_s2[B, SH2] — zeros (so dh_s2 outputs zero; not under test)
///   * w1[H, SH2]   — zeros
///   * w2[K, H]     — zeros (so d_h_post = 0 → dW1/db1 = 0)
///   * hidden_post[B, H] — zeros (so ELU-bwd factor is 1.0)
///
/// With all-zero h_post and w2, the backward kernel's d_h_pre is zero,
/// so dW1, db1, dW2, dh_s2 are all zero. The interesting output is
/// `db2_partial[b, kc] = sh_dlogits[kc] = (softmax[b,kc] - one_hot)/B_valid`
/// for valid rows, zero for masked rows.
#[allow(clippy::too_many_arguments)]
fn run_backward_db2_only(
    stream: &Arc<CudaStream>,
    f: &CudaFunction,
    softmax: &[f32],
    labels: &[i32],
    valid_count: f32,
    b: usize,
    sh2: usize,
    k: i32,
) -> Vec<f32> {
    let h: usize = 32; // AUX_HIDDEN_DIM
    let kk = k as usize;

    // Allocate all forward + partial buffers (mapped-pinned for parity
    // with the production launcher; the kernel only reads dev_ptr).
    let h_s2_buf       = unsafe { MappedF32Buffer::new(b * sh2) }.unwrap();
    let w1_buf         = unsafe { MappedF32Buffer::new(h * sh2) }.unwrap();
    let w2_buf         = unsafe { MappedF32Buffer::new(kk * h) }.unwrap();
    let hidden_post_buf = unsafe { MappedF32Buffer::new(b * h) }.unwrap();
    let softmax_buf    = unsafe { MappedF32Buffer::new(b * kk) }.unwrap();
    let labels_buf     = unsafe { MappedI32Buffer::new(b) }.unwrap();
    let valid_buf      = unsafe { MappedF32Buffer::new(1) }.unwrap();

    let dw1_buf = unsafe { MappedF32Buffer::new(b * h * sh2) }.unwrap();
    let db1_buf = unsafe { MappedF32Buffer::new(b * h) }.unwrap();
    let dw2_buf = unsafe { MappedF32Buffer::new(b * kk * h) }.unwrap();
    let db2_buf = unsafe { MappedF32Buffer::new(b * kk) }.unwrap();
    let dh_s2_buf = unsafe { MappedF32Buffer::new(b * sh2) }.unwrap();

    softmax_buf.write_from_slice(softmax);
    labels_buf.write_from_slice(labels);
    valid_buf.write_from_slice(&[valid_count]);

    let h_s2_dev    = h_s2_buf.dev_ptr;
    let w1_dev      = w1_buf.dev_ptr;
    let w2_dev      = w2_buf.dev_ptr;
    let hpost_dev   = hidden_post_buf.dev_ptr;
    let softmax_dev = softmax_buf.dev_ptr;
    let labels_dev  = labels_buf.dev_ptr;
    let valid_dev   = valid_buf.dev_ptr;
    let dw1_dev     = dw1_buf.dev_ptr;
    let db1_dev     = db1_buf.dev_ptr;
    let dw2_dev     = dw2_buf.dev_ptr;
    let db2_dev     = db2_buf.dev_ptr;
    let dhs2_dev    = dh_s2_buf.dev_ptr;
    let b_i32: i32 = b as i32;
    let sh2_i32: i32 = sh2 as i32;

    let smem_bytes = ((2 * h as u32) + (k as u32)) * std::mem::size_of::<f32>() as u32;
    unsafe {
        stream
            .launch_builder(f)
            .arg(&h_s2_dev)
            .arg(&w1_dev)
            .arg(&w2_dev)
            .arg(&hpost_dev)
            .arg(&softmax_dev)
            .arg(&labels_dev)
            .arg(&valid_dev)
            .arg(&b_i32)
            .arg(&sh2_i32)
            .arg(&k)
            .arg(&dw1_dev)
            .arg(&db1_dev)
            .arg(&dw2_dev)
            .arg(&db2_dev)
            .arg(&dhs2_dev)
            .launch(LaunchConfig {
                grid_dim:         (b as u32, 1, 1),
                block_dim:        (AUX_BLOCK, 1, 1),
                shared_mem_bytes: smem_bytes,
            })
            .expect("launch aux_next_bar_backward");
    }
    stream.synchronize().expect("sync after aux_next_bar_backward");

    db2_buf.read_all()
}

// ── dir_acc + isv_tanh helpers (mirror sp13_phase0 launchers) ───────────

fn run_dir_acc(
    stream: &Arc<CudaStream>,
    f: &CudaFunction,
    softmax: &[f32],
    labels: &[i32],
    k: i32,
) -> [f32; 6] {
    let n = labels.len();
    let alloc_smx = (n * k as usize).max(1);
    let alloc_lbl = n.max(1);
    let softmax_buf = unsafe { MappedF32Buffer::new(alloc_smx) }.unwrap();
    let labels_buf = unsafe { MappedI32Buffer::new(alloc_lbl) }.unwrap();
    if n > 0 {
        softmax_buf.write_from_slice(softmax);
        labels_buf.write_from_slice(labels);
    }
    let out_buf = unsafe { MappedF32Buffer::new(6) }.unwrap();
    let bdim: u32 = 256;
    let b_i32: i32 = n as i32;
    unsafe {
        stream
            .launch_builder(f)
            .arg(&softmax_buf.dev_ptr)
            .arg(&labels_buf.dev_ptr)
            .arg(&b_i32)
            .arg(&k)
            .arg(&out_buf.dev_ptr)
            .launch(LaunchConfig {
                grid_dim:         (1, 1, 1),
                block_dim:        (bdim, 1, 1),
                shared_mem_bytes: 6 * bdim * std::mem::size_of::<i32>() as u32,
            })
            .expect("launch aux_dir_acc_reduce_kernel");
    }
    stream.synchronize().expect("sync after dir_acc");
    let v = out_buf.read_all();
    [v[0], v[1], v[2], v[3], v[4], v[5]]
}

fn run_isv_tanh(
    stream: &Arc<CudaStream>,
    f: &CudaFunction,
    softmax: &[f32],
    k: i32,
) -> f32 {
    let n = if k > 0 { softmax.len() / k as usize } else { 0 };
    let alloc = (n * k as usize).max(1);
    let smx_buf = unsafe { MappedF32Buffer::new(alloc) }.unwrap();
    if !softmax.is_empty() {
        smx_buf.write_from_slice(softmax);
    }
    let isv_buf = unsafe { MappedF32Buffer::new(1) }.unwrap();
    let bdim: u32 = 256;
    let b_i32: i32 = n as i32;
    let isv_off: i32 = 0;
    unsafe {
        stream
            .launch_builder(f)
            .arg(&smx_buf.dev_ptr)
            .arg(&b_i32)
            .arg(&k)
            .arg(&isv_off)
            .arg(&isv_buf.dev_ptr)
            .launch(LaunchConfig {
                grid_dim:         (1, 1, 1),
                block_dim:        (bdim, 1, 1),
                shared_mem_bytes: bdim * std::mem::size_of::<f32>() as u32,
            })
            .expect("launch aux_pred_to_isv_tanh_kernel");
    }
    stream.synchronize().expect("sync after isv_tanh");
    isv_buf.read_all()[0]
}

// ─── Test 7: CE loss single-row hand-crafted ──────────────────────────────

/// Single sample, K=2. softmax = [0.25, 0.75], label = 1 (up).
/// Expected loss = -log(0.75) ≈ 0.2877. valid_count = 1.
#[test]
#[ignore = "requires GPU"]
fn ce_loss_single_row_handcrafted() {
    let stream = make_test_stream();
    let f = load_loss_reduce(&stream);
    let softmax = vec![0.25_f32, 0.75];
    let labels  = vec![1_i32];
    let (loss, valid) = run_loss_reduce(&stream, &f, &softmax, &labels, 2);
    let expected = (-0.75_f32.ln()) / 1.0;
    assert!(
        (loss - expected).abs() < 1e-5,
        "expected loss = {expected}, got {loss}"
    );
    assert!(
        (valid - 1.0).abs() < 1e-6,
        "expected valid_count = 1.0, got {valid}"
    );
}

// ─── Test 8: CE loss batch of 4 mixed ─────────────────────────────────────

/// 4 rows: 3 valid + 1 mask.
/// rows 0,1: softmax = [0.2, 0.8], label = 1 → CE = -log(0.8) each.
/// row 2:    softmax = [0.9, 0.1], label = 0 → CE = -log(0.9).
/// row 3:    softmax = [0.5, 0.5], label = -1 → masked, contributes 0.
/// Expected: B_valid = 3; mean = (2*-ln(0.8) + -ln(0.9)) / 3.
#[test]
#[ignore = "requires GPU"]
fn ce_loss_batch_mixed() {
    let stream = make_test_stream();
    let f = load_loss_reduce(&stream);
    let softmax = vec![
        0.2_f32, 0.8,
        0.2,     0.8,
        0.9,     0.1,
        0.5,     0.5,
    ];
    let labels = vec![1_i32, 1, 0, -1];
    let (loss, valid) = run_loss_reduce(&stream, &f, &softmax, &labels, 2);
    let numer = 2.0 * (-0.8_f32.ln()) + (-0.9_f32.ln());
    let expected = numer / 3.0;
    assert!(
        (loss - expected).abs() < 1e-5,
        "expected mean CE = {expected}, got {loss}"
    );
    assert!(
        (valid - 3.0).abs() < 1e-6,
        "expected valid_count = 3.0, got {valid}"
    );
}

// ─── Test 9: CE loss all-skip batch ───────────────────────────────────────

/// 4 rows all masked → B_valid = 0; loss = 0/max(0,1) = 0 (no NaN).
/// `valid_count_out` reports the actual count (0.0).
#[test]
#[ignore = "requires GPU"]
fn ce_loss_all_skip_returns_zero_no_nan() {
    let stream = make_test_stream();
    let f = load_loss_reduce(&stream);
    let softmax = vec![0.5_f32; 8]; // 4 rows, all balanced
    let labels = vec![-1_i32; 4];
    let (loss, valid) = run_loss_reduce(&stream, &f, &softmax, &labels, 2);
    assert!(
        loss.is_finite(),
        "expected finite loss on all-mask batch, got {loss}"
    );
    assert!(
        loss.abs() < 1e-6,
        "expected loss = 0.0 on all-mask batch, got {loss}"
    );
    assert!(
        valid.abs() < 1e-6,
        "expected valid_count = 0.0 on all-mask batch, got {valid}"
    );
}

// ─── Test 10: CE backward single-row ──────────────────────────────────────

/// Single row, K=2. softmax = [0.3, 0.7], label = 1 (up).
/// Expected `db2_partial[0, :] = (softmax - one_hot(1)) / B_valid`
///                            = ([0.3, 0.7] - [0, 1]) / 1
///                            = [0.3, -0.3].
#[test]
#[ignore = "requires GPU"]
fn ce_backward_single_row_handcrafted() {
    let stream = make_test_stream();
    let f = load_backward(&stream);
    let softmax = vec![0.3_f32, 0.7];
    let labels  = vec![1_i32];
    let valid   = 1.0_f32;
    let db2 = run_backward_db2_only(&stream, &f, &softmax, &labels, valid, 1, 4, 2);
    assert!(
        (db2[0] - 0.3).abs() < 1e-5,
        "expected db2[0] = 0.3, got {}",
        db2[0]
    );
    assert!(
        (db2[1] - (-0.3)).abs() < 1e-5,
        "expected db2[1] = -0.3, got {}",
        db2[1]
    );
}

// ─── Test 11: CE backward batch of 4 mixed ────────────────────────────────

/// 4 rows: 3 valid + 1 mask. Batch_valid = 3.
/// row 0: softmax = [0.2, 0.8], label = 1 → d_logits = [0.2, -0.2] / 3
/// row 1: softmax = [0.9, 0.1], label = 0 → d_logits = [-0.1, 0.1] / 3
/// row 2: softmax = [0.4, 0.6], label = 1 → d_logits = [0.4, -0.4] / 3
/// row 3: softmax = [0.5, 0.5], label = -1 → masked → zero across K.
#[test]
#[ignore = "requires GPU"]
fn ce_backward_batch_mixed() {
    let stream = make_test_stream();
    let f = load_backward(&stream);
    let softmax = vec![
        0.2_f32, 0.8,
        0.9,     0.1,
        0.4,     0.6,
        0.5,     0.5,
    ];
    let labels = vec![1_i32, 0, 1, -1];
    let valid  = 3.0_f32;
    let db2 = run_backward_db2_only(&stream, &f, &softmax, &labels, valid, 4, 4, 2);
    // Row 0
    assert!((db2[0] - 0.2 / 3.0).abs() < 1e-5, "db2[0,0] mismatch: {}", db2[0]);
    assert!((db2[1] - (-0.2) / 3.0).abs() < 1e-5, "db2[0,1] mismatch: {}", db2[1]);
    // Row 1
    assert!((db2[2] - (-0.1) / 3.0).abs() < 1e-5, "db2[1,0] mismatch: {}", db2[2]);
    assert!((db2[3] - 0.1 / 3.0).abs() < 1e-5, "db2[1,1] mismatch: {}", db2[3]);
    // Row 2
    assert!((db2[4] - 0.4 / 3.0).abs() < 1e-5, "db2[2,0] mismatch: {}", db2[4]);
    assert!((db2[5] - (-0.4) / 3.0).abs() < 1e-5, "db2[2,1] mismatch: {}", db2[5]);
    // Row 3 (masked) → zero
    assert!(db2[6].abs() < 1e-6, "db2[3,0] expected 0 (masked), got {}", db2[6]);
    assert!(db2[7].abs() < 1e-6, "db2[3,1] expected 0 (masked), got {}", db2[7]);
}

// ─── Test 12: dir_acc argmax correctness on hand-crafted ─────────────────

/// 4 rows: argmax-vs-label cases:
///   row 0: softmax [0.1, 0.9], label 1  → correct
///   row 1: softmax [0.1, 0.9], label 1  → correct
///   row 2: softmax [0.1, 0.9], label 0  → wrong
///   row 3: softmax [0.5, 0.5], label -1 → masked
/// Valid bars = 3 (2 correct + 1 wrong). dir_acc = 2/3.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_handcrafted_argmax() {
    let stream = make_test_stream();
    let f = load_dir_acc(&stream);
    let softmax = vec![
        0.1_f32, 0.9,
        0.1,     0.9,
        0.1,     0.9,
        0.5,     0.5,
    ];
    let labels = vec![1_i32, 1, 0, -1];
    let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
    assert!(
        (out[0] - (2.0 / 3.0)).abs() < 1e-5,
        "expected dir_acc = 2/3, got {}",
        out[0]
    );
    assert!((out[3] - 1.0).abs() < 1e-6, "n_down expected 1, got {}", out[3]);
    assert!((out[4] - 2.0).abs() < 1e-6, "n_up expected 2, got {}", out[4]);
    assert!((out[5] - 1.0).abs() < 1e-6, "n_skip expected 1, got {}", out[5]);
}

// ─── Test 13: dir_acc all-skip (NaN-safe) ────────────────────────────────

/// All-mask batch → sentinel 0.5 in slots 0..3, raw counts 0/0/B in
/// slots 3..6. NO NaN anywhere.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_all_skip_nan_safe() {
    let stream = make_test_stream();
    let f = load_dir_acc(&stream);
    let softmax = vec![0.5_f32; 8];
    let labels = vec![-1_i32; 4];
    let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
    for (i, v) in out.iter().enumerate() {
        assert!(v.is_finite(), "slot {i} non-finite: {v}");
    }
    assert!(
        (out[0] - 0.5).abs() < 1e-6,
        "expected dir_acc sentinel 0.5, got {}",
        out[0]
    );
    assert!((out[3]).abs() < 1e-6, "n_down expected 0, got {}", out[3]);
    assert!((out[4]).abs() < 1e-6, "n_up expected 0, got {}", out[4]);
    assert!((out[5] - 4.0).abs() < 1e-6, "n_skip expected 4, got {}", out[5]);
}

// ─── Test 14: aux_pred_to_isv_tanh bounded in [-1, +1] (3 fuzz) ──────────

/// Three randomly-shaped softmax tiles; each must produce mean diff
/// inside `[-1, +1]` (structural bound). Uses a simple PRNG so the
/// test is deterministic across runs.
#[test]
#[ignore = "requires GPU"]
fn isv_tanh_bounded_fuzz() {
    let stream = make_test_stream();
    let f = load_isv_tanh(&stream);

    // Three deterministic fuzz inputs: skewed up, skewed down, mixed.
    let cases: [Vec<f32>; 3] = [
        // 8 rows skewed up: each row ≈ [0.2, 0.8].
        vec![
            0.2, 0.8, 0.15, 0.85, 0.25, 0.75, 0.3, 0.7,
            0.18, 0.82, 0.22, 0.78, 0.21, 0.79, 0.27, 0.73,
        ],
        // 8 rows skewed down: each row ≈ [0.8, 0.2].
        vec![
            0.8, 0.2, 0.85, 0.15, 0.75, 0.25, 0.7, 0.3,
            0.82, 0.18, 0.78, 0.22, 0.79, 0.21, 0.73, 0.27,
        ],
        // 4 mixed rows.
        vec![0.1, 0.9, 0.9, 0.1, 0.4, 0.6, 0.6, 0.4],
    ];

    for (i, softmax) in cases.iter().enumerate() {
        let out = run_isv_tanh(&stream, &f, softmax, 2);
        assert!(
            out >= -1.0 && out <= 1.0,
            "case {i}: out {out} outside [-1, +1] structural bound"
        );
        assert!(out.is_finite(), "case {i}: non-finite out {out}");
    }
}

// ─── Test 15: aux_pred_to_isv_tanh mean correctness on hand-crafted ──────

/// 3 rows: [0.2, 0.8], [0.6, 0.4], [0.5, 0.5].
/// Diffs:                +0.6,      -0.2,      0.0
/// Mean:                 (0.6 - 0.2 + 0.0) / 3 = 0.4 / 3 ≈ 0.1333.
#[test]
#[ignore = "requires GPU"]
fn isv_tanh_mean_handcrafted() {
    let stream = make_test_stream();
    let f = load_isv_tanh(&stream);
    let softmax = vec![0.2_f32, 0.8, 0.6, 0.4, 0.5, 0.5];
    let out = run_isv_tanh(&stream, &f, &softmax, 2);
    let expected = (0.6 - 0.2 + 0.0) / 3.0;
    assert!(
        (out - expected).abs() < 1e-5,
        "expected mean diff = {expected}, got {out}"
    );
}

// ─── Test 16: Fingerprint regression (CPU-only; no GPU required) ─────────

/// Inline FNV-1a 64-bit hash for the regression test. Mirrors the
/// `const fn fnv1a_64` in `gpu_dqn_trainer.rs` so the test computes
/// the same hash the production fingerprint uses.
const fn fnv1a_64(bytes: &[u8]) -> u64 {
    const OFFSET: u64 = 0xcbf29ce484222325;
    const PRIME:  u64 = 0x00000100000001b3;
    let mut h: u64 = OFFSET;
    let mut i = 0;
    while i < bytes.len() {
        h ^= bytes[i] as u64;
        h = h.wrapping_mul(PRIME);
        i += 1;
    }
    h
}

/// Pre-B1.1a seed — the exact same seed string as the post-B1.0 layout
/// fingerprint EXCEPT the next-bar tensor names are
/// `PARAM_AUX_NB_W2` / `PARAM_AUX_NB_B2` (regression-mode names) rather
/// than the B1.1a `_K2` suffix variants. Hashing this constant gives
/// the pre-B1.1a fingerprint that the bump must differ from. Any
/// silent revert of the rename would make this test fail.
///
/// Layout: this is the verbatim seed string from `gpu_dqn_trainer.rs`'s
/// `layout_fingerprint_seed()` at HEAD `75e94858c` (B1.0). The only
/// section that changed in B1.1a is the `PARAM_AUX_NB_W2/B2 →
/// PARAM_AUX_NB_W2_K2/B2_K2` renames.
const PRE_B1_1A_SEED: &[u8] = b"SLOT_0_Q_DRIFT=0;\
      SLOT_1_GRAD_NORM_EMA=1;\
      SLOT_2_TD_ERR_EMA=2;\
      SLOT_3_ENS_VAR_EMA=3;\
      SLOT_4_ENS_VAR_VEL=4;\
      SLOT_5_REWARD_EMA=5;\
      SLOT_6_ATOM_UTIL_EMA=6;\
      SLOT_7_LOSS_EMA=7;\
      SLOT_8_ADX_EMA=8;\
      SLOT_9_REGIME_DISAGREE=9;\
      SLOT_10_REGIME_VEL_EMA=10;\
      SLOT_11_REGIME_STABILITY=11;\
      LEARNING_HEALTH=12;\
      Q_MAG_MEAN_QUARTER=13;Q_MAG_MEAN_HALF=14;Q_MAG_MEAN_FULL=15;Q_ABS_REF=16;\
      Q_DIR_MEAN_SHORT=17;Q_DIR_MEAN_HOLD=18;Q_DIR_MEAN_LONG=19;Q_DIR_MEAN_FLAT=20;Q_DIR_ABS_REF=21;\
      SHARPE_EMA=22;\
      V_CENTER_DIR=23;V_HALF_DIR=24;V_CENTER_MAG=25;V_HALF_MAG=26;\
      V_CENTER_ORD=27;V_HALF_ORD=28;V_CENTER_URG=29;V_HALF_URG=30;\
      GRAD_NORM_TARGET_DIR=31;GRAD_NORM_TARGET_MAG=32;GRAD_NORM_TARGET_ORD=33;GRAD_NORM_TARGET_URG=34;\
      GRAD_SCALE_LIMIT=35;IQL_BRANCH_SCALE_FLOOR=36;\
      EPOCH_IDX=39;TOTAL_EPOCHS=40;\
      EPSILON_EFF=41;TAU_EFF=42;\
      GAMMA_DIR_EFF=43;GAMMA_MAG_EFF=44;GAMMA_ORD_EFF=45;GAMMA_URG_EFF=46;\
      KELLY_CAP_EFF=47;CQL_ALPHA=48;PLAN_THRESHOLD=49;\
      Q_P05_DIR=50;Q_P05_MAG=51;Q_P05_ORD=52;Q_P05_URG=53;\
      Q_P95_DIR=54;Q_P95_MAG=55;Q_P95_ORD=56;Q_P95_URG=57;\
      TLOB_REGIME_FOCUS_EMA=60;\
      REWARD_POPART_EMA=63;REWARD_CF_EMA=64;REWARD_TRAIL_EMA=65;\
      REWARD_MICRO_EMA=66;REWARD_OPP_COST_EMA=67;REWARD_BONUS_EMA=68;\
      TRADE_ATTEMPT_RATE_EMA=71;TRADE_TARGET_RATE=72;\
      READINESS_EMA=75;\
      STATE_KL_EMA=78;STATE_KL_AMP=79;\
      SEED_STEPS_TARGET=82;SEED_STEPS_DONE=83;SEED_FRAC_EMA=84;\
      VSN_MAG_EMA=87;VSN_DIR_EMA=88;MAMBA2_RETENTION_EMA=89;\
      TARGET_DRIFT_MAG_EMA=92;TARGET_DRIFT_DIR_EMA=93;\
      H_S2_RMS_EMA=96;\
      IQN_Q_P05_EMA=99;IQN_Q_P25_EMA=100;IQN_Q_P75_EMA=101;IQN_Q_P95_EMA=102;\
      VSN_MASK_GROUP_0_EMA=105;VSN_MASK_GROUP_1_EMA=106;VSN_MASK_GROUP_2_EMA=107;\
      VSN_MASK_GROUP_3_EMA=108;VSN_MASK_GROUP_4_EMA=109;VSN_MASK_GROUP_5_EMA=110;\
      AUX_NEXT_BAR_MSE_EMA=113;AUX_REGIME_CE_EMA=114;\
      ISV_LAYOUT_FINGERPRINT_LO=115;ISV_LAYOUT_FINGERPRINT_HI=116;\
      MOE_EXPERT_UTIL_EMA_BASE=118;MOE_EXPERT_UTIL_EMA_COUNT=8;\
      MOE_GATE_ENTROPY_EMA=126;\
      MOE_LAMBDA_EFF=128;\
      Q_DRIFT_RATE=129;\
      FOLD_WARMUP_FACTOR=130;\
      TARGET_Q_BOUND=131;\
      ATOM_POS_BOUND_BRANCH_0=132;ATOM_POS_BOUND_BRANCH_1=133;ATOM_POS_BOUND_BRANCH_2=134;ATOM_POS_BOUND_BRANCH_3=135;\
      WEIGHT_BOUND_GROUP_0=136;WEIGHT_BOUND_GROUP_1=137;WEIGHT_BOUND_GROUP_2=138;WEIGHT_BOUND_GROUP_3=139;\
      WEIGHT_BOUND_GROUP_4=140;WEIGHT_BOUND_GROUP_5=141;WEIGHT_BOUND_GROUP_6=142;WEIGHT_BOUND_GROUP_7=143;\
      ADAM_M_BOUND_GROUP_0=144;ADAM_M_BOUND_GROUP_1=145;ADAM_M_BOUND_GROUP_2=146;ADAM_M_BOUND_GROUP_3=147;\
      ADAM_M_BOUND_GROUP_4=148;ADAM_M_BOUND_GROUP_5=149;ADAM_M_BOUND_GROUP_6=150;ADAM_M_BOUND_GROUP_7=151;\
      ADAM_V_BOUND_GROUP_0=152;ADAM_V_BOUND_GROUP_1=153;ADAM_V_BOUND_GROUP_2=154;ADAM_V_BOUND_GROUP_3=155;\
      ADAM_V_BOUND_GROUP_4=156;ADAM_V_BOUND_GROUP_5=157;ADAM_V_BOUND_GROUP_6=158;ADAM_V_BOUND_GROUP_7=159;\
      WD_RATE_GROUP_0=160;WD_RATE_GROUP_1=161;WD_RATE_GROUP_2=162;WD_RATE_GROUP_3=163;\
      WD_RATE_GROUP_4=164;WD_RATE_GROUP_5=165;WD_RATE_GROUP_6=166;WD_RATE_GROUP_7=167;\
      GRAD_CLIP_BOUND=168;H_S2_BOUND=169;L1_LAMBDA_TRUNK=170;\
      BW_D_H_S2_BOUND=171;Q_DIR_GRAD_BOUND=172;\
      SP5_BASE=174;ATOM_V_CENTER_BASE=174;ATOM_V_HALF_BASE=178;ATOM_HEADROOM_BASE=182;\
      ATOM_CLIP_RATE_BASE=186;BUDGET_C51_BASE=190;BUDGET_IQN_BASE=194;BUDGET_CQL_BASE=198;\
      BUDGET_ENS_BASE=202;FLATNESS_BASE=206;NOISY_SIGMA_BASE=210;SIGMA_FRACTION_BASE=214;\
      BRANCH_ENTROPY_BASE=218;Q_VAR_PER_BRANCH_BASE=222;ADAM_BETA1_BASE=226;ADAM_BETA2_BASE=234;\
      ADAM_EPS_BASE=242;IQN_TAU_BASE=250;TRAIL_DIST_PER_DIR_BASE=270;ATOM_NUM_ATOMS_BASE=274;\
      KELLY_F_SMOOTH=280;CONVICTION_SMOOTH=281;TRADE_VAR_SMOOTH=282;\
      KELLY_SAMPLE_COUNT=283;WIN_RATE_SMOOTH=284;LOSS_RATE_SMOOTH=285;\
      PNL_TOTAL=286;PNL_MEAN=287;PNL_VAR=288;PNL_MAX_DD=289;\
      HEALTH_SCORE=290;Q_GAP_NORM=291;Q_VAR_NORM=292;GRAD_NORM_NORM=293;\
      TRAINING_SHARPE_EMA=294;MAX_DD_EMA=295;LOW_DD_RATIO=296;\
      LB_DIFF_VAR_CQL_BASE=297;LB_SAMPLE_VAR_CQL_BASE=301;\
      LB_DIFF_VAR_C51_BASE=305;LB_SAMPLE_VAR_C51_BASE=309;\
      LB_CQL_ACTIVE_BASE=313;LB_C51_ACTIVE_BASE=317;\
      TRAIN_ACTIVE_FRAC_INDEX=321;\
      LB_MAX_BUDGET_CQL_BASE=322;LB_MAX_BUDGET_C51_BASE=326;\
      KELLY_WARMUP_FLOOR=330;Q_VAR_MAG_EMA=331;\
      INTENT_EVAL_DIVERGENCE=332;KELLY_SAMPLE_COUNT_TARGET=333;\
      KELLY_DIVERGENCE_TARGET=334;KELLY_TEMPORAL_TARGET=335;\
      EVAL_DIST_Q=336;EVAL_DIST_H=337;EVAL_DIST_F=338;\
      EVAL_THOMPSON_TEMP=339;\
      REWARD_POPART_WEIGHT=340;REWARD_CF_WEIGHT=341;REWARD_TRAIL_WEIGHT=342;\
      REWARD_MICRO_WEIGHT=343;REWARD_OPP_COST_WEIGHT=344;REWARD_BONUS_WEIGHT=345;\
      CURIOSITY_PRESSURE=346;SABOTEUR_INTENSITY_MULT=347;\
      REWARD_WEIGHT_FLOOR=348;CURIOSITY_BOUND=349;\
      VAL_SHARPE_DELTA_EMA=350;VAL_SHARPE_VAR_EMA=351;\
      REWARD_COMPONENT_MAG_RATIO_BASE=352;\
      SABOTEUR_ENGAGEMENT_RATE=358;PNL_REWARD_MAGNITUDE_EMA=359;\
      POPART_COMPONENT_MAG_EMA=360;\
      REWARD_COMPONENT_VAR_EMA_BASE=361;\
      TARGET_DIR_ACC=372;AUX_DIR_ACC_SHORT_EMA=373;AUX_DIR_ACC_LONG_EMA=374;\
      AUX_DIR_PREDICTION=375;DIR_SKILL_BONUS_ALPHA=376;DIR_SKILL_BONUS_BETA=377;\
      LUCK_WIN_DISCOUNT=378;SKILL_BONUS_CAP_RATIO=379;\
      HOLD_COST=380;HOLD_RATE_TARGET=381;HOLD_RATE_OBSERVED_EMA=382;\
      ISV_TOTAL_DIM=383;\
      PARAM_W_A_H_S1=0;PARAM_B_A_H_S1=1;PARAM_W_B_H_S1=2;PARAM_B_B_H_S1=3;\
      PARAM_W_RESIDUAL_H_S1=4;PARAM_GAMMA_H_S1=5;PARAM_BETA_H_S1=6;\
      PARAM_W_A_H_S2=7;PARAM_B_A_H_S2=8;PARAM_W_B_H_S2=9;PARAM_B_B_H_S2=10;\
      PARAM_GAMMA_H_S2=11;PARAM_BETA_H_S2=12;\
      PARAM_W_V1=13;PARAM_B_V1=14;PARAM_W_V2=15;PARAM_B_V2=16;\
      PARAM_W_B0FC=17;PARAM_B_B0FC=18;PARAM_W_B0OUT=19;PARAM_B_B0OUT=20;\
      PARAM_W_B1FC=21;PARAM_B_B1FC=22;PARAM_W_B1OUT=23;PARAM_B_B1OUT=24;\
      PARAM_W_B2FC=25;PARAM_B_B2FC=26;PARAM_W_B2OUT=27;PARAM_B_B2OUT=28;\
      PARAM_W_B3FC=29;PARAM_B_B3FC=30;PARAM_W_B3OUT=31;PARAM_B_B3OUT=32;\
      PARAM_W_BN=33;PARAM_B_BN=34;\
      PARAM_W_VSN1_0=35;PARAM_W_VSN2_0=36;\
      PARAM_W_VSN1_1=37;PARAM_W_VSN2_1=38;\
      PARAM_W_VSN1_2=39;PARAM_W_VSN2_2=40;\
      PARAM_W_VSN1_3=41;PARAM_W_VSN2_3=42;\
      PARAM_W_GATE_0=43;PARAM_B_GATE_0=44;\
      PARAM_W_GATE_1=45;PARAM_B_GATE_1=46;\
      PARAM_W_GATE_2=47;PARAM_B_GATE_2=48;\
      PARAM_W_GATE_3=49;PARAM_B_GATE_3=50;\
      PARAM_KAN_COEFF_0=51;PARAM_KAN_RESID_0=52;\
      PARAM_KAN_COEFF_1=53;PARAM_KAN_RESID_1=54;\
      PARAM_KAN_COEFF_2=55;PARAM_KAN_RESID_2=56;\
      PARAM_KAN_COEFF_3=57;PARAM_KAN_RESID_3=58;\
      PARAM_W_REGIME=59;PARAM_B_REGIME=60;\
      PARAM_SPACING_RAW_0=61;PARAM_SPACING_RAW_1=62;\
      PARAM_SPACING_RAW_2=63;PARAM_SPACING_RAW_3=64;\
      PARAM_W_V1_5BAR=65;PARAM_B_V1_5BAR=66;\
      PARAM_W_V2_5BAR=67;PARAM_B_V2_5BAR=68;\
      PARAM_W_V1_20BAR=69;PARAM_B_V1_20BAR=70;\
      PARAM_W_V2_20BAR=71;PARAM_B_V2_20BAR=72;\
      PARAM_W_RISK_FC=73;PARAM_B_RISK_FC=74;\
      PARAM_W_RISK_OUT=75;PARAM_B_RISK_OUT=76;\
      PARAM_W_ISV_FC1=77;PARAM_B_ISV_FC1=78;\
      PARAM_W_ISV_FC2=79;PARAM_B_ISV_FC2=80;\
      PARAM_W_ISV_GATE=81;PARAM_B_ISV_GATE=82;\
      PARAM_W_ISV_GAMMA=83;PARAM_B_ISV_GAMMA=84;\
      PARAM_W_CONF_FC=85;PARAM_B_CONF_FC=86;\
      PARAM_W_FEATURE_GATE=87;PARAM_B_FEATURE_GATE=88;\
      PARAM_W_TEMPORAL_ROUTE=89;PARAM_B_TEMPORAL_ROUTE=90;\
      PARAM_W_PLAN_FC=91;PARAM_B_PLAN_FC=92;\
      PARAM_W_PLAN_OUT=93;PARAM_B_PLAN_OUT=94;\
      PARAM_VSN_W1_G0=95;PARAM_VSN_B1_G0=96;PARAM_VSN_W2_G0=97;PARAM_VSN_B2_G0=98;\
      PARAM_VSN_W1_G1=99;PARAM_VSN_B1_G1=100;PARAM_VSN_W2_G1=101;PARAM_VSN_B2_G1=102;\
      PARAM_VSN_W1_G2=103;PARAM_VSN_B1_G2=104;PARAM_VSN_W2_G2=105;PARAM_VSN_B2_G2=106;\
      PARAM_VSN_W1_G3=107;PARAM_VSN_B1_G3=108;PARAM_VSN_W2_G3=109;PARAM_VSN_B2_G3=110;\
      PARAM_VSN_W1_G4=111;PARAM_VSN_B1_G4=112;PARAM_VSN_W2_G4=113;PARAM_VSN_B2_G4=114;\
      PARAM_VSN_W1_G5=115;PARAM_VSN_B1_G5=116;PARAM_VSN_W2_G5=117;PARAM_VSN_B2_G5=118;\
      PARAM_AUX_NB_W1=119;PARAM_AUX_NB_B1=120;PARAM_AUX_NB_W2=121;PARAM_AUX_NB_B2=122;\
      PARAM_AUX_RG_W1=123;PARAM_AUX_RG_B1=124;PARAM_AUX_RG_W2=125;PARAM_AUX_RG_B2=126;\
      PARAM_MOE_GATE_W1=127;PARAM_MOE_GATE_B1=128;PARAM_MOE_GATE_W2=129;PARAM_MOE_GATE_B2=130;\
      PARAM_MOE_EXPERT_0_W1=131;PARAM_MOE_EXPERT_0_B1=132;PARAM_MOE_EXPERT_0_W2=133;PARAM_MOE_EXPERT_0_B2=134;\
      PARAM_MOE_EXPERT_1_W1=135;PARAM_MOE_EXPERT_1_B1=136;PARAM_MOE_EXPERT_1_W2=137;PARAM_MOE_EXPERT_1_B2=138;\
      PARAM_MOE_EXPERT_2_W1=139;PARAM_MOE_EXPERT_2_B1=140;PARAM_MOE_EXPERT_2_W2=141;PARAM_MOE_EXPERT_2_B2=142;\
      PARAM_MOE_EXPERT_3_W1=143;PARAM_MOE_EXPERT_3_B1=144;PARAM_MOE_EXPERT_3_W2=145;PARAM_MOE_EXPERT_3_B2=146;\
      PARAM_MOE_EXPERT_4_W1=147;PARAM_MOE_EXPERT_4_B1=148;PARAM_MOE_EXPERT_4_W2=149;PARAM_MOE_EXPERT_4_B2=150;\
      PARAM_MOE_EXPERT_5_W1=151;PARAM_MOE_EXPERT_5_B1=152;PARAM_MOE_EXPERT_5_W2=153;PARAM_MOE_EXPERT_5_B2=154;\
      PARAM_MOE_EXPERT_6_W1=155;PARAM_MOE_EXPERT_6_B1=156;PARAM_MOE_EXPERT_6_W2=157;PARAM_MOE_EXPERT_6_B2=158;\
      PARAM_MOE_EXPERT_7_W1=159;PARAM_MOE_EXPERT_7_B1=160;PARAM_MOE_EXPERT_7_W2=161;PARAM_MOE_EXPERT_7_B2=162;\
      PARAM_TOTAL_TENSORS=163";

#[test]
fn fingerprint_bumped_from_pre_b1_1a() {
    use ml::cuda_pipeline::gpu_dqn_trainer::LAYOUT_FINGERPRINT_CURRENT;
    let pre_b1_1a_fp = fnv1a_64(PRE_B1_1A_SEED);
    assert_ne!(
        LAYOUT_FINGERPRINT_CURRENT, pre_b1_1a_fp,
        "B1.1a fingerprint must differ from pre-B1.1a value (the W2/B2 \
         rename to _K2 must change the seed hash). \
         current = {:#018x}, pre-B1.1a = {:#018x}",
        LAYOUT_FINGERPRINT_CURRENT, pre_b1_1a_fp
    );
}

// ─── Test 18: HEALTH_DIAG snapshot size unchanged at 149*4 ────────────────

/// B1.1a does NOT touch the HEALTH_DIAG snap-words layout — the aux
/// block remained at 3 words after B1.0 retired the `aux_label_scale`
/// entry (B1.0 dropped the size from 150 → 149). B1.1a's struct flips
/// (aux_nb_label_buf f32→i32, K=1→2 buffer growth, dir_acc_buf 3→6)
/// are all GPU-only: HEALTH_DIAG-snap remains 149 floats × 4 bytes =
/// 596 bytes. This test guards against accidental snap-layout
/// regressions during the cascade.
#[test]
fn health_diag_snap_size_stable_at_149_floats() {
    use ml::cuda_pipeline::health_diag::HealthDiagSnapshot;
    let n = std::mem::size_of::<HealthDiagSnapshot>();
    assert_eq!(
        n, 149 * 4,
        "expected HealthDiagSnapshot to be 149*4 = 596 bytes (B1.0 stable, \
         B1.1a unchanged), got {n}"
    );
}

// ═══════════════════════════════════════════════════════════════════════════
// SP13 Layer B Commit B1.1b: producer kernel correctness tests
// ═══════════════════════════════════════════════════════════════════════════
//
// `aux_sign_label_kernel` is a pure per-thread map. Tests below construct
// synthetic price trajectories in a `[total_bars, 6]` (stride 6, col 2 =
// raw_close) mapped-pinned buffer + a per-experience `bar_indices` array,
// launch the kernel, and verify the resulting `[total] i32` labels match
// the expected -1/0/1 contract:
//   -1 = skip (bar < 0 OR bar+lookahead >= total_bars)
//    0 = down/flat (p_fut <= p_now under strict greater-than tie-break)
//    1 = up      (p_fut > p_now)
//
// Lookahead = 30 here matches the production `config.hindsight_lookahead`
// default; the kernel's lookahead arg lets future tests vary it.

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

/// Run the producer kernel and return the host-side labels vector.
/// `targets_close` is a slice of length `total_bars` containing the
/// raw_close column values; the kernel reads them at stride-6 offset 2,
/// so we materialize a `[total_bars * 6]` buffer with col 2 = the input.
fn run_producer(
    targets_close: &[f32],
    bar_indices: &[i32],
    lookahead: i32,
) -> Vec<i32> {
    let stream = make_test_stream();
    let kernel = load_aux_sign_label(&stream);

    let total_bars = targets_close.len();
    let total = bar_indices.len();

    // Build full [total_bars, 6] targets buffer with raw_close at col 2.
    let mut targets_full = vec![0.0_f32; total_bars * 6];
    for (i, &p) in targets_close.iter().enumerate() {
        targets_full[i * 6 + 2] = p;
    }

    let targets_buf = unsafe { MappedF32Buffer::new(total_bars * 6) }.unwrap();
    let bar_idx_buf = unsafe { MappedI32Buffer::new(total) }.unwrap();
    let out_buf     = unsafe { MappedI32Buffer::new(total) }.unwrap();
    targets_buf.write_from_slice(&targets_full);
    bar_idx_buf.write_from_slice(bar_indices);

    let total_i32 = total as i32;
    let total_bars_i32 = total_bars as i32;
    let blocks = ((total as u32 + 255) / 256).max(1);

    unsafe {
        stream
            .launch_builder(&kernel)
            .arg(&targets_buf.dev_ptr)
            .arg(&bar_idx_buf.dev_ptr)
            .arg(&out_buf.dev_ptr)
            .arg(&total_i32)
            .arg(&total_bars_i32)
            .arg(&lookahead)
            .launch(LaunchConfig {
                grid_dim: (blocks, 1, 1),
                block_dim: (256, 1, 1),
                shared_mem_bytes: 0,
            })
            .expect("aux_sign_label_kernel launch");
    }
    unsafe {
        cudarc::driver::sys::cuStreamSynchronize(stream.cu_stream());
    }
    out_buf.read_all()
}

// ─── Test 1: monotone-up trajectory ────────────────────────────────────────

#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_monotone_up_all_ones() {
    // 60 bars, +0.1 per step. Every valid bar has p_fut > p_now ⇒ label = 1.
    let total_bars = 60;
    let lookahead = 30;
    let targets: Vec<f32> = (0..total_bars).map(|i| 100.0 + i as f32 * 0.1).collect();
    // 30 valid experiences (bar 0..29), all have lookahead window in-range.
    let bar_indices: Vec<i32> = (0..30).collect();
    let labels = run_producer(&targets, &bar_indices, lookahead);
    for (i, &l) in labels.iter().enumerate() {
        assert_eq!(l, 1, "monotone-up: bar {i} should be label 1, got {l}");
    }
}

// ─── Test 2: monotone-down trajectory ──────────────────────────────────────

#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_monotone_down_all_zeros() {
    // 60 bars, -0.1 per step. Every valid bar has p_fut < p_now ⇒ label = 0.
    let total_bars = 60;
    let lookahead = 30;
    let targets: Vec<f32> = (0..total_bars).map(|i| 100.0 - i as f32 * 0.1).collect();
    let bar_indices: Vec<i32> = (0..30).collect();
    let labels = run_producer(&targets, &bar_indices, lookahead);
    for (i, &l) in labels.iter().enumerate() {
        assert_eq!(l, 0, "monotone-down: bar {i} should be label 0, got {l}");
    }
}

// ─── Test 3: flat trajectory (strict greater-than tie-break ⇒ 0) ───────────

#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_flat_all_zeros_strict_gt() {
    // 60 bars all at 100.0. p_fut == p_now under strict greater-than maps
    // to label 0 (down/flat) — the K=2 head encodes "up" vs "not-up".
    let total_bars = 60;
    let lookahead = 30;
    let targets = vec![100.0_f32; total_bars];
    let bar_indices: Vec<i32> = (0..30).collect();
    let labels = run_producer(&targets, &bar_indices, lookahead);
    for (i, &l) in labels.iter().enumerate() {
        assert_eq!(l, 0, "flat: bar {i} should be label 0 (strict-gt tie-break), got {l}");
    }
}

// ─── Test 4: last-30 bars get skip sentinel ────────────────────────────────

#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_last_30_bars_skip() {
    // 60 bars; bar+30 must be < total_bars=60. So bar=30..59 fail the
    // bound check (bar+30 ∈ [60..89] >= 60) and get -1.
    let total_bars = 60;
    let lookahead = 30;
    let targets: Vec<f32> = (0..total_bars).map(|i| 100.0 + i as f32 * 0.1).collect();
    let bar_indices: Vec<i32> = (0..total_bars as i32).collect();
    let labels = run_producer(&targets, &bar_indices, lookahead);
    for bar in 0..total_bars {
        let expected = if bar + 30 >= total_bars { -1 } else { 1 };
        assert_eq!(
            labels[bar], expected,
            "bar {bar}: expected {expected}, got {}", labels[bar]
        );
    }
    // Sanity: exactly 30 -1s and 30 1s.
    let n_skip = labels.iter().filter(|&&l| l == -1).count();
    let n_up = labels.iter().filter(|&&l| l == 1).count();
    assert_eq!(n_skip, 30, "expected 30 skip-labels, got {n_skip}");
    assert_eq!(n_up, 30, "expected 30 up-labels, got {n_up}");
}

// ─── Test 5: boundary — bar=0 valid, bar=total_bars-1 skip ────────────────

#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_boundary_first_valid_last_skip() {
    let total_bars = 100;
    let lookahead = 30;
    let targets: Vec<f32> = (0..total_bars).map(|i| 100.0 + i as f32 * 0.05).collect();
    let bar_indices: Vec<i32> = vec![0, (total_bars - 1) as i32];
    let labels = run_producer(&targets, &bar_indices, lookahead);
    assert_eq!(labels[0], 1, "bar=0 with monotone-up trajectory: expected label 1, got {}", labels[0]);
    assert_eq!(
        labels[1], -1,
        "bar=total_bars-1=99: 99+30=129 >= 100, expected -1, got {}", labels[1]
    );
}

// ─── Test 6: multi-episode — per-episode skip semantics ────────────────────

#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_multi_episode_per_episode_skip() {
    // 2 episodes × 50 bars at distinct trajectories. Total bars = 100
    // (concatenated). Episode 0: bars 0..49 (monotone up). Episode 1:
    // bars 50..99 (monotone down). All 100 experiences have bar_indices
    // pointing at their respective bar; only the last 30 of episode 1
    // (bars 70..99) hit the global total_bars boundary because
    // total_bars=100 and bar+30 >= 100 ⇔ bar >= 70.
    //
    // NB: Skip semantics here are GLOBAL (against total_bars), not
    // per-episode. The producer kernel doesn't know about episode
    // boundaries — it only checks `bar + lookahead < total_bars`. The
    // production code in `gpu_experience_collector.rs` enforces episode
    // boundaries by virtue of `episode_starts[ep] + t` always being
    // within the same episode's contiguous bar range. So the same global
    // check applies.
    let total_bars = 100;
    let lookahead = 30;
    let mut targets = vec![0.0_f32; total_bars];
    for i in 0..50 {
        targets[i] = 100.0 + i as f32 * 0.1;       // ep 0 monotone up
        targets[50 + i] = 200.0 - i as f32 * 0.1;  // ep 1 monotone down (starts above)
    }
    let bar_indices: Vec<i32> = (0..total_bars as i32).collect();
    let labels = run_producer(&targets, &bar_indices, lookahead);

    // bars 0..49: ep 0 monotone up; bars 0..19 valid (label 1), bars 20..49
    //   straddle ep boundary into ep 1 (bar+30 ∈ [50..79], reads ep 1 trajectory).
    //   bar=20 reads p_fut=200 - (20+30-50)*0.1 = 200; p_now = 100 + 20*0.1 = 102.
    //   p_fut=200 > p_now=102 ⇒ label 1.
    //   Skip sentinel only fires for bar+30 >= 100 ⇒ bar >= 70.
    // bars 50..69: ep 1 monotone down; p_fut < p_now ⇒ label 0.
    // bars 70..99: skip ⇒ label -1.
    for bar in 0..total_bars {
        let expected = if bar + 30 >= total_bars {
            -1
        } else {
            let p_now = targets[bar];
            let p_fut = targets[bar + 30];
            if p_fut > p_now { 1 } else { 0 }
        };
        assert_eq!(
            labels[bar], expected,
            "multi-episode bar {bar} (p_now={}, p_fut={}): expected {expected}, got {}",
            targets[bar],
            if bar + 30 < total_bars { targets[bar + 30] } else { f32::NAN },
            labels[bar]
        );
    }
    let n_skip = labels.iter().filter(|&&l| l == -1).count();
    assert_eq!(n_skip, 30, "expected 30 skip-labels (bars 70..99), got {n_skip}");
}
