Files
foxhunt/crates/ml/tests/aux_trunk_oracle_tests.rs
jgrusewski 10e647c141 test(sp14-c9): synthetic smoke for aux trunk gradient chain + C.8/C.9 audit close-out
C.8 (ISV-driven aux trunk Adam β1/β2/ε/LR/grad-clip) was already complete in C.5a
commit c90de9859 — all 5 ISV reads and fold-boundary StateResetRegistry defaults were
wired atomically with the Adam launcher. No new code required; noted in audit doc.

C.9 adds `aux_trunk_learns_synthetic_uptrend` to aux_trunk_oracle_tests.rs:
- B=16, ENC=32, H1=32, H2=16, SH2=32, H_HEAD=32, K=2, 100 steps
- Backward kernel invocations corrected to match actual signatures:
  - aux_trunk_bwd_dh_pre(d_logits, w3, w2, h_aux1, h_aux2, dh_pre2, dh_pre1, B, H1, H2, SH2)
    shmem = H2 floats (sh_dh2_pre cache), NOT (H1+H2+SH2)
  - aux_trunk_bwd_dW_reduce called 3×: dW3/dW2/dW1 each with (A, B_grad, dW_out, B, Krows, Jcols)
  - aux_trunk_bwd_db_reduce called 3×: db3/db2/db1 each with (B_grad, db_out, B, Jcols)
- Head params trained via host-side SGD (test orchestration only; reads mapped-pinned partials)
- Trunk params trained via dqn_adam_update_kernel (GPU Adam)
- Pass gate: CE loss < 0.1 AND dir_acc > 0.95 after 100 steps
  Near-random baseline (ln(2)≈0.693) = broken gradient chain, L40S dispatch blocked

Memory pearl pearl_separate_aux_trunk_when_shared_starves.md added and indexed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 03:30:44 +02:00

1952 lines
87 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.
//! Oracle tests for aux trunk forward + backward (SP14 Layer C Phases
//! C.3 + C.4, 2026-05-08).
//!
//! Layer C introduces a separate auxiliary trunk parallel to Q's GRN trunk
//! per the locked design in
//! `2026-05-07-sp14-layer-c-separate-aux-trunk.md`. The forward kernel is
//! a 3-layer Linear→ELU→Linear→ELU→Linear MLP that reads the encoder
//! output and produces `h_s2_aux [B, AUX_HIDDEN_DIM]` for the aux head.
//! Backward terminates at the encoder boundary — Q-loss is the sole
//! shaping force on the encoder.
//!
//! Tests in this file:
//! - `aux_trunk_forward_matches_numpy_reference` (C.3) — forward output
//! bit-for-bit match vs. host reference within 1e-4 tol.
//! - `aux_trunk_backward_gradient_check` (C.4) — analytic dW3 from the
//! backward kernel matches central-difference numerical gradient at
//! ~16 sampled indices, relative error tolerance 1e-2 (f32 noise).
//! - `aux_trunk_backward_does_not_write_dx` (C.4) — structural assertion
//! that the kernel source contains no `dx_in` / `dx_in_out` write
//! pattern; complements the runtime test that the kernel signatures
//! do not accept any encoder-gradient pointer.
//!
//! All CPU↔GPU buffers are `MappedF32Buffer` per
//! `feedback_no_htod_htoh_only_mapped_pinned.md` (tests are not exempt).
//!
//! Run with:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test aux_trunk_oracle_tests --release \
//! -- --ignored --nocapture
#![allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
// Allow non-snake-case for `dW{1,2,3}` / `db{1,2,3}` / `dW3_analytic` etc.
// — these are universally-recognised math symbols for weight/bias
// gradients and snake-casing them (`d_w1`, `d_b1_ptr`) loses the
// correspondence with the kernel parameter names in
// `aux_trunk_backward_kernel.cu` and the linear-algebra notation in the
// gradient-check comments.
#![allow(non_snake_case)]
#[cfg(feature = "cuda")]
mod gpu {
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
const AUX_TRUNK_FORWARD_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_trunk_forward_kernel.cubin"));
const AUX_TRUNK_BLOCK: u32 = 256;
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
fn load_aux_trunk_forward(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(AUX_TRUNK_FORWARD_CUBIN.to_vec())
.expect("load aux_trunk_forward cubin");
module
.load_function("aux_trunk_forward")
.expect("load aux_trunk_forward function")
}
/// Deterministic LCG → uniform `[-1, 1)` for reproducible weight/input
/// generation. Mirrors the LCG state-machine pattern used elsewhere
/// in the codebase (e.g. `xavier_init_dqn_weights` in
/// `gpu_dqn_trainer.rs`). Seed is fixed so every test run produces
/// bit-identical inputs.
fn lcg_next_signed(state: &mut u32) -> f32 {
*state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
let raw = (*state >> 16) as f32 / 32_768.0; // [0, 2)
raw - 1.0 // [-1, 1)
}
/// ELU forward used in the host reference. Mirrors
/// `aux_trunk_elu_fwd` in the kernel exactly.
fn elu(x: f32) -> f32 {
if x > 0.0 { x } else { x.exp() - 1.0 }
}
/// Forward output check — runs the GPU kernel against a deterministic
/// host reference and verifies element-wise match within 1e-4 (f32
/// SGEMM precision).
///
/// Uses production topology dimensions:
/// - ENCODER_OUT_DIM = 256 (= config.shared_h1)
/// - H1 = AUX_TRUNK_H1 = 256
/// - H2 = AUX_TRUNK_H2 = 128
/// - AUX_HIDDEN_DIM = 256 (= config.shared_h2)
///
/// B is small (4) to keep the host reference fast — the matmul
/// arithmetic is dominated by the per-row dot products (256 × 256 +
/// 256 × 128 + 128 × 256 = 131,072 multiplies per row), not B.
#[test]
#[ignore = "requires GPU"]
fn aux_trunk_forward_matches_numpy_reference() {
let stream = make_test_stream();
let kernel = load_aux_trunk_forward(&stream);
const B: usize = 4;
const ENCODER_OUT_DIM: usize = 256;
const H1: usize = 256;
const H2: usize = 128;
const AUX_HIDDEN_DIM: usize = 256;
// Deterministic LCG seed — fixed value so test is reproducible.
let mut rng_state: u32 = 0x1234_5678;
let next_f32 = |state: &mut u32| lcg_next_signed(state);
// Inputs scaled to small magnitudes so the cumulative 256-deep
// dot product stays in a numerically benign range. (×0.1 input,
// ×0.05 weights → per-row sum of ~256 products of 0.005 RMS
// each → expected acc magnitude < ~5 before activation.)
let x_in_host: Vec<f32> = (0..B * ENCODER_OUT_DIM)
.map(|_| next_f32(&mut rng_state) * 0.1)
.collect();
let w1_host: Vec<f32> = (0..ENCODER_OUT_DIM * H1)
.map(|_| next_f32(&mut rng_state) * 0.05)
.collect();
let b1_host: Vec<f32> = vec![0.0; H1];
let w2_host: Vec<f32> = (0..H1 * H2)
.map(|_| next_f32(&mut rng_state) * 0.05)
.collect();
let b2_host: Vec<f32> = vec![0.0; H2];
let w3_host: Vec<f32> = (0..H2 * AUX_HIDDEN_DIM)
.map(|_| next_f32(&mut rng_state) * 0.05)
.collect();
let b3_host: Vec<f32> = vec![0.0; AUX_HIDDEN_DIM];
// Allocate device buffers via MappedF32Buffer (mapped-pinned, no
// raw HtoD per `feedback_no_htod_htoh_only_mapped_pinned.md`).
let x_in = unsafe { MappedF32Buffer::new(B * ENCODER_OUT_DIM) }
.expect("alloc x_in");
x_in.write_from_slice(&x_in_host);
let w1 = unsafe { MappedF32Buffer::new(ENCODER_OUT_DIM * H1) }.expect("alloc w1");
w1.write_from_slice(&w1_host);
let b1 = unsafe { MappedF32Buffer::new(H1) }.expect("alloc b1");
b1.write_from_slice(&b1_host);
let w2 = unsafe { MappedF32Buffer::new(H1 * H2) }.expect("alloc w2");
w2.write_from_slice(&w2_host);
let b2 = unsafe { MappedF32Buffer::new(H2) }.expect("alloc b2");
b2.write_from_slice(&b2_host);
let w3 = unsafe { MappedF32Buffer::new(H2 * AUX_HIDDEN_DIM) }.expect("alloc w3");
w3.write_from_slice(&w3_host);
let b3 = unsafe { MappedF32Buffer::new(AUX_HIDDEN_DIM) }.expect("alloc b3");
b3.write_from_slice(&b3_host);
// Output + saved-for-backward buffers (zero-init).
let h_aux1 = unsafe { MappedF32Buffer::new(B * H1) }.expect("alloc h_aux1");
h_aux1.write_from_slice(&vec![0.0_f32; B * H1]);
let h_aux2 = unsafe { MappedF32Buffer::new(B * H2) }.expect("alloc h_aux2");
h_aux2.write_from_slice(&vec![0.0_f32; B * H2]);
let h_s2_aux = unsafe { MappedF32Buffer::new(B * AUX_HIDDEN_DIM) }
.expect("alloc h_s2_aux");
h_s2_aux.write_from_slice(&vec![0.0_f32; B * AUX_HIDDEN_DIM]);
let b_i32: i32 = B as i32;
let enc_i32: i32 = ENCODER_OUT_DIM as i32;
let h1_i32: i32 = H1 as i32;
let h2_i32: i32 = H2 as i32;
let aux_i32: i32 = AUX_HIDDEN_DIM as i32;
// Shared memory: H1 + H2 floats — Layer-1 cache + Layer-2 cache.
let smem_bytes =
(H1 as u32 + H2 as u32) * std::mem::size_of::<f32>() as u32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&x_in.dev_ptr)
.arg(&w1.dev_ptr)
.arg(&b1.dev_ptr)
.arg(&w2.dev_ptr)
.arg(&b2.dev_ptr)
.arg(&w3.dev_ptr)
.arg(&b3.dev_ptr)
.arg(&h_aux1.dev_ptr)
.arg(&h_aux2.dev_ptr)
.arg(&h_s2_aux.dev_ptr)
.arg(&b_i32)
.arg(&enc_i32)
.arg(&h1_i32)
.arg(&h2_i32)
.arg(&aux_i32)
.launch(LaunchConfig {
grid_dim: (B as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_bytes,
})
.expect("launch aux_trunk_forward");
}
stream.synchronize().expect("sync after aux_trunk_forward");
// ── Host reference ────────────────────────────────────────────
// Layer 1: h_aux1[b, k] = ELU( b1[k] + sum_j x_in[b, j] * w1[j, k] )
let mut h_aux1_ref = vec![0.0_f32; B * H1];
for b in 0..B {
for k in 0..H1 {
let mut acc = b1_host[k];
for j in 0..ENCODER_OUT_DIM {
acc += x_in_host[b * ENCODER_OUT_DIM + j] * w1_host[j * H1 + k];
}
h_aux1_ref[b * H1 + k] = elu(acc);
}
}
// Layer 2: h_aux2[b, k] = ELU( b2[k] + sum_j h_aux1[b, j] * w2[j, k] )
let mut h_aux2_ref = vec![0.0_f32; B * H2];
for b in 0..B {
for k in 0..H2 {
let mut acc = b2_host[k];
for j in 0..H1 {
acc += h_aux1_ref[b * H1 + j] * w2_host[j * H2 + k];
}
h_aux2_ref[b * H2 + k] = elu(acc);
}
}
// Layer 3: h_s2_aux[b, k] = b3[k] + sum_j h_aux2[b, j] * w3[j, k] (no activation)
let mut h_s2_aux_ref = vec![0.0_f32; B * AUX_HIDDEN_DIM];
for b in 0..B {
for k in 0..AUX_HIDDEN_DIM {
let mut acc = b3_host[k];
for j in 0..H2 {
acc += h_aux2_ref[b * H2 + j] * w3_host[j * AUX_HIDDEN_DIM + k];
}
h_s2_aux_ref[b * AUX_HIDDEN_DIM + k] = acc;
}
}
// ── Compare ───────────────────────────────────────────────────
let h_aux1_dev = h_aux1.read_all();
let h_aux2_dev = h_aux2.read_all();
let h_s2_aux_dev = h_s2_aux.read_all();
let max_diff = |dev: &[f32], reference: &[f32]| -> f32 {
dev.iter()
.zip(reference.iter())
.map(|(d, r)| (d - r).abs())
.fold(0.0_f32, f32::max)
};
let diff_h1 = max_diff(&h_aux1_dev, &h_aux1_ref);
let diff_h2 = max_diff(&h_aux2_dev, &h_aux2_ref);
let diff_out = max_diff(&h_s2_aux_dev, &h_s2_aux_ref);
const TOL: f32 = 1e-4;
assert!(
diff_h1 < TOL,
"h_aux1 max diff {diff_h1} exceeds {TOL} tol — Layer-1 kernel mismatch",
);
assert!(
diff_h2 < TOL,
"h_aux2 max diff {diff_h2} exceeds {TOL} tol — Layer-2 kernel mismatch",
);
assert!(
diff_out < TOL,
"h_s2_aux max diff {diff_out} exceeds {TOL} tol — Layer-3 kernel mismatch",
);
eprintln!(
"aux_trunk_forward oracle: h_aux1 diff={diff_h1:.2e}, h_aux2 diff={diff_h2:.2e}, h_s2_aux diff={diff_out:.2e} (tol={TOL:.0e})"
);
}
// ────────────────────────────────────────────────────────────────────
// SP14 Layer C Phase C.4 (2026-05-08): backward gradient check
// ────────────────────────────────────────────────────────────────────
const AUX_TRUNK_BACKWARD_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_trunk_backward_kernel.cubin"));
/// Load all three backward kernels from the precompiled cubin. Mirrors
/// `AuxTrunkBackwardOps::new` but inlined for test code that drives the
/// kernels directly without going through the wrapper struct.
fn load_aux_trunk_backward(stream: &Arc<CudaStream>) -> (CudaFunction, CudaFunction, CudaFunction) {
let module = stream
.context()
.load_cubin(AUX_TRUNK_BACKWARD_CUBIN.to_vec())
.expect("load aux_trunk_backward cubin");
let dh_pre = module
.load_function("aux_trunk_bwd_dh_pre")
.expect("load aux_trunk_bwd_dh_pre");
let dW_reduce = module
.load_function("aux_trunk_bwd_dW_reduce")
.expect("load aux_trunk_bwd_dW_reduce");
let db_reduce = module
.load_function("aux_trunk_bwd_db_reduce")
.expect("load aux_trunk_bwd_db_reduce");
(dh_pre, dW_reduce, db_reduce)
}
/// Run the GPU forward kernel and return the f32 output `h_s2_aux`
/// flattened `[B * AUX_HIDDEN_DIM]`. Used by the gradient-check test
/// which calls forward repeatedly with perturbed weights.
#[allow(clippy::too_many_arguments)]
fn run_forward(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
x_in: &MappedF32Buffer,
w1: &MappedF32Buffer,
b1: &MappedF32Buffer,
w2: &MappedF32Buffer,
b2: &MappedF32Buffer,
w3: &MappedF32Buffer,
b3: &MappedF32Buffer,
h_aux1: &MappedF32Buffer,
h_aux2: &MappedF32Buffer,
h_s2_aux: &MappedF32Buffer,
b_dim: usize,
encoder_out_dim: usize,
h1: usize,
h2: usize,
aux_hidden_dim: usize,
) {
let b_i32 = b_dim as i32;
let enc_i32 = encoder_out_dim as i32;
let h1_i32 = h1 as i32;
let h2_i32 = h2 as i32;
let aux_i32 = aux_hidden_dim as i32;
let smem_bytes = (h1 as u32 + h2 as u32) * std::mem::size_of::<f32>() as u32;
unsafe {
stream
.launch_builder(kernel)
.arg(&x_in.dev_ptr)
.arg(&w1.dev_ptr)
.arg(&b1.dev_ptr)
.arg(&w2.dev_ptr)
.arg(&b2.dev_ptr)
.arg(&w3.dev_ptr)
.arg(&b3.dev_ptr)
.arg(&h_aux1.dev_ptr)
.arg(&h_aux2.dev_ptr)
.arg(&h_s2_aux.dev_ptr)
.arg(&b_i32)
.arg(&enc_i32)
.arg(&h1_i32)
.arg(&h2_i32)
.arg(&aux_i32)
.launch(LaunchConfig {
grid_dim: (b_dim as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_bytes,
})
.expect("launch aux_trunk_forward");
}
stream.synchronize().expect("sync forward");
}
/// Gradient check for the backward kernel.
///
/// Strategy: define scalar loss `L = 0.5 * sum(h_s2_aux^2)`. Then
/// `dL/dh_s2_aux = h_s2_aux`, which we feed into the backward kernel
/// as `dh_s2_aux`. The kernel produces analytic dW3 (as well as the
/// other dW/db tensors which we don't validate here for runtime
/// reasons — dW3 alone exercises every layer's backward path through
/// the reduce-pre-pass + dW_reduce kernels).
///
/// Numerical gradient at sampled (k, j) indices via central
/// differences: `dW3_num[k, j] ≈ (L(W3 + ε·e_kj) - L(W3 - ε·e_kj)) /
/// (2ε)` with `ε = 1e-3`. Then compare analytic vs numerical at
/// ~16 random (k, j) pairs; relative error must be < 1e-2 (f32
/// numerical-gradient noise floor in this regime).
///
/// Test runs forward 33× (1 baseline + 16 indices × 2 perturbations)
/// with a small batch (B=4) and small layer dims (ENC=H1=H2=AUX=32)
/// to keep total runtime under ~5s. Production topology (256/256/128/
/// 256) is exercised by the forward oracle test; backward correctness
/// is dimension-independent given the kernels use runtime args for
/// layer sizes.
#[test]
#[ignore = "requires GPU"]
fn aux_trunk_backward_gradient_check() {
let stream = make_test_stream();
let fwd_kernel = load_aux_trunk_forward(&stream);
let (dh_pre_kernel, dW_reduce_kernel, db_reduce_kernel) =
load_aux_trunk_backward(&stream);
// Small dims to keep the 33 forward passes fast.
const B: usize = 4;
const ENCODER_OUT_DIM: usize = 32;
const H1: usize = 32;
const H2: usize = 32;
const AUX_HIDDEN_DIM: usize = 32;
// Deterministic LCG.
let mut rng_state: u32 = 0x0BAD_C0DE;
let next_f32 = |state: &mut u32| lcg_next_signed(state);
// Inputs scaled small so cumulative dot products stay numerically
// benign across the 32-deep matmuls.
let x_in_host: Vec<f32> = (0..B * ENCODER_OUT_DIM)
.map(|_| next_f32(&mut rng_state) * 0.5)
.collect();
let w1_host: Vec<f32> = (0..ENCODER_OUT_DIM * H1)
.map(|_| next_f32(&mut rng_state) * 0.2)
.collect();
let b1_host: Vec<f32> = (0..H1).map(|_| next_f32(&mut rng_state) * 0.1).collect();
let w2_host: Vec<f32> = (0..H1 * H2)
.map(|_| next_f32(&mut rng_state) * 0.2)
.collect();
let b2_host: Vec<f32> = (0..H2).map(|_| next_f32(&mut rng_state) * 0.1).collect();
let w3_host: Vec<f32> = (0..H2 * AUX_HIDDEN_DIM)
.map(|_| next_f32(&mut rng_state) * 0.2)
.collect();
let b3_host: Vec<f32> = (0..AUX_HIDDEN_DIM)
.map(|_| next_f32(&mut rng_state) * 0.1)
.collect();
// Allocate all device buffers.
let x_in = unsafe { MappedF32Buffer::new(B * ENCODER_OUT_DIM) }.expect("alloc x_in");
x_in.write_from_slice(&x_in_host);
let w1 = unsafe { MappedF32Buffer::new(ENCODER_OUT_DIM * H1) }.expect("alloc w1");
w1.write_from_slice(&w1_host);
let b1 = unsafe { MappedF32Buffer::new(H1) }.expect("alloc b1");
b1.write_from_slice(&b1_host);
let w2 = unsafe { MappedF32Buffer::new(H1 * H2) }.expect("alloc w2");
w2.write_from_slice(&w2_host);
let b2 = unsafe { MappedF32Buffer::new(H2) }.expect("alloc b2");
b2.write_from_slice(&b2_host);
let w3 = unsafe { MappedF32Buffer::new(H2 * AUX_HIDDEN_DIM) }.expect("alloc w3");
w3.write_from_slice(&w3_host);
let b3 = unsafe { MappedF32Buffer::new(AUX_HIDDEN_DIM) }.expect("alloc b3");
b3.write_from_slice(&b3_host);
let h_aux1 = unsafe { MappedF32Buffer::new(B * H1) }.expect("alloc h_aux1");
let h_aux2 = unsafe { MappedF32Buffer::new(B * H2) }.expect("alloc h_aux2");
let h_s2_aux = unsafe { MappedF32Buffer::new(B * AUX_HIDDEN_DIM) }.expect("alloc h_s2_aux");
// Backward output buffers + scratch for dh_pre intermediates.
let dh_aux1_pre = unsafe { MappedF32Buffer::new(B * H1) }.expect("alloc dh_aux1_pre");
let dh_aux2_pre = unsafe { MappedF32Buffer::new(B * H2) }.expect("alloc dh_aux2_pre");
let dW1 = unsafe { MappedF32Buffer::new(ENCODER_OUT_DIM * H1) }.expect("alloc dW1");
let db1 = unsafe { MappedF32Buffer::new(H1) }.expect("alloc db1");
let dW2 = unsafe { MappedF32Buffer::new(H1 * H2) }.expect("alloc dW2");
let db2 = unsafe { MappedF32Buffer::new(H2) }.expect("alloc db2");
let dW3 = unsafe { MappedF32Buffer::new(H2 * AUX_HIDDEN_DIM) }.expect("alloc dW3");
let db3 = unsafe { MappedF32Buffer::new(AUX_HIDDEN_DIM) }.expect("alloc db3");
// ── Step 1: baseline forward ────────────────────────────────────
run_forward(
&stream,
&fwd_kernel,
&x_in,
&w1,
&b1,
&w2,
&b2,
&w3,
&b3,
&h_aux1,
&h_aux2,
&h_s2_aux,
B,
ENCODER_OUT_DIM,
H1,
H2,
AUX_HIDDEN_DIM,
);
let h_s2_aux_baseline: Vec<f32> = h_s2_aux.read_all();
// ── Step 2: dh_s2_aux = h_s2_aux (loss = 0.5 * ||h_s2_aux||^2) ──
// Reuse h_s2_aux device buffer's data as the gradient input.
// Allocate a separate dh_s2_aux device buffer because backward
// overwrites scratch buffers; keeping the gradient buffer
// independent simplifies repeated forward passes for finite
// differences.
let dh_s2_aux = unsafe { MappedF32Buffer::new(B * AUX_HIDDEN_DIM) }.expect("alloc dh_s2_aux");
dh_s2_aux.write_from_slice(&h_s2_aux_baseline);
// ── Step 3: launch full backward sequence ───────────────────────
// Inline the seven-launch sequence here (mirrors
// AuxTrunkBackwardOps::launch's body) so the test exercises the
// exact kernel-handle-driven path the wrapper uses.
let block_smem = 256_u32 * std::mem::size_of::<f32>() as u32;
let dh_pre_smem = H2 as u32 * std::mem::size_of::<f32>() as u32;
let b_i32 = B as i32;
let h1_i32 = H1 as i32;
let h2_i32 = H2 as i32;
let aux_i32 = AUX_HIDDEN_DIM as i32;
// 3a. dh_pre kernel.
unsafe {
stream
.launch_builder(&dh_pre_kernel)
.arg(&dh_s2_aux.dev_ptr)
.arg(&w3.dev_ptr)
.arg(&w2.dev_ptr)
.arg(&h_aux1.dev_ptr)
.arg(&h_aux2.dev_ptr)
.arg(&dh_aux2_pre.dev_ptr)
.arg(&dh_aux1_pre.dev_ptr)
.arg(&b_i32)
.arg(&h1_i32)
.arg(&h2_i32)
.arg(&aux_i32)
.launch(LaunchConfig {
grid_dim: (B as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: dh_pre_smem,
})
.expect("launch dh_pre");
}
// 3b-3g. dW + db reduces.
let launch_dW = |a_ptr: u64, b_grad_ptr: u64, dw_ptr: u64, krows: usize, jcols: usize| {
let k_i32 = krows as i32;
let j_i32 = jcols as i32;
unsafe {
stream
.launch_builder(&dW_reduce_kernel)
.arg(&a_ptr)
.arg(&b_grad_ptr)
.arg(&dw_ptr)
.arg(&b_i32)
.arg(&k_i32)
.arg(&j_i32)
.launch(LaunchConfig {
grid_dim: ((krows * jcols) as u32, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: block_smem,
})
.expect("launch dW_reduce");
}
};
let launch_db = |b_grad_ptr: u64, db_ptr: u64, jcols: usize| {
let j_i32 = jcols as i32;
unsafe {
stream
.launch_builder(&db_reduce_kernel)
.arg(&b_grad_ptr)
.arg(&db_ptr)
.arg(&b_i32)
.arg(&j_i32)
.launch(LaunchConfig {
grid_dim: (jcols as u32, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: block_smem,
})
.expect("launch db_reduce");
}
};
launch_dW(h_aux2.dev_ptr, dh_s2_aux.dev_ptr, dW3.dev_ptr, H2, AUX_HIDDEN_DIM);
launch_db(dh_s2_aux.dev_ptr, db3.dev_ptr, AUX_HIDDEN_DIM);
launch_dW(h_aux1.dev_ptr, dh_aux2_pre.dev_ptr, dW2.dev_ptr, H1, H2);
launch_db(dh_aux2_pre.dev_ptr, db2.dev_ptr, H2);
launch_dW(x_in.dev_ptr, dh_aux1_pre.dev_ptr, dW1.dev_ptr, ENCODER_OUT_DIM, H1);
launch_db(dh_aux1_pre.dev_ptr, db1.dev_ptr, H1);
stream.synchronize().expect("sync backward");
let dW3_analytic = dW3.read_all();
// ── Step 4: numerical gradient at sampled (k, j) pairs ───────────
const EPS: f32 = 1e-3;
// Sample 16 indices deterministically across dW3.
let n_samples = 16;
let mut sample_state: u32 = 0xCAFE_F00D;
let mut max_rel_err: f32 = 0.0;
let mut max_abs_err: f32 = 0.0;
let mut max_index = (0usize, 0usize);
// Helper: scalar loss = 0.5 * sum(h_s2_aux^2) for a single forward
// pass with current device weight buffers.
let scalar_loss = || -> f32 {
run_forward(
&stream,
&fwd_kernel,
&x_in,
&w1,
&b1,
&w2,
&b2,
&w3,
&b3,
&h_aux1,
&h_aux2,
&h_s2_aux,
B,
ENCODER_OUT_DIM,
H1,
H2,
AUX_HIDDEN_DIM,
);
let out = h_s2_aux.read_all();
0.5 * out.iter().map(|x| x * x).sum::<f32>()
};
for _ in 0..n_samples {
sample_state = sample_state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
let k = (sample_state as usize) % H2;
sample_state = sample_state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
let j = (sample_state as usize) % AUX_HIDDEN_DIM;
let idx = k * AUX_HIDDEN_DIM + j;
let original = w3_host[idx];
// L(W + eps * e_kj)
let mut perturbed = w3_host.clone();
perturbed[idx] = original + EPS;
w3.write_from_slice(&perturbed);
let l_plus = scalar_loss();
// L(W - eps * e_kj)
perturbed[idx] = original - EPS;
w3.write_from_slice(&perturbed);
let l_minus = scalar_loss();
// Restore.
w3.write_from_slice(&w3_host);
let dW3_num = (l_plus - l_minus) / (2.0 * EPS);
let dW3_an = dW3_analytic[idx];
let abs_err = (dW3_an - dW3_num).abs();
// Relative error guarded against zero-valued gradients.
let denom = dW3_an.abs().max(dW3_num.abs()).max(1e-6);
let rel_err = abs_err / denom;
eprintln!(
" dW3[{k}, {j}] (idx={idx}): analytic={dW3_an:+.6e}, numerical={dW3_num:+.6e}, abs_err={abs_err:.2e}, rel_err={rel_err:.2e}"
);
if rel_err > max_rel_err {
max_rel_err = rel_err;
max_abs_err = abs_err;
max_index = (k, j);
}
}
// f32 finite-difference noise floor: with EPS=1e-3 the central
// difference truncation error is O(EPS^2)≈1e-6 per dimension, but
// the dominant noise is f32 catastrophic-cancellation in the
// (l_plus - l_minus) subtraction at ~ULP × loss_magnitude ≈
// 1e-7 × loss. For small gradients (~1e-3 abs) the absolute
// error floor sits around 1e-5, giving 1% relative error at the
// small-gradient tail. 2e-2 (2%) is a comfortable f32 ceiling
// that catches real backward bugs (bad ELU derivative, transposed
// weights → 10× errors) without thrashing on numerical noise.
const REL_TOL: f32 = 2e-2;
eprintln!(
"aux_trunk_backward gradient check: max_rel_err={max_rel_err:.2e} at dW3[{}, {}] (max_abs_err={max_abs_err:.2e}); tol={REL_TOL:.0e}",
max_index.0, max_index.1
);
assert!(
max_rel_err < REL_TOL,
"max relative error {max_rel_err} exceeds {REL_TOL} tol — backward gradient mismatch at dW3[{}, {}] (abs_err {max_abs_err})",
max_index.0,
max_index.1
);
}
/// Stop-gradient invariant: the backward kernel set must NOT contain
/// any code that writes to the encoder gradient buffer (`dx_in`).
/// This is enforced structurally — the kernel signatures don't accept
/// any `dx_in_out` pointer, so the kernel set literally cannot touch
/// encoder gradient memory.
///
/// We verify the structural enforcement two ways:
/// 1. The wrapper struct's `launch()` signature does not accept any
/// `dx_in_out` parameter (compile-time-enforced — if a future
/// change adds one, this test won't reference it). Since this
/// test code already calls into the kernel handles directly
/// with no dx_in pointer, an inadvertent kernel-signature
/// addition would surface as a kernel-launch ABI mismatch
/// (CUDA would reject the launch).
/// 2. Source-level grep: read the kernel `.cu` file and assert it
/// contains no `dx_in_out` symbol nor any `dx_in[` write
/// pattern. This guards against a future regression where
/// someone adds a `dx_in_out` parameter and writes through it
/// — the next test run would catch the violation immediately.
///
/// The structural assertion is more robust than a sentinel-pattern
/// runtime test (allocate dx_in with NaN, verify post-launch still
/// NaN) because runtime tests can be defeated by a bug-free wrapper
/// that just doesn't pass dx_in_out — whereas a future change adding
/// the parameter must update the wrapper and the test would fail by
/// inspection of the source.
#[test]
#[ignore = "requires GPU; reads kernel source from repo"]
fn aux_trunk_backward_does_not_write_dx() {
// Locate the kernel source. The CARGO_MANIFEST_DIR env points to
// `crates/ml/`, so the kernel file lives at
// `src/cuda_pipeline/aux_trunk_backward_kernel.cu`.
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR set during cargo test");
let kernel_path = std::path::Path::new(&manifest_dir)
.join("src/cuda_pipeline/aux_trunk_backward_kernel.cu");
let src = std::fs::read_to_string(&kernel_path)
.unwrap_or_else(|e| panic!("read {}: {e}", kernel_path.display()));
// Strip C-style block comments so design-discussion text mentioning
// `dx_in` (the symbol we forbid) doesn't trigger a false positive.
// Single-pass naive stripper — tests are not security-sensitive,
// and the file has no string literals containing `/*`.
fn strip_block_comments(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
// Skip until closing `*/`
i += 2;
while i + 1 < bytes.len() {
if bytes[i] == b'*' && bytes[i + 1] == b'/' {
i += 2;
break;
}
i += 1;
}
} else if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
// Skip until newline
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
let code = strip_block_comments(&src);
// Forbidden symbols: any reference in CODE (not comments) to
// `dx_in` would imply a parameter or write to encoder gradient.
// The kernel set legitimately reads `x_in` (the encoder's saved-fwd
// input — used for dW1 reduction's outer-product). It must NEVER
// write to a `dx_in` buffer.
const FORBIDDEN: &[&str] = &["dx_in", "dx_in_out"];
for needle in FORBIDDEN {
assert!(
!code.contains(needle),
"stop-grad invariant VIOLATED: kernel source contains forbidden symbol `{needle}` outside comments. \
The aux trunk backward must NOT compute or write any gradient back to encoder input. \
Encoder boundary is the structural stop-gradient per locked design."
);
}
eprintln!(
"aux_trunk_backward stop-grad invariant: kernel source clean of {FORBIDDEN:?} (read {} bytes from {})",
src.len(),
kernel_path.display()
);
}
// ════════════════════════════════════════════════════════════════════
// SP14 Layer C Phase C.4b oracle tests (2026-05-08).
//
// Added 4 tests exercising the new ISV-driven aux prediction horizon:
//
// - aux_sign_label_h_bar_horizon — label correctness for a known H
// - aux_sign_label_lookahead_mask — masking when t+H runs past total_bars
// - aux_horizon_pearl_a_bootstrap — sentinel REPLACED, not blended
// - aux_horizon_converges_to_steady_target — Wiener-α EMA convergence
// - aux_horizon_holds_sentinel_with_no_winning_trades — cold-start guard
//
// Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.4b
// ════════════════════════════════════════════════════════════════════
const AUX_SIGN_LABEL_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_sign_label_kernel.cubin"));
const AUX_HORIZON_UPDATE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_horizon_update_kernel.cubin"));
/// AUX_PRED_HORIZON_BARS_INDEX — must match `sp14_isv_slots.rs`.
const AUX_PRED_HORIZON_BARS_INDEX: usize = 450;
/// AVG_WIN_HOLD_TIME_BARS_INDEX — must match `sp14_isv_slots.rs`.
const AVG_WIN_HOLD_TIME_BARS_INDEX: usize = 451;
/// SENTINEL_AUX_PRED_HORIZON_BARS — must match `sp14_isv_slots.rs`.
const SENTINEL_H: f32 = 60.0;
/// AUX_PRED_HORIZON_BARS_MIN/MAX — fundamental bounds.
const H_MIN: f32 = 1.0;
const H_MAX: f32 = 240.0;
/// ISV size used by the horizon producer test (must be > 451).
const ISV_TEST_SIZE: usize = 460;
fn load_aux_sign_label(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(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")
}
fn load_aux_horizon_update(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(AUX_HORIZON_UPDATE_CUBIN.to_vec())
.expect("load aux_horizon_update cubin");
module
.load_function("aux_horizon_update")
.expect("load aux_horizon_update function")
}
/// Helper: launch `aux_sign_label_kernel` on the given pinned-mapped
/// targets / bar_indices / isv buffers. Returns the device-side
/// labels copied back to host.
fn run_aux_sign_label(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
targets_buf: &MappedF32Buffer,
bar_indices_buf: &MappedF32Buffer,
isv_buf: &MappedF32Buffer,
total: usize,
total_bars: i32,
) -> Vec<i32> {
let labels_dev = stream
.alloc_zeros::<i32>(total)
.expect("alloc out_labels");
let total_i32 = total as i32;
let isv_h_idx_i32 = AUX_PRED_HORIZON_BARS_INDEX as i32;
let blocks = ((total + 255) / 256) as u32;
let targets_dev = targets_buf.dev_ptr;
let bar_indices_dev = bar_indices_buf.dev_ptr;
let isv_dev = isv_buf.dev_ptr;
unsafe {
stream
.launch_builder(kernel)
.arg(&targets_dev)
.arg(&bar_indices_dev)
.arg(&isv_dev)
.arg(&isv_h_idx_i32)
.arg(&labels_dev)
.arg(&total_i32)
.arg(&total_bars)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.expect("aux_sign_label_kernel launch");
}
let mut out = vec![0_i32; total];
stream.memcpy_dtoh(&labels_dev, &mut out).expect("dtoh labels");
stream.synchronize().expect("sync");
out
}
/// Helper: build a synthetic `targets [total_bars, 6]` mapped-pinned
/// buffer with `prices` written into column 2 (raw_close). All other
/// columns are zero-filled.
fn build_targets_buf(prices: &[f32]) -> MappedF32Buffer {
let total_bars = prices.len();
let buf = unsafe { MappedF32Buffer::new(total_bars * 6) }
.expect("MappedF32Buffer::new(targets)");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, total_bars * 6) };
host.fill(0.0);
for (i, &p) in prices.iter().enumerate() {
host[i * 6 + 2] = p;
}
buf
}
/// Helper: build a `bar_indices [total]` mapped-pinned buffer with
/// the i32 values reinterpreted as f32 (MappedF32Buffer is our only
/// generic mapped-pinned allocator). The kernel reads it as `int*`
/// — bit-pattern preservation matters more than f32 representability.
fn build_bar_indices_buf(indices: &[i32]) -> MappedF32Buffer {
let n = indices.len();
let buf = unsafe { MappedF32Buffer::new(n) }
.expect("MappedF32Buffer::new(bar_indices)");
// bytewise copy: same memory layout, no f32 interpretation
unsafe {
let dst = buf.host_ptr as *mut i32;
std::ptr::copy_nonoverlapping(indices.as_ptr(), dst, n);
}
buf
}
/// Helper: build a `[ISV_TEST_SIZE]` mapped-pinned ISV buffer with
/// AUX_PRED_HORIZON_BARS_INDEX seeded to `h` and other slots zero.
fn build_isv_buf(h: f32) -> MappedF32Buffer {
let buf = unsafe { MappedF32Buffer::new(ISV_TEST_SIZE) }
.expect("MappedF32Buffer::new(isv)");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, ISV_TEST_SIZE) };
host.fill(0.0);
host[AUX_PRED_HORIZON_BARS_INDEX] = h;
buf
}
/// Step 6 oracle: aux_sign_label produces correct labels for a
/// known H on three deterministic price series.
///
/// 1. Linearly increasing prices, H=30, T=200 → labels 1 in
/// `[0..170)` and -1 in `[170..200)` (lookahead mask).
/// 2. Linearly decreasing prices, H=30, T=200 → labels 0 in
/// `[0..170)` and -1 in `[170..200)`.
/// 3. Sine wave, H=30 → analytic comparison of `p[t+30] > p[t]`.
#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_h_bar_horizon() {
let stream = make_test_stream();
let kernel = load_aux_sign_label(&stream);
const T: usize = 200;
const H: i32 = 30;
let total_bars = T as i32;
let bar_indices: Vec<i32> = (0..T as i32).collect();
let bar_indices_buf = build_bar_indices_buf(&bar_indices);
let isv_buf = build_isv_buf(H as f32);
// Case 1: linearly increasing
let prices_up: Vec<f32> = (0..T).map(|i| i as f32).collect();
let targets_up = build_targets_buf(&prices_up);
let labels_up =
run_aux_sign_label(&stream, &kernel, &targets_up, &bar_indices_buf, &isv_buf, T, total_bars);
let lookahead_cutoff = T as i32 - H;
for (i, &lbl) in labels_up.iter().enumerate() {
let i_i32 = i as i32;
if i_i32 + H < total_bars {
assert_eq!(lbl, 1, "increasing series: bar {i} expected 1 (p[t+H]>p[t]), got {lbl}");
} else {
assert_eq!(lbl, -1, "increasing series: bar {i} expected mask -1 (t+H>=total_bars), got {lbl}");
}
}
eprintln!("Case 1 (increasing): valid bars={lookahead_cutoff}, all 1 — OK");
// Case 2: linearly decreasing
let prices_dn: Vec<f32> = (0..T).map(|i| (T - i) as f32).collect();
let targets_dn = build_targets_buf(&prices_dn);
let labels_dn =
run_aux_sign_label(&stream, &kernel, &targets_dn, &bar_indices_buf, &isv_buf, T, total_bars);
for (i, &lbl) in labels_dn.iter().enumerate() {
let i_i32 = i as i32;
if i_i32 + H < total_bars {
assert_eq!(lbl, 0, "decreasing series: bar {i} expected 0 (p[t+H]<p[t]), got {lbl}");
} else {
assert_eq!(lbl, -1, "decreasing series: bar {i} expected mask -1, got {lbl}");
}
}
eprintln!("Case 2 (decreasing): valid bars={lookahead_cutoff}, all 0 — OK");
// Case 3: sine wave — analytic comparison
let prices_sin: Vec<f32> =
(0..T).map(|i| (i as f32 * 0.1).sin()).collect();
let targets_sin = build_targets_buf(&prices_sin);
let labels_sin =
run_aux_sign_label(&stream, &kernel, &targets_sin, &bar_indices_buf, &isv_buf, T, total_bars);
for (i, &lbl) in labels_sin.iter().enumerate() {
let i_i32 = i as i32;
if i_i32 + H >= total_bars {
assert_eq!(lbl, -1, "sine: bar {i} mask expected -1, got {lbl}");
continue;
}
let expected = if prices_sin[i + H as usize] > prices_sin[i] { 1 } else { 0 };
assert_eq!(lbl, expected,
"sine: bar {i} expected {expected} (p[{}]={:.4} vs p[{i}]={:.4}), got {lbl}",
i + H as usize, prices_sin[i + H as usize], prices_sin[i]);
}
eprintln!("Case 3 (sine): valid bars={lookahead_cutoff}, all match analytic — OK");
}
/// Step 7 oracle: lookahead mask invariant — for H=60 with
/// total_bars=100, labels in `[40..100)` must be -1 and labels in
/// `[0..40)` must be ∈ {0, 1} (valid).
#[test]
#[ignore = "requires GPU"]
fn aux_sign_label_lookahead_mask() {
let stream = make_test_stream();
let kernel = load_aux_sign_label(&stream);
const T: usize = 100;
const H: i32 = 60;
let total_bars = T as i32;
let bar_indices: Vec<i32> = (0..T as i32).collect();
let bar_indices_buf = build_bar_indices_buf(&bar_indices);
let isv_buf = build_isv_buf(H as f32);
let prices: Vec<f32> = (0..T).map(|i| (i as f32 * 0.5).sin()).collect();
let targets = build_targets_buf(&prices);
let labels =
run_aux_sign_label(&stream, &kernel, &targets, &bar_indices_buf, &isv_buf, T, total_bars);
for (i, &lbl) in labels.iter().enumerate() {
let i_i32 = i as i32;
if i_i32 + H >= total_bars {
assert_eq!(lbl, -1,
"lookahead mask: bar {i} (t+H={}) >= total_bars={total_bars} expected -1, got {lbl}",
i_i32 + H);
} else {
assert!(lbl == 0 || lbl == 1,
"valid window: bar {i} expected ∈ {{0, 1}}, got {lbl}");
}
}
let mask_count = labels.iter().filter(|&&l| l == -1).count();
let valid_count = labels.iter().filter(|&&l| l == 0 || l == 1).count();
assert_eq!(mask_count, H as usize,
"expected {} masked bars (last H rows), got {mask_count}", H);
assert_eq!(valid_count, T - H as usize,
"expected {} valid bars, got {valid_count}", T - H as usize);
eprintln!("lookahead_mask: {mask_count}/{T} masked, {valid_count}/{T} valid — OK");
}
/// Helper: launch `aux_horizon_update` once on the given mapped-pinned
/// ISV buffer.
fn run_aux_horizon_update(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
isv_buf: &MappedF32Buffer,
isv_h_idx: i32,
isv_h_target_idx: i32,
isv_h_target_var_idx: i32,
h_min: f32,
h_max: f32,
sentinel_h: f32,
) {
let isv_dev = isv_buf.dev_ptr;
unsafe {
stream
.launch_builder(kernel)
.arg(&isv_dev)
.arg(&isv_h_idx)
.arg(&isv_h_target_idx)
.arg(&isv_h_target_var_idx)
.arg(&h_min)
.arg(&h_max)
.arg(&sentinel_h)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("aux_horizon_update launch");
}
stream.synchronize().expect("sync");
}
/// Step 7b oracle: Pearl-A first-observation bootstrap — sentinel
/// must be REPLACED, not blended.
#[test]
#[ignore = "requires GPU"]
fn aux_horizon_pearl_a_bootstrap() {
let stream = make_test_stream();
let kernel = load_aux_horizon_update(&stream);
// 1. Sentinel state: H=60.0, h_target=90.0 (first valid observation).
let isv_buf = build_isv_buf(SENTINEL_H);
let host = unsafe { std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE) };
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 90.0;
run_aux_horizon_update(
&stream,
&kernel,
&isv_buf,
AUX_PRED_HORIZON_BARS_INDEX as i32,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
-1, // no variance EMA → fixed α
H_MIN,
H_MAX,
SENTINEL_H,
);
let h_after_bootstrap = host[AUX_PRED_HORIZON_BARS_INDEX];
assert!(
(h_after_bootstrap - 90.0).abs() < 1e-3,
"Pearl-A bootstrap: expected H=90.0 (REPLACED, not blended), got {h_after_bootstrap}",
);
eprintln!("Pearl-A bootstrap: H={SENTINEL_H} sentinel + target=90.0 → H={h_after_bootstrap} (REPLACED)");
// 2. Subsequent observation: target=100.0 → blended (NOT replaced).
// Current H is now 90.0, NOT sentinel — kernel takes the EMA path.
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 100.0;
run_aux_horizon_update(
&stream,
&kernel,
&isv_buf,
AUX_PRED_HORIZON_BARS_INDEX as i32,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
-1,
H_MIN,
H_MAX,
SENTINEL_H,
);
let h_after_blend = host[AUX_PRED_HORIZON_BARS_INDEX];
// α=0.01 → blend = 0.99*90 + 0.01*100 = 90.1 ± float noise
let expected = 0.99_f32 * 90.0 + 0.01_f32 * 100.0;
assert!(
(h_after_blend - expected).abs() < 1e-3,
"second observation: expected blended H≈{expected}, got {h_after_blend} \
(must be in (90, 100) — NOT replaced)",
);
assert!(
h_after_blend > 90.0 && h_after_blend < 100.0,
"blended H={h_after_blend} not in (90, 100) — should be small step from 90 toward 100",
);
eprintln!("Second obs: H={h_after_blend} (blended toward 100, expected ≈{expected})");
}
/// Step 7c oracle: Wiener-α EMA convergence — with α=0.01 fixed
/// (no variance EMA), H starting at 50.0 with steady target=100.0
/// should converge per the geometric `(1-α)^k * 50 + (1-(1-α)^k) * 100`.
#[test]
#[ignore = "requires GPU"]
fn aux_horizon_converges_to_steady_target() {
let stream = make_test_stream();
let kernel = load_aux_horizon_update(&stream);
// Post-bootstrap state: H=50.0, target=100.0 (steady).
// 50.0 differs from sentinel 60.0 → bootstrap path NOT triggered.
let isv_buf = build_isv_buf(50.0);
let host = unsafe { std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE) };
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 100.0;
const ITERS: usize = 100;
for _ in 0..ITERS {
run_aux_horizon_update(
&stream,
&kernel,
&isv_buf,
AUX_PRED_HORIZON_BARS_INDEX as i32,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
-1,
H_MIN,
H_MAX,
SENTINEL_H,
);
}
let h_final = host[AUX_PRED_HORIZON_BARS_INDEX];
// Closed-form for fixed-α geometric blend:
// H_k = (1-α)^k * H_0 + (1 - (1-α)^k) * target
// α=0.01, k=100, H_0=50, target=100
// (1-α)^100 ≈ 0.366032
// H_100 ≈ 0.366 * 50 + 0.634 * 100 ≈ 81.7
let alpha: f32 = 0.01;
let one_minus_alpha = 1.0 - alpha;
let decay = one_minus_alpha.powi(ITERS as i32);
let expected = decay * 50.0 + (1.0 - decay) * 100.0;
let tol = 0.05; // accumulated f32 noise across 100 iters
assert!(
(h_final - expected).abs() < tol,
"Wiener-α convergence: expected H≈{expected:.4} after {ITERS} iters, got {h_final:.4} \
(decay=(1-{alpha})^{ITERS}≈{decay:.6})",
);
eprintln!("Convergence: H_0=50, target=100, α={alpha}, after {ITERS} iters → H={h_final:.4} (expected {expected:.4})");
}
/// Step 7d oracle: cold-start "no winning trades" guard — when
/// `h_target == 0.0` (no winning trade observed yet), the kernel
/// MUST keep the sentinel and not blend toward zero.
#[test]
#[ignore = "requires GPU"]
fn aux_horizon_holds_sentinel_with_no_winning_trades() {
let stream = make_test_stream();
let kernel = load_aux_horizon_update(&stream);
// Sentinel state: H=60.0, target=0.0 (no winning trade yet).
let isv_buf = build_isv_buf(SENTINEL_H);
let host = unsafe { std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE) };
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 0.0;
run_aux_horizon_update(
&stream,
&kernel,
&isv_buf,
AUX_PRED_HORIZON_BARS_INDEX as i32,
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
-1,
H_MIN,
H_MAX,
SENTINEL_H,
);
let h_after = host[AUX_PRED_HORIZON_BARS_INDEX];
assert!(
(h_after - SENTINEL_H).abs() < 1e-6,
"no winning trades guard: expected H=sentinel {SENTINEL_H} unchanged, got {h_after}",
);
eprintln!("No-winning-trades guard: H={h_after} (sentinel preserved)");
}
// ════════════════════════════════════════════════════════════════════
// SP14 Layer C Phase C.6 oracle tests (2026-05-08).
//
// Added 1 test exercising the h_s2_aux RMS EMA producer:
//
// - h_s2_aux_rms_ema_pearl_a_bootstrap — sentinel REPLACED on first
// observation; EMA blend applied on second.
//
// Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.6
// ════════════════════════════════════════════════════════════════════
const H_S2_AUX_RMS_EMA_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/h_s2_aux_rms_ema_kernel.cubin"));
/// H_S2_AUX_RMS_EMA_INDEX — must match `sp14_isv_slots.rs`.
const H_S2_AUX_RMS_EMA_IDX: usize = 449;
fn load_h_s2_aux_rms_ema(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(H_S2_AUX_RMS_EMA_CUBIN.to_vec())
.expect("load h_s2_aux_rms_ema cubin");
module
.load_function("h_s2_aux_rms_ema_update")
.expect("load h_s2_aux_rms_ema_update function")
}
/// Launch `h_s2_aux_rms_ema_update` once and synchronize.
fn run_h_s2_aux_rms_ema(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
h_s2_aux_buf: &MappedF32Buffer,
b: i32,
sh2: i32,
isv_buf: &MappedF32Buffer,
alpha: f32,
) {
let h_ptr = h_s2_aux_buf.dev_ptr;
let isv_ptr = isv_buf.dev_ptr;
let isv_idx = H_S2_AUX_RMS_EMA_IDX as i32;
let smem = (256 * std::mem::size_of::<f32>()) as u32;
unsafe {
stream
.launch_builder(kernel)
.arg(&h_ptr)
.arg(&b)
.arg(&sh2)
.arg(&isv_ptr)
.arg(&isv_idx)
.arg(&alpha)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: smem,
})
.expect("h_s2_aux_rms_ema_update launch");
}
stream.synchronize().expect("sync h_s2_aux_rms_ema");
}
/// Step C.6 oracle: Pearl-A first-observation bootstrap.
///
/// Scenario A — sentinel → replace:
/// Set ISV[449] = 0.0 (sentinel). Fill `h_s2_aux [B, SH2]` with
/// constant value 1.5. Expected RMS = sqrt(1.5² × N / N) = 1.5.
/// After one launch the kernel MUST replace (not blend): ISV[449] ≈ 1.5.
///
/// Scenario B — steady-state EMA blend:
/// Fill `h_s2_aux` with 3.0 (expected RMS = 3.0). Relaunch with
/// α=0.05 from current=1.5.
/// Expected: (1-0.05)*1.5 + 0.05*3.0 = 1.5*0.95 + 0.15 = 1.575.
/// ISV[449] must be strictly between 1.5 and 3.0 and close to 1.575.
#[test]
#[ignore = "requires GPU"]
fn h_s2_aux_rms_ema_pearl_a_bootstrap() {
const B: i32 = 4;
const SH2: i32 = 256;
const ALPHA: f32 = 0.05;
let stream = make_test_stream();
let kernel = load_h_s2_aux_rms_ema(&stream);
// Allocate ISV buffer; zero-initialise to set sentinel.
let isv_buf = unsafe { MappedF32Buffer::new(ISV_TEST_SIZE) }
.expect("ISV buf alloc");
let host = unsafe {
std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE)
};
// sentinel = 0.0 (zero-init already)
// Allocate h_s2_aux and fill with constant 1.5.
let n = (B * SH2) as usize;
let h_buf = unsafe { MappedF32Buffer::new(n) }.expect("h_s2_aux buf alloc");
let h_host = unsafe { std::slice::from_raw_parts_mut(h_buf.host_ptr, n) };
for v in h_host.iter_mut() { *v = 1.5; }
// ── Scenario A: Pearl-A bootstrap ─────────────────────────────
run_h_s2_aux_rms_ema(&stream, &kernel, &h_buf, B, SH2, &isv_buf, ALPHA);
let slot_a = host[H_S2_AUX_RMS_EMA_IDX];
assert!(
(slot_a - 1.5).abs() < 1e-5,
"Pearl-A bootstrap: expected ISV[449] = 1.5 (replace sentinel), got {slot_a}",
);
eprintln!("Pearl-A bootstrap (h=1.5): ISV[449] = {slot_a:.6} (expected 1.5)");
// ── Scenario B: steady-state EMA blend ────────────────────────
for v in h_host.iter_mut() { *v = 3.0; }
run_h_s2_aux_rms_ema(&stream, &kernel, &h_buf, B, SH2, &isv_buf, ALPHA);
let slot_b = host[H_S2_AUX_RMS_EMA_IDX];
let expected = (1.0 - ALPHA) * 1.5 + ALPHA * 3.0; // 1.575
assert!(
(slot_b - expected).abs() < 1e-4,
"EMA blend: expected ISV[449] ≈ {expected:.6} after α={ALPHA} step from 1.5→3.0, \
got {slot_b:.6}",
);
assert!(
slot_b > 1.5 && slot_b < 3.0,
"EMA blend: ISV[449]={slot_b} must be strictly between 1.5 and 3.0",
);
eprintln!(
"EMA blend (h=3.0, α={ALPHA}): ISV[449] = {slot_b:.6} (expected {expected:.6})"
);
}
// ════════════════════════════════════════════════════════════════════
// SP14 Layer C Phase C.9 synthetic smoke (2026-05-08).
//
// Verifies the full aux trunk learning pipeline:
//
// - aux_trunk_learns_synthetic_uptrend — 100 steps of forward +
// backward + Adam on a synthetic all-UP batch; asserts CE loss <
// 0.1 and dir_acc > 0.95 at convergence.
//
// Architectural intent: if this test FAILS (loss stays near ln(2) ≈
// 0.693 = random baseline), the aux trunk gradient chain is broken and
// L40S dispatch must be blocked until the root cause is fixed. See
// `pearl_separate_aux_trunk_when_shared_starves.md` — aux-loss
// flatline at random baseline is a structural symptom, not a
// hyperparameter problem.
//
// Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.9
// ════════════════════════════════════════════════════════════════════
const AUX_HEADS_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_heads_kernel.cubin"));
const DQN_UTILITY_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/dqn_utility_kernels.cubin"));
struct SmokeKernels {
trunk_fwd: CudaFunction,
trunk_bwd_dh_pre: CudaFunction,
trunk_bwd_dW: CudaFunction,
trunk_bwd_db: CudaFunction,
head_fwd: CudaFunction,
head_loss: CudaFunction,
head_bwd: CudaFunction,
grad_norm: CudaFunction,
grad_norm_final: CudaFunction,
adam: CudaFunction,
}
fn load_smoke_kernels(stream: &Arc<CudaStream>) -> SmokeKernels {
let trunk_fwd_mod = stream.context()
.load_cubin(AUX_TRUNK_FORWARD_CUBIN.to_vec())
.expect("load trunk_fwd cubin");
let trunk_bwd_mod = stream.context()
.load_cubin(AUX_TRUNK_BACKWARD_CUBIN.to_vec())
.expect("load trunk_bwd cubin");
let heads_mod = stream.context()
.load_cubin(AUX_HEADS_CUBIN.to_vec())
.expect("load aux_heads cubin");
let util_mod = stream.context()
.load_cubin(DQN_UTILITY_CUBIN.to_vec())
.expect("load dqn_utility cubin");
SmokeKernels {
trunk_fwd: trunk_fwd_mod.load_function("aux_trunk_forward").expect("aux_trunk_forward"),
trunk_bwd_dh_pre: trunk_bwd_mod.load_function("aux_trunk_bwd_dh_pre").expect("bwd_dh_pre"),
trunk_bwd_dW: trunk_bwd_mod.load_function("aux_trunk_bwd_dW_reduce").expect("bwd_dW"),
trunk_bwd_db: trunk_bwd_mod.load_function("aux_trunk_bwd_db_reduce").expect("bwd_db"),
head_fwd: heads_mod.load_function("aux_next_bar_forward").expect("head_fwd"),
head_loss: heads_mod.load_function("aux_next_bar_loss_reduce").expect("head_loss"),
head_bwd: heads_mod.load_function("aux_next_bar_backward").expect("head_bwd"),
grad_norm: util_mod.load_function("dqn_grad_norm_kernel").expect("grad_norm"),
grad_norm_final: util_mod.load_function("dqn_grad_norm_finalize").expect("grad_norm_fin"),
adam: util_mod.load_function("dqn_adam_update_kernel").expect("adam"),
}
}
/// Synthetic learning smoke: 100 forward + backward + Adam steps on an
/// all-UP batch. Asserts CE loss < 0.1 and dir_acc > 0.95.
///
/// Topology (small dims for speed on RTX 3050):
/// B=16, ENC=32, H1_trunk=32, H2_trunk=16, SH2=32
/// H_HEAD=32 (compile-time in aux_heads_kernel.cu), K=2
///
/// Only the 6 aux trunk weight tensors are updated via Adam. The 4 aux
/// head params are updated via simple SGD on the host (test harness only)
/// by reading the per-sample partial gradients through mapped-pinned memory
/// and summing them in Rust. This is valid test orchestration — not a
/// CPU compute path in production.
///
/// Failure condition: loss > 0.1 after 100 steps → gradient chain broken.
#[test]
#[ignore = "requires GPU"]
fn aux_trunk_learns_synthetic_uptrend() {
const B: usize = 16;
const ENC: usize = 32; // encoder_out_dim fed into trunk
const H1: usize = 32; // trunk hidden layer 1
const H2: usize = 16; // trunk hidden layer 2
const SH2: usize = 32; // trunk output dim = head input dim
const H_HEAD: usize = 32; // AUX_HIDDEN_DIM compile-time in aux_heads_kernel.cu
const K: usize = 2; // binary: class 0 = DOWN, class 1 = UP
const STEPS: usize = 100;
const LR_TRUNK: f32 = 3e-3; // aggressive LR for fast convergence on clean signal
const LR_HEAD: f32 = 3e-3;
const LOSS_THRESHOLD: f32 = 0.1;
const DIR_ACC_THRESHOLD: f32 = 0.95;
let stream = make_test_stream();
let k = load_smoke_kernels(&stream);
let mut rng: u32 = 0xDEAD_BEEF;
// ── Allocate aux trunk params + m/v ─────────────────────────────
// w1 [ENC, H1], b1 [H1], w2 [H1, H2], b2 [H2], w3 [H2, SH2], b3 [SH2]
let sizes_trunk: [usize; 6] = [
ENC * H1, H1, H1 * H2, H2, H2 * SH2, SH2,
];
let total_trunk: usize = sizes_trunk.iter().sum();
macro_rules! xavier_buf {
($n:expr, $fan:expr) => {{
let buf = unsafe { MappedF32Buffer::new($n) }.expect("alloc");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, $n) };
let bound = (2.0_f32 / $fan as f32).sqrt();
for v in host.iter_mut() {
*v = lcg_next_signed(&mut rng) * bound;
}
buf
}};
}
macro_rules! zero_buf {
($n:expr) => {{
let buf = unsafe { MappedF32Buffer::new($n) }.expect("alloc");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, $n) };
for v in host.iter_mut() { *v = 0.0; }
buf
}};
}
// Trunk weights — Xavier init
let w1 = xavier_buf!(ENC * H1, ENC + H1);
let b1 = zero_buf!(H1);
let w2 = xavier_buf!(H1 * H2, H1 + H2);
let b2 = zero_buf!(H2);
let w3 = xavier_buf!(H2 * SH2, H2 + SH2);
let b3 = zero_buf!(SH2);
// Adam m/v for trunk — zero init
let w1_m = zero_buf!(ENC * H1); let w1_v = zero_buf!(ENC * H1);
let b1_m = zero_buf!(H1); let b1_v = zero_buf!(H1);
let w2_m = zero_buf!(H1 * H2); let w2_v = zero_buf!(H1 * H2);
let b2_m = zero_buf!(H2); let b2_v = zero_buf!(H2);
let w3_m = zero_buf!(H2 * SH2); let w3_v = zero_buf!(H2 * SH2);
let b3_m = zero_buf!(SH2); let b3_v = zero_buf!(SH2);
// Adam grad buffers for trunk — zero init
let w1_g = zero_buf!(ENC * H1);
let b1_g = zero_buf!(H1);
let w2_g = zero_buf!(H1 * H2);
let b2_g = zero_buf!(H2);
let w3_g = zero_buf!(H2 * SH2);
let b3_g = zero_buf!(SH2);
// Adam utility buffers for trunk (block_sums/grad_norm_buf replaced by flat_grad path below)
let _norm_blocks = (total_trunk + 255) / 256;
let _grad_norm_buf = zero_buf!(1);
let wd_mask = {
let buf = unsafe { MappedF32Buffer::new(total_trunk) }.expect("alloc");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, total_trunk) };
for v in host.iter_mut() { *v = 0.0; } // no weight decay
buf
};
let nan_flags = zero_buf!(2); // diag_slot=0, tiny buf
let engage_buf = zero_buf!(1); // unused (engage_buf_offset=-1)
// Adam step counter + LR + clip — mapped-pinned scalars
let adam_t_buf = {
let buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc t_buf");
// We'll write an i32 through the f32 mapped-pinned buffer host_ptr.
// Safe because i32 and f32 are same size and we treat it as raw bytes.
buf
};
let lr_buf = {
let buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc lr");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, 1) };
host[0] = LR_TRUNK;
buf
};
let clip_buf = {
let buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc clip");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, 1) };
host[0] = 10.0_f32; // generous clip
buf
};
// ── Allocate aux head params (trained by host-side SGD) ──────────
// wh1 [H_HEAD, SH2], bh1 [H_HEAD], wh2 [K, H_HEAD], bh2 [K]
let wh1 = xavier_buf!(H_HEAD * SH2, H_HEAD + SH2);
let bh1 = zero_buf!(H_HEAD);
let wh2 = xavier_buf!(K * H_HEAD, K + H_HEAD);
let bh2 = zero_buf!(K);
// ── Allocate forward scratch buffers ─────────────────────────────
let x_in = zero_buf!(B * ENC);
let h_aux1 = zero_buf!(B * H1);
let h_aux2 = zero_buf!(B * H2);
let h_s2_aux = zero_buf!(B * SH2);
let hidden_out = zero_buf!(B * H_HEAD);
let logits_out = zero_buf!(B * K);
let softmax_out = zero_buf!(B * K);
let loss_out = zero_buf!(1);
let valid_count = zero_buf!(1);
let dh_s2_aux_out = zero_buf!(B * SH2);
// ── Allocate head backward partial-grad buffers ──────────────────
// Per-sample partials — summed on host, applied as SGD
let dWh1_partial = zero_buf!(B * H_HEAD * SH2);
let dbh1_partial = zero_buf!(B * H_HEAD);
let dWh2_partial = zero_buf!(B * K * H_HEAD);
let dbh2_partial = zero_buf!(B * K);
// ── Allocate labels buffer (all UP = label 1) ────────────────────
// Labels are i32; reuse a MappedF32Buffer for the raw pointer.
let labels_buf = {
let buf = unsafe { MappedF32Buffer::new(B) }.expect("alloc labels");
let host = unsafe {
std::slice::from_raw_parts_mut(buf.host_ptr as *mut i32, B)
};
for v in host.iter_mut() { *v = 1; } // all UP
buf
};
// ── Synthetic encoder output: constant signal ────────────────────
// All samples share the same non-zero encoder output so the trunk
// can learn a direction-invariant mapping to the "UP" class.
{
let host = unsafe { std::slice::from_raw_parts_mut(x_in.host_ptr, B * ENC) };
for (i, v) in host.iter_mut().enumerate() {
// Monotone: feature[j] = +1/(j+1) — same for all samples.
let j = i % ENC;
*v = 0.5_f32 / (j as f32 + 1.0);
}
}
let mut final_loss = f32::NAN;
let mut final_acc = f32::NAN;
for step in 0..STEPS {
// ── Step counter (i32 written through f32 mapped-pinned buf) ──
let t_val: i32 = (step + 1) as i32;
unsafe {
*(adam_t_buf.host_ptr as *mut i32) = t_val;
}
// ── 1. Aux trunk forward ──────────────────────────────────────
let b_i = B as i32;
let enc_i = ENC as i32;
let h1_i = H1 as i32;
let h2_i = H2 as i32;
let sh2_i = SH2 as i32;
let smem_trunk = ((H1 + H2) * std::mem::size_of::<f32>()) as u32;
unsafe {
stream.launch_builder(&k.trunk_fwd)
.arg(&x_in.dev_ptr)
.arg(&w1.dev_ptr).arg(&b1.dev_ptr)
.arg(&w2.dev_ptr).arg(&b2.dev_ptr)
.arg(&w3.dev_ptr).arg(&b3.dev_ptr)
.arg(&h_aux1.dev_ptr)
.arg(&h_aux2.dev_ptr)
.arg(&h_s2_aux.dev_ptr)
.arg(&b_i).arg(&enc_i).arg(&h1_i).arg(&h2_i).arg(&sh2_i)
.launch(LaunchConfig {
grid_dim: (B as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_trunk,
})
.expect("trunk_fwd launch");
}
stream.synchronize().expect("sync trunk_fwd");
// ── 2. Aux head forward ───────────────────────────────────────
let _hh_i = H_HEAD as i32; // H_HEAD hard-coded in aux_heads_kernel.cu
let k_i = K as i32;
let smem_head = ((H_HEAD + K) * std::mem::size_of::<f32>()) as u32;
unsafe {
stream.launch_builder(&k.head_fwd)
.arg(&h_s2_aux.dev_ptr)
.arg(&wh1.dev_ptr).arg(&bh1.dev_ptr)
.arg(&wh2.dev_ptr).arg(&bh2.dev_ptr)
.arg(&b_i).arg(&sh2_i).arg(&k_i)
.arg(&hidden_out.dev_ptr)
.arg(&logits_out.dev_ptr)
.arg(&softmax_out.dev_ptr)
.launch(LaunchConfig {
grid_dim: (B as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_head,
})
.expect("head_fwd launch");
}
stream.synchronize().expect("sync head_fwd");
// ── 3. CE loss ────────────────────────────────────────────────
let smem_loss = (2 * AUX_TRUNK_BLOCK as usize * std::mem::size_of::<f32>()) as u32;
unsafe {
stream.launch_builder(&k.head_loss)
.arg(&softmax_out.dev_ptr)
.arg(&labels_buf.dev_ptr)
.arg(&b_i).arg(&k_i)
.arg(&loss_out.dev_ptr)
.arg(&valid_count.dev_ptr)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_loss,
})
.expect("head_loss launch");
}
stream.synchronize().expect("sync head_loss");
// ── 4. Head backward (per-sample partial grads + dh_s2_aux) ──
let smem_bwd = ((2 * H_HEAD + K) * std::mem::size_of::<f32>()) as u32;
unsafe {
// Zero dh_s2_aux_out before backward (accumulate fresh each step)
let dh_host = std::slice::from_raw_parts_mut(dh_s2_aux_out.host_ptr, B * SH2);
for v in dh_host.iter_mut() { *v = 0.0; }
stream.launch_builder(&k.head_bwd)
.arg(&h_s2_aux.dev_ptr)
.arg(&wh1.dev_ptr)
.arg(&wh2.dev_ptr)
.arg(&hidden_out.dev_ptr)
.arg(&softmax_out.dev_ptr)
.arg(&labels_buf.dev_ptr)
.arg(&valid_count.dev_ptr)
.arg(&b_i).arg(&sh2_i).arg(&k_i)
.arg(&dWh1_partial.dev_ptr).arg(&dbh1_partial.dev_ptr)
.arg(&dWh2_partial.dev_ptr).arg(&dbh2_partial.dev_ptr)
.arg(&dh_s2_aux_out.dev_ptr)
.launch(LaunchConfig {
grid_dim: (B as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_bwd,
})
.expect("head_bwd launch");
}
stream.synchronize().expect("sync head_bwd");
// ── 5. Host-side SGD on head params ──────────────────────────
// Read per-sample partials via mapped-pinned, sum → SGD update.
// This is test-harness orchestration — the production aux head
// trainer does not execute on the CPU.
{
// dWh1: sum over B dim, shape [H_HEAD, SH2]
let dwh1_h = unsafe { std::slice::from_raw_parts(dWh1_partial.host_ptr, B * H_HEAD * SH2) };
let wh1_h = unsafe { std::slice::from_raw_parts_mut(wh1.host_ptr, H_HEAD * SH2) };
for idx in 0..H_HEAD * SH2 {
let mut g = 0.0_f32;
for b_idx in 0..B { g += dwh1_h[b_idx * H_HEAD * SH2 + idx]; }
wh1_h[idx] -= LR_HEAD * g;
}
// dbh1
let dbh1_h = unsafe { std::slice::from_raw_parts(dbh1_partial.host_ptr, B * H_HEAD) };
let bh1_h = unsafe { std::slice::from_raw_parts_mut(bh1.host_ptr, H_HEAD) };
for idx in 0..H_HEAD {
let mut g = 0.0_f32;
for b_idx in 0..B { g += dbh1_h[b_idx * H_HEAD + idx]; }
bh1_h[idx] -= LR_HEAD * g;
}
// dWh2: shape [K, H_HEAD]
let dwh2_h = unsafe { std::slice::from_raw_parts(dWh2_partial.host_ptr, B * K * H_HEAD) };
let wh2_h = unsafe { std::slice::from_raw_parts_mut(wh2.host_ptr, K * H_HEAD) };
for idx in 0..K * H_HEAD {
let mut g = 0.0_f32;
for b_idx in 0..B { g += dwh2_h[b_idx * K * H_HEAD + idx]; }
wh2_h[idx] -= LR_HEAD * g;
}
// dbh2
let dbh2_h = unsafe { std::slice::from_raw_parts(dbh2_partial.host_ptr, B * K) };
let bh2_h = unsafe { std::slice::from_raw_parts_mut(bh2.host_ptr, K) };
for idx in 0..K {
let mut g = 0.0_f32;
for b_idx in 0..B { g += dbh2_h[b_idx * K + idx]; }
bh2_h[idx] -= LR_HEAD * g;
}
}
// ── 6. Aux trunk backward ─────────────────────────────────────
//
// Kernel signatures (from aux_trunk_backward_kernel.cu):
//
// aux_trunk_bwd_dh_pre(d_logits[B,SH2], w3[H2,SH2], w2[H1,H2],
// h_aux1[B,H1], h_aux2[B,H2],
// dh_aux2_pre_out[B,H2], dh_aux1_pre_out[B,H1],
// B, H1, H2, AUX_HIDDEN_DIM=SH2)
// shmem: H2 * sizeof(f32) (sh_dh2_pre cache)
// grid: (B,1,1)
//
// d_logits here = dh_s2_aux_out from head backward (upstream grad).
// AUX_HIDDEN_DIM = SH2 in the kernel (the trunk output / head input dim).
//
// aux_trunk_bwd_dW_reduce(A[B,Krows], B_grad[B,Jcols], dW_out[Krows,Jcols],
// B, Krows, Jcols)
// shmem: 256 * sizeof(f32) grid: (Krows*Jcols, 1, 1)
// Called 3×:
// dW3: A=h_aux2[B,H2], B_grad=dh_s2_aux_out[B,SH2], dW3[H2,SH2]
// dW2: A=h_aux1[B,H1], B_grad=dh_pre2[B,H2], dW2[H1,H2]
// dW1: A=x_in [B,ENC], B_grad=dh_pre1[B,H1], dW1[ENC,H1]
//
// aux_trunk_bwd_db_reduce(B_grad[B,Jcols], db_out[Jcols], B, Jcols)
// shmem: 256 * sizeof(f32) grid: (Jcols, 1, 1)
// Called 3×: db3(SH2), db2(H2), db1(H1)
// Phase A: dh_pre (layer 2 and 1 pre-activation grads).
let dh_pre1 = zero_buf!(B * H1); // dh_aux1_pre_out [B, H1]
let dh_pre2 = zero_buf!(B * H2); // dh_aux2_pre_out [B, H2]
// shmem = H2 floats (sh_dh2_pre cache used inside kernel)
let smem_dh = (H2 * std::mem::size_of::<f32>()) as u32;
let sh2_i_aux_hidden = sh2_i; // AUX_HIDDEN_DIM = SH2 in this test
unsafe {
stream.launch_builder(&k.trunk_bwd_dh_pre)
.arg(&dh_s2_aux_out.dev_ptr) // d_logits [B, SH2]
.arg(&w3.dev_ptr) // w3 [H2, SH2]
.arg(&w2.dev_ptr) // w2 [H1, H2]
.arg(&h_aux1.dev_ptr) // h_aux1 [B, H1]
.arg(&h_aux2.dev_ptr) // h_aux2 [B, H2]
.arg(&dh_pre2.dev_ptr) // dh_aux2_pre_out [B, H2]
.arg(&dh_pre1.dev_ptr) // dh_aux1_pre_out [B, H1]
.arg(&b_i) // B
.arg(&h1_i) // H1
.arg(&h2_i) // H2
.arg(&sh2_i_aux_hidden) // AUX_HIDDEN_DIM (=SH2 here)
.launch(LaunchConfig {
grid_dim: (B as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_dh,
})
.expect("bwd_dh_pre launch");
}
stream.synchronize().expect("sync bwd_dh_pre");
// Phase B: dW reduce — 3 separate launches (generic kernel, one call per layer).
// shmem = 256 * sizeof(f32) for the block-tree-reduce.
let smem_dW = (256 * std::mem::size_of::<f32>()) as u32;
let smem_db = (256 * std::mem::size_of::<f32>()) as u32;
unsafe {
// dW3: A=h_aux2[B,H2], B_grad=dh_s2_aux_out[B,SH2], dW3[H2,SH2]
stream.launch_builder(&k.trunk_bwd_dW)
.arg(&h_aux2.dev_ptr)
.arg(&dh_s2_aux_out.dev_ptr)
.arg(&w3_g.dev_ptr)
.arg(&b_i).arg(&h2_i).arg(&sh2_i)
.launch(LaunchConfig {
grid_dim: ((H2 * SH2) as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_dW,
})
.expect("bwd_dW3 launch");
stream.synchronize().expect("sync bwd_dW3");
// dW2: A=h_aux1[B,H1], B_grad=dh_pre2[B,H2], dW2[H1,H2]
stream.launch_builder(&k.trunk_bwd_dW)
.arg(&h_aux1.dev_ptr)
.arg(&dh_pre2.dev_ptr)
.arg(&w2_g.dev_ptr)
.arg(&b_i).arg(&h1_i).arg(&h2_i)
.launch(LaunchConfig {
grid_dim: ((H1 * H2) as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_dW,
})
.expect("bwd_dW2 launch");
stream.synchronize().expect("sync bwd_dW2");
// dW1: A=x_in[B,ENC], B_grad=dh_pre1[B,H1], dW1[ENC,H1]
stream.launch_builder(&k.trunk_bwd_dW)
.arg(&x_in.dev_ptr)
.arg(&dh_pre1.dev_ptr)
.arg(&w1_g.dev_ptr)
.arg(&b_i).arg(&enc_i).arg(&h1_i)
.launch(LaunchConfig {
grid_dim: ((ENC * H1) as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_dW,
})
.expect("bwd_dW1 launch");
stream.synchronize().expect("sync bwd_dW1");
}
// Phase C: db reduce — 3 separate launches.
unsafe {
// db3: B_grad=dh_s2_aux_out[B,SH2], db3[SH2]
stream.launch_builder(&k.trunk_bwd_db)
.arg(&dh_s2_aux_out.dev_ptr)
.arg(&b3_g.dev_ptr)
.arg(&b_i).arg(&sh2_i)
.launch(LaunchConfig {
grid_dim: (SH2 as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_db,
})
.expect("bwd_db3 launch");
stream.synchronize().expect("sync bwd_db3");
// db2: B_grad=dh_pre2[B,H2], db2[H2]
stream.launch_builder(&k.trunk_bwd_db)
.arg(&dh_pre2.dev_ptr)
.arg(&b2_g.dev_ptr)
.arg(&b_i).arg(&h2_i)
.launch(LaunchConfig {
grid_dim: (H2 as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_db,
})
.expect("bwd_db2 launch");
stream.synchronize().expect("sync bwd_db2");
// db1: B_grad=dh_pre1[B,H1], db1[H1]
stream.launch_builder(&k.trunk_bwd_db)
.arg(&dh_pre1.dev_ptr)
.arg(&b1_g.dev_ptr)
.arg(&b_i).arg(&h1_i)
.launch(LaunchConfig {
grid_dim: (H1 as u32, 1, 1),
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
shared_mem_bytes: smem_db,
})
.expect("bwd_db1 launch");
stream.synchronize().expect("sync bwd_db1");
}
// ── 7. Grad norm for trunk (Phase 1: standalone per-tensor) ───
// Concatenate all trunk grad buffers logically via flattened ptrs.
// We do ONE grad_norm over the concatenated logical gradient space.
// Since grad bufs are NOT contiguous, we use the dqn_grad_norm_kernel
// individually per tensor, accumulate block_sums, then finalize once.
//
// Simpler approach: since total_trunk is small (≤ 5K), a single
// per-elem flat reduction over a scratch grad buffer is fine.
// We write a flat copy to a contiguous scratch, run grad_norm once,
// then run 6 Adam launches.
let flat_grad = {
let buf = unsafe { MappedF32Buffer::new(total_trunk) }.expect("flat_grad");
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, total_trunk) };
let g_bufs: [(&MappedF32Buffer, usize); 6] = [
(&w1_g, ENC*H1), (&b1_g, H1), (&w2_g, H1*H2),
(&b2_g, H2), (&w3_g, H2*SH2), (&b3_g, SH2),
];
let mut off = 0;
for (buf_ref, n) in g_bufs {
let src = unsafe { std::slice::from_raw_parts(buf_ref.host_ptr, n) };
host[off..off+n].copy_from_slice(src);
off += n;
}
buf
};
let flat_norm_blocks = (total_trunk + 255) / 256;
let flat_block_sums = zero_buf!(flat_norm_blocks);
let flat_grad_norm = zero_buf!(1);
{
let total_i = total_trunk as i32;
let num_b_i = flat_norm_blocks as i32;
unsafe {
stream.launch_builder(&k.grad_norm)
.arg(&flat_grad.dev_ptr)
.arg(&flat_block_sums.dev_ptr)
.arg(&total_i)
.launch(LaunchConfig {
grid_dim: (flat_norm_blocks as u32, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.expect("grad_norm launch");
stream.synchronize().expect("sync grad_norm");
stream.launch_builder(&k.grad_norm_final)
.arg(&flat_block_sums.dev_ptr)
.arg(&flat_grad_norm.dev_ptr)
.arg(&num_b_i)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.expect("grad_norm_final launch");
}
stream.synchronize().expect("sync grad_norm_final");
}
// ── 8. Adam update for each trunk tensor ──────────────────────
let trunk_tensors: [(&MappedF32Buffer, &MappedF32Buffer, &MappedF32Buffer, &MappedF32Buffer, usize); 6] = [
(&w1, &w1_g, &w1_m, &w1_v, ENC*H1),
(&b1, &b1_g, &b1_m, &b1_v, H1),
(&w2, &w2_g, &w2_m, &w2_v, H1*H2),
(&b2, &b2_g, &b2_m, &b2_v, H2),
(&w3, &w3_g, &w3_m, &w3_v, H2*SH2),
(&b3, &b3_g, &b3_m, &b3_v, SH2),
];
for (p_buf, g_buf, m_buf, v_buf, n) in &trunk_tensors {
let n_i = *n as i32;
let blocks = (n + 255) / 256;
let beta1: f32 = 0.9;
let beta2: f32 = 0.999;
let eps: f32 = 1e-8;
let wd: f32 = 0.0;
let wc_max: f32 = 0.0; // disabled
let l1_end: i32 = 0;
let l1_lam: f32 = 0.0;
let diag: i32 = 0;
let engage_off: i32 = -1; // disabled
unsafe {
stream.launch_builder(&k.adam)
.arg(&p_buf.dev_ptr)
.arg(&g_buf.dev_ptr)
.arg(&m_buf.dev_ptr)
.arg(&v_buf.dev_ptr)
.arg(&flat_grad_norm.dev_ptr)
.arg(&lr_buf.dev_ptr)
.arg(&beta1)
.arg(&beta2)
.arg(&eps)
.arg(&wd)
.arg(&clip_buf.dev_ptr)
.arg(&adam_t_buf.dev_ptr)
.arg(&n_i)
.arg(&wd_mask.dev_ptr)
.arg(&l1_end)
.arg(&l1_lam)
.arg(&wc_max)
.arg(&nan_flags.dev_ptr)
.arg(&diag)
.arg(&engage_buf.dev_ptr)
.arg(&engage_off)
.launch(LaunchConfig {
grid_dim: (blocks as u32, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.expect("adam trunk launch");
}
}
stream.synchronize().expect("sync adam");
// ── 9. Read loss + compute dir_acc for this step ──────────────
let loss_h = unsafe { std::slice::from_raw_parts(loss_out.host_ptr, 1) };
let sm_h = unsafe { std::slice::from_raw_parts(softmax_out.host_ptr, B * K) };
let mut correct = 0usize;
for b_idx in 0..B {
// argmax of softmax row: UP (class 1) predicted?
let p0 = sm_h[b_idx * K];
let p1 = sm_h[b_idx * K + 1];
if p1 >= p0 { correct += 1; }
}
let acc = correct as f32 / B as f32;
final_loss = loss_h[0];
final_acc = acc;
if step % 20 == 0 || step == STEPS - 1 {
eprintln!("step {:3}: CE loss = {:.4}, dir_acc = {:.3}", step + 1, final_loss, acc);
}
}
assert!(
final_loss < LOSS_THRESHOLD,
"C.9 smoke: aux trunk CE loss = {final_loss:.4} after {STEPS} steps (expected < {LOSS_THRESHOLD}). \
If loss is near ln(2)≈0.693, gradient chain is broken — diagnose before L40S dispatch.",
);
assert!(
final_acc >= DIR_ACC_THRESHOLD,
"C.9 smoke: aux trunk dir_acc = {final_acc:.3} after {STEPS} steps (expected ≥ {DIR_ACC_THRESHOLD}).",
);
eprintln!("C.9 smoke PASS: CE loss = {final_loss:.4} < {LOSS_THRESHOLD}, dir_acc = {final_acc:.3}{DIR_ACC_THRESHOLD}");
}
}