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

//! SP13 Phase 0a directional-accuracy reducer GPU oracle tests.
//!
//! Validates the single-block tree-reduce kernel introduced by P0a.T2:
//!   `aux_dir_acc_reduce_kernel.cu`
//!
//! SP13 B1.1a (2026-05-05): the kernel ABI flipped — reads
//! `softmax [B, K=2]` + i32 labels (was regression-mode `aux_pred f32 +
//! label_f32`) and writes 6 floats (was 3): tail of the output gained
//! `n_down`, `n_up`, `n_skip` per-class counts for HEALTH_DIAG. The
//! tests below drive synthetic `(softmax, i32 labels)` pairs through
//! the production kernel and verify the 6-element output against
//! analytically-known expected values. 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 tests are `#[ignore = "requires GPU"]`-gated to match every other
//! GPU oracle test in this crate (sp4/sp5/sp11/sp12). Run on a GPU host:
//!
//!     SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//!       cargo test -p ml --test sp13_phase0_oracle_tests --features cuda \
//!         -- --ignored --nocapture
//!
//! Argmax convention (kernel side: `(softmax[1] > softmax[0]) ? 1 : 0`):
//!   - `softmax[1] > softmax[0]`   → predicts class 1 (up)
//!   - `softmax[1] <= softmax[0]`  → predicts class 0 (down) — incl. ties
//!   - `label == -1` → mask/skip bar, excluded from denominator entirely;
//!                     contributes to `n_skip` only.
//!
//! Sentinel:
//!   - When the entire batch is masked (empty / all-mask labels),
//!     `(dir_acc, pos_pred_frac, pos_label_frac) = (0.5, 0.5, 0.5)`.
//!     Matches `DIR_ACC_EMA_SENTINEL` in `sp13_isv_slots.rs` per
//!     `pearl_first_observation_bootstrap.md`. The trailing
//!     `(n_down, n_up, n_skip)` triple emits actual counts regardless.

#![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 built by `crates/ml/build.rs`.
const SP13_AUX_DIR_ACC_REDUCE_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/aux_dir_acc_reduce_kernel.cubin"));

/// Resolve a CUDA stream against device 0. Mirrors `make_test_stream` in
/// every other oracle test in this crate (`sp4_producer_unit_tests.rs`,
/// `sp11_producer_unit_tests.rs`, `sp12_reward_math_tests.rs`).
fn make_test_stream() -> Arc<CudaStream> {
    let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
    ctx.default_stream()
}

/// Load the reducer kernel function from the embedded cubin.
fn load_kernel(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")
}

/// Drive one launch of the production reducer kernel with the given
/// per-bar prediction + label slices and return
/// `[dir_acc, pos_pred_frac, pos_label_frac]`.
///
/// All buffers are mapped-pinned (`MappedF32Buffer`) per
/// `feedback_no_htod_htoh_only_mapped_pinned.md`. The kernel writes the
/// 3-element output through `dev_ptr` with `__threadfence_system()`; the
/// test reads through `read_all()`'s volatile read after stream sync.
///
/// Block dim 256, shared mem `6 × 256 × sizeof(int) = 6 KB` — matches
/// the production launcher's config exactly (SP13 B1.1a grew shmem
/// 4 → 6 arrays for n_down/n_up/n_skip).
///
/// Returns the full 6-element output:
///   `[dir_acc, pos_pred_frac, pos_label_frac, n_down, n_up, n_skip]`
fn run_dir_acc(
    stream: &Arc<CudaStream>,
    f: &CudaFunction,
    softmax: &[f32],
    labels: &[i32],
    k: i32,
) -> [f32; 6] {
    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
    );

    // Empty-batch case: even if `n == 0` the kernel still needs valid
    // device pointers for the input args (CUDA dereferences them only
    // inside the strided loop, which doesn't execute, but the launch
    // builder still passes the pointers). Allocate 1-element placeholders
    // so the dev_ptr is non-zero.
    let alloc_len_smx = (n * k as usize).max(1);
    let alloc_len_lbl = n.max(1);

    // Safety: CUDA context active on this thread (resolved via the
    // stream's context above). All CPU↔GPU buffers are mapped-pinned.
    let softmax_buf = unsafe { MappedF32Buffer::new(alloc_len_smx) }
        .expect("alloc softmax buffer (mapped-pinned)");
    let labels_buf = unsafe { MappedI32Buffer::new(alloc_len_lbl) }
        .expect("alloc labels buffer (mapped-pinned)");
    if n > 0 {
        softmax_buf.write_from_slice(softmax);
        labels_buf.write_from_slice(labels);
    }

    let out_buf = unsafe { MappedF32Buffer::new(6) }
        .expect("alloc out buffer (6 f32 mapped-pinned)");

    let softmax_dev = softmax_buf.dev_ptr;
    let labels_dev  = labels_buf.dev_ptr;
    let out_dev     = out_buf.dev_ptr;
    let batch_size_i32: i32 = n as i32;

    let bdim: u32 = 256;
    unsafe {
        stream
            .launch_builder(f)
            .arg(&softmax_dev)
            .arg(&labels_dev)
            .arg(&batch_size_i32)
            .arg(&k)
            .arg(&out_dev)
            .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 aux_dir_acc_reduce launch");

    let out = out_buf.read_all();
    [out[0], out[1], out[2], out[3], out[4], out[5]]
}

/// Build a one-hot `[B, K=2]` softmax tile from per-bar argmax bits.
/// `pred_class[b] == 1` produces `[0.0, 1.0]` in row b (predicts up);
/// `pred_class[b] == 0` produces `[1.0, 0.0]` (predicts down).
/// One-hot tiles deliberately exercise the kernel's argmax branch
/// without softmax noise (the kernel only compares the two probs;
/// any strict ordering of `[p0, p1]` that matches the desired argmax
/// is sufficient).
fn one_hot_softmax(pred_class: &[i32]) -> Vec<f32> {
    let mut out = Vec::with_capacity(pred_class.len() * 2);
    for &c in pred_class {
        if c == 1 {
            out.push(0.0);
            out.push(1.0);
        } else {
            out.push(1.0);
            out.push(0.0);
        }
    }
    out
}

// ─── Six P0a.T2 oracle cases (SP13 B1.1a-rewritten) ─────────────────────

/// All four bars predict the correct class → dir_acc = 1.0.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_all_correct_returns_one() {
    let stream = make_test_stream();
    let f = load_kernel(&stream);
    // pred [1, 1, 0, 0], label [1, 1, 0, 0] → all correct.
    let softmax = one_hot_softmax(&[1, 1, 0, 0]);
    let labels  = vec![1_i32, 1, 0, 0];
    let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
    assert!(
        (out[0] - 1.0).abs() < 1e-6,
        "expected dir_acc = 1.0, got {}",
        out[0]
    );
    // Tail counts: 2 down + 2 up + 0 skip.
    assert!((out[3] - 2.0).abs() < 1e-6, "n_down expected 2, got {}", out[3]);
    assert!((out[4] - 2.0).abs() < 1e-6, "n_up expected 2, got {}", out[4]);
    assert!((out[5] - 0.0).abs() < 1e-6, "n_skip expected 0, got {}", out[5]);
}

/// All four bars predict the wrong class → dir_acc = 0.0.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_all_wrong_returns_zero() {
    let stream = make_test_stream();
    let f = load_kernel(&stream);
    // pred [1, 1, 0, 0], label [0, 0, 1, 1] → all wrong.
    let softmax = one_hot_softmax(&[1, 1, 0, 0]);
    let labels  = vec![0_i32, 0, 1, 1];
    let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
    assert!(
        out[0].abs() < 1e-6,
        "expected dir_acc = 0.0, got {}",
        out[0]
    );
}

/// Half right + asymmetric prediction/label distributions.
///
/// pred  = [1, 1, 1, 1]  → 4 up predictions / 4 valid bars = 1.0
/// label = [1, 1, 0, 0]  → 2 up labels      / 4 valid bars = 0.5
/// dir_acc = 2/4 = 0.5 (bars 0+1 correct, bars 2+3 wrong).
#[test]
#[ignore = "requires GPU"]
fn dir_acc_half_correct() {
    let stream = make_test_stream();
    let f = load_kernel(&stream);
    let softmax = one_hot_softmax(&[1, 1, 1, 1]);
    let labels  = vec![1_i32, 1, 0, 0];
    let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
    assert!(
        (out[0] - 0.5).abs() < 1e-6,
        "expected dir_acc = 0.5, got {}",
        out[0]
    );
    assert!(
        (out[1] - 1.0).abs() < 1e-6,
        "expected pos_pred_frac = 1.0 (4/4 up predictions), got {}",
        out[1]
    );
    assert!(
        (out[2] - 0.5).abs() < 1e-6,
        "expected pos_label_frac = 0.5 (2/4 up labels), got {}",
        out[2]
    );
}

/// Bars whose label is exactly -1 are excluded from the denominator
/// entirely (B1.1a mask sentinel).
///
/// pred  = [1, 1, 0]
/// label = [1, -1, 0]  ← bar 1 is masked and skipped
/// valid bars = 2 (indexes 0 and 2). Both correct → dir_acc = 2/2 = 1.0.
/// n_skip should equal 1 (the mask bar contributes to n_skip only).
#[test]
#[ignore = "requires GPU"]
fn dir_acc_skips_mask_labels() {
    let stream = make_test_stream();
    let f = load_kernel(&stream);
    let softmax = one_hot_softmax(&[1, 1, 0]);
    let labels  = vec![1_i32, -1, 0];
    let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
    assert!(
        (out[0] - 1.0).abs() < 1e-6,
        "expected dir_acc = 1.0 (bar 1 with label=-1 excluded), got {}",
        out[0]
    );
    assert!((out[5] - 1.0).abs() < 1e-6, "n_skip expected 1, got {}", out[5]);
    assert!((out[3] - 1.0).abs() < 1e-6, "n_down expected 1, got {}", out[3]);
    assert!((out[4] - 1.0).abs() < 1e-6, "n_up expected 1, got {}", out[4]);
}

/// Argmax convention: `(softmax[1] > softmax[0]) ? 1 : 0` treats ties
/// (both equal) as predicting class 0 (down). The B1.1a softmax is
/// produced by a stable softmax over logits — equal logits produce
/// equal probabilities, which the kernel must round to class 0.
///
/// softmax = [[0.5, 0.5], [0.5, 0.5]]  ← both predict class 0 (tie → 0)
/// label   = [0, 0]                     ← both down
/// dir_acc = 2/2 = 1.0 (both correct).
#[test]
#[ignore = "requires GPU"]
fn dir_acc_softmax_tie_is_class_zero() {
    let stream = make_test_stream();
    let f = load_kernel(&stream);
    let softmax = vec![0.5_f32, 0.5, 0.5, 0.5];
    let labels  = vec![0_i32, 0];
    let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
    assert!(
        (out[0] - 1.0).abs() < 1e-6,
        "expected dir_acc = 1.0 (tied softmax → class 0 → matches down label), got {}",
        out[0]
    );
}

/// Empty batch (or all-mask-label batch) returns the random-baseline
/// sentinel 0.5 across the first three outputs. Matches
/// `DIR_ACC_EMA_SENTINEL` in `sp13_isv_slots.rs` per
/// `pearl_first_observation_bootstrap.md` — the downstream EMA's first-
/// observation replacement consumes the sentinel naturally. The trailing
/// `(n_down, n_up, n_skip)` triple emits 0/0/0 for empty (no rows
/// counted), matching the actual count semantics.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_empty_batch_returns_sentinel() {
    let stream = make_test_stream();
    let f = load_kernel(&stream);
    let softmax: Vec<f32> = vec![];
    let labels:  Vec<i32> = vec![];
    let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
    assert!(
        (out[0] - 0.5).abs() < 1e-6,
        "expected dir_acc sentinel = 0.5 on empty batch, got {}",
        out[0]
    );
    assert!(
        (out[1] - 0.5).abs() < 1e-6,
        "expected pos_pred_frac sentinel = 0.5 on empty batch, got {}",
        out[1]
    );
    assert!(
        (out[2] - 0.5).abs() < 1e-6,
        "expected pos_label_frac sentinel = 0.5 on empty batch, got {}",
        out[2]
    );
    // n_down/n_up/n_skip = 0/0/0 on truly empty batch.
    assert!((out[3]).abs() < 1e-6, "n_down expected 0 on empty, got {}", out[3]);
    assert!((out[4]).abs() < 1e-6, "n_up expected 0 on empty, got {}", out[4]);
    assert!((out[5]).abs() < 1e-6, "n_skip expected 0 on empty, got {}", out[5]);
}

// ─── SP13 v3 P0a.T3 Hold-rate observer oracle cases ─────────────────────
//
// Validates the second SP13 Phase 0a single-block tree-reduce:
//   `hold_rate_observer_kernel.cu`
//
// Drives synthetic per-bar PACKED factored-action tiles (`batch_actions [B]`
// of `i32`) through the production kernel and verifies the GPU-computed
// fraction `count(decoded_dir == DIR_HOLD) / batch_size` against
// analytically-known expected values. Same `MappedF32Buffer` discipline
// as the dir-acc oracle above; the actions tile is allocated through a
// dedicated `MappedI32Buffer` helper since the production input is `i32`
// rather than `f32`.
//
// Action encoding under test (mirrors `experience_action_select` at
// `experience_kernels.cu:1453`):
//
//   action_idx = dir * (NUM_MAGNITUDES * NUM_ORD * NUM_URG)
//              +  mag * (NUM_ORD * NUM_URG)
//              +  ord *  NUM_URG
//              +  urg
//
// Production branch sizes are NUM_MAGNITUDES = NUM_ORD = NUM_URG = 3
// (see `state_layout.cuh`); the per-direction stride is 27.
//
// Sign convention under test:
//   - decoded `dir == DIR_HOLD` (=1) — bars whose packed action_idx
//     decodes to direction Hold contribute 1 to the count
//   - any other direction (`DIR_SHORT=0`, `DIR_LONG=2`, `DIR_FLAT=3`)
//     contributes 0
//   - empty batch → out = 0.0 (matches the Pearl A sentinel for slot
//     382 / `HOLD_RATE_OBSERVED_EMA_INDEX`)

const SP13_HOLD_RATE_OBSERVER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/hold_rate_observer_kernel.cubin"));

/// Production branch sizes (mirror `state_layout.cuh`: `NUM_MAGNITUDES`,
/// `NUM_ORD`, `NUM_URG`). Authoritative source — every consumer of the
/// packed factored encoding must use the same values per
/// `feedback_no_partial_refactor.md`.
const TEST_NUM_MAGNITUDES: i32 = 3;
const TEST_NUM_ORD: i32        = 3;
const TEST_NUM_URG: i32        = 3;

/// Pack a 4-tuple `(dir, mag, ord, urg)` into a factored action_idx using
/// the same encoding as `experience_action_select`. Direction stride is
/// `M * O * U`; magnitude stride is `O * U`; order stride is `U`.
fn pack_action(dir: i32, mag: i32, ord: i32, urg: i32) -> i32 {
    dir * (TEST_NUM_MAGNITUDES * TEST_NUM_ORD * TEST_NUM_URG)
        + mag * (TEST_NUM_ORD * TEST_NUM_URG)
        + ord * TEST_NUM_URG
        + urg
}

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

/// Drive one launch of the production Hold-rate observer reducer with
/// the given per-bar direction-action slice and return the Hold-pick
/// fraction.
///
/// Block dim 256, shared mem `256 × sizeof(i32) = 1 KB` — matches the
/// production launcher's config exactly. All CPU↔GPU buffers are
/// mapped-pinned (`MappedI32Buffer` for the i32 actions tile,
/// `MappedF32Buffer` for the f32 output) per
/// `feedback_no_htod_htoh_only_mapped_pinned.md`.
fn run_hold_rate_observer(
    stream: &Arc<CudaStream>,
    f: &CudaFunction,
    actions: &[i32],
) -> f32 {
    let n = actions.len();
    // Empty-batch case: kernel still needs a non-null device pointer for
    // the actions arg (strided loop is empty but launch builder still
    // passes the pointer). Mirrors `run_dir_acc`.
    let alloc_len = n.max(1);

    // Safety: CUDA context active on this thread (resolved via the
    // stream's context above). All CPU↔GPU buffers are mapped-pinned.
    let actions_buf = unsafe { MappedI32Buffer::new(alloc_len) }
        .expect("alloc actions buffer (mapped-pinned)");
    if n > 0 {
        actions_buf.write_from_slice(actions);
    }

    let out_buf = unsafe { MappedF32Buffer::new(1) }
        .expect("alloc out buffer (1 f32 mapped-pinned)");

    let actions_dev = actions_buf.dev_ptr;
    let out_dev = out_buf.dev_ptr;
    let batch_size_i32: i32 = n as i32;
    let bdim: u32 = 256;

    unsafe {
        stream
            .launch_builder(f)
            .arg(&actions_dev)
            .arg(&batch_size_i32)
            .arg(&out_dev)
            .launch(LaunchConfig {
                grid_dim:         (1, 1, 1),
                block_dim:        (bdim, 1, 1),
                shared_mem_bytes: bdim * std::mem::size_of::<i32>() as u32,
            })
            .expect("launch hold_rate_observer_kernel");
    }
    stream.synchronize().expect("sync after hold_rate_observer launch");

    out_buf.read_all()[0]
}

/// Every action in the batch decodes to Hold direction → out = 1.0.
/// Uses non-zero (mag, ord, urg) sub-indices to exercise the divisor —
/// would catch a kernel that mistakenly treated the raw action_idx as the
/// direction (it's only the direction *after* dividing by M*O*U).
#[test]
#[ignore = "requires GPU"]
fn hold_rate_observer_all_hold_returns_one() {
    let stream = make_test_stream();
    let f = load_hold_rate_observer(&stream);
    // Mix Hold actions across different (mag, ord, urg) sub-indices to
    // verify the kernel's divisor. action_idx for Hold ranges over
    // [27, 54): every value in that interval decodes to dir=1.
    let actions: Vec<i32> = vec![
        pack_action(1, 0, 0, 0), // 27
        pack_action(1, 1, 0, 0), // 36
        pack_action(1, 0, 2, 0), // 33
        pack_action(1, 2, 2, 2), // 53 (Hold high-corner)
        pack_action(1, 0, 0, 1), // 28
        pack_action(1, 1, 1, 1), // 40
        pack_action(1, 2, 0, 0), // 45
        pack_action(1, 0, 1, 2), // 32
        pack_action(1, 1, 2, 1), // 43
        pack_action(1, 0, 0, 2), // 29
    ];
    let out = run_hold_rate_observer(&stream, &f, &actions);
    assert!(
        (out - 1.0).abs() < 1e-6,
        "expected hold_rate = 1.0 on all-Hold batch (decoded), got {}",
        out
    );
}

/// No action in the batch decodes to Hold (mix of Short=0/Long=2/Flat=3
/// across all (mag, ord, urg)) → out = 0.0. Verifies that no off-by-one
/// in the divisor flips a non-Hold packed value into the Hold band.
#[test]
#[ignore = "requires GPU"]
fn hold_rate_observer_no_hold_returns_zero() {
    let stream = make_test_stream();
    let f = load_hold_rate_observer(&stream);
    // Mix non-Hold direction values across the (mag, ord, urg) sub-space.
    // Short band = [0, 27), Long band = [54, 81), Flat band = [81, 108).
    // None of these decode to dir=1.
    let actions: Vec<i32> = vec![
        pack_action(0, 0, 0, 0), //  0  (Short low-corner)
        pack_action(0, 2, 2, 2), // 26  (Short high-corner)
        pack_action(2, 0, 0, 0), // 54  (Long low-corner)
        pack_action(2, 2, 2, 2), // 80  (Long high-corner)
        pack_action(3, 0, 0, 0), // 81  (Flat low-corner)
        pack_action(3, 2, 2, 2), // 107 (Flat high-corner)
    ];
    let out = run_hold_rate_observer(&stream, &f, &actions);
    assert!(
        out.abs() < 1e-6,
        "expected hold_rate = 0.0 on no-Hold batch (decoded), got {}",
        out
    );
}

/// Half the batch decodes to Hold → out = 0.5. Verifies the tree-reduce
/// arithmetic produces the correct fraction (not just the boolean any-
/// Hold/no-Hold extremes the previous two tests cover) AND that every
/// non-Hold direction (Short/Long/Flat) decodes correctly to non-Hold —
/// would catch an off-by-one comparing against DIR_SHORT instead of
/// DIR_HOLD.
#[test]
#[ignore = "requires GPU"]
fn hold_rate_observer_half_hold_returns_half() {
    let stream = make_test_stream();
    let f = load_hold_rate_observer(&stream);
    // 4 Hold + 4 non-Hold = 0.5 fraction. Each Hold has different
    // (mag, ord, urg); the 4 non-Hold span all 3 alternative directions.
    let actions: Vec<i32> = vec![
        pack_action(1, 0, 0, 0), // Hold
        pack_action(0, 1, 1, 1), // Short
        pack_action(1, 1, 1, 1), // Hold
        pack_action(2, 0, 0, 0), // Long
        pack_action(1, 2, 2, 2), // Hold
        pack_action(3, 1, 1, 1), // Flat
        pack_action(1, 0, 1, 2), // Hold
        pack_action(0, 2, 2, 2), // Short
    ];
    let out = run_hold_rate_observer(&stream, &f, &actions);
    assert!(
        (out - 0.5).abs() < 1e-6,
        "expected hold_rate = 0.5 on half-Hold batch (decoded), got {}",
        out
    );
}

// ─── SP13 P0a.T4 fixed-α EMA applicator oracle cases ────────────────────
//
// Validates the SP13 fixed-α EMA applicator kernel introduced by P0a.T4:
//   `apply_fixed_alpha_ema_kernel.cu`
//
// Drives synthetic (sample, prev_state, alpha, sentinel) tuples through
// the production kernel and verifies the GPU-computed
// `(1-α) · prev + α · sample` blend (or sentinel-bootstrap direct
// replacement) against analytically-known expected values. Same
// `MappedF32Buffer` discipline as the other oracles; the kernel is N-slot
// generalisable but P0a always launches with N=1, so the tests stay at
// N=1 to cover the production launch shape.

const SP13_APPLY_FIXED_ALPHA_EMA_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/apply_fixed_alpha_ema_kernel.cubin"));

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

/// Drive one launch of the fixed-α EMA applicator on a 1-slot ISV
/// scratch buffer pre-seeded with `prev_state`. Returns the post-launch
/// ISV slot value.
fn run_fixed_alpha_ema(
    stream: &Arc<CudaStream>,
    f: &CudaFunction,
    sample: f32,
    prev_state: f32,
    alpha: f32,
    sentinel: f32,
) -> f32 {
    // Allocate a 1-elem mapped-pinned ISV scratch + 1-elem sample buffer.
    let isv_buf = unsafe { MappedF32Buffer::new(1) }
        .expect("alloc isv buffer (mapped-pinned)");
    isv_buf.write_from_slice(&[prev_state]);

    let sample_buf = unsafe { MappedF32Buffer::new(1) }
        .expect("alloc sample buffer (mapped-pinned)");
    sample_buf.write_from_slice(&[sample]);

    let sample_dev = sample_buf.dev_ptr;
    let isv_dev    = isv_buf.dev_ptr;
    let n_slots:    i32 = 1;
    let isv_offset: i32 = 0;
    unsafe {
        stream
            .launch_builder(f)
            .arg(&sample_dev)
            .arg(&n_slots)
            .arg(&isv_offset)
            .arg(&alpha)
            .arg(&sentinel)
            .arg(&isv_dev)
            .launch(LaunchConfig {
                grid_dim:         (1, 1, 1),
                block_dim:        (1, 1, 1),
                shared_mem_bytes: 0,
            })
            .expect("launch apply_fixed_alpha_ema_kernel");
    }
    stream.synchronize().expect("sync after apply_fixed_alpha_ema launch");

    isv_buf.read_all()[0]
}

/// Standard blend: prev=0.5, sample=1.0, α=0.5 → 0.75.
/// Sentinel is intentionally distinct from `prev` so the bootstrap
/// branch does NOT fire — this test exercises the steady-state blend
/// formula `(1-α) · prev + α · sample`.
#[test]
#[ignore = "requires GPU"]
fn fixed_alpha_ema_steady_state_blend() {
    let stream = make_test_stream();
    let f = load_apply_fixed_alpha_ema(&stream);
    let out = run_fixed_alpha_ema(
        &stream, &f,
        /* sample */    1.0,
        /* prev */      0.5,
        /* alpha */     0.5,
        /* sentinel */  -1.0, // far from prev — bootstrap MUST NOT fire
    );
    assert!(
        (out - 0.75).abs() < 1e-6,
        "expected (1-0.5)*0.5 + 0.5*1.0 = 0.75, got {}",
        out
    );
}

/// First-observation bootstrap: prev == sentinel → replace directly.
/// Mirrors `pearl_first_observation_bootstrap` — the SP13 dir-acc
/// EMAs use sentinel=0.5 and the hold-rate EMA uses sentinel=0.0;
/// after fold reset the slot holds the sentinel, and the very first
/// non-sentinel observation must replace it directly so the EMA tracks
/// the new fold's signal without the sentinel's bias.
#[test]
#[ignore = "requires GPU"]
fn fixed_alpha_ema_sentinel_bootstrap() {
    let stream = make_test_stream();
    let f = load_apply_fixed_alpha_ema(&stream);
    // SP13 dir-acc sentinel = 0.5.  First real observation = 0.62 (>
    // baseline). The kernel must REPLACE prev directly, not blend —
    // a blend at α=0.3 would produce 0.5*0.7 + 0.62*0.3 = 0.536 which
    // would carry the sentinel bias into the EMA forever.
    let out = run_fixed_alpha_ema(
        &stream, &f,
        /* sample */    0.62,
        /* prev */      0.5,
        /* alpha */     0.3,
        /* sentinel */  0.5,  // matches prev — bootstrap MUST fire
    );
    assert!(
        (out - 0.62).abs() < 1e-6,
        "expected sentinel bootstrap to replace directly with 0.62, got {}",
        out
    );
}

// ─── SP13 P0a.T4 aux-pred → ISV[375] oracle cases (B1.1a-rewritten) ────
//
// Validates the SP13 aux-head per-bar prediction → ISV[AUX_DIR_PREDICTION_INDEX=375]
// bounded scalar producer kernel introduced by P0a.T4 and rewritten by
// SP13 B1.1a (2026-05-05) to read the K=2 softmax tile directly:
//   `aux_pred_to_isv_tanh_kernel.cu`
//
// Drives synthetic `softmax [B, 2]` tiles through the production kernel
// and verifies the GPU-computed `mean(softmax[:, 1] - softmax[:, 0])`
// ∈ [-1, +1] against analytically-known expected values. The kernel
// filename + ISV slot name are preserved for layout stability across
// the B1.1a rewrite (the "tanh" in the name is historical — the runtime
// squash is retired since softmax provides the bound structurally).

const SP13_AUX_PRED_TO_ISV_TANH_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/aux_pred_to_isv_tanh_kernel.cubin"));

fn load_aux_pred_to_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")
}

/// Drive one launch of the aux-softmax → ISV[375] producer with the
/// given `softmax [B, 2]` tile. Returns ISV slot 0 post-launch.
fn run_aux_pred_to_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_len = (n * k as usize).max(1);

    let softmax_buf = unsafe { MappedF32Buffer::new(alloc_len) }
        .expect("alloc softmax buffer (mapped-pinned)");
    if !softmax.is_empty() {
        softmax_buf.write_from_slice(softmax);
    }

    // 1-elem ISV scratch — kernel only writes slot 0.
    let isv_buf = unsafe { MappedF32Buffer::new(1) }
        .expect("alloc isv buffer (mapped-pinned)");

    let softmax_dev = softmax_buf.dev_ptr;
    let isv_dev     = isv_buf.dev_ptr;
    let batch_i32:   i32 = n as i32;
    let isv_off_i32: i32 = 0;
    let bdim: u32 = 256;

    unsafe {
        stream
            .launch_builder(f)
            .arg(&softmax_dev)
            .arg(&batch_i32)
            .arg(&k)
            .arg(&isv_off_i32)
            .arg(&isv_dev)
            .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 aux_pred_to_isv_tanh launch");

    isv_buf.read_all()[0]
}

/// Balanced batch (every row is `[0.5, 0.5]`) → mean diff = 0.0.
/// Mirrors the regression-mode "symmetric input → mean=0" test, but
/// for the B1.1a softmax tile the equivalent is "every row is split
/// 50/50 between classes".
#[test]
#[ignore = "requires GPU"]
fn aux_pred_tanh_balanced_softmax_returns_zero() {
    let stream = make_test_stream();
    let f = load_aux_pred_to_isv_tanh(&stream);
    // 4 rows of [0.5, 0.5] → diff = 0 per row → mean = 0.
    let softmax = vec![0.5_f32; 8];
    let out = run_aux_pred_to_isv_tanh(&stream, &f, &softmax, 2);
    assert!(
        out.abs() < 1e-6,
        "expected mean(softmax[1]-softmax[0]) = 0.0 on balanced batch, got {}",
        out
    );
}

/// Symmetric (mixed) batch: 2 rows fully up + 2 rows fully down →
/// mean = (1.0 + 1.0 + (-1.0) + (-1.0)) / 4 = 0.0.
#[test]
#[ignore = "requires GPU"]
fn aux_pred_tanh_symmetric_batch_returns_zero() {
    let stream = make_test_stream();
    let f = load_aux_pred_to_isv_tanh(&stream);
    let softmax = one_hot_softmax(&[1, 1, 0, 0]);
    let out = run_aux_pred_to_isv_tanh(&stream, &f, &softmax, 2);
    assert!(
        out.abs() < 1e-6,
        "expected mean = 0.0 on symmetric batch (2 up + 2 down), got {}",
        out
    );
}

/// Saturated batch (every row strongly predicts up) →
/// mean ≈ +1.0; the structural bound `[-1, +1]` holds without any
/// runtime tanh squash. Mirror test for fully-down case.
#[test]
#[ignore = "requires GPU"]
fn aux_pred_tanh_large_magnitude_saturates_in_bounds() {
    let stream = make_test_stream();
    let f = load_aux_pred_to_isv_tanh(&stream);

    // All up: every row is [0.0, 1.0] → diff = +1.0 per row → mean = +1.0.
    let softmax_up = one_hot_softmax(&[1; 32]);
    let out_up = run_aux_pred_to_isv_tanh(&stream, &f, &softmax_up, 2);
    assert!(
        (out_up - 1.0).abs() < 1e-6 && out_up <= 1.0,
        "expected mean = +1.0 on all-up batch, got {}",
        out_up
    );

    // All down: every row is [1.0, 0.0] → diff = -1.0 per row → mean = -1.0.
    let softmax_dn = one_hot_softmax(&[0; 32]);
    let out_dn = run_aux_pred_to_isv_tanh(&stream, &f, &softmax_dn, 2);
    assert!(
        (out_dn + 1.0).abs() < 1e-6 && out_dn >= -1.0,
        "expected mean = -1.0 on all-down batch, got {}",
        out_dn
    );
}
