Files
foxhunt/crates/ml/tests/sp20_aggregate_inputs_test.rs
jgrusewski 4d4cd996db feat(sp21): T3.3 ISV defrost — hold_reward_ema (atomic Phase 3.2 wireup)
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>
2026-05-10 20:35:39 +02:00

731 lines
33 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.
//! 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}",
);
}
}