Files
foxhunt/crates/ml/tests/sp11_producer_unit_tests.rs
jgrusewski b3b4d02789 fix(sp11): B1b smoke-recovery — z-score normalization for mag-ratio canary
Linear magnitude ratios in reward_component_mag_ratio_compute_kernel
amplified popart's intrinsic O(100) magnitude over the other 5
components' O(0.1-2) magnitudes, causing controller to saturate
w_pop toward MAX_WEIGHT regardless of actual signal quality.

Replaced with z-score: z[c] = mag[c] / max(sqrt(var[c]), EPS_DIV).
6 new ISV slots [361..367) for per-component variance EMAs computed
via Welford's online algorithm in extended popart_component_ema_kernel
and reward_component_ema_kernel.

Atomic per feedback_no_partial_refactor: slot allocation +
state-reset registry + 2 producer kernels + canary signature +
launcher Pearls A+D + tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:51:30 +02:00

947 lines
36 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.
//! SP11 (Fix 39) Task A1 producer kernel unit tests.
//!
//! Validates the three canary producer kernels added by Task A1:
//! 1. `val_sharpe_delta_compute_kernel.cu`
//! 2. `saboteur_engagement_compute_kernel.cu`
//! 3. `reward_component_mag_ratio_compute_kernel.cu`
//!
//! Each test loads the producer cubin directly (skipping the chained
//! Pearls A+D launch — the launchers in `gpu_dqn_trainer.rs` chain that
//! step in production) and verifies the producer's scratch outputs
//! against known synthetic inputs. The chained Pearls A+D applicator is
//! exercised separately in `sp4_producer_unit_tests.rs`.
//!
//! All tests are `#[ignore = "requires GPU"]`-gated. Run on a GPU host:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test sp11_producer_unit_tests --features cuda \
//! -- --ignored --nocapture
//!
//! Per `feedback_no_htod_htoh_only_mapped_pinned`: every CPU↔GPU buffer
//! is a `MappedF32Buffer` allocated via `cuMemHostAlloc(DEVICEMAP|PORTABLE)`.
//! Zero `htod_copy`, zero `dtoh_sync_copy`, zero `alloc_zeros`. Tests are
//! NOT exempt from this rule. The host writes inputs through `host_slice_mut()`
//! / `write_from_slice()` (mapped-pinned coherence — kernel sees the
//! values after a stream sync), launches the kernel via `dev_ptr`, then
//! reads outputs via `read_all()` (volatile reads that defeat CPU caching).
#![cfg(feature = "cuda")]
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
use ml::cuda_pipeline::sp11_isv_slots::{
PNL_REWARD_MAGNITUDE_EMA_INDEX, REWARD_COMPONENT_MAG_RATIO_BASE,
REWARD_COMPONENT_VAR_EMA_BASE,
SABOTEUR_ENGAGEMENT_RATE_INDEX,
VAL_SHARPE_DELTA_EMA_INDEX, VAL_SHARPE_VAR_EMA_INDEX,
};
/// ISV total dimension (must match `gpu_dqn_trainer::ISV_TOTAL_DIM`).
/// Tests allocate a full-size mapped-pinned ISV buffer and write the
/// few slots they need; the kernels read by ISV slot index, so a
/// shorter buffer would be unsafe even with synthetic-only writes.
///
/// Bumped 360 → 361 by SP11 B1b fix-up (2026-05-04, spec §4 amendment
/// "Why slot 360") which adds POPART_COMPONENT_MAG_EMA_INDEX=360.
/// Bumped 361 → 367 by SP11 B1b smoke-recovery (2026-05-04, spec §4
/// amendment "Why z-score" lines 564-619) which adds 6 per-component
/// variance EMA slots at REWARD_COMPONENT_VAR_EMA_BASE=361..367.
const ISV_TOTAL_DIM: usize = 367;
/// REWARD_POPART_EMA_INDEX from `gpu_dqn_trainer.rs` (the SP4 reward-
/// component magnitude EMA base slot, mirrored here as a const so the
/// test imports stay scoped to the SP11 producer surface).
const REWARD_POPART_EMA_INDEX: usize = 63;
/// POPART_COMPONENT_MAG_EMA_INDEX = 360 (SP11 B1b fix-up). The mag-ratio
/// kernel post-fix-up reads the popart axis from this slot instead of
/// slot 63's overloaded total-reward value. Mirrored here as a const so
/// the unit test stays scoped to the SP11 producer surface.
const POPART_COMPONENT_MAG_EMA_INDEX: usize = 360;
/// Cubin paths for each producer kernel under test. Each cubin is built
/// by `crates/ml/build.rs` from the `.cu` source and dropped into
/// `OUT_DIR` per the standard kernel-registration pattern.
const SP11_VAL_SHARPE_DELTA_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/val_sharpe_delta_compute_kernel.cubin"));
const SP11_SABOTEUR_ENGAGEMENT_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/saboteur_engagement_compute_kernel.cubin"));
const SP11_MAG_RATIO_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/reward_component_mag_ratio_compute_kernel.cubin"));
/// SP11 A2 (2026-05-04): controller cubin for the 3 oracle tests below.
const SP11_REWARD_SUBSYSTEM_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/reward_subsystem_controller_kernel.cubin"));
/// SP11 B1b smoke-recovery (2026-05-04): popart_component_ema cubin —
/// extended with Welford variance pass. Used by the variance-correctness
/// oracle test below.
const SP11_POPART_COMPONENT_EMA_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/popart_component_ema_kernel.cubin"));
/// Build a CUDA stream against device 0. Mirrors `make_test_stream` in
/// `sp4_producer_unit_tests.rs` and `distributional_q_tests.rs`.
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
/// Load a kernel function from an embedded cubin by name.
fn load_kernel(stream: &Arc<CudaStream>, cubin: &[u8], func_name: &str) -> CudaFunction {
let module = stream
.context()
.load_cubin(cubin.to_vec())
.unwrap_or_else(|e| panic!("load cubin for {func_name}: {e}"));
module
.load_function(func_name)
.unwrap_or_else(|e| panic!("load function {func_name}: {e}"))
}
/// SP11 A1.1 — val-sharpe Δ + variance producer first observation.
///
/// Inputs: history = [10.0, 15.0] (Δ = 5.0); ISV[VAL_SHARPE_DELTA_EMA_INDEX]
/// = 0 (sentinel). Expected outputs: scratch[0] = 5.0 (raw Δ), scratch[1]
/// = (5.0 0)² = 25.0 (squared deviation). The chained Pearls A+D
/// applicator (not exercised here) bootstraps both EMAs from these
/// values via Pearl A's first-observation replacement.
#[test]
#[ignore = "requires GPU"]
fn val_sharpe_delta_first_observation_writes_raw_delta() {
let stream = make_test_stream();
let f = load_kernel(&stream, SP11_VAL_SHARPE_DELTA_CUBIN, "val_sharpe_delta_compute_kernel");
// Safety: CUDA context active on this thread (resolved through the
// stream's context above).
let mut history = unsafe { MappedF32Buffer::new(2) }
.expect("alloc val_sharpe_history (2 f32)");
{
let h = history.host_slice_mut();
h[0] = 10.0; // prev epoch
h[1] = 15.0; // curr epoch — Δ = 5.0
}
let scratch = unsafe { MappedF32Buffer::new(2) }
.expect("alloc scratch (2 f32)");
let isv = unsafe { MappedF32Buffer::new(ISV_TOTAL_DIM) }
.expect("alloc isv (367 f32)");
// ISV[VAL_SHARPE_DELTA_EMA_INDEX] is constructor-zero — sentinel state
// for Pearl A's first-observation replacement. No host writes needed.
let history_dev = history.dev_ptr;
let scratch_dev = scratch.dev_ptr;
let isv_dev = isv.dev_ptr;
let delta_slot_i32: i32 = VAL_SHARPE_DELTA_EMA_INDEX as i32;
let var_slot_i32: i32 = VAL_SHARPE_VAR_EMA_INDEX as i32;
unsafe {
stream
.launch_builder(&f)
.arg(&history_dev)
.arg(&scratch_dev)
.arg(&isv_dev)
.arg(&delta_slot_i32)
.arg(&var_slot_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch val_sharpe_delta_compute_kernel");
}
stream.synchronize().expect("sync after val_sharpe_delta launch");
let out = scratch.read_all();
let raw_delta = out[0];
let sq_dev = out[1];
assert!(
(raw_delta - 5.0_f32).abs() < 1e-6,
"expected raw delta = 5.0, got {raw_delta}"
);
// Squared deviation against zero ema = 5.0² = 25.0
assert!(
(sq_dev - 25.0_f32).abs() < 1e-4,
"expected squared deviation = 25.0 (against zero ema), got {sq_dev}"
);
}
/// SP11 A1.2 — saboteur engagement classifier counts only bars whose
/// |Δreward| exceeds the signal-relative threshold.
///
/// Inputs: 4-bar synthetic Δreward = [0.0, 0.005, 0.05, 0.5]; ISV at
/// PNL_REWARD_MAGNITUDE_EMA_INDEX = 1.0. Threshold = 0.01 × 1.0 = 0.01.
/// Bars 0 and 1 fall below the threshold; bars 2 and 3 exceed it.
/// Expected engagement = 2/4 = 0.5.
#[test]
#[ignore = "requires GPU"]
fn saboteur_engagement_counts_above_threshold_only() {
let stream = make_test_stream();
let f = load_kernel(&stream, SP11_SABOTEUR_ENGAGEMENT_CUBIN, "saboteur_engagement_compute_kernel");
let delta_reward = unsafe { MappedF32Buffer::new(4) }
.expect("alloc delta_reward (4 f32)");
delta_reward.write_from_slice(&[0.0_f32, 0.005, 0.05, 0.5]);
let scratch = unsafe { MappedF32Buffer::new(1) }
.expect("alloc scratch (1 f32)");
let mut isv = unsafe { MappedF32Buffer::new(ISV_TOTAL_DIM) }
.expect("alloc isv (367 f32)");
isv.host_slice_mut()[PNL_REWARD_MAGNITUDE_EMA_INDEX] = 1.0;
let delta_dev = delta_reward.dev_ptr;
let scratch_dev = scratch.dev_ptr;
let isv_dev = isv.dev_ptr;
let n_bars_i32: i32 = 4;
let pnl_slot_i32: i32 = PNL_REWARD_MAGNITUDE_EMA_INDEX as i32;
unsafe {
stream
.launch_builder(&f)
.arg(&delta_dev)
.arg(&scratch_dev)
.arg(&isv_dev)
.arg(&n_bars_i32)
.arg(&pnl_slot_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch saboteur_engagement_compute_kernel");
}
stream.synchronize().expect("sync after saboteur engagement launch");
let engagement = scratch.read_all()[0];
assert!(
(engagement - 0.5_f32).abs() < 1e-4,
"expected engagement = 0.5 (2/4 bars above threshold), got {engagement}"
);
}
/// Helper to launch the post-B1b-smoke-recovery mag-ratio kernel with
/// the four slot-arg signature: (popart_mag_slot=360, cf_others_mag_base=64,
/// popart_var_slot=361, cf_others_var_base=362). Synchronises before
/// returning.
fn launch_mag_ratio_kernel(
stream: &Arc<CudaStream>,
f: &CudaFunction,
isv: &MappedF32Buffer,
scratch: &MappedF32Buffer,
) {
let isv_dev = isv.dev_ptr;
let scratch_dev = scratch.dev_ptr;
let popart_specific_slot_i32: i32 = POPART_COMPONENT_MAG_EMA_INDEX as i32;
let cf_others_base_slot_i32: i32 = (REWARD_POPART_EMA_INDEX + 1) as i32;
let popart_var_slot_i32: i32 = REWARD_COMPONENT_VAR_EMA_BASE as i32;
let cf_others_var_base_slot_i32: i32 = (REWARD_COMPONENT_VAR_EMA_BASE + 1) as i32;
unsafe {
stream
.launch_builder(f)
.arg(&isv_dev)
.arg(&scratch_dev)
.arg(&popart_specific_slot_i32)
.arg(&cf_others_base_slot_i32)
.arg(&popart_var_slot_i32)
.arg(&cf_others_var_base_slot_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (6, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch reward_component_mag_ratio_compute_kernel");
}
stream.synchronize().expect("sync after mag_ratio launch");
}
/// SP11 A1.3 + B1b fix-up + B1b smoke-recovery — per-component
/// magnitude-ratio normaliser preserves Σ=1 (with z-score normalisation)
/// and mirrors popart-component RAW magnitude into scratch[6].
///
/// Post-B1b-smoke-recovery the kernel takes FOUR slot indices:
/// popart_specific_slot = POPART_COMPONENT_MAG_EMA_INDEX = 360
/// cf_others_base_slot = REWARD_POPART_EMA_INDEX + 1 = 64
/// popart_var_slot = REWARD_COMPONENT_VAR_EMA_BASE = 361
/// cf_others_var_base_slot = REWARD_COMPONENT_VAR_EMA_BASE + 1 = 362
///
/// Inputs: ISV[POPART_COMPONENT_MAG_EMA_INDEX] = 1.0,
/// ISV[REWARD_POPART_EMA_INDEX+1..+6) = [2.0, 3.0, 4.0, 5.0, 6.0]
/// (cf=64, trail=65, micro=66, opp_cost=67, bonus=68).
/// All variances set to 1.0 ⇒ σ=1 ⇒ z[c] = mag[c] ⇒ z-score reduces to
/// raw magnitude. Sum = 21. Expected ratios = [1/21, 2/21, 3/21, 4/21,
/// 5/21, 6/21]. Mirror at scratch[6] = 1.0 (popart-component RAW
/// magnitude — NOT z-score; consumed by curiosity bound + saboteur
/// engagement which need physical scale).
#[test]
#[ignore = "requires GPU"]
fn reward_component_mag_ratio_normalises_and_mirrors_popart() {
let stream = make_test_stream();
let f = load_kernel(&stream, SP11_MAG_RATIO_CUBIN, "reward_component_mag_ratio_compute_kernel");
let scratch = unsafe { MappedF32Buffer::new(7) }
.expect("alloc scratch (7 f32 — 6 ratios + 1 popart mirror)");
let mut isv = unsafe { MappedF32Buffer::new(ISV_TOTAL_DIM) }
.expect("alloc isv (367 f32)");
{
let s = isv.host_slice_mut();
// B1b fix-up: popart axis sourced from slot 360, not 63.
s[POPART_COMPONENT_MAG_EMA_INDEX] = 1.0;
// cf, trail, micro, opp_cost, bonus at slots 64..68 (cf_others_base_slot = 64).
s[REWARD_POPART_EMA_INDEX + 1] = 2.0; // cf
s[REWARD_POPART_EMA_INDEX + 2] = 3.0; // trail
s[REWARD_POPART_EMA_INDEX + 3] = 4.0; // micro
s[REWARD_POPART_EMA_INDEX + 4] = 5.0; // opp_cost
s[REWARD_POPART_EMA_INDEX + 5] = 6.0; // bonus
// B1b smoke-recovery: variance = 1.0 for every component ⇒ σ=1 ⇒
// z-score reduces to raw magnitude (verifying the legacy
// ratio shape works under the new formula when σ is unit).
s[REWARD_COMPONENT_VAR_EMA_BASE] = 1.0; // popart var
s[REWARD_COMPONENT_VAR_EMA_BASE + 1] = 1.0; // cf var
s[REWARD_COMPONENT_VAR_EMA_BASE + 2] = 1.0; // trail var
s[REWARD_COMPONENT_VAR_EMA_BASE + 3] = 1.0; // micro var
s[REWARD_COMPONENT_VAR_EMA_BASE + 4] = 1.0; // opp_cost var
s[REWARD_COMPONENT_VAR_EMA_BASE + 5] = 1.0; // bonus var
}
launch_mag_ratio_kernel(&stream, &f, &isv, &scratch);
let out = scratch.read_all();
let sum_in: f32 = 1.0 + 2.0 + 3.0 + 4.0 + 5.0 + 6.0;
// Per-component ratio assertions.
for c in 0..6_usize {
let expected = (c as f32 + 1.0) / sum_in;
assert!(
(out[c] - expected).abs() < 1e-5,
"component {c}: expected ratio {expected}, got {}",
out[c]
);
}
// Σ ratios == 1.0 ± 1e-5.
let sum_out: f32 = out[0..6].iter().sum();
assert!(
(sum_out - 1.0_f32).abs() < 1e-5,
"expected Σratios = 1.0, got {sum_out}"
);
// Popart mirror at scratch[6] — sourced from slot 360 post-B1b-fix.
// This is RAW magnitude (not z-score), per spec §3.4.2 / §3.3.1
// — slot 359 consumers (curiosity bound + saboteur engagement)
// need physical P&L scale.
assert!(
(out[6] - 1.0_f32).abs() < 1e-6,
"expected popart mirror = 1.0 (= isv[POPART_COMPONENT_MAG_EMA_INDEX=360]), got {}",
out[6]
);
}
/// SP11 B1b smoke-recovery — z-score canary produces uniform 1/6
/// ratios when all components have equal z-scores (equal mag, equal
/// variance ⇒ equal σ ⇒ equal z = mag/σ).
///
/// Inputs:
/// ISV[360]=10, ISV[64..68]=10 (equal magnitudes, popart split).
/// ISV[361]=4, ISV[362..366]=4 (equal variances ⇒ σ=2 each).
/// z[c] = 10/2 = 5 ∀c ⇒ Σz = 30 ⇒ ratio[c] = 5/30 = 1/6 ∀c.
#[test]
#[ignore = "requires GPU"]
fn mag_ratio_z_score_uniform_inputs_yields_uniform_ratios() {
let stream = make_test_stream();
let f = load_kernel(&stream, SP11_MAG_RATIO_CUBIN, "reward_component_mag_ratio_compute_kernel");
let scratch = unsafe { MappedF32Buffer::new(7) }
.expect("alloc scratch (7 f32)");
let mut isv = unsafe { MappedF32Buffer::new(ISV_TOTAL_DIM) }
.expect("alloc isv (367 f32)");
{
let s = isv.host_slice_mut();
// Magnitudes: popart at slot 360, cf/trail/micro/opp_cost/bonus at 64..68 — all 10.0.
s[POPART_COMPONENT_MAG_EMA_INDEX] = 10.0;
for c in 1..6 {
s[REWARD_POPART_EMA_INDEX + c] = 10.0;
}
// Variances: popart at slot 361, others at 362..366 — all 4.0 (σ=2).
s[REWARD_COMPONENT_VAR_EMA_BASE] = 4.0;
for c in 1..6 {
s[REWARD_COMPONENT_VAR_EMA_BASE + c] = 4.0;
}
}
launch_mag_ratio_kernel(&stream, &f, &isv, &scratch);
let out = scratch.read_all();
let expected = 1.0_f32 / 6.0;
for c in 0..6_usize {
assert!(
(out[c] - expected).abs() < 1e-5,
"component {c}: expected ratio 1/6 = {expected}, got {}",
out[c]
);
}
// Σ ratios = 1.0 (sanity).
let sum_out: f32 = out[0..6].iter().sum();
assert!(
(sum_out - 1.0_f32).abs() < 1e-5,
"expected Σratios = 1.0, got {sum_out}"
);
}
/// SP11 B1b smoke-recovery — THE ACTUAL FIX VERIFICATION.
/// Asymmetric input (popart 100× bigger linear magnitude AND 100× bigger
/// variance) produces a balanced ratio under the z-score formula
/// instead of the linear formula's pathological 95% popart share.
///
/// Inputs:
/// ISV[360]=100, ISV[64..68]=1 (popart 100× bigger linear)
/// ISV[361]=400, ISV[362..366]=4 (popart variance 100× bigger ⇒
/// σ_popart=20 vs σ_others=2)
/// Z-scores:
/// z[0] = 100 / 20 = 5
/// z[c] = 1 / 2 = 0.5 for c=1..5
/// Σz = 5 + 5×0.5 = 7.5
/// Ratios:
/// ratio[0] = 5 / 7.5 ≈ 0.6667
/// ratio[c] = 0.5 / 7.5 ≈ 0.0667 (c=1..5)
///
/// The pre-z-score linear formula would give:
/// ratio[0] = 100 / (100+5) ≈ 0.952 ← saturates w_pop
/// ratio[c] = 1 / (100+5) ≈ 0.0095 (c=1..5)
/// — exactly the B1b smoke pathology.
#[test]
#[ignore = "requires GPU"]
fn mag_ratio_z_score_asymmetric_input_yields_balanced_ratios() {
let stream = make_test_stream();
let f = load_kernel(&stream, SP11_MAG_RATIO_CUBIN, "reward_component_mag_ratio_compute_kernel");
let scratch = unsafe { MappedF32Buffer::new(7) }
.expect("alloc scratch (7 f32)");
let mut isv = unsafe { MappedF32Buffer::new(ISV_TOTAL_DIM) }
.expect("alloc isv (367 f32)");
{
let s = isv.host_slice_mut();
s[POPART_COMPONENT_MAG_EMA_INDEX] = 100.0;
for c in 1..6 {
s[REWARD_POPART_EMA_INDEX + c] = 1.0;
}
s[REWARD_COMPONENT_VAR_EMA_BASE] = 400.0; // σ=20
for c in 1..6 {
s[REWARD_COMPONENT_VAR_EMA_BASE + c] = 4.0; // σ=2
}
}
launch_mag_ratio_kernel(&stream, &f, &isv, &scratch);
let out = scratch.read_all();
// ratio[0] = 5/7.5 = 0.6667; ratio[1..6] = 0.5/7.5 = 0.0667 each.
let expected_popart = 5.0_f32 / 7.5;
let expected_others = 0.5_f32 / 7.5;
assert!(
(out[0] - expected_popart).abs() < 1e-4,
"popart ratio = {} (expected ≈ {expected_popart}); the linear formula \
would have given ≈ 0.952 — z-score keeps the canary balanced.",
out[0]
);
for c in 1..6_usize {
assert!(
(out[c] - expected_others).abs() < 1e-4,
"component {c} ratio = {} (expected ≈ {expected_others})",
out[c]
);
}
// Σ = 1.0 still.
let sum_out: f32 = out[0..6].iter().sum();
assert!(
(sum_out - 1.0_f32).abs() < 1e-5,
"expected Σratios = 1.0, got {sum_out}"
);
}
/// SP11 B1b smoke-recovery — EPS_DIV floor honored on zero variance
/// (cold start before Pearl A bootstraps any variance EMA).
///
/// Inputs: equal magnitudes, all variances = 0 ⇒ σ_floor = EPS_DIV =
/// 1e-6 ⇒ z[c] = mag[c] / 1e-6 = huge but proportional. Ratios still
/// normalize correctly.
#[test]
#[ignore = "requires GPU"]
fn mag_ratio_z_score_zero_variance_eps_floor_honored() {
let stream = make_test_stream();
let f = load_kernel(&stream, SP11_MAG_RATIO_CUBIN, "reward_component_mag_ratio_compute_kernel");
let scratch = unsafe { MappedF32Buffer::new(7) }
.expect("alloc scratch (7 f32)");
let mut isv = unsafe { MappedF32Buffer::new(ISV_TOTAL_DIM) }
.expect("alloc isv (367 f32)");
{
let s = isv.host_slice_mut();
// Magnitudes [1, 2, 3, 4, 5, 6] → Σmag = 21; under uniform σ-floor
// ratios reduce to (mag[c] / sum_mag).
s[POPART_COMPONENT_MAG_EMA_INDEX] = 1.0;
for c in 1..6 {
s[REWARD_POPART_EMA_INDEX + c] = (c + 1) as f32;
}
// All variances zero — kernel must apply EPS_DIV floor on σ.
s[REWARD_COMPONENT_VAR_EMA_BASE] = 0.0;
for c in 1..6 {
s[REWARD_COMPONENT_VAR_EMA_BASE + c] = 0.0;
}
}
launch_mag_ratio_kernel(&stream, &f, &isv, &scratch);
let out = scratch.read_all();
// Σ ratios = 1.0 ± epsilon — the canary stays well-defined under
// cold-start zero variance.
let sum_out: f32 = out[0..6].iter().sum();
assert!(
(sum_out - 1.0_f32).abs() < 1e-4,
"expected Σratios ≈ 1.0 under zero-variance EPS_DIV floor, got {sum_out}"
);
// Each ratio ∈ [0, 1].
for c in 0..6_usize {
assert!(
out[c] >= 0.0 && out[c] <= 1.0 + 1e-5,
"component {c} ratio = {} out of [0, 1]",
out[c]
);
}
// Under uniform EPS_DIV floor σ all components get the same scale
// multiplier ⇒ ratio[c] reduces to mag[c] / Σmag.
let sum_mag: f32 = 1.0 + 2.0 + 3.0 + 4.0 + 5.0 + 6.0;
for c in 0..6_usize {
let expected = (c as f32 + 1.0) / sum_mag;
assert!(
(out[c] - expected).abs() < 1e-4,
"component {c}: expected ratio {expected} (uniform EPS_DIV σ), got {}",
out[c]
);
}
}
// ── SP11 Task A2 — reward-subsystem controller GPU oracle tests ──────────
/// Populate ISV with the 5 controller canary inputs the kernel reads.
/// `mag_ratios` are written contiguously at REWARD_COMPONENT_MAG_RATIO_BASE
/// (slots [352..358) in the production layout); the val-sharpe Δ + var
/// canaries land at slots 350/351; saboteur engagement at 358; PnL EMA at
/// 359. Each test calls this with synthetic-but-physically-meaningful
/// values so the assertion targets are derivable from the kernel's
/// algebra in spec §3.4 — no CPU-reference oracle is required (and per
/// `feedback_no_cpu_test_fallbacks.md` would be forbidden anyway).
fn populate_controller_isv(
isv: &mut MappedF32Buffer,
delta: f32,
var: f32,
mag_ratios: [f32; 6],
saboteur_engagement: f32,
pnl_mag: f32,
) {
let h = isv.host_slice_mut();
h[VAL_SHARPE_DELTA_EMA_INDEX] = delta;
h[VAL_SHARPE_VAR_EMA_INDEX] = var;
for c in 0..6 {
h[REWARD_COMPONENT_MAG_RATIO_BASE + c] = mag_ratios[c];
}
h[SABOTEUR_ENGAGEMENT_RATE_INDEX] = saboteur_engagement;
h[PNL_REWARD_MAGNITUDE_EMA_INDEX] = pnl_mag;
}
/// Launch the controller producer with the standard launch config (1
/// block, 10 threads). Callers pre-populate `isv` via
/// `populate_controller_isv`; outputs land in `scratch[0..10)` per the
/// kernel signature (the launcher does NOT apply the kernel-side
/// scratch-base offset because the test allocates a fresh 10-element
/// scratch — the production launcher's offset arithmetic is exercised in
/// the integration-tier smoke tests).
fn launch_controller_kernel(
stream: &Arc<CudaStream>,
f: &CudaFunction,
isv: &MappedF32Buffer,
scratch: &MappedF32Buffer,
) {
let isv_dev = isv.dev_ptr;
let scratch_dev = scratch.dev_ptr;
let delta_ema_slot_i32: i32 = VAL_SHARPE_DELTA_EMA_INDEX as i32;
let var_ema_slot_i32: i32 = VAL_SHARPE_VAR_EMA_INDEX as i32;
let mag_ratio_base_slot_i32: i32 = REWARD_COMPONENT_MAG_RATIO_BASE as i32;
let saboteur_engagement_slot_i32: i32 = SABOTEUR_ENGAGEMENT_RATE_INDEX as i32;
let pnl_mag_ema_slot_i32: i32 = PNL_REWARD_MAGNITUDE_EMA_INDEX as i32;
unsafe {
stream
.launch_builder(f)
.arg(&isv_dev)
.arg(&scratch_dev)
.arg(&delta_ema_slot_i32)
.arg(&var_ema_slot_i32)
.arg(&mag_ratio_base_slot_i32)
.arg(&saboteur_engagement_slot_i32)
.arg(&pnl_mag_ema_slot_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (10, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch reward_subsystem_controller_kernel");
}
stream.synchronize().expect("sync after controller launch");
}
/// SP11 A2 — controller midpoint behavior at z=0.
///
/// Inputs:
/// delta = 0, var = 1 → dz = 0 / sqrt(1) = 0 → improving = sigmoid(0) = 0.5,
/// stagnant = 0.5
/// mag_ratios = [1/6, 1/6, 1/6, 1/6, 1/6, 1/6] → uniform, min_grad_ratio = 1/6
/// adaptive_floor = max(0.01, 0.5 × 1/6) = 0.0833...
/// pnl_mag = 1.0 → curiosity_bound = 1.0 × 0.3 = 0.3
/// curiosity_floor = 0.2 × 0.3 = 0.06
/// curiosity_dynamic = 0.5 × 0.3 = 0.15
/// curiosity_pressure = max(0.15, 0.06) = 0.15
/// saboteur_engagement = 0.5 → intensity_z_term = 0.5 + 1.5 × 0.5 = 1.25
/// modulated = 1.25 × max(0.5, 0.1) = 0.625
/// post-clamp [0.5, 2.0] = 0.625 (in range)
///
/// Per-component winner_weight = 1/6, diversifier = (1 - 1/6)/5 = 1/6;
/// blend = 0.5 × 1/6 + 0.5 × 1/6 = 1/6 each. After floor (0.0833 < 1/6) and
/// renorm to mean = 1 (Σ = N = 6), each weight becomes 1.0 (uniform pre-
/// renorm × 6/Σ = 1/6 × 6/1 = 1.0). Per spec §3.4.3 amended 2026-05-04.
#[test]
#[ignore = "requires GPU"]
fn controller_z_score_at_zero_yields_midpoint_outputs() {
let stream = make_test_stream();
let f = load_kernel(
&stream,
SP11_REWARD_SUBSYSTEM_CONTROLLER_CUBIN,
"reward_subsystem_controller_kernel",
);
let mut isv = unsafe { MappedF32Buffer::new(ISV_TOTAL_DIM) }
.expect("alloc isv (367 f32)");
populate_controller_isv(
&mut isv,
/*delta*/ 0.0,
/*var*/ 1.0,
/*mag_ratios*/ [1.0_f32 / 6.0; 6],
/*saboteur_engagement*/ 0.5,
/*pnl_mag*/ 1.0,
);
let scratch = unsafe { MappedF32Buffer::new(10) }
.expect("alloc scratch (10 f32)");
launch_controller_kernel(&stream, &f, &isv, &scratch);
let s = scratch.read_all();
// mean(weights) = 1.0 ± 1e-5 (Σweights = N = 6) — renormalization
// invariant per spec §3.4.3 amended 2026-05-04.
let weight_sum: f32 = s[0..6].iter().sum();
assert!(
(weight_sum - 6.0).abs() < 1e-5,
"weights sum = {weight_sum}, expected 6.0 (mean = 1.0)"
);
// Each weight at 1.0 (uniform input → uniform output through the
// 50/50 blend → renorm preserves uniformity at the mean=1 level).
for c in 0..6 {
assert!(
(s[c] - 1.0_f32).abs() < 1e-4,
"weight[{c}] = {}, expected 1.0 (uniform mean)",
s[c]
);
}
// Curiosity pressure at midpoint: dynamic dominates floor.
assert!(
(s[6] - 0.15).abs() < 1e-4,
"curiosity_pressure = {}, expected 0.15 (= 0.5 × 0.3)",
s[6]
);
// Saboteur intensity: 0.625 (in-range, post-clamp is identity).
assert!(
(s[7] - 0.625).abs() < 1e-3,
"saboteur_intensity = {}, expected 0.625",
s[7]
);
// Adaptive floor = max(WEIGHT_HARD_FLOOR=0.01, 0.5 × 1/6) = 0.0833...
let expected_floor = 0.5_f32 * (1.0_f32 / 6.0);
assert!(
(s[8] - expected_floor).abs() < 1e-4,
"adaptive_floor = {}, expected {expected_floor}",
s[8]
);
// Curiosity bound = 0.3 (= pnl_mag × CURIOSITY_BOUND_FRACTION).
assert!(
(s[9] - 0.3).abs() < 1e-5,
"curiosity_bound = {}, expected 0.3",
s[9]
);
}
/// SP11 A2 — weight renormalization works after the per-component floor.
///
/// Pathological mag_ratios: one large (0.95), five small (0.01). The
/// pre-floor blend with delta=1, var=1 (dz=1, improving≈0.731,
/// stagnant≈0.269) yields heavily skewed weights; the floor
/// (`0.5 × 0.01 = 0.005` ≤ WEIGHT_HARD_FLOOR=0.01, so adaptive_floor =
/// 0.01) clamps the small weights to 0.01; renorm to mean = 1 (Σ = N = 6)
/// then scales by 6/Σ_pre. With the dominant pre-renorm weight ≈ 0.697,
/// post-renorm w[0] = 4.18 which is above MAX_WEIGHT=3.0 — the
/// per-component cap binds, pulling Σ_post below the nominal 6.
///
/// The test asserts the structural envelope per spec §3.4.3 amended
/// 2026-05-04: each weight in [WEIGHT_HARD_FLOOR, MAX_WEIGHT], dominant
/// weight saturates at MAX_WEIGHT, and Σweights ≤ N + tolerance.
#[test]
#[ignore = "requires GPU"]
fn controller_weights_renormalize_after_floor() {
let stream = make_test_stream();
let f = load_kernel(
&stream,
SP11_REWARD_SUBSYSTEM_CONTROLLER_CUBIN,
"reward_subsystem_controller_kernel",
);
let mut isv = unsafe { MappedF32Buffer::new(ISV_TOTAL_DIM) }
.expect("alloc isv (367 f32)");
populate_controller_isv(
&mut isv,
/*delta*/ 1.0, // any non-zero
/*var*/ 1.0,
/*mag_ratios*/ [0.95_f32, 0.01, 0.01, 0.01, 0.01, 0.01],
/*saboteur_engagement*/ 0.5,
/*pnl_mag*/ 1.0,
);
let scratch = unsafe { MappedF32Buffer::new(10) }
.expect("alloc scratch (10 f32)");
launch_controller_kernel(&stream, &f, &isv, &scratch);
let s = scratch.read_all();
// Renormalization invariant: Σweights ≤ N (= 6.0). PopArt's reward-
// magnitude EMA assumes a consistent reward-scale; per spec §3.4.3
// amended 2026-05-04 the controller normalises to mean=1 (Σ=N), with
// a per-component MAX_WEIGHT=3.0 cap that may pull Σ_post below N
// when one component dominates pre-renorm — as it does here (w[0]
// would be ≈ 4.18 without the cap). The cap is structural, not a
// soft suggestion; clipping below 6 is the expected behaviour.
let weight_sum: f32 = s[0..6].iter().sum();
assert!(
weight_sum <= 6.0 + 1e-5,
"weights sum = {weight_sum} after renormalization, expected ≤ 6.0 (mean ≤ 1.0)"
);
// No weight below WEIGHT_HARD_FLOOR=0.01 after renorm. With one
// weight dominating (0.95-derived blend ≈ 0.697) and five at floor
// (≈ 0.0606), blend_sum ≈ 1.0; scale = 6/1.0 = 6.0; floored weights
// post-renorm = 0.0606 × 6 ≈ 0.36 — comfortably above 0.01. The
// lower-bound check `>= 0.01 - tolerance` validates the floor's
// structural guarantee. The companion upper-bound check (≤
// MAX_WEIGHT) validates the new cap.
for c in 0..6 {
assert!(
s[c] >= 0.01 - 1e-5,
"weight[{c}] = {} below WEIGHT_HARD_FLOOR=0.01 after renorm",
s[c]
);
assert!(
s[c] <= 3.0 + 1e-5,
"weight[{c}] = {} above MAX_WEIGHT=3.0 after renorm",
s[c]
);
}
// Dominant component saturates at MAX_WEIGHT — the cap is binding
// for this pathological input, demonstrating the cap mechanism.
assert!(
(s[0] - 3.0).abs() < 1e-4,
"dominant weight[0] = {}, expected MAX_WEIGHT=3.0 (cap should bind)",
s[0]
);
}
/// SP11 A2 — saboteur post-clamp holds SABOTEUR_MIN at extreme regression.
///
/// Inputs:
/// delta = -100, var = 1 → dz = -100 / 1 = -100 → improving ≈ 0,
/// stagnant ≈ 1
/// intensity_z_term = 0.5 + 1.5 × 0 = 0.5
/// saboteur_engagement = 0.0 → max(0.0, 0.1) = 0.1 (engagement floor)
/// modulated = 0.5 × 0.1 = 0.05
/// post-clamp = max(0.5, min(2.0, 0.05)) = 0.5 ← SABOTEUR_MIN
///
/// Without the post-clamp the engagement floor would silently pull the
/// output to 0.05, well below SABOTEUR_MIN=0.5. The post-clamp is the
/// non-negotiable guarantee per spec §3.4 — this test fails if the
/// kernel ever drops the post-clamp.
///
/// Curiosity at extreme regression: stagnant ≈ 1 → curiosity_dynamic ≈
/// 0.3; curiosity_floor = 0.06; pressure = max(0.3, 0.06) = 0.3 — the
/// permanent-floor pattern protects the high-stagnation peak from
/// numerical dips.
#[test]
#[ignore = "requires GPU"]
fn controller_saboteur_post_clamp_holds_min() {
let stream = make_test_stream();
let f = load_kernel(
&stream,
SP11_REWARD_SUBSYSTEM_CONTROLLER_CUBIN,
"reward_subsystem_controller_kernel",
);
let mut isv = unsafe { MappedF32Buffer::new(ISV_TOTAL_DIM) }
.expect("alloc isv (367 f32)");
populate_controller_isv(
&mut isv,
/*delta*/ -100.0, // extreme regression
/*var*/ 1.0,
/*mag_ratios*/ [1.0_f32 / 6.0; 6], // uniform — keeps the test focused on saboteur
/*saboteur_engagement*/ 0.0, // tests engagement floor
/*pnl_mag*/ 1.0,
);
let scratch = unsafe { MappedF32Buffer::new(10) }
.expect("alloc scratch (10 f32)");
launch_controller_kernel(&stream, &f, &isv, &scratch);
let s = scratch.read_all();
// Saboteur post-clamp at SABOTEUR_MIN=0.5. Without the post-clamp the
// engagement floor (0.1) would multiply intensity_z_term=0.5 to give
// 0.05 — well below 0.5.
assert!(
(s[7] - 0.5).abs() < 1e-4,
"saboteur_intensity = {}, expected SABOTEUR_MIN=0.5 after post-clamp",
s[7]
);
// Curiosity at max regression: stagnant ≈ 1.0 → curiosity_dynamic ≈
// curiosity_bound = 0.3. Permanent floor 0.06 below that → max() =
// 0.3.
assert!(
(s[6] - 0.3).abs() < 1e-3,
"curiosity_pressure at max regression = {}, expected ≈ 0.3 (curiosity_bound)",
s[6]
);
}
// ── SP11 B1b smoke-recovery — popart_component_ema Welford variance ────
/// Helper to launch the popart_component_ema kernel with the standard
/// (popart_buf, scratch, n_samples) signature. BLOCK_SIZE=256.
fn launch_popart_component_ema(
stream: &Arc<CudaStream>,
f: &CudaFunction,
popart_buf: &MappedF32Buffer,
scratch: &MappedF32Buffer,
n_samples: i32,
) {
let popart_dev = popart_buf.dev_ptr;
let scratch_dev = scratch.dev_ptr;
unsafe {
stream
.launch_builder(f)
.arg(&popart_dev)
.arg(&scratch_dev)
.arg(&n_samples)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 256 * std::mem::size_of::<f32>() as u32,
})
.expect("launch popart_component_ema_kernel");
}
stream.synchronize().expect("sync after popart_component_ema launch");
}
/// SP11 B1b smoke-recovery — popart_component_ema_kernel writes both
/// mean(|x|) AND var(|x|) via single-pass two-tree-reduce Welford.
///
/// Constant-input case: input = [1.0, 1.0, 1.0, 1.0] ⇒ |x| = 1 ∀i ⇒
/// mean = 1.0, var = 0.0 (no deviation).
#[test]
#[ignore = "requires GPU"]
fn popart_component_ema_welford_constant_input_zero_variance() {
let stream = make_test_stream();
let f = load_kernel(&stream, SP11_POPART_COMPONENT_EMA_CUBIN, "popart_component_ema_kernel");
let popart = unsafe { MappedF32Buffer::new(4) }
.expect("alloc popart buf (4 f32)");
popart.write_from_slice(&[1.0_f32, 1.0, 1.0, 1.0]);
let scratch = unsafe { MappedF32Buffer::new(2) }
.expect("alloc scratch (2 f32 — mean + var)");
launch_popart_component_ema(&stream, &f, &popart, &scratch, 4);
let out = scratch.read_all();
assert!(
(out[0] - 1.0_f32).abs() < 1e-6,
"expected mean = 1.0 on constant input, got {}",
out[0]
);
assert!(
out[1].abs() < 1e-6,
"expected var = 0.0 on constant input, got {}",
out[1]
);
}
/// SP11 B1b smoke-recovery — Welford variance correctness on bimodal
/// input.
///
/// Input = [0.0, 2.0, 0.0, 2.0] ⇒ |x| = [0, 2, 0, 2] ⇒ mean = 1.0;
/// deviations = [-1, 1, -1, 1] ⇒ var = (1 + 1 + 1 + 1) / 4 = 1.0.
#[test]
#[ignore = "requires GPU"]
fn popart_component_ema_welford_bimodal_input_unit_variance() {
let stream = make_test_stream();
let f = load_kernel(&stream, SP11_POPART_COMPONENT_EMA_CUBIN, "popart_component_ema_kernel");
let popart = unsafe { MappedF32Buffer::new(4) }
.expect("alloc popart buf (4 f32)");
popart.write_from_slice(&[0.0_f32, 2.0, 0.0, 2.0]);
let scratch = unsafe { MappedF32Buffer::new(2) }
.expect("alloc scratch (2 f32 — mean + var)");
launch_popart_component_ema(&stream, &f, &popart, &scratch, 4);
let out = scratch.read_all();
assert!(
(out[0] - 1.0_f32).abs() < 1e-6,
"expected mean = 1.0 on bimodal input, got {}",
out[0]
);
assert!(
(out[1] - 1.0_f32).abs() < 1e-5,
"expected var = 1.0 on bimodal input (Welford), got {}",
out[1]
);
}