Files
foxhunt/crates/ml/tests/sp20_event_reward_test.rs
jgrusewski eaaab152fc feat(sp20): Phase 2 Task 2.1 — sp20_compute_event_reward + 8 GPU oracle tests
Lands the SP20 Component 1 reward math as a header-only `__device__
__forceinline__` function in a new `sp20_reward.cuh` header, plus the
GPU oracle test scaffold for the 4-quadrant truth table per spec §4.1.

* `sp20_reward.cuh` — `sp20_compute_event_reward(close_pnl,
  label_at_open_sign, loss_cap)` function. Returns bounded scalar
  `R_event ∈ [loss_cap, +1.0]`. 4-quadrant table:
    +1, +1 →  +1.0  (right reason, right outcome)
    +1, -1 →  +0.5  (wrong reason, right outcome)
    -1, -1 →  -0.5  (right reason, wrong outcome)
    -1, +1 →  loss_cap  (wrong reason, wrong outcome — ISV-driven)
     0, *  →   0.0  (no information — close_pnl == 0)

  Sentinel `label_at_open_sign == 0` (Task 2.0 contract) maps to
  dir_match=false ⇒ wrong-reason quadrant. Documented as the safer
  default in the header comment.

* `sp20_event_reward_test_kernel.cu` — standalone single-thread test
  wrapper, mirrors the `sp12_reward_math_test_kernel.cu` /
  `thompson_test_kernel.cu` pattern. Outputs to a mapped-pinned [1]
  f32 with `__threadfence_system()` for PCIe-visible coherence.

* `tests/sp20_event_reward_test.rs` — 8 GPU oracle tests
  (`#[ignore = "requires GPU"]`-gated):
    1. quadrant_right_reason_right_outcome
    2. quadrant_wrong_reason_right_outcome
    3. quadrant_right_reason_wrong_outcome
    4. quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_1
    5. quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_2
    6. zero_pnl_returns_zero
    7. sentinel_label_winning_trade_lands_shoulder (Task 2.0 contract)
    8. sentinel_label_losing_trade_lands_loss_cap (Task 2.0 contract)

* `experience_kernels.cu` — `#include "sp20_reward.cuh"` so Task 2.2's
  trade-close site replacement has the function in scope.

* `build.rs` — `sp20_event_reward_test_kernel.cu` cubin entry.

No production callers in this commit. Task 2.2 atomically:
  - replaces the SP12 v3 reward block at experience_kernels.cu:3216-3380
    with the SP20 4-quadrant reward;
  - adds the `is_close` consumer of `label_at_open_per_env[i]` (Task
    2.0's per-env scratch);
  - threads per-env alpha through the SP20 aggregation kernel,
    making `alpha_ema` non-zero post-trade-close (replacing the
    Phase 1.4 0.0 forward-reference placeholder).

Per `feedback_no_partial_refactor.md` the device function ships
BEFORE the production caller so Task 2.1's GPU oracle tests can
exercise the math in isolation; the partial refactor that breaks
the no-partial-refactor rule is "feature behind the function with
no caller", which this commit's tests resolve in scope.

Per `feedback_no_cpu_test_fallbacks.md` GPU oracle only — no CPU
reference impl. Per `feedback_no_htod_htoh_only_mapped_pinned.md`
all CPU↔GPU buffers are mapped-pinned (`MappedF32Buffer`).

Verification:
  SQLX_OFFLINE=true cargo check -p ml --tests --features cuda  # clean

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

255 lines
9.8 KiB
Rust

#![allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
//! SP20 Phase 2 Task 2.1 — `sp20_compute_event_reward` GPU oracle tests.
//!
//! Validates the 4-quadrant directional-correctness event reward defined
//! in `crates/ml/src/cuda_pipeline/sp20_reward.cuh` (spec §4.1). Each
//! test drives a synthetic `(close_pnl, label_at_open_sign, loss_cap)`
//! triple through the production device function via the standalone
//! wrapper `sp20_event_reward_test_kernel.cu` and verifies the
//! GPU-computed result against the analytically-known expected value.
//! Per `feedback_no_cpu_test_fallbacks`: GPU oracle only, no CPU
//! reference impl.
//!
//! 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 sp20_event_reward_test --features cuda \
//! -- --ignored --nocapture
//!
//! ## 4-quadrant truth table (spec §4.1)
//!
//! | `pnl_sign` | `dir_match` | `R_event` | Test |
//! |------------|--------------------|---------------|--------------------------------------------------------------|
//! | `+1` | `true` | `+1.0` | `right_reason_right_outcome` |
//! | `+1` | `false` | `+0.5` | `wrong_reason_right_outcome` |
//! | `-1` | `true` | `-0.5` | `right_reason_wrong_outcome` |
//! | `-1` | `false` | `loss_cap` | `wrong_reason_wrong_outcome_loss_cap_at_minus_1` / `..._minus_2` |
//! | `0` | `*` | `0.0` | `zero_pnl_returns_zero` |
#![cfg(feature = "cuda")]
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
/// Test-only cubin built by `crates/ml/build.rs`.
const SP20_EVENT_REWARD_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/sp20_event_reward_test_kernel.cubin"
));
/// Resolve a CUDA stream against device 0. Mirrors `make_test_stream` in
/// every other oracle test in this crate.
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
/// Load the test kernel function from the embedded cubin.
fn load_test_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP20_EVENT_REWARD_CUBIN.to_vec())
.expect("load sp20_event_reward_test_kernel cubin");
module
.load_function("sp20_event_reward_test_kernel")
.expect("load sp20_event_reward_test_kernel function")
}
/// Drive one `(close_pnl, label_at_open_sign, loss_cap) → R_event`
/// triple through the device function. Single-thread / single-block
/// launch — the function is pure register arithmetic, no parallelism
/// to exploit.
///
/// Returns the GPU-computed `R_event` scalar.
fn launch_sp20_event_reward(
stream: &Arc<CudaStream>,
f: &CudaFunction,
close_pnl: f32,
label_at_open_sign: i32,
loss_cap: f32,
) -> f32 {
let out = unsafe { MappedF32Buffer::new(1) }.expect("alloc out_r_event (1 f32)");
out.write_from_slice(&[f32::NAN]); // poison-fill so test fails loud if kernel didn't write
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let out_dev = out.dev_ptr;
unsafe {
stream
.launch_builder(f)
.arg(&close_pnl)
.arg(&label_at_open_sign)
.arg(&loss_cap)
.arg(&out_dev)
.launch(cfg)
.expect("launch sp20_event_reward_test_kernel");
}
stream
.synchronize()
.expect("sync after sp20_event_reward_test_kernel");
out.read_all()[0]
}
// ── 4-quadrant truth table ─────────────────────────────────────────────
/// Quadrant 1: `pnl_sign=+1`, `dir_match=true` ⇒ `R_event = +SP20_WIN_CAP = +1.0`.
/// "Right reason for right outcome" — the canonical maximum reward.
#[test]
#[ignore = "requires GPU"]
fn sp20_event_reward_quadrant_right_reason_right_outcome() {
let stream = make_test_stream();
let f = load_test_kernel(&stream);
let r = launch_sp20_event_reward(
&stream, &f,
/* close_pnl =*/ 0.5, /* label_at_open_sign =*/ 1, /* loss_cap =*/ -1.0,
);
assert!(
(r - 1.0).abs() < 1e-6,
"expected R_event = +1.0 (right reason, right outcome); got {r}",
);
}
/// Quadrant 2: `pnl_sign=+1`, `dir_match=false` ⇒ `R_event = +0.5`.
/// "Wrong reason, right outcome" — partial reward for a winning trade
/// the aux head did NOT predict (lucky win).
#[test]
#[ignore = "requires GPU"]
fn sp20_event_reward_quadrant_wrong_reason_right_outcome() {
let stream = make_test_stream();
let f = load_test_kernel(&stream);
let r = launch_sp20_event_reward(
&stream, &f,
/* close_pnl =*/ 0.3, /* label_at_open_sign =*/ -1, /* loss_cap =*/ -1.0,
);
assert!(
(r - 0.5).abs() < 1e-6,
"expected R_event = +0.5 (wrong reason, right outcome); got {r}",
);
}
/// Quadrant 3: `pnl_sign=-1`, `dir_match=true` ⇒ `R_event = -0.5`.
/// "Right reason, wrong outcome" — partial penalty for a losing trade
/// the aux head DID predict correctly (correct call, market disagreed).
#[test]
#[ignore = "requires GPU"]
fn sp20_event_reward_quadrant_right_reason_wrong_outcome() {
let stream = make_test_stream();
let f = load_test_kernel(&stream);
let r = launch_sp20_event_reward(
&stream, &f,
/* close_pnl =*/ -0.2, /* label_at_open_sign =*/ -1, /* loss_cap =*/ -1.0,
);
assert!(
(r - (-0.5)).abs() < 1e-6,
"expected R_event = -0.5 (right reason, wrong outcome); got {r}",
);
}
/// Quadrant 4 cold-start: `pnl_sign=-1`, `dir_match=false`,
/// `loss_cap=-1.0` (early training, WR_EMA below 50%) ⇒ `R_event = -1.0`.
/// "Wrong reason, wrong outcome" — full loss cap.
#[test]
#[ignore = "requires GPU"]
fn sp20_event_reward_quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_1() {
let stream = make_test_stream();
let f = load_test_kernel(&stream);
let r = launch_sp20_event_reward(
&stream, &f,
/* close_pnl =*/ -0.4, /* label_at_open_sign =*/ 1, /* loss_cap =*/ -1.0,
);
assert!(
(r - (-1.0)).abs() < 1e-6,
"expected R_event = -1.0 (wrong reason, wrong outcome at cold-start cap); got {r}",
);
}
/// Quadrant 4 mature: `pnl_sign=-1`, `dir_match=false`, `loss_cap=-2.0`
/// (WR_EMA at or above 55%) ⇒ `R_event = -2.0`. Full mature loss cap.
/// Validates that `loss_cap` is correctly threaded through, not
/// hard-coded inside the device function.
#[test]
#[ignore = "requires GPU"]
fn sp20_event_reward_quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_2() {
let stream = make_test_stream();
let f = load_test_kernel(&stream);
let r = launch_sp20_event_reward(
&stream, &f,
/* close_pnl =*/ -0.4, /* label_at_open_sign =*/ 1, /* loss_cap =*/ -2.0,
);
assert!(
(r - (-2.0)).abs() < 1e-6,
"expected R_event = -2.0 (wrong reason, wrong outcome at mature cap); got {r}",
);
}
/// Edge case: `close_pnl == 0.0` ⇒ `R_event = 0.0` regardless of
/// `label_at_open_sign` / `loss_cap` (no information case per spec
/// §4.1). Validates the early-return path.
#[test]
#[ignore = "requires GPU"]
fn sp20_event_reward_zero_pnl_returns_zero() {
let stream = make_test_stream();
let f = load_test_kernel(&stream);
let r = launch_sp20_event_reward(
&stream, &f,
/* close_pnl =*/ 0.0, /* label_at_open_sign =*/ 1, /* loss_cap =*/ -1.0,
);
assert!(
r.abs() < 1e-6,
"expected R_event = 0.0 (close_pnl == 0 / no information); got {r}",
);
}
// ── Sentinel-handling sanity (Task 2.0 contract) ───────────────────────
/// `label_at_open_sign == 0` (sentinel: no aux signal at trade open) +
/// `pnl_sign == +1` ⇒ `dir_match = false` ⇒ `R_event = +0.5` (wrong-
/// reason right-outcome shoulder). Validates the Task 2.0 sentinel
/// contract: when the aux skip-window or no-position-yet sentinel
/// reaches the reward function, the wrong-reason path lands the +0.5
/// shoulder on a winning trade.
#[test]
#[ignore = "requires GPU"]
fn sp20_event_reward_sentinel_label_winning_trade_lands_shoulder() {
let stream = make_test_stream();
let f = load_test_kernel(&stream);
let r = launch_sp20_event_reward(
&stream, &f,
/* close_pnl =*/ 0.7, /* label_at_open_sign =*/ 0, /* loss_cap =*/ -1.0,
);
assert!(
(r - 0.5).abs() < 1e-6,
"expected R_event = +0.5 (sentinel label + winning trade ⇒ wrong-reason shoulder); got {r}",
);
}
/// `label_at_open_sign == 0` (sentinel) + `pnl_sign == -1` ⇒
/// `dir_match = false` ⇒ `R_event = loss_cap` (wrong-reason
/// wrong-outcome quadrant). Validates the safer-default semantic for
/// no-aux-signal losing trades (full loss cap, NOT the right-reason
/// -0.5 shoulder).
#[test]
#[ignore = "requires GPU"]
fn sp20_event_reward_sentinel_label_losing_trade_lands_loss_cap() {
let stream = make_test_stream();
let f = load_test_kernel(&stream);
let r = launch_sp20_event_reward(
&stream, &f,
/* close_pnl =*/ -0.4, /* label_at_open_sign =*/ 0, /* loss_cap =*/ -1.5,
);
assert!(
(r - (-1.5)).abs() < 1e-6,
"expected R_event = loss_cap = -1.5 (sentinel label + losing trade ⇒ wrong-reason wrong-outcome); got {r}",
);
}