Closes the Phase 3.2 forward-reference loop in the SP20 aggregator.
Previously `out_inputs->per_bar_hold_reward = 0.0f` was hardcoded; the
per-bar Hold opportunity-cost producer existed
(experience_kernels.cu:3823 — `per_bar_opp_cost = -aux_conf × cost_scale`)
and wrote to `hold_baseline_buffer` and `r_micro` directly, but never
reached HOLD_REWARD_EMA. Result: HOLD_REWARD_EMA frozen at sentinel 0.0
across all observed epochs; the SP20 reward centering loop
(`r_micro += per_bar_opp_cost - HOLD_REWARD_EMA`) stayed uncentered,
biasing the policy's reward signal away from zero-mean.
T3.3 wireup mirrors the T2.2 alpha refactor: a per-env scratch buffer
that the producer writes at every step (alongside the existing
`hold_baseline_buffer` write), and the aggregator reads at the same
step. Sums opp_cost over Hold-direction envs only; emits the mean as
`per_bar_hold_reward`. The HOLD_REWARD_EMA's gate (`hold_fraction > 0.5f`
from T3.2) preserves the strict-majority semantic from before.
Atomic across producer site, kernel signature, aggregator, launcher,
collector, and tests (per feedback_no_partial_refactor):
- experience_kernels.cu — new `float* per_bar_opp_cost_per_env`
kernel arg, NULL-tolerant write at line ~3823.
- sp20_aggregate_inputs_kernel.cu — new arg, 6th shmem stripe
(`sh_opp_cost_sum`), per-thread Hold-gated accumulation, tree
reduction extended to 6 stripes, output `per_bar_hold_reward =
opp_cost_sum / hold_count` when hold_count > 0 else 0.
- sp20_aggregate_inputs.rs — launcher signature, dynamic_shmem_bytes
5→6 stripes, doc table, internal test.
- gpu_experience_collector.rs — new `per_bar_opp_cost_per_env:
CudaSlice<f32>` field, alloc, struct construction, kernel-arg
pass at both experience_env_step and sp20_aggregate_inputs
launches (raw_ptr).
- sp20_aggregate_inputs_test.rs — helper renamed
`run_kernel_with_is_win_and_opp_cost`, 4 existing sites pass
NULL, 2 NEW oracle tests verifying real producer + NULL fallback.
- sp20_phase1_4_wireup_test.rs — NULL fallback at the wireup site;
HOLD_REWARD_EMA-stays-at-zero assertion remains valid.
Verification:
- cargo check -p ml --tests: passes (warnings only)
- cargo test -p ml --test sp20_aggregate_inputs_test --features cuda
-- --ignored: 12/12 GPU oracle tests pass on RTX 3050 Ti, including
both new T3.3 tests (per_bar_hold_reward_means_over_hold_envs_only,
null_per_bar_opp_cost_emits_zero).
- cargo test -p ml --test sp20_phase1_4_wireup_test --features cuda
-- --ignored: 2/2 pass under the new NULL-tolerant contract.
Plan reference: docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md
Tier 3 status: T3.1 ✓, T3.2 ✓, T3.3 ✓ (this commit), T3.4 withdrawn,
T3.5 cascade-pending, T3.6 withdrawn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
731 lines
33 KiB
Rust
731 lines
33 KiB
Rust
//! SP20 Phase 1.4 (2026-05-09) — `sp20_aggregate_inputs_kernel` GPU oracle tests.
|
||
//!
|
||
//! Validates the per-env → SP20EmaInputs aggregation rules atomically
|
||
//! with the Path C buffer-arg refactor of `sp20_emas_compute_kernel`.
|
||
//! The aggregation kernel is the GPU-side replacement for what would
|
||
//! otherwise need a host-side reduction over `trade_close_per_sample`,
|
||
//! `step_ret_per_sample`, etc. — forbidden by `feedback_cpu_is_read_only`
|
||
//! + `feedback_no_htod_htoh_only_mapped_pinned`.
|
||
//!
|
||
//! Each test constructs synthetic per-env arrays in mapped-pinned f32/i32
|
||
//! buffers, primes the upstream stats / dir_acc inputs, launches the
|
||
//! aggregation kernel, then reads back the resulting `SP20EmaInputs`
|
||
//! struct via the same f32-aliased buffer trick the EMA tests use.
|
||
//!
|
||
//! Run on a GPU host:
|
||
//!
|
||
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
|
||
//! cargo test -p ml --test sp20_aggregate_inputs_test --features cuda \
|
||
//! -- --ignored --nocapture
|
||
|
||
#![allow(clippy::tests_outside_test_module)]
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
mod gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream};
|
||
use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
|
||
use ml::cuda_pipeline::sp20_aggregate_inputs::{
|
||
launch_sp20_aggregate_inputs, SP20_EMA_INPUTS_F32_LEN,
|
||
};
|
||
|
||
const SP20_AGGREGATE_INPUTS_CUBIN: &[u8] = include_bytes!(concat!(
|
||
env!("OUT_DIR"),
|
||
"/sp20_aggregate_inputs_kernel.cubin"
|
||
));
|
||
|
||
/// Production direction divisor (`NUM_MAGNITUDES * NUM_ORD * NUM_URG = 27`)
|
||
/// matching `state_layout.cuh` and `experience_action_select`.
|
||
const DIR_DIVISOR: i32 = 27;
|
||
|
||
/// `DIR_HOLD = 1` per `state_layout.cuh`. The aggregation kernel
|
||
/// flags `action_is_hold` when the majority of envs decode to this
|
||
/// direction.
|
||
const HOLD_ACTION_ID: i32 = 1;
|
||
|
||
/// Encode a packed factored action with the given direction.
|
||
/// `dir * 27 + 0` lands at the `mag=0, ord=0, urg=0` slot of the
|
||
/// chosen direction — the aggregation kernel only cares about the
|
||
/// direction component.
|
||
fn encode_dir(dir: i32) -> i32 {
|
||
dir * DIR_DIVISOR
|
||
}
|
||
|
||
fn make_test_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_aggregation_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(SP20_AGGREGATE_INPUTS_CUBIN.to_vec())
|
||
.expect("load sp20_aggregate_inputs_kernel cubin");
|
||
module
|
||
.load_function("sp20_aggregate_inputs_kernel")
|
||
.expect("load sp20_aggregate_inputs_kernel function")
|
||
}
|
||
|
||
/// Read the post-launch `SP20EmaInputs` struct out of the f32-aliased
|
||
/// buffer. Returns `(is_close, win_fraction, trade_duration,
|
||
/// hold_fraction, alpha, per_bar_hold_reward, p50, std, dir_acc)`.
|
||
/// SP21 T3.1+T3.2 (2026-05-10): slots 1 and 3 are native f32
|
||
/// (`win_fraction`, `hold_fraction`) — were binary `is_win` /
|
||
/// `action_is_hold` previously.
|
||
fn read_ema_inputs(buf: &MappedF32Buffer) -> (i32, f32, i32, f32, f32, f32, f32, f32, f32) {
|
||
let v = buf.read_all();
|
||
assert_eq!(v.len(), SP20_EMA_INPUTS_F32_LEN);
|
||
// 2 i32 fields + 7 f32 fields. i32 fields come back via to_bits;
|
||
// f32 fields are direct reads.
|
||
let is_close = v[0].to_bits() as i32;
|
||
let win_fraction = v[1];
|
||
let trade_duration = v[2].to_bits() as i32;
|
||
let hold_fraction = v[3];
|
||
let alpha = v[4];
|
||
let per_bar_hr = v[5];
|
||
let p50 = v[6];
|
||
let std = v[7];
|
||
let dir_acc = v[8];
|
||
(is_close, win_fraction, trade_duration, hold_fraction,
|
||
alpha, per_bar_hr, p50, std, dir_acc)
|
||
}
|
||
|
||
/// Construct a 1-stride arrangement (one slice per env at offset
|
||
/// `env_id * 1`). This mirrors a per-step slice that has already
|
||
/// been offset to the current timestep — the production wire-up
|
||
/// passes `base + current_t` so the kernel reads `[env*L + 0]`.
|
||
/// In tests we use stride=1 and pack envs contiguously.
|
||
fn run_kernel(
|
||
trade_close: &[i32],
|
||
step_ret: &[f32],
|
||
hold_at_exit: &[f32],
|
||
actions: &[i32],
|
||
aux_logits_p50: f32,
|
||
aux_logits_std: f32,
|
||
aux_dir_acc: f32,
|
||
) -> (i32, f32, i32, f32, f32, f32, f32, f32, f32) {
|
||
let stream = make_test_stream();
|
||
let kernel = load_aggregation_kernel(&stream);
|
||
let n = trade_close.len();
|
||
assert!(n > 0, "test n_envs must be > 0");
|
||
assert_eq!(step_ret.len(), n);
|
||
assert_eq!(hold_at_exit.len(), n);
|
||
assert_eq!(actions.len(), n);
|
||
|
||
let tc_buf = unsafe { MappedI32Buffer::new(n) }.expect("alloc tc");
|
||
tc_buf.write_from_slice(trade_close);
|
||
let sr_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc sr");
|
||
sr_buf.write_from_slice(step_ret);
|
||
let he_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc he");
|
||
he_buf.write_from_slice(hold_at_exit);
|
||
let act_buf = unsafe { MappedI32Buffer::new(n) }.expect("alloc act");
|
||
act_buf.write_from_slice(actions);
|
||
|
||
let p50_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc p50");
|
||
p50_buf.write_from_slice(&[aux_logits_p50]);
|
||
let std_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc std");
|
||
std_buf.write_from_slice(&[aux_logits_std]);
|
||
let dir_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc dir");
|
||
dir_buf.write_from_slice(&[aux_dir_acc]);
|
||
|
||
let out_buf = unsafe { MappedF32Buffer::new(SP20_EMA_INPUTS_F32_LEN) }
|
||
.expect("alloc out");
|
||
// Pre-fill with sentinel so we can detect a kernel that fails to write.
|
||
out_buf.write_from_slice(&[f32::NAN; SP20_EMA_INPUTS_F32_LEN]);
|
||
|
||
unsafe {
|
||
launch_sp20_aggregate_inputs(
|
||
&stream,
|
||
&kernel,
|
||
tc_buf.dev_ptr,
|
||
sr_buf.dev_ptr,
|
||
he_buf.dev_ptr,
|
||
act_buf.dev_ptr,
|
||
/* env_stride = */ 1,
|
||
p50_buf.dev_ptr,
|
||
std_buf.dev_ptr,
|
||
dir_buf.dev_ptr,
|
||
/* alpha_per_env_dev = */ 0, // NULL — test scaffold without SP20 reward producer
|
||
/* is_win_per_env_dev = */ 0, // NULL — falls back to `sr > 0` legacy predicate
|
||
/* per_bar_opp_cost_per_env_dev = */ 0, // SP21 T3.3 NULL — emits per_bar_hold_reward=0
|
||
/* n_envs = */ n as i32,
|
||
DIR_DIVISOR,
|
||
HOLD_ACTION_ID,
|
||
out_buf.dev_ptr,
|
||
).expect("launch aggregate kernel");
|
||
}
|
||
stream.synchronize().expect("sync after aggregate");
|
||
read_ema_inputs(&out_buf)
|
||
}
|
||
|
||
/// Variant of `run_kernel` that wires the SP20 Phase 2 Task 2.2
|
||
/// per-env alpha producer (`alpha_per_env: &[f32]`). Used by
|
||
/// `alpha_mean_over_closed_envs_phase_2_contract` to validate the
|
||
/// new alpha aggregation rule (mean over closed envs).
|
||
fn run_kernel_with_alpha(
|
||
trade_close: &[i32],
|
||
step_ret: &[f32],
|
||
hold_at_exit: &[f32],
|
||
actions: &[i32],
|
||
alpha_per_env: &[f32],
|
||
aux_logits_p50: f32,
|
||
aux_logits_std: f32,
|
||
aux_dir_acc: f32,
|
||
) -> (i32, f32, i32, f32, f32, f32, f32, f32, f32) {
|
||
let stream = make_test_stream();
|
||
let kernel = load_aggregation_kernel(&stream);
|
||
let n = trade_close.len();
|
||
assert!(n > 0, "test n_envs must be > 0");
|
||
assert_eq!(step_ret.len(), n);
|
||
assert_eq!(hold_at_exit.len(), n);
|
||
assert_eq!(actions.len(), n);
|
||
assert_eq!(alpha_per_env.len(), n);
|
||
|
||
let tc_buf = unsafe { MappedI32Buffer::new(n) }.expect("alloc tc");
|
||
tc_buf.write_from_slice(trade_close);
|
||
let sr_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc sr");
|
||
sr_buf.write_from_slice(step_ret);
|
||
let he_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc he");
|
||
he_buf.write_from_slice(hold_at_exit);
|
||
let act_buf = unsafe { MappedI32Buffer::new(n) }.expect("alloc act");
|
||
act_buf.write_from_slice(actions);
|
||
let alpha_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc alpha");
|
||
alpha_buf.write_from_slice(alpha_per_env);
|
||
|
||
let p50_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc p50");
|
||
p50_buf.write_from_slice(&[aux_logits_p50]);
|
||
let std_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc std");
|
||
std_buf.write_from_slice(&[aux_logits_std]);
|
||
let dir_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc dir");
|
||
dir_buf.write_from_slice(&[aux_dir_acc]);
|
||
|
||
let out_buf = unsafe { MappedF32Buffer::new(SP20_EMA_INPUTS_F32_LEN) }
|
||
.expect("alloc out");
|
||
out_buf.write_from_slice(&[f32::NAN; SP20_EMA_INPUTS_F32_LEN]);
|
||
|
||
unsafe {
|
||
launch_sp20_aggregate_inputs(
|
||
&stream,
|
||
&kernel,
|
||
tc_buf.dev_ptr,
|
||
sr_buf.dev_ptr,
|
||
he_buf.dev_ptr,
|
||
act_buf.dev_ptr,
|
||
/* env_stride = */ 1,
|
||
p50_buf.dev_ptr,
|
||
std_buf.dev_ptr,
|
||
dir_buf.dev_ptr,
|
||
alpha_buf.dev_ptr, // SP20 Phase 2 Task 2.2 alpha producer
|
||
/* is_win_per_env_dev = */ 0, // NULL — alpha-only variant; legacy `sr > 0` predicate
|
||
/* per_bar_opp_cost_per_env_dev = */ 0, // SP21 T3.3 NULL — emits per_bar_hold_reward=0
|
||
/* n_envs = */ n as i32,
|
||
DIR_DIVISOR,
|
||
HOLD_ACTION_ID,
|
||
out_buf.dev_ptr,
|
||
).expect("launch aggregate kernel");
|
||
}
|
||
stream.synchronize().expect("sync after aggregate");
|
||
read_ema_inputs(&out_buf)
|
||
}
|
||
|
||
/// SP20 Phase 2 Task 2.2-fix (2026-05-10) variant of `run_kernel`
|
||
/// that exercises the new `is_win_per_env` per-env trade-close win
|
||
/// flag (i32, 1 = segment_return > 0, 0 = otherwise). Used by
|
||
/// `wr_ema_uses_segment_pnl_via_is_win_per_env_predicate` to
|
||
/// validate the WR_EMA-fix contract: when `is_win_per_env` is wired,
|
||
/// `wins_count` derives from segment-level outcome rather than the
|
||
/// per-bar `step_ret > 0` predicate at close (which is dominated by
|
||
/// tx-cost so almost always false at close bars, pinning WR_EMA at
|
||
/// 0 in production).
|
||
///
|
||
/// SP21 T3.3 (2026-05-10): also accepts `per_bar_opp_cost_per_env`
|
||
/// to exercise the new Hold opportunity-cost producer path. Pass
|
||
/// an empty slice to use the NULL fallback (matches pre-T3.3 contract).
|
||
fn run_kernel_with_is_win_and_opp_cost(
|
||
trade_close: &[i32],
|
||
step_ret: &[f32],
|
||
hold_at_exit: &[f32],
|
||
actions: &[i32],
|
||
is_win_per_env: &[i32],
|
||
per_bar_opp_cost_per_env: &[f32],
|
||
aux_logits_p50: f32,
|
||
aux_logits_std: f32,
|
||
aux_dir_acc: f32,
|
||
) -> (i32, f32, i32, f32, f32, f32, f32, f32, f32) {
|
||
let stream = make_test_stream();
|
||
let kernel = load_aggregation_kernel(&stream);
|
||
let n = trade_close.len();
|
||
assert!(n > 0, "test n_envs must be > 0");
|
||
assert_eq!(step_ret.len(), n);
|
||
assert_eq!(hold_at_exit.len(), n);
|
||
assert_eq!(actions.len(), n);
|
||
assert_eq!(is_win_per_env.len(), n);
|
||
|
||
let tc_buf = unsafe { MappedI32Buffer::new(n) }.expect("alloc tc");
|
||
tc_buf.write_from_slice(trade_close);
|
||
let sr_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc sr");
|
||
sr_buf.write_from_slice(step_ret);
|
||
let he_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc he");
|
||
he_buf.write_from_slice(hold_at_exit);
|
||
let act_buf = unsafe { MappedI32Buffer::new(n) }.expect("alloc act");
|
||
act_buf.write_from_slice(actions);
|
||
let win_buf = unsafe { MappedI32Buffer::new(n) }.expect("alloc is_win");
|
||
win_buf.write_from_slice(is_win_per_env);
|
||
|
||
// SP21 T3.3 (2026-05-10): per-env per-bar Hold opp-cost producer
|
||
// buffer. When the caller passes an empty slice, we pass NULL
|
||
// (preserving pre-T3.3 contract). When non-empty, we wire the
|
||
// producer so the aggregator emits a non-zero per_bar_hold_reward
|
||
// for tests that exercise the new T3.3 path.
|
||
let opp_buf_opt = if per_bar_opp_cost_per_env.is_empty() {
|
||
None
|
||
} else {
|
||
assert_eq!(per_bar_opp_cost_per_env.len(), n,
|
||
"per_bar_opp_cost_per_env len must match n_envs (or be empty for NULL)");
|
||
let buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc opp_cost");
|
||
buf.write_from_slice(per_bar_opp_cost_per_env);
|
||
Some(buf)
|
||
};
|
||
let opp_buf_dev = opp_buf_opt.as_ref().map_or(0_u64, |b| b.dev_ptr);
|
||
|
||
let p50_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc p50");
|
||
p50_buf.write_from_slice(&[aux_logits_p50]);
|
||
let std_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc std");
|
||
std_buf.write_from_slice(&[aux_logits_std]);
|
||
let dir_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc dir");
|
||
dir_buf.write_from_slice(&[aux_dir_acc]);
|
||
|
||
let out_buf = unsafe { MappedF32Buffer::new(SP20_EMA_INPUTS_F32_LEN) }
|
||
.expect("alloc out");
|
||
out_buf.write_from_slice(&[f32::NAN; SP20_EMA_INPUTS_F32_LEN]);
|
||
|
||
unsafe {
|
||
launch_sp20_aggregate_inputs(
|
||
&stream,
|
||
&kernel,
|
||
tc_buf.dev_ptr,
|
||
sr_buf.dev_ptr,
|
||
he_buf.dev_ptr,
|
||
act_buf.dev_ptr,
|
||
/* env_stride = */ 1,
|
||
p50_buf.dev_ptr,
|
||
std_buf.dev_ptr,
|
||
dir_buf.dev_ptr,
|
||
/* alpha_per_env_dev = */ 0,
|
||
win_buf.dev_ptr, // SP20 Phase 2 Task 2.2-fix is_win producer
|
||
opp_buf_dev, // SP21 T3.3 — wired iff per_bar_opp_cost_per_env non-empty
|
||
/* n_envs = */ n as i32,
|
||
DIR_DIVISOR,
|
||
HOLD_ACTION_ID,
|
||
out_buf.dev_ptr,
|
||
).expect("launch aggregate kernel");
|
||
}
|
||
stream.synchronize().expect("sync after aggregate");
|
||
read_ema_inputs(&out_buf)
|
||
}
|
||
|
||
/// All envs flat (no trade close), all picking Long. Expected:
|
||
/// is_close = 0, action_is_hold = 0, trade_duration = 0,
|
||
/// alpha = per_bar_hold_reward = 0 (Phase 2 / 3.2 placeholders),
|
||
/// upstream stats forwarded.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn no_trades_no_holds_emits_sentinel_state() {
|
||
let trade_close = vec![0_i32; 8];
|
||
let step_ret = vec![0.0_f32; 8];
|
||
let hold_at_exit = vec![0.0_f32; 8];
|
||
let actions = vec![encode_dir(2 /* DIR_LONG */); 8];
|
||
let (ic, iw, td, ah, alpha, hr, p50, std, dir) =
|
||
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
0.3, 0.1, 0.55);
|
||
assert_eq!(ic, 0, "is_close must be 0 (no trades closed)");
|
||
assert_eq!(iw, 0.0, "win_fraction must be 0.0 when is_close=0");
|
||
assert_eq!(td, 0, "trade_duration must be 0 when is_close=0");
|
||
assert_eq!(ah, 0.0, "hold_fraction must be 0.0 (all envs picked Long)");
|
||
assert_eq!(alpha, 0.0, "alpha is Phase 2 forward ref placeholder = 0.0");
|
||
assert_eq!(hr, 0.0, "per_bar_hold_reward is Phase 3.2 forward ref = 0.0");
|
||
assert!((p50 - 0.3).abs() < 1e-6, "aux_logits_p50 forwarded");
|
||
assert!((std - 0.1).abs() < 1e-6, "aux_logits_std forwarded");
|
||
assert!((dir - 0.55).abs() < 1e-6, "aux_dir_acc forwarded");
|
||
}
|
||
|
||
/// Half envs win, half lose; all close. Expected:
|
||
/// is_close = 1, win_fraction = 0.5 (4 wins / 8 closed).
|
||
/// trade_duration = round(mean(hold_at_exit)) = round(7.5) = 8
|
||
/// (round-to-nearest-even at 7.5 ⇒ 8).
|
||
/// SP21 T3.1: was `is_win = 1` (binary majority threshold); now
|
||
/// `win_fraction = 0.5` exact.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn half_wins_meets_threshold_and_duration_rounds() {
|
||
// 4 wins, 4 losses. Hold-at-exit mean = (5+6+7+8+7+8+9+10)/8 = 7.5.
|
||
let trade_close = vec![1; 8];
|
||
let step_ret = vec![ 0.5, 0.3, 0.2, 0.1, -0.5, -0.3, -0.2, -0.1];
|
||
let hold_at_exit = vec![5.0, 6.0, 7.0, 8.0, 7.0, 8.0, 9.0, 10.0];
|
||
let actions = vec![encode_dir(0); 8]; // all DIR_SHORT — not Hold
|
||
let (ic, iw, td, ah, _, _, _, _, _) =
|
||
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
0.0, 0.0, 0.0);
|
||
assert_eq!(ic, 1);
|
||
assert!((iw - 0.5).abs() < 1e-6,
|
||
"win_fraction = 4/8 = 0.5 exactly; got {iw}");
|
||
// round-to-nearest-even at 7.5 = 8 (banker's rounding); CUDA
|
||
// __float2int_rn uses round-to-nearest-even. Either 7 or 8 are
|
||
// structurally acceptable; the kernel uses CUDA's RN so we
|
||
// accept both as a bound test.
|
||
assert!(
|
||
td == 7 || td == 8,
|
||
"trade_duration must round 7.5 to 7 (down) or 8 (RN-even); got {td}",
|
||
);
|
||
assert_eq!(ah, 0.0,
|
||
"hold_fraction must be 0.0 (all envs picked Long, no Hold)");
|
||
}
|
||
|
||
/// 5 of 8 envs picking Hold → hold_fraction = 5/8 = 0.625 (SP21 T3.2:
|
||
/// was binary `action_is_hold = 1` under strict majority).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn five_of_eight_hold_yields_fraction_0_625() {
|
||
let trade_close = vec![0; 8];
|
||
let step_ret = vec![0.0; 8];
|
||
let hold_at_exit = vec![0.0; 8];
|
||
let mut actions = Vec::with_capacity(8);
|
||
for i in 0..8 {
|
||
actions.push(if i < 5 { encode_dir(HOLD_ACTION_ID) } else { encode_dir(0) });
|
||
}
|
||
let (_, _, _, ah, _, _, _, _, _) =
|
||
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
0.0, 0.0, 0.0);
|
||
assert!(
|
||
(ah - 0.625).abs() < 1e-6,
|
||
"5 of 8 Hold => hold_fraction = 5/8 = 0.625 exact; got {ah}",
|
||
);
|
||
}
|
||
|
||
/// Half of envs picking Hold → hold_fraction = 4/8 = 0.5 exact (SP21
|
||
/// T3.2: was `action_is_hold = 0` under strict majority — ties did
|
||
/// not trigger). The HOLD_REWARD_EMA gate `hold_fraction > 0.5f`
|
||
/// preserves the strict-majority semantic at the consumer site.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn half_hold_yields_fraction_0_5_exact() {
|
||
let trade_close = vec![0; 8];
|
||
let step_ret = vec![0.0; 8];
|
||
let hold_at_exit = vec![0.0; 8];
|
||
let mut actions = Vec::with_capacity(8);
|
||
for i in 0..8 {
|
||
actions.push(if i < 4 { encode_dir(HOLD_ACTION_ID) } else { encode_dir(2) });
|
||
}
|
||
let (_, _, _, ah, _, _, _, _, _) =
|
||
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
0.0, 0.0, 0.0);
|
||
assert!(
|
||
(ah - 0.5).abs() < 1e-6,
|
||
"4 of 8 Hold => hold_fraction = 4/8 = 0.5 exact; got {ah}",
|
||
);
|
||
}
|
||
|
||
/// Single env closed with a loss: is_close=1, win_fraction=0.0
|
||
/// (0/1), trade_duration = round(3.0) = 3.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn single_env_loss_close() {
|
||
let trade_close = vec![1, 0, 0, 0];
|
||
let step_ret = vec![-0.2, 0.0, 0.0, 0.0];
|
||
let hold_at_exit = vec![3.0, 0.0, 0.0, 0.0];
|
||
let actions = vec![encode_dir(0); 4];
|
||
let (ic, iw, td, _, _, _, _, _, _) =
|
||
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
0.0, 0.0, 0.0);
|
||
assert_eq!(ic, 1);
|
||
assert_eq!(iw, 0.0, "loss (0 wins out of 1 closed) => win_fraction=0.0");
|
||
assert_eq!(td, 3, "trade_duration = round(3.0) = 3");
|
||
}
|
||
|
||
/// SP20 Phase 2 Task 2.2 (2026-05-09) — alpha is now load-bearing
|
||
/// (mean over closed envs of `alpha_per_env`). `per_bar_hold_reward`
|
||
/// remains a Phase 3.2 forward-reference placeholder.
|
||
///
|
||
/// **NULL pre-Task-2.2 contract preserved**: when `alpha_per_env_dev
|
||
/// = 0` (NULL), the kernel emits `alpha = 0.0` matching the Phase
|
||
/// 1.4 placeholder behavior. Tests that don't exercise the SP20
|
||
/// reward producer (run_kernel) still see the old `alpha = 0.0`
|
||
/// invariant. This test validates the NULL-tolerance contract.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn null_alpha_producer_keeps_phase_1_4_placeholder_contract() {
|
||
let trade_close = vec![1, 1, 1];
|
||
let step_ret = vec![0.5, 0.4, 0.3];
|
||
let hold_at_exit = vec![10.0, 10.0, 10.0];
|
||
let actions = vec![encode_dir(HOLD_ACTION_ID); 3];
|
||
let (_, _, _, _, alpha, hr, _, _, _) =
|
||
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
0.5, 0.5, 0.99);
|
||
assert_eq!(
|
||
alpha, 0.0,
|
||
"alpha is 0.0 when alpha_per_env producer is NULL (test scaffold); \
|
||
matches Phase 1.4 placeholder behavior for backward compat",
|
||
);
|
||
assert_eq!(
|
||
hr, 0.0,
|
||
"per_bar_hold_reward is Phase 3.2 placeholder; aggregation \
|
||
kernel must NOT surface a per-env signal here",
|
||
);
|
||
}
|
||
|
||
/// SP20 Phase 2 Task 2.2 (2026-05-09) — alpha aggregation contract.
|
||
/// When `alpha_per_env` is wired (non-NULL), `EmaInputs.alpha` is
|
||
/// the **mean** of `alpha_per_env[env]` over envs with
|
||
/// `trade_close_per_env[env] != 0`. Non-close envs do NOT contribute
|
||
/// to the mean (gated read at the producer site).
|
||
///
|
||
/// Test setup: 4 envs, 3 close (1 with alpha=+1.0, 2 with
|
||
/// alpha=-0.5), 1 doesn't close (alpha=+99 — should be ignored).
|
||
/// Expected: alpha_out = mean(1.0, -0.5, -0.5) = 0.0.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn alpha_mean_over_closed_envs_phase_2_contract() {
|
||
let trade_close = vec![1, 1, 1, 0]; // env 3 doesn't close
|
||
let step_ret = vec![0.5, -0.3, -0.2, 0.0];
|
||
let hold_at_exit = vec![5.0, 6.0, 7.0, 0.0];
|
||
let actions = vec![encode_dir(0); 4];
|
||
let alpha_per_env = vec![1.0, -0.5, -0.5, 99.0]; // env 3 ignored
|
||
let (ic, _, _, _, alpha, hr, _, _, _) =
|
||
run_kernel_with_alpha(&trade_close, &step_ret, &hold_at_exit,
|
||
&actions, &alpha_per_env, 0.0, 0.0, 0.0);
|
||
assert_eq!(ic, 1, "is_close = 1 (3 envs closed)");
|
||
// mean(1.0 + (-0.5) + (-0.5)) / 3 = 0.0 — env 3's alpha=99 must
|
||
// not leak in (gated by trade_close_per_env[3] == 0 at producer).
|
||
assert!(
|
||
alpha.abs() < 1e-6,
|
||
"expected alpha = mean(1.0, -0.5, -0.5) over closed envs = 0.0; got {alpha}",
|
||
);
|
||
assert_eq!(hr, 0.0, "per_bar_hold_reward stays Phase 3.2 placeholder");
|
||
}
|
||
|
||
/// SP20 Phase 2 Task 2.2 — alpha is forced to `0.0` when
|
||
/// `is_close == 0` (no env closed this step), regardless of any
|
||
/// stale `alpha_per_env` content. Validates the close-gated emit
|
||
/// at the kernel's `tid == 0` write block.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn alpha_zero_when_no_close_phase_2_contract() {
|
||
let trade_close = vec![0_i32; 4]; // no env closes
|
||
let step_ret = vec![0.0_f32; 4];
|
||
let hold_at_exit = vec![0.0_f32; 4];
|
||
let actions = vec![encode_dir(0); 4];
|
||
// Stale alpha values from prior trade closes — kernel MUST gate
|
||
// these out via the close-count check.
|
||
let alpha_per_env = vec![0.5, -0.7, 2.3, -1.4];
|
||
let (ic, _, _, _, alpha, _, _, _, _) =
|
||
run_kernel_with_alpha(&trade_close, &step_ret, &hold_at_exit,
|
||
&actions, &alpha_per_env, 0.0, 0.0, 0.0);
|
||
assert_eq!(ic, 0, "is_close = 0 (no envs closed)");
|
||
assert_eq!(
|
||
alpha, 0.0,
|
||
"alpha must be 0.0 when no env closes — stale alpha_per_env \
|
||
values must NOT leak into the EMA on non-close steps",
|
||
);
|
||
}
|
||
|
||
/// SP20 Phase 2 Task 2.2-fix (2026-05-10) — WR_EMA pinning bug.
|
||
///
|
||
/// **Root cause** (production HEALTH_DIAG observed `wr_ema = 0.0000`
|
||
/// at every epoch while `alpha_ema` evolved with real values):
|
||
/// the aggregation kernel's win predicate was
|
||
/// `step_ret_per_env[env] > 0.0f` at the close bar, which is the
|
||
/// per-bar mark-to-market change `(new_value - prev_equity) /
|
||
/// prev_equity` of the closing bar, NOT the trade segment outcome.
|
||
///
|
||
/// At close bars `step_ret_core ≈ position * Δprice * mult −
|
||
/// tx_cost`. The per-bar tick `Δprice` is small (one bar) while
|
||
/// `tx_cost` is fixed at trade-close → `step_ret_core < 0` is the
|
||
/// dominant case across closing bars even for trades that closed
|
||
/// profitably overall. Result: `wins_count = 0` always, `is_win =
|
||
/// 0` always, `WR_EMA` pinned at 0 across all training epochs,
|
||
/// LOSS_CAP pinned at -1.0 (the WR=0.5 cold-start cap, never
|
||
/// ramping up to -2.0 per spec §4.1).
|
||
///
|
||
/// **Fix**: derive `is_win` from the segment-level realized P&L
|
||
/// signed by trade close, plumbed through a new `is_win_per_env`
|
||
/// per-env i32 buffer that the kernel reads in place of the
|
||
/// per-bar `step_ret > 0` predicate. This test pins the contract:
|
||
/// when `is_win_per_env` is wired (non-NULL) the kernel's
|
||
/// `wins_count` MUST equal the count of closed envs with
|
||
/// `is_win_per_env[env] != 0`, regardless of `step_ret`'s sign.
|
||
///
|
||
/// **Why this test catches the bug**: the scenario is the realistic
|
||
/// production case where `step_ret < 0` for all 4 closing envs (the
|
||
/// per-bar tick at close is dominated by tx-cost) BUT 3 of 4
|
||
/// trades were profitable overall (`is_win_per_env = [1, 1, 1,
|
||
/// 0]`). With the legacy `sr > 0` predicate `wins_count = 0` ⇒
|
||
/// `is_win = 0`. With the fix `wins_count = 3` ⇒ `2 * 3 >= 4` ⇒
|
||
/// `is_win = 1`.
|
||
///
|
||
/// **TDD note**: in commit 1 the kernel signature accepts
|
||
/// `is_win_per_env` but the kernel BODY still uses `sr > 0`, so
|
||
/// this test FAILS at the assertion (kernel emits `is_win = 0`
|
||
/// despite `is_win_per_env = [1,1,1,0]`). Commit 2's kernel body
|
||
/// change closes the gap and this test passes. Per the
|
||
/// `feedback_no_partial_refactor` exception for TDD-style commits
|
||
/// — the gap is closed in the IMMEDIATE next commit.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn wr_ema_uses_segment_pnl_via_is_win_per_env_predicate() {
|
||
// 4 envs, all closing this step.
|
||
let trade_close = vec![1_i32; 4];
|
||
|
||
// step_ret all negative (tx-cost dominated per-bar return at
|
||
// close bars — the realistic production case). With the legacy
|
||
// `sr > 0` predicate, `wins_count = 0` so `is_win = 0` (bug).
|
||
let step_ret = vec![-0.001_f32; 4];
|
||
|
||
let hold_at_exit = vec![5.0_f32; 4];
|
||
let actions = vec![encode_dir(0); 4]; // not Hold
|
||
|
||
// 3 of 4 segment-level wins (segment_return > 0). With the
|
||
// fix, the kernel reads is_win_per_env in the close-gated path
|
||
// and accumulates `wins_count = 3`.
|
||
let is_win_per_env = vec![1_i32, 1, 1, 0];
|
||
|
||
let (ic, iw, _, _, _, _, _, _, _) = run_kernel_with_is_win_and_opp_cost(
|
||
&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
&is_win_per_env, &[], // SP21 T3.3 NULL: opp-cost producer not exercised here
|
||
0.0, 0.0, 0.0,
|
||
);
|
||
|
||
assert_eq!(ic, 1, "is_close = 1 (4 envs closed)");
|
||
assert!(
|
||
(iw - 0.75).abs() < 1e-6,
|
||
"win_fraction MUST be 3/4 = 0.75 — segment-level wins \
|
||
(is_win_per_env = [1,1,1,0]) put 3 of 4 closed envs in the \
|
||
win bucket. SP21 T3.1: aggregator now emits the actual \
|
||
fraction, not a binary majority indicator. The legacy \
|
||
`step_ret > 0` predicate would have given win_fraction = 0 \
|
||
(all step_rets negative due to tx-cost domination at close \
|
||
bars), pinning WR_EMA at 0 in production. got iw={iw}",
|
||
);
|
||
}
|
||
|
||
/// SP20 Phase 2 Task 2.2-fix — the kernel's NULL-tolerance for
|
||
/// `is_win_per_env` MUST preserve the legacy `sr > 0` predicate
|
||
/// for the aggregate-kernel oracle tests + Phase 1.4 wireup test
|
||
/// scaffolds that don't wire a segment-level producer. This test
|
||
/// pins that contract: with `is_win_per_env` NULL and step_ret all
|
||
/// positive (fake "all wins"), the legacy predicate emits
|
||
/// `wins_count = n_envs` ⇒ `is_win = 1`.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn null_is_win_per_env_falls_back_to_legacy_step_ret_predicate() {
|
||
// All envs close with positive step_ret (the contrived oracle-
|
||
// test signal that the legacy predicate honors).
|
||
let trade_close = vec![1_i32; 4];
|
||
let step_ret = vec![0.5_f32; 4]; // legacy predicate fires
|
||
let hold_at_exit = vec![3.0_f32; 4];
|
||
let actions = vec![encode_dir(0); 4];
|
||
|
||
let (ic, iw, _, _, _, _, _, _, _) =
|
||
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
0.0, 0.0, 0.0);
|
||
|
||
assert_eq!(ic, 1);
|
||
assert!(
|
||
(iw - 1.0).abs() < 1e-6,
|
||
"NULL is_win_per_env falls back to `sr > 0` legacy \
|
||
predicate — all 4 step_rets positive ⇒ wins_count = 4 ⇒ \
|
||
win_fraction = 4/4 = 1.0 (the pre-fix oracle-test contract \
|
||
preserved via NULL-tolerance). SP21 T3.1: aggregator emits \
|
||
fraction not binary majority. got iw={iw}",
|
||
);
|
||
}
|
||
|
||
/// SP21 T3.3 (2026-05-10) — per_bar_hold_reward producer wired.
|
||
///
|
||
/// Constructs 4 envs, 3 in DIR_HOLD with synthetic per-bar opp-costs
|
||
/// `[-0.10, -0.20, -0.05, _]` (1 env in Long has its slot ignored —
|
||
/// only Hold-direction envs contribute to the mean). Expected:
|
||
/// `per_bar_hold_reward = mean(-0.10, -0.20, -0.05) = -0.1167`.
|
||
/// Pins the contract that the aggregator (a) sums opp_cost only
|
||
/// over Hold-dir envs, (b) divides by hold_count (NOT n_envs), and
|
||
/// (c) emits the mean as a real signal (not 0.0 placeholder).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn per_bar_hold_reward_means_over_hold_envs_only() {
|
||
let trade_close = vec![0_i32; 4]; // no closes this step
|
||
let step_ret = vec![0.0_f32; 4];
|
||
let hold_at_exit = vec![0.0_f32; 4];
|
||
// 3 Hold envs (DIR_HOLD=1), 1 Long env (DIR_LONG=2).
|
||
let actions = vec![
|
||
encode_dir(HOLD_ACTION_ID),
|
||
encode_dir(HOLD_ACTION_ID),
|
||
encode_dir(HOLD_ACTION_ID),
|
||
encode_dir(2),
|
||
];
|
||
let is_win_per_env = vec![0_i32; 4]; // no wins (no closes)
|
||
// Hold envs have opp_costs; Long env's slot is read by the
|
||
// kernel but the per-thread accumulator is gated on
|
||
// `dir == hold_action_id`, so its value (here -1.0 sentinel)
|
||
// is correctly excluded from the sum.
|
||
let per_bar_opp_cost = vec![-0.10_f32, -0.20, -0.05, -1.0];
|
||
|
||
let (_, _, _, ah, _, hr, _, _, _) = run_kernel_with_is_win_and_opp_cost(
|
||
&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
&is_win_per_env, &per_bar_opp_cost,
|
||
0.0, 0.0, 0.0,
|
||
);
|
||
|
||
// hold_fraction = 3/4 = 0.75 (T3.2 contract).
|
||
assert!(
|
||
(ah - 0.75).abs() < 1e-6,
|
||
"hold_fraction = 3/4 = 0.75 exact; got {ah}",
|
||
);
|
||
// per_bar_hold_reward = mean over Hold-dir envs only.
|
||
// = (-0.10 + -0.20 + -0.05) / 3 = -0.35 / 3 ≈ -0.11667.
|
||
// The Long env's -1.0 slot MUST NOT contribute (gated out).
|
||
let expected = (-0.10_f32 - 0.20 - 0.05) / 3.0;
|
||
assert!(
|
||
(hr - expected).abs() < 1e-6,
|
||
"per_bar_hold_reward = mean over Hold-dir envs = {expected:.6}; \
|
||
got {hr}. The Long env's -1.0 sentinel MUST be excluded from \
|
||
the sum (per-thread gate on `dir == hold_action_id`).",
|
||
);
|
||
}
|
||
|
||
/// SP21 T3.3 (2026-05-10) — NULL producer fallback contract.
|
||
///
|
||
/// When `per_bar_opp_cost_per_env` is NULL (oracle scaffolds without
|
||
/// the new producer), the aggregator MUST emit
|
||
/// `per_bar_hold_reward = 0.0f` regardless of hold_count or any
|
||
/// other state. Preserves the pre-T3.3 Phase 3.2 forward-reference
|
||
/// placeholder behavior bit-identically for backward compat.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn null_per_bar_opp_cost_emits_zero() {
|
||
let trade_close = vec![0_i32; 4];
|
||
let step_ret = vec![0.0_f32; 4];
|
||
let hold_at_exit = vec![0.0_f32; 4];
|
||
// All 4 in Hold — hold_count = 4, hold_fraction = 1.0.
|
||
let actions = vec![encode_dir(HOLD_ACTION_ID); 4];
|
||
let is_win_per_env = vec![0_i32; 4];
|
||
|
||
let (_, _, _, ah, _, hr, _, _, _) = run_kernel_with_is_win_and_opp_cost(
|
||
&trade_close, &step_ret, &hold_at_exit, &actions,
|
||
&is_win_per_env, &[], // NULL — no opp-cost producer wired
|
||
0.0, 0.0, 0.0,
|
||
);
|
||
|
||
assert!(
|
||
(ah - 1.0).abs() < 1e-6,
|
||
"hold_fraction = 4/4 = 1.0; got {ah}",
|
||
);
|
||
assert_eq!(
|
||
hr, 0.0,
|
||
"NULL per_bar_opp_cost_per_env MUST emit per_bar_hold_reward=0.0 \
|
||
(pre-T3.3 Phase 3.2 forward-reference contract preserved); \
|
||
got {hr}",
|
||
);
|
||
}
|
||
}
|