Files
foxhunt/crates/ml/tests/sp13_layer_b_oracle_tests.rs
jgrusewski 6657e56265 feat(sp13): B1.1b — producer kernel + replay direct path + experience collector
Final piece of the SP13 Layer B chain. B1.1a flipped the aux head from K=1
MSE regression to K=2 softmax CE classification but aux_nb_label_buf was
zero-init — model was training on "all bars are class 0 (down)". B1.1b
lands the producer kernel that fills i32 -1/0/1 labels from the 30-bar
price trajectory, the replay direct-path 8th gather that carries those
labels into the trainer, and the experience collector hoist that ensures
bar_indices_pinned is always populated (producer + hindsight relabel both
consume it). Aux head finally trains on real classification signal.

Recovery commit: this completes a B1.1b agent dispatch that crashed
mid-edit. The implementer had landed ~95% of the cascade (kernel file,
build.rs, replay buffer signature + direct path, fused_training getter,
trainer accessor, both training_loop.rs callers, kernel field + cubin
loader on the experience collector) before being killed. The missing
pieces (experience collector launch + bar_indices_pinned hoist + 6
producer tests + audit doc) were completed manually post-crash and
verified end-to-end.

Three contracts (atomic single commit per feedback_no_partial_refactor):

  1. NEW aux_sign_label_kernel.cu producer — pure per-thread O(1) map
     reading targets[bar*6+2] (raw_close column) at bar and bar+lookahead,
     writing -1 (skip if bar+lookahead >= total_bars), 0 (down/flat under
     strict greater-than tie-break), or 1 (up). Replaces B0 alloc_zeros.

  2. Replay direct-path 8th gather — set_trainer_buffers gains 8th arg
     trainer_aux_sign_labels_ptr; direct branch in sample_proportional
     adds gather_i32_scalar into the trainer ptr; fallback gather wrapped
     in if !direct_to_trainer (avoids wasted DtoD). Direct-mode
     GpuBatchPtrs return points aux_sign_labels_ptr at trainer ptr.

  3. Experience collector bar_indices_pinned cpu-fill hoist — moved out
     of if hindsight_fraction > 0.0 so producer + hindsight share it.

Files (9 total):
  - crates/ml/src/cuda_pipeline/aux_sign_label_kernel.cu (NEW)
  - crates/ml/build.rs (cubin registration)
  - crates/ml/src/cuda_pipeline/gpu_experience_collector.rs (kernel
    field + cubin loader + struct init + hoist + producer launch)
  - crates/ml-dqn/src/gpu_replay_buffer.rs (8th arg + direct gather +
    fallback skip + GpuBatchPtrs return)
  - crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs (aux_nb_label_buf_ptr
    accessor mirrors 6 existing trainer-buf accessors)
  - crates/ml/src/trainers/dqn/fused_training.rs
    (trainer_aux_sign_labels_buf_ptr getter)
  - crates/ml/src/trainers/dqn/trainer/training_loop.rs (both
    set_trainer_buffers callers updated)
  - crates/ml/tests/sp13_layer_b_oracle_tests.rs (6 NEW producer tests)
  - docs/dqn-wire-up-audit.md (B1.1b section)

Hard rules upheld:
  - feedback_no_partial_refactor: every consumer of the 3 contracts
    migrates atomically
  - feedback_no_atomicadd: producer is pure map; no reductions
  - feedback_cpu_is_read_only: producer GPU-only; only host work is
    pre-existing bar_indices_pinned cpu-fill (hoisted unchanged)
  - feedback_no_stubs: kernel output flows through real chain — ring
    buffer → direct gather → aux_nb_label_buf → CE consumer
  - feedback_no_legacy_aliases: 8-arg setter gets
    #[allow(clippy::too_many_arguments)] not an alias shim
  - feedback_no_htod_htoh_only_mapped_pinned: targets_buf and
    bar_indices_pinned both pre-existing mapped-pinned

Build + test:
  - cargo check --workspace clean (only pre-existing warnings)
  - cargo check --workspace --tests clean
  - 17 tests in sp13_layer_b_oracle_tests.rs:
    - 2 CPU-only (fingerprint bump + HEALTH_DIAG snap stable) pass
    - 15 GPU on RTX 3050 Ti pass (9 B1.1a + 6 new B1.1b producer):
        aux_sign_label_monotone_up_all_ones
        aux_sign_label_monotone_down_all_zeros
        aux_sign_label_flat_all_zeros_strict_gt
        aux_sign_label_last_30_bars_skip
        aux_sign_label_boundary_first_valid_last_skip
        aux_sign_label_multi_episode_per_episode_skip

Producer tests cover every edge case in the kernel:
  - Monotone trajectories (label=1 / label=0 across all valid bars)
  - Flat tie-break (strict greater-than means flat → 0)
  - Skip sentinel for last lookahead bars
  - First/last bar boundary (bar=0 valid, bar=L-1 skip)
  - Multi-episode global skip semantics

Next: Smoke A — L40S 5-epoch validation of full SP13 stack
(P0a + P0b + B0 + B0.1 + B1.0 + B1.1a + B1.1b). Expected: aux head
trains on real K=2 softmax CE labels; aux_dir_acc_short_ema rises above
0.5 within first epoch (vs B1.1a degraded baseline at 0.5);
HEALTH_DIAG aux_b1_diag emits per-epoch with n_down/n_up/n_skip/mask_frac.
If aux_dir_acc_short_ema > 0.55 by epoch 5, B1.1b is validated and the
chain merges to main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:45:47 +02:00

1054 lines
44 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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}");
}