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>
793 lines
30 KiB
Rust
793 lines
30 KiB
Rust
#![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
|
||
);
|
||
}
|