#![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 { let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?"); ctx.default_stream() } fn load_loss_reduce(stream: &Arc) -> 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) -> 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) -> 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) -> 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, 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::() 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, f: &CudaFunction, softmax: &[f32], labels: &[i32], valid_count: f32, b: usize, sh2: usize, k: i32, ) -> Vec { 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::() 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, 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::() 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, 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::() 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; 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::(); 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) -> 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 { 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 = (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 = (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 = (0..total_bars).map(|i| 100.0 - i as f32 * 0.1).collect(); let bar_indices: Vec = (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 = (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 = (0..total_bars).map(|i| 100.0 + i as f32 * 0.1).collect(); let bar_indices: Vec = (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 = (0..total_bars).map(|i| 100.0 + i as f32 * 0.05).collect(); let bar_indices: Vec = 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 = (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}"); }