Files
foxhunt/crates/ml/tests/sp13_phase0_oracle_tests.rs
jgrusewski 7d10ea8b3e feat(sp13): B1.1a — K=1→2 + softmax CE kernel rewrites + struct flips
Flips the aux next-bar head from K=1 MSE regression to K=2 softmax
cross-entropy classification. Kernel ABIs, struct fields, partial-buf
shapes, and per-step ISV producers all migrate atomically; the producer
that fills `aux_sign_labels` with real -1/0/1 from the price trajectory
lands separately in B1.1b.

Why split B1.1a from B1.1b: the original B1 brief was decomposed (B1.0
+ B1.1) after six full-B1 dispatches confirmed agent-session capacity
is the bottleneck, not cascade understanding. B1.1 itself is now further
split into B1.1a (kernel/struct/test cascade — this commit) and B1.1b
(producer kernel + replay direct path + experience collector hoist +
remaining tests + Smoke A). B1.1a is contract-consistent atomic
kernel-side; B1.1b lands the producer + smoke.

Why labels stay zero-init: B1.1a is producer-less by design. The
`aux_nb_label_buf: CudaSlice<i32>` is `alloc_zeros<i32>` so every
sample receives label 0 ("down"). The model converges on "predict
class 0 (down) everywhere" until B1.1b lands the producer kernel that
fills real -1/0/1 from the 30-bar price trajectory. This degraded
training behavior is intentional and known — the cascade is internally
consistent (every consumer of K-flip / softmax tile / CE / i32 label
migrates atomically per `feedback_no_partial_refactor`); the labels are
placeholder. Local unit tests (CE correctness, dir_acc correctness,
isv_tanh correctness, fingerprint bump) validate B1.1a in isolation;
no L40S smoke runs between B1.1a and B1.1b.

Four contracts (atomic in this commit):
  1. K_NB flip 1 → 2: AUX_NEXT_BAR_K constant, compute_param_sizes
     ([121]/[122] grow), fingerprint seed rename
     (PARAM_AUX_NB_W2/B2 → PARAM_AUX_NB_W2_K2/B2_K2 — bumps the hash),
     forward + backward kernels, partial-buf allocs (nb_w2 [B,H]→[B,K,H],
     nb_b2 [B]→[B,K]), saxpy spec table, max_aux_tensor_len,
     aux_nb_pred_buf renamed to aux_nb_logits_buf per
     feedback_no_legacy_aliases.
  2. Softmax tile: aux_next_bar_forward writes [B, K] softmax via
     in-kernel stable softmax (max-shift form, K=2 single-thread fanout
     mirrors regime kernel); 4 consumers read the tile (loss, backward,
     dir-acc, isv-tanh). NEW field aux_nb_softmax_buf [B, K].
  3. MSE → CE: aux_next_bar_loss_reduce reads softmax + i32 labels,
     masks -1, divides by B_valid (mean-over-valid-rows), writes loss +
     B_valid scalar. aux_next_bar_backward reads B_valid so loss + grad
     share the same divisor — derivatives of the same scalar function.
     NEW field aux_nb_valid_count_buf [1]. All-skip batch produces
     loss = 0 (no NaN; fmaxf(valid, 1) divisor) and zero gradients.
     Numerical floor 1e-30 prevents -log(0) = +inf in extreme-logit path.
  4. i32 label dtype: aux_nb_label_buf flipped f32 → i32; -1 mask
     sentinel handled across loss + backward + dir-acc + isv-tanh.
     The strided_gather of next_states[:, 0] retired entirely.

Cascade (atomic per feedback_no_partial_refactor):
- aux_heads_kernel.cu: aux_next_bar_forward gains K + softmax tile
  output (via in-kernel stable softmax); aux_next_bar_loss_reduce
  ABI flipped (softmax + i32 labels, mean-over-valid CE,
  valid_count_out); aux_next_bar_backward ABI flipped (softmax +
  i32 labels + valid_count, K-fanout d_logits, masked rows zero
  across the K-vector)
- aux_dir_acc_reduce_kernel.cu: read softmax + i32 labels, argmax
  over K, output grew 3 → 6 floats (added n_down/n_up/n_skip);
  shmem 4 → 6 int arrays
- aux_pred_to_isv_tanh_kernel.cu: read softmax tile, compute
  mean(softmax[:, 1] - softmax[:, 0]); tanh transcend retired
  (structural [-1, +1] bound via softmax components per
  pearl_bounded_modifier_outputs_require_structural_activation)
- gpu_aux_heads.rs: AUX_NEXT_BAR_K 1 → 2; forward_next_bar gains K +
  logits_out + softmax_out args; next_bar_loss_reduce gains K +
  valid_count_out; backward_next_bar gains softmax_in + labels_i32_in
  + valid_count_in + K
- gpu_dqn_trainer.rs: compute_param_sizes ([121]/[122]); fingerprint
  seed rename (W2/B2 → W2_K2/B2_K2); struct fields (logits, softmax,
  i32 label, valid_count); aux_dir_acc_buf 3 → 6 floats; partial-buf
  allocs grow; max_aux_tensor_len extended; saxpy spec table updated;
  orchestrator launchers (launch_aux_dir_acc_reduce,
  launch_aux_pred_to_isv_tanh, launch_sp13_aux_dir_metrics) gain K
  arg; strided_gather block deleted entirely
- training_loop.rs: aux_b1_diag HEALTH_DIAG line reads
  aux_dir_acc_buf [3..6] for n_down / n_up / n_skip + mask_frac;
  doc comment update for the per-step aux dir-metrics block
- tests/sp13_phase0_oracle_tests.rs: 6 dir_acc + 3 isv_tanh tests
  rewritten in-place to new ABI (no shadow tests, per
  feedback_no_legacy_aliases)
- tests/sp13_layer_b_oracle_tests.rs (NEW): 11 B1.1a tests — 5 CE
  loss/backward (single-row, batch-mixed, all-skip-NaN, backward
  single-row, backward batch-mixed); 2 dir_acc (handcrafted argmax,
  all-skip NaN-safe); 2 isv_tanh (bounded fuzz, mean handcrafted);
  2 layout regression (fingerprint bump, HEALTH_DIAG snap stable)
- docs/dqn-wire-up-audit.md: B1.1a section

Hard rules upheld:
- feedback_no_partial_refactor: every consumer of K-flip / softmax tile
  / CE / i32 labels migrates atomically — kernels + orchestrators +
  struct + diag + existing oracle tests
- feedback_no_atomicadd: block tree-reduce only; CE loss reduce uses
  2 parallel partial-reduction strips; CE backward uses existing
  per-sample partial → final aux_param_grad_reduce pattern
- feedback_cpu_is_read_only: aux_nb_label_buf is GPU-resident
  CudaSlice<i32>; HEALTH_DIAG aux_b1_diag reads via mapped-pinned
  aux_dir_acc_buf (no DtoH)
- feedback_no_stubs: every new buffer + kernel arg wired through to
  a real consumer; CE forward / loss / backward chain executes
  end-to-end against placeholder labels (degraded behavior, not stub)
- feedback_no_legacy_aliases: aux_nb_pred_buf renamed in-place
  (no shim); PARAM_AUX_NB_W2/B2 renamed to _W2_K2/_B2_K2 in seed
  (no _DEPRECATED alias); 9 existing oracle tests rewritten in-place
- feedback_no_cpu_test_fallbacks: 9 GPU tests gated #[ignore]; 2
  layout-regression tests are CPU-only (pub const + size_of)
- feedback_no_htod_htoh_only_mapped_pinned: every CPU↔GPU buffer
  in tests + production is MappedF32Buffer / MappedI32Buffer
- feedback_isv_for_adaptive_bounds: no hardcoded thresholds added
  (1e-30 numerical floor on log is stability epsilon, not tunable)
- feedback_trust_code_not_docs: 8/8 Phase 0 anchors verified at
  HEAD 75e94858c before editing
- pearl_bounded_modifier_outputs_require_structural_activation:
  softmax IS the structural activation; runtime tanh squash retired

Build: cargo check --workspace --tests clean.
Tests:
  - snapshot_size_is_stable passes at 149*4=596 bytes (B1.1a
    doesn't touch HEALTH_DIAG snap-words)
  - fingerprint_bumped_from_pre_b1_1a passes (proves W2/B2 rename
    flipped seed hash)
  - health_diag_snap_size_stable_at_149_floats passes
  - 9 GPU oracle tests in sp13_layer_b_oracle_tests pass on local
    RTX 3050 Ti (B=1/4)
  - 14 tests in sp13_phase0_oracle_tests pass (6 dir_acc + 3
    isv_tanh rewrites + 5 unchanged hold/EMA tests)

Pre-existing test failures (14) on `cargo test -p ml --lib` are
environmental (OFI test data missing, etc.) and reproduce identically
at HEAD 75e94858c (B1.0) — verified via git stash round-trip.

Net delta: 9 files (8 source + 1 audit doc), +1039 / −539 LOC.

Next: B1.1b — producer kernel + replay direct-path 8th gather +
experience collector hoist + remaining 7 tests (6 producer + 1
end-to-end round-trip) + Smoke A on L40S.

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

793 lines
30 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
//! SP13 Phase 0a directional-accuracy reducer GPU oracle tests.
//!
//! Validates the single-block tree-reduce kernel introduced by P0a.T2:
//! `aux_dir_acc_reduce_kernel.cu`
//!
//! SP13 B1.1a (2026-05-05): the kernel ABI flipped — reads
//! `softmax [B, K=2]` + i32 labels (was regression-mode `aux_pred f32 +
//! label_f32`) and writes 6 floats (was 3): tail of the output gained
//! `n_down`, `n_up`, `n_skip` per-class counts for HEALTH_DIAG. The
//! tests below drive synthetic `(softmax, i32 labels)` pairs through
//! the production kernel and verify the 6-element output against
//! analytically-known expected values. Per `feedback_no_cpu_test_fallbacks.md`:
//! GPU oracle only, no CPU reference impl. Per
//! `feedback_no_htod_htoh_only_mapped_pinned.md`: every CPU↔GPU buffer
//! is a `MappedF32Buffer` / `MappedI32Buffer` (cuMemHostAlloc with
//! DEVICEMAP|PORTABLE); zero `htod_copy`, zero `dtoh_sync_copy`. Tests
//! are NOT exempt from this rule.
//!
//! All tests are `#[ignore = "requires GPU"]`-gated to match every other
//! GPU oracle test in this crate (sp4/sp5/sp11/sp12). Run on a GPU host:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test sp13_phase0_oracle_tests --features cuda \
//! -- --ignored --nocapture
//!
//! Argmax convention (kernel side: `(softmax[1] > softmax[0]) ? 1 : 0`):
//! - `softmax[1] > softmax[0]` → predicts class 1 (up)
//! - `softmax[1] <= softmax[0]` → predicts class 0 (down) — incl. ties
//! - `label == -1` → mask/skip bar, excluded from denominator entirely;
//! contributes to `n_skip` only.
//!
//! Sentinel:
//! - When the entire batch is masked (empty / all-mask labels),
//! `(dir_acc, pos_pred_frac, pos_label_frac) = (0.5, 0.5, 0.5)`.
//! Matches `DIR_ACC_EMA_SENTINEL` in `sp13_isv_slots.rs` per
//! `pearl_first_observation_bootstrap.md`. The trailing
//! `(n_down, n_up, n_skip)` triple emits actual counts regardless.
#![cfg(feature = "cuda")]
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
/// Test-only cubin built by `crates/ml/build.rs`.
const SP13_AUX_DIR_ACC_REDUCE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_dir_acc_reduce_kernel.cubin"));
/// Resolve a CUDA stream against device 0. Mirrors `make_test_stream` in
/// every other oracle test in this crate (`sp4_producer_unit_tests.rs`,
/// `sp11_producer_unit_tests.rs`, `sp12_reward_math_tests.rs`).
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
/// Load the reducer kernel function from the embedded cubin.
fn load_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP13_AUX_DIR_ACC_REDUCE_CUBIN.to_vec())
.expect("load aux_dir_acc_reduce cubin");
module
.load_function("aux_dir_acc_reduce_kernel")
.expect("load aux_dir_acc_reduce_kernel function")
}
/// Drive one launch of the production reducer kernel with the given
/// per-bar prediction + label slices and return
/// `[dir_acc, pos_pred_frac, pos_label_frac]`.
///
/// All buffers are mapped-pinned (`MappedF32Buffer`) per
/// `feedback_no_htod_htoh_only_mapped_pinned.md`. The kernel writes the
/// 3-element output through `dev_ptr` with `__threadfence_system()`; the
/// test reads through `read_all()`'s volatile read after stream sync.
///
/// Block dim 256, shared mem `6 × 256 × sizeof(int) = 6 KB` — matches
/// the production launcher's config exactly (SP13 B1.1a grew shmem
/// 4 → 6 arrays for n_down/n_up/n_skip).
///
/// Returns the full 6-element output:
/// `[dir_acc, pos_pred_frac, pos_label_frac, n_down, n_up, n_skip]`
fn run_dir_acc(
stream: &Arc<CudaStream>,
f: &CudaFunction,
softmax: &[f32],
labels: &[i32],
k: i32,
) -> [f32; 6] {
let n = labels.len();
assert_eq!(
softmax.len(),
n * k as usize,
"softmax must be [B, K]; got {} for B={} K={}",
softmax.len(),
n,
k
);
// Empty-batch case: even if `n == 0` the kernel still needs valid
// device pointers for the input args (CUDA dereferences them only
// inside the strided loop, which doesn't execute, but the launch
// builder still passes the pointers). Allocate 1-element placeholders
// so the dev_ptr is non-zero.
let alloc_len_smx = (n * k as usize).max(1);
let alloc_len_lbl = n.max(1);
// Safety: CUDA context active on this thread (resolved via the
// stream's context above). All CPU↔GPU buffers are mapped-pinned.
let softmax_buf = unsafe { MappedF32Buffer::new(alloc_len_smx) }
.expect("alloc softmax buffer (mapped-pinned)");
let labels_buf = unsafe { MappedI32Buffer::new(alloc_len_lbl) }
.expect("alloc labels buffer (mapped-pinned)");
if n > 0 {
softmax_buf.write_from_slice(softmax);
labels_buf.write_from_slice(labels);
}
let out_buf = unsafe { MappedF32Buffer::new(6) }
.expect("alloc out buffer (6 f32 mapped-pinned)");
let softmax_dev = softmax_buf.dev_ptr;
let labels_dev = labels_buf.dev_ptr;
let out_dev = out_buf.dev_ptr;
let batch_size_i32: i32 = n as i32;
let bdim: u32 = 256;
unsafe {
stream
.launch_builder(f)
.arg(&softmax_dev)
.arg(&labels_dev)
.arg(&batch_size_i32)
.arg(&k)
.arg(&out_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: 6 * bdim * std::mem::size_of::<i32>() as u32,
})
.expect("launch aux_dir_acc_reduce_kernel");
}
stream.synchronize().expect("sync after aux_dir_acc_reduce launch");
let out = out_buf.read_all();
[out[0], out[1], out[2], out[3], out[4], out[5]]
}
/// Build a one-hot `[B, K=2]` softmax tile from per-bar argmax bits.
/// `pred_class[b] == 1` produces `[0.0, 1.0]` in row b (predicts up);
/// `pred_class[b] == 0` produces `[1.0, 0.0]` (predicts down).
/// One-hot tiles deliberately exercise the kernel's argmax branch
/// without softmax noise (the kernel only compares the two probs;
/// any strict ordering of `[p0, p1]` that matches the desired argmax
/// is sufficient).
fn one_hot_softmax(pred_class: &[i32]) -> Vec<f32> {
let mut out = Vec::with_capacity(pred_class.len() * 2);
for &c in pred_class {
if c == 1 {
out.push(0.0);
out.push(1.0);
} else {
out.push(1.0);
out.push(0.0);
}
}
out
}
// ─── Six P0a.T2 oracle cases (SP13 B1.1a-rewritten) ─────────────────────
/// All four bars predict the correct class → dir_acc = 1.0.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_all_correct_returns_one() {
let stream = make_test_stream();
let f = load_kernel(&stream);
// pred [1, 1, 0, 0], label [1, 1, 0, 0] → all correct.
let softmax = one_hot_softmax(&[1, 1, 0, 0]);
let labels = vec![1_i32, 1, 0, 0];
let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
assert!(
(out[0] - 1.0).abs() < 1e-6,
"expected dir_acc = 1.0, got {}",
out[0]
);
// Tail counts: 2 down + 2 up + 0 skip.
assert!((out[3] - 2.0).abs() < 1e-6, "n_down expected 2, got {}", out[3]);
assert!((out[4] - 2.0).abs() < 1e-6, "n_up expected 2, got {}", out[4]);
assert!((out[5] - 0.0).abs() < 1e-6, "n_skip expected 0, got {}", out[5]);
}
/// All four bars predict the wrong class → dir_acc = 0.0.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_all_wrong_returns_zero() {
let stream = make_test_stream();
let f = load_kernel(&stream);
// pred [1, 1, 0, 0], label [0, 0, 1, 1] → all wrong.
let softmax = one_hot_softmax(&[1, 1, 0, 0]);
let labels = vec![0_i32, 0, 1, 1];
let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
assert!(
out[0].abs() < 1e-6,
"expected dir_acc = 0.0, got {}",
out[0]
);
}
/// Half right + asymmetric prediction/label distributions.
///
/// pred = [1, 1, 1, 1] → 4 up predictions / 4 valid bars = 1.0
/// label = [1, 1, 0, 0] → 2 up labels / 4 valid bars = 0.5
/// dir_acc = 2/4 = 0.5 (bars 0+1 correct, bars 2+3 wrong).
#[test]
#[ignore = "requires GPU"]
fn dir_acc_half_correct() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let softmax = one_hot_softmax(&[1, 1, 1, 1]);
let labels = vec![1_i32, 1, 0, 0];
let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
assert!(
(out[0] - 0.5).abs() < 1e-6,
"expected dir_acc = 0.5, got {}",
out[0]
);
assert!(
(out[1] - 1.0).abs() < 1e-6,
"expected pos_pred_frac = 1.0 (4/4 up predictions), got {}",
out[1]
);
assert!(
(out[2] - 0.5).abs() < 1e-6,
"expected pos_label_frac = 0.5 (2/4 up labels), got {}",
out[2]
);
}
/// Bars whose label is exactly -1 are excluded from the denominator
/// entirely (B1.1a mask sentinel).
///
/// pred = [1, 1, 0]
/// label = [1, -1, 0] ← bar 1 is masked and skipped
/// valid bars = 2 (indexes 0 and 2). Both correct → dir_acc = 2/2 = 1.0.
/// n_skip should equal 1 (the mask bar contributes to n_skip only).
#[test]
#[ignore = "requires GPU"]
fn dir_acc_skips_mask_labels() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let softmax = one_hot_softmax(&[1, 1, 0]);
let labels = vec![1_i32, -1, 0];
let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
assert!(
(out[0] - 1.0).abs() < 1e-6,
"expected dir_acc = 1.0 (bar 1 with label=-1 excluded), got {}",
out[0]
);
assert!((out[5] - 1.0).abs() < 1e-6, "n_skip expected 1, got {}", out[5]);
assert!((out[3] - 1.0).abs() < 1e-6, "n_down expected 1, got {}", out[3]);
assert!((out[4] - 1.0).abs() < 1e-6, "n_up expected 1, got {}", out[4]);
}
/// Argmax convention: `(softmax[1] > softmax[0]) ? 1 : 0` treats ties
/// (both equal) as predicting class 0 (down). The B1.1a softmax is
/// produced by a stable softmax over logits — equal logits produce
/// equal probabilities, which the kernel must round to class 0.
///
/// softmax = [[0.5, 0.5], [0.5, 0.5]] ← both predict class 0 (tie → 0)
/// label = [0, 0] ← both down
/// dir_acc = 2/2 = 1.0 (both correct).
#[test]
#[ignore = "requires GPU"]
fn dir_acc_softmax_tie_is_class_zero() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let softmax = vec![0.5_f32, 0.5, 0.5, 0.5];
let labels = vec![0_i32, 0];
let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
assert!(
(out[0] - 1.0).abs() < 1e-6,
"expected dir_acc = 1.0 (tied softmax → class 0 → matches down label), got {}",
out[0]
);
}
/// Empty batch (or all-mask-label batch) returns the random-baseline
/// sentinel 0.5 across the first three outputs. Matches
/// `DIR_ACC_EMA_SENTINEL` in `sp13_isv_slots.rs` per
/// `pearl_first_observation_bootstrap.md` — the downstream EMA's first-
/// observation replacement consumes the sentinel naturally. The trailing
/// `(n_down, n_up, n_skip)` triple emits 0/0/0 for empty (no rows
/// counted), matching the actual count semantics.
#[test]
#[ignore = "requires GPU"]
fn dir_acc_empty_batch_returns_sentinel() {
let stream = make_test_stream();
let f = load_kernel(&stream);
let softmax: Vec<f32> = vec![];
let labels: Vec<i32> = vec![];
let out = run_dir_acc(&stream, &f, &softmax, &labels, 2);
assert!(
(out[0] - 0.5).abs() < 1e-6,
"expected dir_acc sentinel = 0.5 on empty batch, got {}",
out[0]
);
assert!(
(out[1] - 0.5).abs() < 1e-6,
"expected pos_pred_frac sentinel = 0.5 on empty batch, got {}",
out[1]
);
assert!(
(out[2] - 0.5).abs() < 1e-6,
"expected pos_label_frac sentinel = 0.5 on empty batch, got {}",
out[2]
);
// n_down/n_up/n_skip = 0/0/0 on truly empty batch.
assert!((out[3]).abs() < 1e-6, "n_down expected 0 on empty, got {}", out[3]);
assert!((out[4]).abs() < 1e-6, "n_up expected 0 on empty, got {}", out[4]);
assert!((out[5]).abs() < 1e-6, "n_skip expected 0 on empty, got {}", out[5]);
}
// ─── SP13 v3 P0a.T3 Hold-rate observer oracle cases ─────────────────────
//
// Validates the second SP13 Phase 0a single-block tree-reduce:
// `hold_rate_observer_kernel.cu`
//
// Drives synthetic per-bar PACKED factored-action tiles (`batch_actions [B]`
// of `i32`) through the production kernel and verifies the GPU-computed
// fraction `count(decoded_dir == DIR_HOLD) / batch_size` against
// analytically-known expected values. Same `MappedF32Buffer` discipline
// as the dir-acc oracle above; the actions tile is allocated through a
// dedicated `MappedI32Buffer` helper since the production input is `i32`
// rather than `f32`.
//
// Action encoding under test (mirrors `experience_action_select` at
// `experience_kernels.cu:1453`):
//
// action_idx = dir * (NUM_MAGNITUDES * NUM_ORD * NUM_URG)
// + mag * (NUM_ORD * NUM_URG)
// + ord * NUM_URG
// + urg
//
// Production branch sizes are NUM_MAGNITUDES = NUM_ORD = NUM_URG = 3
// (see `state_layout.cuh`); the per-direction stride is 27.
//
// Sign convention under test:
// - decoded `dir == DIR_HOLD` (=1) — bars whose packed action_idx
// decodes to direction Hold contribute 1 to the count
// - any other direction (`DIR_SHORT=0`, `DIR_LONG=2`, `DIR_FLAT=3`)
// contributes 0
// - empty batch → out = 0.0 (matches the Pearl A sentinel for slot
// 382 / `HOLD_RATE_OBSERVED_EMA_INDEX`)
const SP13_HOLD_RATE_OBSERVER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/hold_rate_observer_kernel.cubin"));
/// Production branch sizes (mirror `state_layout.cuh`: `NUM_MAGNITUDES`,
/// `NUM_ORD`, `NUM_URG`). Authoritative source — every consumer of the
/// packed factored encoding must use the same values per
/// `feedback_no_partial_refactor.md`.
const TEST_NUM_MAGNITUDES: i32 = 3;
const TEST_NUM_ORD: i32 = 3;
const TEST_NUM_URG: i32 = 3;
/// Pack a 4-tuple `(dir, mag, ord, urg)` into a factored action_idx using
/// the same encoding as `experience_action_select`. Direction stride is
/// `M * O * U`; magnitude stride is `O * U`; order stride is `U`.
fn pack_action(dir: i32, mag: i32, ord: i32, urg: i32) -> i32 {
dir * (TEST_NUM_MAGNITUDES * TEST_NUM_ORD * TEST_NUM_URG)
+ mag * (TEST_NUM_ORD * TEST_NUM_URG)
+ ord * TEST_NUM_URG
+ urg
}
fn load_hold_rate_observer(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP13_HOLD_RATE_OBSERVER_CUBIN.to_vec())
.expect("load hold_rate_observer cubin");
module
.load_function("hold_rate_observer_kernel")
.expect("load hold_rate_observer_kernel function")
}
/// Drive one launch of the production Hold-rate observer reducer with
/// the given per-bar direction-action slice and return the Hold-pick
/// fraction.
///
/// Block dim 256, shared mem `256 × sizeof(i32) = 1 KB` — matches the
/// production launcher's config exactly. All CPU↔GPU buffers are
/// mapped-pinned (`MappedI32Buffer` for the i32 actions tile,
/// `MappedF32Buffer` for the f32 output) per
/// `feedback_no_htod_htoh_only_mapped_pinned.md`.
fn run_hold_rate_observer(
stream: &Arc<CudaStream>,
f: &CudaFunction,
actions: &[i32],
) -> f32 {
let n = actions.len();
// Empty-batch case: kernel still needs a non-null device pointer for
// the actions arg (strided loop is empty but launch builder still
// passes the pointer). Mirrors `run_dir_acc`.
let alloc_len = n.max(1);
// Safety: CUDA context active on this thread (resolved via the
// stream's context above). All CPU↔GPU buffers are mapped-pinned.
let actions_buf = unsafe { MappedI32Buffer::new(alloc_len) }
.expect("alloc actions buffer (mapped-pinned)");
if n > 0 {
actions_buf.write_from_slice(actions);
}
let out_buf = unsafe { MappedF32Buffer::new(1) }
.expect("alloc out buffer (1 f32 mapped-pinned)");
let actions_dev = actions_buf.dev_ptr;
let out_dev = out_buf.dev_ptr;
let batch_size_i32: i32 = n as i32;
let bdim: u32 = 256;
unsafe {
stream
.launch_builder(f)
.arg(&actions_dev)
.arg(&batch_size_i32)
.arg(&out_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: bdim * std::mem::size_of::<i32>() as u32,
})
.expect("launch hold_rate_observer_kernel");
}
stream.synchronize().expect("sync after hold_rate_observer launch");
out_buf.read_all()[0]
}
/// Every action in the batch decodes to Hold direction → out = 1.0.
/// Uses non-zero (mag, ord, urg) sub-indices to exercise the divisor —
/// would catch a kernel that mistakenly treated the raw action_idx as the
/// direction (it's only the direction *after* dividing by M*O*U).
#[test]
#[ignore = "requires GPU"]
fn hold_rate_observer_all_hold_returns_one() {
let stream = make_test_stream();
let f = load_hold_rate_observer(&stream);
// Mix Hold actions across different (mag, ord, urg) sub-indices to
// verify the kernel's divisor. action_idx for Hold ranges over
// [27, 54): every value in that interval decodes to dir=1.
let actions: Vec<i32> = vec![
pack_action(1, 0, 0, 0), // 27
pack_action(1, 1, 0, 0), // 36
pack_action(1, 0, 2, 0), // 33
pack_action(1, 2, 2, 2), // 53 (Hold high-corner)
pack_action(1, 0, 0, 1), // 28
pack_action(1, 1, 1, 1), // 40
pack_action(1, 2, 0, 0), // 45
pack_action(1, 0, 1, 2), // 32
pack_action(1, 1, 2, 1), // 43
pack_action(1, 0, 0, 2), // 29
];
let out = run_hold_rate_observer(&stream, &f, &actions);
assert!(
(out - 1.0).abs() < 1e-6,
"expected hold_rate = 1.0 on all-Hold batch (decoded), got {}",
out
);
}
/// No action in the batch decodes to Hold (mix of Short=0/Long=2/Flat=3
/// across all (mag, ord, urg)) → out = 0.0. Verifies that no off-by-one
/// in the divisor flips a non-Hold packed value into the Hold band.
#[test]
#[ignore = "requires GPU"]
fn hold_rate_observer_no_hold_returns_zero() {
let stream = make_test_stream();
let f = load_hold_rate_observer(&stream);
// Mix non-Hold direction values across the (mag, ord, urg) sub-space.
// Short band = [0, 27), Long band = [54, 81), Flat band = [81, 108).
// None of these decode to dir=1.
let actions: Vec<i32> = vec![
pack_action(0, 0, 0, 0), // 0 (Short low-corner)
pack_action(0, 2, 2, 2), // 26 (Short high-corner)
pack_action(2, 0, 0, 0), // 54 (Long low-corner)
pack_action(2, 2, 2, 2), // 80 (Long high-corner)
pack_action(3, 0, 0, 0), // 81 (Flat low-corner)
pack_action(3, 2, 2, 2), // 107 (Flat high-corner)
];
let out = run_hold_rate_observer(&stream, &f, &actions);
assert!(
out.abs() < 1e-6,
"expected hold_rate = 0.0 on no-Hold batch (decoded), got {}",
out
);
}
/// Half the batch decodes to Hold → out = 0.5. Verifies the tree-reduce
/// arithmetic produces the correct fraction (not just the boolean any-
/// Hold/no-Hold extremes the previous two tests cover) AND that every
/// non-Hold direction (Short/Long/Flat) decodes correctly to non-Hold —
/// would catch an off-by-one comparing against DIR_SHORT instead of
/// DIR_HOLD.
#[test]
#[ignore = "requires GPU"]
fn hold_rate_observer_half_hold_returns_half() {
let stream = make_test_stream();
let f = load_hold_rate_observer(&stream);
// 4 Hold + 4 non-Hold = 0.5 fraction. Each Hold has different
// (mag, ord, urg); the 4 non-Hold span all 3 alternative directions.
let actions: Vec<i32> = vec![
pack_action(1, 0, 0, 0), // Hold
pack_action(0, 1, 1, 1), // Short
pack_action(1, 1, 1, 1), // Hold
pack_action(2, 0, 0, 0), // Long
pack_action(1, 2, 2, 2), // Hold
pack_action(3, 1, 1, 1), // Flat
pack_action(1, 0, 1, 2), // Hold
pack_action(0, 2, 2, 2), // Short
];
let out = run_hold_rate_observer(&stream, &f, &actions);
assert!(
(out - 0.5).abs() < 1e-6,
"expected hold_rate = 0.5 on half-Hold batch (decoded), got {}",
out
);
}
// ─── SP13 P0a.T4 fixed-α EMA applicator oracle cases ────────────────────
//
// Validates the SP13 fixed-α EMA applicator kernel introduced by P0a.T4:
// `apply_fixed_alpha_ema_kernel.cu`
//
// Drives synthetic (sample, prev_state, alpha, sentinel) tuples through
// the production kernel and verifies the GPU-computed
// `(1-α) · prev + α · sample` blend (or sentinel-bootstrap direct
// replacement) against analytically-known expected values. Same
// `MappedF32Buffer` discipline as the other oracles; the kernel is N-slot
// generalisable but P0a always launches with N=1, so the tests stay at
// N=1 to cover the production launch shape.
const SP13_APPLY_FIXED_ALPHA_EMA_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/apply_fixed_alpha_ema_kernel.cubin"));
fn load_apply_fixed_alpha_ema(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP13_APPLY_FIXED_ALPHA_EMA_CUBIN.to_vec())
.expect("load apply_fixed_alpha_ema cubin");
module
.load_function("apply_fixed_alpha_ema_kernel")
.expect("load apply_fixed_alpha_ema_kernel function")
}
/// Drive one launch of the fixed-α EMA applicator on a 1-slot ISV
/// scratch buffer pre-seeded with `prev_state`. Returns the post-launch
/// ISV slot value.
fn run_fixed_alpha_ema(
stream: &Arc<CudaStream>,
f: &CudaFunction,
sample: f32,
prev_state: f32,
alpha: f32,
sentinel: f32,
) -> f32 {
// Allocate a 1-elem mapped-pinned ISV scratch + 1-elem sample buffer.
let isv_buf = unsafe { MappedF32Buffer::new(1) }
.expect("alloc isv buffer (mapped-pinned)");
isv_buf.write_from_slice(&[prev_state]);
let sample_buf = unsafe { MappedF32Buffer::new(1) }
.expect("alloc sample buffer (mapped-pinned)");
sample_buf.write_from_slice(&[sample]);
let sample_dev = sample_buf.dev_ptr;
let isv_dev = isv_buf.dev_ptr;
let n_slots: i32 = 1;
let isv_offset: i32 = 0;
unsafe {
stream
.launch_builder(f)
.arg(&sample_dev)
.arg(&n_slots)
.arg(&isv_offset)
.arg(&alpha)
.arg(&sentinel)
.arg(&isv_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch apply_fixed_alpha_ema_kernel");
}
stream.synchronize().expect("sync after apply_fixed_alpha_ema launch");
isv_buf.read_all()[0]
}
/// Standard blend: prev=0.5, sample=1.0, α=0.5 → 0.75.
/// Sentinel is intentionally distinct from `prev` so the bootstrap
/// branch does NOT fire — this test exercises the steady-state blend
/// formula `(1-α) · prev + α · sample`.
#[test]
#[ignore = "requires GPU"]
fn fixed_alpha_ema_steady_state_blend() {
let stream = make_test_stream();
let f = load_apply_fixed_alpha_ema(&stream);
let out = run_fixed_alpha_ema(
&stream, &f,
/* sample */ 1.0,
/* prev */ 0.5,
/* alpha */ 0.5,
/* sentinel */ -1.0, // far from prev — bootstrap MUST NOT fire
);
assert!(
(out - 0.75).abs() < 1e-6,
"expected (1-0.5)*0.5 + 0.5*1.0 = 0.75, got {}",
out
);
}
/// First-observation bootstrap: prev == sentinel → replace directly.
/// Mirrors `pearl_first_observation_bootstrap` — the SP13 dir-acc
/// EMAs use sentinel=0.5 and the hold-rate EMA uses sentinel=0.0;
/// after fold reset the slot holds the sentinel, and the very first
/// non-sentinel observation must replace it directly so the EMA tracks
/// the new fold's signal without the sentinel's bias.
#[test]
#[ignore = "requires GPU"]
fn fixed_alpha_ema_sentinel_bootstrap() {
let stream = make_test_stream();
let f = load_apply_fixed_alpha_ema(&stream);
// SP13 dir-acc sentinel = 0.5. First real observation = 0.62 (>
// baseline). The kernel must REPLACE prev directly, not blend —
// a blend at α=0.3 would produce 0.5*0.7 + 0.62*0.3 = 0.536 which
// would carry the sentinel bias into the EMA forever.
let out = run_fixed_alpha_ema(
&stream, &f,
/* sample */ 0.62,
/* prev */ 0.5,
/* alpha */ 0.3,
/* sentinel */ 0.5, // matches prev — bootstrap MUST fire
);
assert!(
(out - 0.62).abs() < 1e-6,
"expected sentinel bootstrap to replace directly with 0.62, got {}",
out
);
}
// ─── SP13 P0a.T4 aux-pred → ISV[375] oracle cases (B1.1a-rewritten) ────
//
// Validates the SP13 aux-head per-bar prediction → ISV[AUX_DIR_PREDICTION_INDEX=375]
// bounded scalar producer kernel introduced by P0a.T4 and rewritten by
// SP13 B1.1a (2026-05-05) to read the K=2 softmax tile directly:
// `aux_pred_to_isv_tanh_kernel.cu`
//
// Drives synthetic `softmax [B, 2]` tiles through the production kernel
// and verifies the GPU-computed `mean(softmax[:, 1] - softmax[:, 0])`
// ∈ [-1, +1] against analytically-known expected values. The kernel
// filename + ISV slot name are preserved for layout stability across
// the B1.1a rewrite (the "tanh" in the name is historical — the runtime
// squash is retired since softmax provides the bound structurally).
const SP13_AUX_PRED_TO_ISV_TANH_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/aux_pred_to_isv_tanh_kernel.cubin"));
fn load_aux_pred_to_isv_tanh(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP13_AUX_PRED_TO_ISV_TANH_CUBIN.to_vec())
.expect("load aux_pred_to_isv_tanh cubin");
module
.load_function("aux_pred_to_isv_tanh_kernel")
.expect("load aux_pred_to_isv_tanh_kernel function")
}
/// Drive one launch of the aux-softmax → ISV[375] producer with the
/// given `softmax [B, 2]` tile. Returns ISV slot 0 post-launch.
fn run_aux_pred_to_isv_tanh(
stream: &Arc<CudaStream>,
f: &CudaFunction,
softmax: &[f32],
k: i32,
) -> f32 {
let n = if k > 0 { softmax.len() / k as usize } else { 0 };
let alloc_len = (n * k as usize).max(1);
let softmax_buf = unsafe { MappedF32Buffer::new(alloc_len) }
.expect("alloc softmax buffer (mapped-pinned)");
if !softmax.is_empty() {
softmax_buf.write_from_slice(softmax);
}
// 1-elem ISV scratch — kernel only writes slot 0.
let isv_buf = unsafe { MappedF32Buffer::new(1) }
.expect("alloc isv buffer (mapped-pinned)");
let softmax_dev = softmax_buf.dev_ptr;
let isv_dev = isv_buf.dev_ptr;
let batch_i32: i32 = n as i32;
let isv_off_i32: i32 = 0;
let bdim: u32 = 256;
unsafe {
stream
.launch_builder(f)
.arg(&softmax_dev)
.arg(&batch_i32)
.arg(&k)
.arg(&isv_off_i32)
.arg(&isv_dev)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (bdim, 1, 1),
shared_mem_bytes: bdim * std::mem::size_of::<f32>() as u32,
})
.expect("launch aux_pred_to_isv_tanh_kernel");
}
stream.synchronize().expect("sync after aux_pred_to_isv_tanh launch");
isv_buf.read_all()[0]
}
/// Balanced batch (every row is `[0.5, 0.5]`) → mean diff = 0.0.
/// Mirrors the regression-mode "symmetric input → mean=0" test, but
/// for the B1.1a softmax tile the equivalent is "every row is split
/// 50/50 between classes".
#[test]
#[ignore = "requires GPU"]
fn aux_pred_tanh_balanced_softmax_returns_zero() {
let stream = make_test_stream();
let f = load_aux_pred_to_isv_tanh(&stream);
// 4 rows of [0.5, 0.5] → diff = 0 per row → mean = 0.
let softmax = vec![0.5_f32; 8];
let out = run_aux_pred_to_isv_tanh(&stream, &f, &softmax, 2);
assert!(
out.abs() < 1e-6,
"expected mean(softmax[1]-softmax[0]) = 0.0 on balanced batch, got {}",
out
);
}
/// Symmetric (mixed) batch: 2 rows fully up + 2 rows fully down →
/// mean = (1.0 + 1.0 + (-1.0) + (-1.0)) / 4 = 0.0.
#[test]
#[ignore = "requires GPU"]
fn aux_pred_tanh_symmetric_batch_returns_zero() {
let stream = make_test_stream();
let f = load_aux_pred_to_isv_tanh(&stream);
let softmax = one_hot_softmax(&[1, 1, 0, 0]);
let out = run_aux_pred_to_isv_tanh(&stream, &f, &softmax, 2);
assert!(
out.abs() < 1e-6,
"expected mean = 0.0 on symmetric batch (2 up + 2 down), got {}",
out
);
}
/// Saturated batch (every row strongly predicts up) →
/// mean ≈ +1.0; the structural bound `[-1, +1]` holds without any
/// runtime tanh squash. Mirror test for fully-down case.
#[test]
#[ignore = "requires GPU"]
fn aux_pred_tanh_large_magnitude_saturates_in_bounds() {
let stream = make_test_stream();
let f = load_aux_pred_to_isv_tanh(&stream);
// All up: every row is [0.0, 1.0] → diff = +1.0 per row → mean = +1.0.
let softmax_up = one_hot_softmax(&[1; 32]);
let out_up = run_aux_pred_to_isv_tanh(&stream, &f, &softmax_up, 2);
assert!(
(out_up - 1.0).abs() < 1e-6 && out_up <= 1.0,
"expected mean = +1.0 on all-up batch, got {}",
out_up
);
// All down: every row is [1.0, 0.0] → diff = -1.0 per row → mean = -1.0.
let softmax_dn = one_hot_softmax(&[0; 32]);
let out_dn = run_aux_pred_to_isv_tanh(&stream, &f, &softmax_dn, 2);
assert!(
(out_dn + 1.0).abs() < 1e-6 && out_dn >= -1.0,
"expected mean = -1.0 on all-down batch, got {}",
out_dn
);
}