Lifts the Phase 2 caps from sentinel-driven cold-start (5.0/-10.0
fixed) to p99(|step_ret|) over Long/Short trade closes × 1.5 safety
factor with Wiener-optimal alpha blend. The Phase 2 consumer
(compute_sp18_hold_opportunity_cost) sees producer-driven slots
[483]/[484] from epoch 1 onward; sentinel branch in the consumer
remains as the cold-start fallback path (zero-trade-close epoch ⇒
producer early-returns ⇒ slots stay at sentinel ⇒ consumer falls
through to the +5/-10 macro defaults — bit-identical to pre-Phase-3).
Mirrors SP14 P0-A reward_cap_update_kernel structural template with
three differences: (1) filter (is_close && step_ret != 0) — both
winners AND losers (the consumer needs the magnitude scale of *all*
Long/Short closes); (2) Welford-derived Wiener-α (slots [487..493))
replaces fixed α=0.01, with floor at WELFORD_ALPHA_MIN=0.4 per
pearl_wiener_alpha_floor_for_nonstationary (the policy-realised
distribution is intrinsically non-stationary as the policy adapts);
(3) bounds [0.5, 50.0] (vs. position-side [1.0, 50.0]).
Atomic single-commit per feedback_no_partial_refactor:
- crates/ml/src/cuda_pipeline/hold_reward_cap_update_kernel.cu (NEW)
- crates/ml/build.rs cubin manifest entry
- HoldRewardCapUpdateOps in gpu_aux_trunk.rs (new struct + impl)
- HOLD_REWARD_CAP_UPDATE_CUBIN static + struct field +
launch_hold_reward_cap_update method + constructor instantiation +
field-init in gpu_dqn_trainer.rs (5 sites)
- Per-epoch boundary launch in training_loop.rs right AFTER
launch_reward_cap_update (shared step_ret/trade_close source buffers,
independent ISV slot pairs)
- HEALTH_DIAG[N]: hold_reward_cap [pos={:.4} neg={:.4} fire_rate={:.4}]
- 3 GPU oracle tests (T5 producer-drives-slots, Pearl-A REPLACE,
no-closes preserves-isv) — all pass on local RTX 3050 Ti
- Phase 3 close-out sections in docs/sp18-wireup-audit.md and
docs/dqn-wire-up-audit.md
Pearls applied: feedback_no_atomicadd, pearl_first_observation_bootstrap,
pearl_wiener_optimal_adaptive_alpha, pearl_wiener_alpha_floor_for_nonstationary,
pearl_no_host_branches_in_captured_graph, pearl_symmetric_clamp_audit,
pearl_audit_unboundedness_for_implicit_asymmetry (NEG = -2 × POS at
producer time, single source of truth), feedback_isv_for_adaptive_bounds,
pearl_fused_per_group_statistics_oracle.
Validation: cargo check --workspace clean; 3 GPU oracle tests pass on
local RTX 3050 Ti; scripts/audit_sp18_consumers.sh --check exits 0
(no fingerprint drift in tracked sections).
Plan: docs/superpowers/plans/2026-05-08-sp18-reward-shape-hold-attractor.md
Phase 4-5 (B-leg target-net forward + q_next replacement) follows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1255 lines
55 KiB
Rust
1255 lines
55 KiB
Rust
//! SP18 v2 Phase 0 — D-leg + B-leg diagnostic-emit oracle tests.
|
||
//!
|
||
//! Plan: docs/superpowers/plans/2026-05-08-sp18-reward-shape-hold-attractor.md
|
||
//!
|
||
//! Phase 0 is observability-only: kernels write to new ISV slots
|
||
//! [493..505) and to a 20-float mapped-pinned diagnostic buffer; no
|
||
//! production-path consumer is wired. Tests therefore lock the
|
||
//! per-action reward decomposition kernel's bit-exact output against a
|
||
//! synthetic CPU oracle and confirm the host-side V_SHARE-trend +
|
||
//! TD-error-magnitude diagnostics emit the expected HEALTH_DIAG line
|
||
//! formats.
|
||
//!
|
||
//! Run on a GPU host:
|
||
//!
|
||
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
|
||
//! cargo test -p ml --test sp18_hold_reward_oracle_tests \
|
||
//! --features cuda -- --ignored --nocapture
|
||
//!
|
||
//! All B-tests (`#[ignore = "requires GPU"]`) load cubins via the
|
||
//! workspace `OUT_DIR` and use `MappedF32Buffer` per
|
||
//! `feedback_no_htod_htoh_only_mapped_pinned`.
|
||
|
||
// ── Layer A (CPU oracle): per-action reward decomposition math ───────────
|
||
|
||
/// CPU oracle for the SP18 D-leg per-action reward decomposition.
|
||
///
|
||
/// Construction (4 samples, 4 directions):
|
||
///
|
||
/// sample 0: dir=Hold, components=[popart=0.10, cf=0.0, trail=0.0,
|
||
/// micro=0.20, opp=0.05, bonus=0.0]
|
||
/// sample 1: dir=Long, components=[popart=0.50, cf=0.0, trail=0.0,
|
||
/// micro=0.30, opp=0.0, bonus=0.0]
|
||
/// sample 2: dir=Short, components=[popart=-0.40, cf=0.0, trail=0.0,
|
||
/// micro=-0.20, opp=0.0, bonus=0.0]
|
||
/// sample 3: dir=Flat, components=[popart=0.0, cf=0.0, trail=0.0,
|
||
/// micro=0.0, opp=0.10, bonus=0.0]
|
||
///
|
||
/// Expected per-bin output (bin order Hold, Long, Short, Flat):
|
||
///
|
||
/// Hold: micro=0.20, opp=0.05, popart= 0.10, |abs|=0.35, fire=1.0
|
||
/// Long: micro=0.30, opp=0.0, popart= 0.50, |abs|=0.80, fire=1.0
|
||
/// Short: micro=-0.20, opp=0.0, popart=-0.40, |abs|=0.60, fire=1.0
|
||
/// Flat: micro=0.0, opp=0.10, popart= 0.0, |abs|=0.10, fire=1.0
|
||
///
|
||
/// Pins the per-bin reduction math independent of GPU implementation.
|
||
#[test]
|
||
fn reward_decomp_per_action_cpu_oracle() {
|
||
// Components: [popart, cf, trail, micro, opp_cost, bonus]
|
||
let rc: Vec<[f32; 6]> = vec![
|
||
[ 0.10, 0.0, 0.0, 0.20, 0.05, 0.0], // Hold
|
||
[ 0.50, 0.0, 0.0, 0.30, 0.0, 0.0], // Long
|
||
[-0.40, 0.0, 0.0, -0.20, 0.0, 0.0], // Short
|
||
[ 0.0, 0.0, 0.0, 0.0, 0.10, 0.0], // Flat
|
||
];
|
||
// Logical bin → DIR_*: 0=Hold(1), 1=Long(2), 2=Short(0), 3=Flat(3)
|
||
const BIN_DIRS: [i32; 4] = [1, 2, 0, 3];
|
||
const N: usize = 4;
|
||
|
||
// Per-sample dir assignment matches the construction above.
|
||
let dirs: [i32; N] = [1, 2, 0, 3];
|
||
|
||
// CPU oracle: row-major 4×5 (bin × col).
|
||
let mut out = [0.0_f32; 20];
|
||
for (bin, target_dir) in BIN_DIRS.iter().enumerate() {
|
||
let mut s_micro = 0.0f32;
|
||
let mut s_opp = 0.0f32;
|
||
let mut s_popart = 0.0f32;
|
||
let mut s_abs = 0.0f32;
|
||
let mut s_count = 0.0f32;
|
||
let mut s_fire = 0.0f32;
|
||
for i in 0..N {
|
||
if dirs[i] != *target_dir { continue; }
|
||
let row = rc[i];
|
||
let abs_total: f32 = row.iter().map(|x| x.abs()).sum();
|
||
s_popart += row[0];
|
||
s_micro += row[3];
|
||
s_opp += row[4];
|
||
s_abs += abs_total;
|
||
s_count += 1.0;
|
||
if abs_total > 0.0 { s_fire += 1.0; }
|
||
}
|
||
let denom = if s_count > 0.0 { s_count } else { 1.0 };
|
||
let o = bin * 5;
|
||
out[o + 0] = if s_count > 0.0 { s_micro / denom } else { 0.0 };
|
||
out[o + 1] = if s_count > 0.0 { s_opp / denom } else { 0.0 };
|
||
out[o + 2] = if s_count > 0.0 { s_popart / denom } else { 0.0 };
|
||
out[o + 3] = if s_count > 0.0 { s_abs / denom } else { 0.0 };
|
||
out[o + 4] = if s_count > 0.0 { s_fire / denom } else { 0.0 };
|
||
}
|
||
|
||
// Hold (bin 0).
|
||
assert!((out[0] - 0.20).abs() < 1e-6, "hold micro: got {}", out[0]);
|
||
assert!((out[1] - 0.05).abs() < 1e-6, "hold opp: got {}", out[1]);
|
||
assert!((out[2] - 0.10).abs() < 1e-6, "hold popart: got {}", out[2]);
|
||
assert!((out[3] - 0.35).abs() < 1e-6, "hold abs: got {}", out[3]);
|
||
assert!((out[4] - 1.00).abs() < 1e-6, "hold fire: got {}", out[4]);
|
||
// Long (bin 1).
|
||
assert!((out[5] - 0.30).abs() < 1e-6, "long micro: got {}", out[5]);
|
||
assert!((out[6] - 0.00).abs() < 1e-6, "long opp: got {}", out[6]);
|
||
assert!((out[7] - 0.50).abs() < 1e-6, "long popart: got {}", out[7]);
|
||
assert!((out[8] - 0.80).abs() < 1e-6, "long abs: got {}", out[8]);
|
||
assert!((out[9] - 1.00).abs() < 1e-6, "long fire: got {}", out[9]);
|
||
// Short (bin 2).
|
||
assert!((out[10] - (-0.20)).abs() < 1e-6, "short micro: got {}", out[10]);
|
||
assert!((out[11] - 0.00 ).abs() < 1e-6, "short opp: got {}", out[11]);
|
||
assert!((out[12] - (-0.40)).abs() < 1e-6, "short popart: got {}", out[12]);
|
||
assert!((out[13] - 0.60 ).abs() < 1e-6, "short abs: got {}", out[13]);
|
||
assert!((out[14] - 1.00 ).abs() < 1e-6, "short fire: got {}", out[14]);
|
||
// Flat (bin 3).
|
||
assert!((out[15] - 0.00).abs() < 1e-6, "flat micro: got {}", out[15]);
|
||
assert!((out[16] - 0.10).abs() < 1e-6, "flat opp: got {}", out[16]);
|
||
assert!((out[17] - 0.00).abs() < 1e-6, "flat popart: got {}", out[17]);
|
||
assert!((out[18] - 0.10).abs() < 1e-6, "flat abs: got {}", out[18]);
|
||
assert!((out[19] - 1.00).abs() < 1e-6, "flat fire: got {}", out[19]);
|
||
}
|
||
|
||
// ── SP18 v2 Phase 2 Task 2.1 (T1): CPU oracle for `compute_sp18_hold_opportunity_cost` ──
|
||
|
||
/// CPU-side spec mirror of the `compute_sp18_hold_opportunity_cost` device
|
||
/// function in `crates/ml/src/cuda_pipeline/trade_physics.cuh`.
|
||
///
|
||
/// This is the math contract — the device function must be bit-equivalent
|
||
/// to within an f32 rounding budget (ε=1e-5; see Task 2.3 GPU oracle).
|
||
/// Per `feedback_no_cpu_test_fallbacks.md` this is **not** a production
|
||
/// reference implementation; production runs only the GPU device fn. The
|
||
/// CPU oracle exists solely to lock the closed-form math contract during
|
||
/// TDD.
|
||
///
|
||
/// Inputs / outputs match the device fn signature exactly:
|
||
/// - `dir_idx != DIR_HOLD` ⇒ returns 0.0 exactly (early exit gate).
|
||
/// - DD5(b) MIRRORED sign: positive `next_log_return` ⇒ negative result
|
||
/// (opp cost paid for sitting out a winning Long).
|
||
/// - `compute_asymmetric_capped_pnl(raw, neg_cap, pos_cap)
|
||
/// = fmaxf(neg_cap, fminf(raw, pos_cap))`.
|
||
/// - Multiplied by `shaping_scale` — validation mode (=0) ⇒ returns 0.
|
||
///
|
||
/// EPS=1e-12 mirrors the device fn's vol_proxy guard (numerical safety,
|
||
/// not behavioural — production caller's `fmaxf(atr_pct, 0.0001f)` makes
|
||
/// this floor a no-op in practice).
|
||
fn compute_sp18_hold_opp_cpu(
|
||
dir_idx: i32,
|
||
dir_hold: i32,
|
||
next_log_return: f32,
|
||
vol_proxy: f32,
|
||
isv_neg_cap: f32,
|
||
isv_pos_cap: f32,
|
||
shaping_scale: f32,
|
||
) -> f32 {
|
||
if dir_idx != dir_hold {
|
||
return 0.0;
|
||
}
|
||
let vol_normalized_return = next_log_return / (vol_proxy + 1e-12_f32).sqrt();
|
||
let base = 2.0_f32 * vol_normalized_return;
|
||
let raw_opp = -base;
|
||
let capped = isv_neg_cap.max(raw_opp.min(isv_pos_cap));
|
||
capped * shaping_scale
|
||
}
|
||
|
||
const DIR_HOLD_CPU: i32 = 1; // matches `state_layout.cuh:124`
|
||
|
||
/// T1 (spec §"Behavioral Test Predictions"): CPU oracle for
|
||
/// `compute_sp18_hold_opportunity_cost`.
|
||
///
|
||
/// Locks the closed-form math contract before the device fn lands. With
|
||
/// `next_log_return = +0.005`, `vol_proxy = 0.0001`, `pos_cap = +5.0`,
|
||
/// `neg_cap = -10.0`, `shaping_scale = 1.0`:
|
||
///
|
||
/// - `vol_normalized_return = 0.005 / sqrt(0.0001 + 1e-12) ≈ 0.5`
|
||
/// - `base = 2.0 × 0.5 = 1.0`
|
||
/// - `raw_opp = -1.0`
|
||
/// - `capped = max(-10, min(-1, +5)) = -1.0`
|
||
/// - `result = -1.0 × 1.0 = -1.0`
|
||
///
|
||
/// Verifies: returns negative scalar bounded in [-10, 0] for positive
|
||
/// `next_log_return` (opp cost paid for sitting out a winning Long); a
|
||
/// flipped-sign positive scalar bounded in [0, +5] for negative
|
||
/// `next_log_return` (correctly avoided a losing Long); and 0.0 exactly
|
||
/// for `dir_idx != DIR_HOLD`.
|
||
#[test]
|
||
fn compute_sp18_hold_opportunity_cost_cpu_oracle() {
|
||
// Case A: positive next_log_return, dir=Hold → negative result in [-10, 0].
|
||
let r_pos = compute_sp18_hold_opp_cpu(
|
||
DIR_HOLD_CPU, DIR_HOLD_CPU, 0.005, 0.0001, -10.0, 5.0, 1.0,
|
||
);
|
||
assert!((r_pos - (-1.0)).abs() < 1e-5,
|
||
"pos log-return: expected -1.0, got {r_pos}");
|
||
assert!(r_pos >= -10.0 && r_pos <= 0.0,
|
||
"pos log-return result must be in [-10, 0], got {r_pos}");
|
||
|
||
// Case B: negative next_log_return → positive result in [0, +5].
|
||
let r_neg = compute_sp18_hold_opp_cpu(
|
||
DIR_HOLD_CPU, DIR_HOLD_CPU, -0.005, 0.0001, -10.0, 5.0, 1.0,
|
||
);
|
||
assert!((r_neg - 1.0).abs() < 1e-5,
|
||
"neg log-return: expected +1.0, got {r_neg}");
|
||
assert!(r_neg >= 0.0 && r_neg <= 5.0,
|
||
"neg log-return result must be in [0, +5], got {r_neg}");
|
||
|
||
// Case C: dir != HOLD → 0.0 exactly (early-exit gate).
|
||
for dir in [0_i32, 2, 3] { // SHORT, LONG, FLAT
|
||
let r = compute_sp18_hold_opp_cpu(
|
||
dir, DIR_HOLD_CPU, 0.005, 0.0001, -10.0, 5.0, 1.0,
|
||
);
|
||
assert_eq!(r, 0.0,
|
||
"dir={dir} (non-HOLD): expected 0.0 exactly, got {r}");
|
||
}
|
||
|
||
// Case D: positive saturation — large negative log-return floods +pos_cap.
|
||
// vol_norm = -0.5 / sqrt(0.0001) = -50; base = -100; raw_opp = +100;
|
||
// capped = min(100, +5) = +5; final = +5.0.
|
||
let r_sat_pos = compute_sp18_hold_opp_cpu(
|
||
DIR_HOLD_CPU, DIR_HOLD_CPU, -0.5, 0.0001, -10.0, 5.0, 1.0,
|
||
);
|
||
assert!((r_sat_pos - 5.0).abs() < 1e-5,
|
||
"+pos saturation: expected +5.0, got {r_sat_pos}");
|
||
|
||
// Case E: negative saturation — large positive log-return floods -neg_cap.
|
||
// vol_norm = 0.05 / sqrt(0.0001) = 5; base = 10; raw_opp = -10;
|
||
// capped = max(-10, -10) = -10; final = -10.0.
|
||
let r_sat_neg = compute_sp18_hold_opp_cpu(
|
||
DIR_HOLD_CPU, DIR_HOLD_CPU, 0.05, 0.0001, -10.0, 5.0, 1.0,
|
||
);
|
||
assert!((r_sat_neg - (-10.0)).abs() < 1e-5,
|
||
"-neg saturation: expected -10.0, got {r_sat_neg}");
|
||
|
||
// Case F: shaping_scale = 0.0 (validation mode) → result = 0.0
|
||
// regardless of inputs, mirroring the SP12 lump-sum opp-cost contract.
|
||
let r_validation = compute_sp18_hold_opp_cpu(
|
||
DIR_HOLD_CPU, DIR_HOLD_CPU, 0.005, 0.0001, -10.0, 5.0, 0.0,
|
||
);
|
||
assert_eq!(r_validation, 0.0,
|
||
"shaping_scale=0 (validation): expected 0.0 exactly, got {r_validation}");
|
||
}
|
||
|
||
// ── Layer B (GPU oracle): kernel matches CPU oracle bit-for-bit ──────────
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)]
|
||
mod gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
|
||
use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
|
||
|
||
/// Cubin handle for the SP18 D-leg per-action reward decomposition
|
||
/// diagnostic kernel.
|
||
const SP18_REWARD_DECOMP_DIAG_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/reward_decomp_diag_kernel.cubin"));
|
||
|
||
fn make_test_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_reward_decomp_diag(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(SP18_REWARD_DECOMP_DIAG_CUBIN.to_vec())
|
||
.expect("load reward_decomp_diag_kernel cubin");
|
||
module
|
||
.load_function("reward_decomp_diag")
|
||
.expect("load reward_decomp_diag function")
|
||
}
|
||
|
||
/// SP18 v2 Phase 0 Task 0.1 — GPU oracle for the per-action reward
|
||
/// decomposition diagnostic kernel.
|
||
///
|
||
/// Builds a 4-sample synthetic batch matching the CPU oracle above
|
||
/// (one sample per direction-axis bin), launches the kernel, and
|
||
/// asserts the 20-float mapped-pinned output matches the closed-form
|
||
/// expected values bit-for-bit (within f32 rounding budget).
|
||
///
|
||
/// Branch sizes (b1=b2=b3=3) match production; the factored-action
|
||
/// encoding is `dir * (b1*b2*b3) + ...` so DIR_HOLD=1 → action=27,
|
||
/// DIR_LONG=2 → action=54, DIR_SHORT=0 → action=0, DIR_FLAT=3 →
|
||
/// action=81.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn reward_decomp_per_action_gpu_oracle() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_reward_decomp_diag(&stream);
|
||
|
||
const N: usize = 4;
|
||
const NUM_COMPS: usize = 6;
|
||
const NUM_BINS: usize = 4;
|
||
const NUM_COLS: usize = 5;
|
||
const OUT_LEN: usize = NUM_BINS * NUM_COLS;
|
||
|
||
// ── Inputs ───────────────────────────────────────────────────
|
||
// reward_components_per_sample[N × 6], row-major.
|
||
let rc_host: Vec<f32> = vec![
|
||
// sample 0 — Hold
|
||
0.10, 0.0, 0.0, 0.20, 0.05, 0.0,
|
||
// sample 1 — Long
|
||
0.50, 0.0, 0.0, 0.30, 0.0, 0.0,
|
||
// sample 2 — Short
|
||
-0.40, 0.0, 0.0, -0.20, 0.0, 0.0,
|
||
// sample 3 — Flat
|
||
0.0, 0.0, 0.0, 0.0, 0.10, 0.0,
|
||
];
|
||
assert_eq!(rc_host.len(), N * NUM_COMPS);
|
||
|
||
let rc_buf = unsafe { MappedF32Buffer::new(N * NUM_COMPS) }
|
||
.expect("alloc rc buf");
|
||
rc_buf.write_from_slice(&rc_host);
|
||
|
||
// actions_out[N], factored-action ints. dir = action / (b1*b2*b3).
|
||
const B1: i32 = 3;
|
||
const B2: i32 = 3;
|
||
const B3: i32 = 3;
|
||
const DIR_DIV: i32 = B1 * B2 * B3; // 27
|
||
// DIR_SHORT=0, DIR_HOLD=1, DIR_LONG=2, DIR_FLAT=3.
|
||
let actions_host: [i32; N] = [
|
||
1 * DIR_DIV, // Hold
|
||
2 * DIR_DIV, // Long
|
||
0 * DIR_DIV, // Short
|
||
3 * DIR_DIV, // Flat
|
||
];
|
||
let actions_buf = unsafe { MappedI32Buffer::new(N) }
|
||
.expect("alloc actions buf");
|
||
actions_buf.write_from_slice(&actions_host);
|
||
|
||
// Output buffer — 20 floats, mapped-pinned. Pre-fill with NaN
|
||
// so any unwritten lane shows up as a test failure.
|
||
let out_buf = unsafe { MappedF32Buffer::new(OUT_LEN) }
|
||
.expect("alloc out buf");
|
||
out_buf.write_from_slice(&vec![f32::NAN; OUT_LEN]);
|
||
|
||
// ── Launch ───────────────────────────────────────────────────
|
||
let n_i32 = N as i32;
|
||
const BLOCK_DIM: u32 = 256;
|
||
const NUM_BIN_BLOCKS: u32 = NUM_BINS as u32;
|
||
// Dynamic shmem = 5 × BLOCK_DIM × sizeof(f32) — matches the
|
||
// kernel's `s_buf` carve-up (5 reduction tiles).
|
||
let shmem_bytes: u32 = 5 * BLOCK_DIM * std::mem::size_of::<f32>() as u32;
|
||
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&rc_buf.dev_ptr)
|
||
.arg(&actions_buf.dev_ptr)
|
||
.arg(&n_i32)
|
||
.arg(&B1)
|
||
.arg(&B2)
|
||
.arg(&B3)
|
||
.arg(&out_buf.dev_ptr)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (NUM_BIN_BLOCKS, 1, 1),
|
||
block_dim: (BLOCK_DIM, 1, 1),
|
||
shared_mem_bytes: shmem_bytes,
|
||
})
|
||
.expect("launch reward_decomp_diag");
|
||
}
|
||
stream.synchronize().expect("sync after reward_decomp_diag");
|
||
|
||
// ── Verify ───────────────────────────────────────────────────
|
||
let out = out_buf.read_all();
|
||
for (i, x) in out.iter().enumerate() {
|
||
assert!(x.is_finite(), "out[{i}] is non-finite ({x}) — \
|
||
kernel did not write this lane");
|
||
}
|
||
|
||
// Closed-form expected values (matches the CPU oracle above).
|
||
let expected: [f32; OUT_LEN] = [
|
||
// Hold
|
||
0.20, 0.05, 0.10, 0.35, 1.00,
|
||
// Long
|
||
0.30, 0.00, 0.50, 0.80, 1.00,
|
||
// Short
|
||
-0.20, 0.00, -0.40, 0.60, 1.00,
|
||
// Flat
|
||
0.00, 0.10, 0.00, 0.10, 1.00,
|
||
];
|
||
|
||
let eps = 1e-6_f32;
|
||
for i in 0..OUT_LEN {
|
||
let bin = i / NUM_COLS;
|
||
let col = i % NUM_COLS;
|
||
let bin_name = match bin { 0 => "Hold", 1 => "Long", 2 => "Short", 3 => "Flat", _ => "?" };
|
||
let col_name = match col { 0 => "micro", 1 => "opp", 2 => "popart", 3 => "abs", 4 => "fire", _ => "?" };
|
||
assert!(
|
||
(out[i] - expected[i]).abs() < eps,
|
||
"out[{bin_name}.{col_name}] expected {:.6}, got {:.6} (diff {:.3e})",
|
||
expected[i], out[i], (out[i] - expected[i]).abs(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Cubin handle for the SP18 B-leg TD-error magnitude EMA producer.
|
||
const SP18_TD_ERROR_MAG_EMA_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/td_error_mag_ema_kernel.cubin"));
|
||
|
||
fn load_td_error_mag_ema(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(SP18_TD_ERROR_MAG_EMA_CUBIN.to_vec())
|
||
.expect("load td_error_mag_ema_kernel cubin");
|
||
module
|
||
.load_function("td_error_mag_ema_update")
|
||
.expect("load td_error_mag_ema_update function")
|
||
}
|
||
|
||
/// SP18 v2 Phase 0 Task 0.2 — TD-error magnitude EMA Pearl-A
|
||
/// bootstrap test.
|
||
///
|
||
/// Construct a synthetic td_errors buffer with known magnitudes,
|
||
/// pre-populate the ISV slot with the sentinel (0.0), launch the
|
||
/// kernel, and assert the slot equals the closed-form mean(|td|)
|
||
/// (Pearl-A direct-replace on first observation).
|
||
///
|
||
/// td_errors = [-1.0, +2.0, -0.5, +0.5, -1.0, +1.0, 0.0, +3.0]
|
||
/// mean(|td|) = (1.0+2.0+0.5+0.5+1.0+1.0+0.0+3.0) / 8 = 9.0/8 = 1.125
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn td_error_mag_ema_pearl_a_bootstrap() {
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
TD_ERROR_MAG_EMA_INDEX, SENTINEL_TD_ERROR_MAG_EMA, WELFORD_ALPHA_MIN,
|
||
};
|
||
|
||
let stream = make_test_stream();
|
||
let kernel = load_td_error_mag_ema(&stream);
|
||
|
||
const B: usize = 8;
|
||
let td_errors_host: [f32; B] = [-1.0, 2.0, -0.5, 0.5, -1.0, 1.0, 0.0, 3.0];
|
||
let expected_mean: f32 = (1.0 + 2.0 + 0.5 + 0.5 + 1.0 + 1.0 + 0.0 + 3.0) / B as f32;
|
||
|
||
let td_buf = unsafe { MappedF32Buffer::new(B) }.expect("alloc td buf");
|
||
td_buf.write_from_slice(&td_errors_host);
|
||
|
||
// ISV bus: pre-populate slot 493 with sentinel 0.0 so Pearl-A
|
||
// bootstrap fires (first observation REPLACES directly).
|
||
const ISV_SPAN: usize = 505; // matches ISV_TOTAL_DIM post-PP.2
|
||
let mut isv_host = vec![0.0_f32; ISV_SPAN];
|
||
isv_host[TD_ERROR_MAG_EMA_INDEX] = SENTINEL_TD_ERROR_MAG_EMA;
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_SPAN) }.expect("alloc isv buf");
|
||
isv_buf.write_from_slice(&isv_host);
|
||
|
||
let b_i32 = B as i32;
|
||
let ema_idx_i32: i32 = TD_ERROR_MAG_EMA_INDEX as i32;
|
||
let alpha: f32 = WELFORD_ALPHA_MIN;
|
||
let sentinel: f32 = SENTINEL_TD_ERROR_MAG_EMA;
|
||
const BLOCK_DIM: u32 = 256;
|
||
let shmem_bytes: u32 = BLOCK_DIM * std::mem::size_of::<f32>() as u32;
|
||
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&td_buf.dev_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&isv_buf.dev_ptr)
|
||
.arg(&ema_idx_i32)
|
||
.arg(&alpha)
|
||
.arg(&sentinel)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (BLOCK_DIM, 1, 1),
|
||
shared_mem_bytes: shmem_bytes,
|
||
})
|
||
.expect("launch td_error_mag_ema_update");
|
||
}
|
||
stream.synchronize().expect("sync");
|
||
|
||
let isv_after = isv_buf.read_all();
|
||
let got = isv_after[TD_ERROR_MAG_EMA_INDEX];
|
||
let eps = 1e-6_f32;
|
||
assert!(
|
||
(got - expected_mean).abs() < eps,
|
||
"TD-error mag EMA Pearl-A bootstrap: expected {expected_mean:.6} \
|
||
(== mean|td| since slot was at sentinel), got {got:.6}"
|
||
);
|
||
}
|
||
|
||
/// SP18 v2 Phase 0 Task 0.2 — TD-error magnitude EMA blend math.
|
||
///
|
||
/// Pre-populate the ISV slot with a non-sentinel value (1.0); launch
|
||
/// the kernel against `td_errors = [+0.5, -0.5, +0.5, -0.5]` so
|
||
/// `mean(|td|) = 0.5`; assert the result equals the closed-form
|
||
/// blend `(1 - α) × 1.0 + α × 0.5` with α=0.4.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn td_error_mag_ema_blend_post_bootstrap() {
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
TD_ERROR_MAG_EMA_INDEX, SENTINEL_TD_ERROR_MAG_EMA, WELFORD_ALPHA_MIN,
|
||
};
|
||
|
||
let stream = make_test_stream();
|
||
let kernel = load_td_error_mag_ema(&stream);
|
||
|
||
const B: usize = 4;
|
||
let td_errors_host: [f32; B] = [0.5, -0.5, 0.5, -0.5];
|
||
let mean_abs: f32 = 0.5;
|
||
let prev: f32 = 1.0;
|
||
let alpha = WELFORD_ALPHA_MIN;
|
||
let expected = (1.0 - alpha) * prev + alpha * mean_abs;
|
||
|
||
let td_buf = unsafe { MappedF32Buffer::new(B) }.expect("alloc td buf");
|
||
td_buf.write_from_slice(&td_errors_host);
|
||
|
||
const ISV_SPAN: usize = 505;
|
||
let mut isv_host = vec![0.0_f32; ISV_SPAN];
|
||
isv_host[TD_ERROR_MAG_EMA_INDEX] = prev; // NOT sentinel — blend path
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_SPAN) }.expect("alloc isv buf");
|
||
isv_buf.write_from_slice(&isv_host);
|
||
|
||
let b_i32 = B as i32;
|
||
let ema_idx_i32: i32 = TD_ERROR_MAG_EMA_INDEX as i32;
|
||
let sentinel: f32 = SENTINEL_TD_ERROR_MAG_EMA;
|
||
const BLOCK_DIM: u32 = 256;
|
||
let shmem_bytes: u32 = BLOCK_DIM * std::mem::size_of::<f32>() as u32;
|
||
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&td_buf.dev_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&isv_buf.dev_ptr)
|
||
.arg(&ema_idx_i32)
|
||
.arg(&alpha)
|
||
.arg(&sentinel)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (BLOCK_DIM, 1, 1),
|
||
shared_mem_bytes: shmem_bytes,
|
||
})
|
||
.expect("launch td_error_mag_ema_update");
|
||
}
|
||
stream.synchronize().expect("sync");
|
||
|
||
let isv_after = isv_buf.read_all();
|
||
let got = isv_after[TD_ERROR_MAG_EMA_INDEX];
|
||
let eps = 1e-6_f32;
|
||
assert!(
|
||
(got - expected).abs() < eps,
|
||
"TD-error mag EMA post-bootstrap blend: expected {expected:.6} \
|
||
(= (1 - {alpha}) × {prev} + {alpha} × {mean_abs}), got {got:.6}"
|
||
);
|
||
}
|
||
|
||
/// Empty-bin guard test — when no samples land in a given bin,
|
||
/// the kernel must emit 0.0 for every column (NOT NaN, which would
|
||
/// be the natural division-by-zero default). The HEALTH_DIAG
|
||
/// consumer relies on this contract: a zero block is interpreted as
|
||
/// "bin saw no traffic this epoch", whereas NaN would cascade
|
||
/// downstream into the KILL CRITERION arithmetic.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn reward_decomp_empty_bin_emits_zero_not_nan() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_reward_decomp_diag(&stream);
|
||
|
||
const N: usize = 1;
|
||
const NUM_BINS: usize = 4;
|
||
const NUM_COLS: usize = 5;
|
||
const OUT_LEN: usize = NUM_BINS * NUM_COLS;
|
||
|
||
// Single Long-only sample → bins Hold/Short/Flat are all empty.
|
||
let rc_host: Vec<f32> = vec![0.5, 0.0, 0.0, 0.3, 0.0, 0.0];
|
||
let rc_buf = unsafe { MappedF32Buffer::new(rc_host.len()) }
|
||
.expect("alloc rc buf");
|
||
rc_buf.write_from_slice(&rc_host);
|
||
|
||
const B1: i32 = 3; const B2: i32 = 3; const B3: i32 = 3;
|
||
const DIR_DIV: i32 = B1 * B2 * B3;
|
||
let actions_host: [i32; N] = [2 * DIR_DIV]; // Long
|
||
let actions_buf = unsafe { MappedI32Buffer::new(N) }
|
||
.expect("alloc actions buf");
|
||
actions_buf.write_from_slice(&actions_host);
|
||
|
||
let out_buf = unsafe { MappedF32Buffer::new(OUT_LEN) }
|
||
.expect("alloc out buf");
|
||
out_buf.write_from_slice(&vec![f32::NAN; OUT_LEN]);
|
||
|
||
let n_i32 = N as i32;
|
||
const BLOCK_DIM: u32 = 256;
|
||
let shmem_bytes: u32 = 5 * BLOCK_DIM * std::mem::size_of::<f32>() as u32;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&rc_buf.dev_ptr)
|
||
.arg(&actions_buf.dev_ptr)
|
||
.arg(&n_i32)
|
||
.arg(&B1)
|
||
.arg(&B2)
|
||
.arg(&B3)
|
||
.arg(&out_buf.dev_ptr)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (NUM_BINS as u32, 1, 1),
|
||
block_dim: (BLOCK_DIM, 1, 1),
|
||
shared_mem_bytes: shmem_bytes,
|
||
})
|
||
.expect("launch reward_decomp_diag");
|
||
}
|
||
stream.synchronize().expect("sync");
|
||
|
||
let out = out_buf.read_all();
|
||
// Hold (bin 0), Short (bin 2), Flat (bin 3) — all 5 cols must be 0.0.
|
||
for &bin in &[0_usize, 2, 3] {
|
||
for col in 0..NUM_COLS {
|
||
let v = out[bin * NUM_COLS + col];
|
||
assert!(
|
||
v == 0.0,
|
||
"empty-bin guard: bin={bin} col={col} expected 0.0, got {v}"
|
||
);
|
||
}
|
||
}
|
||
// Long (bin 1) — non-zero (sanity).
|
||
assert!(out[1 * NUM_COLS + 0] > 0.0, "long micro should be > 0");
|
||
assert!(out[1 * NUM_COLS + 4] > 0.0, "long fire should be > 0");
|
||
}
|
||
|
||
// ── SP18 v2 Phase 2 Task 2.3 (T2): GPU oracle for `compute_sp18_hold_opportunity_cost` ──
|
||
|
||
/// Cubin handle for the SP18 D-leg structural Hold opportunity-cost
|
||
/// device-function GPU oracle test wrapper.
|
||
const SP18_HOLD_OPP_TEST_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/sp18_hold_opp_test_kernel.cubin"));
|
||
|
||
fn load_sp18_hold_opp_test(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(SP18_HOLD_OPP_TEST_CUBIN.to_vec())
|
||
.expect("load sp18_hold_opp_test_kernel cubin");
|
||
module
|
||
.load_function("sp18_hold_opp_test_kernel")
|
||
.expect("load sp18_hold_opp_test_kernel function")
|
||
}
|
||
|
||
const DIR_HOLD_GPU: i32 = 1; // matches `state_layout.cuh:124`
|
||
|
||
/// CPU mirror for the GPU oracle assertions — duplicates the
|
||
/// production CPU oracle helper at the top of this file (which lives
|
||
/// outside the `gpu` mod due to `#[cfg(feature = "cuda")]` gating).
|
||
/// Keeping the math inline preserves the per-test readability without
|
||
/// re-exporting helpers across module boundaries.
|
||
fn cpu_oracle(
|
||
dir_idx: i32,
|
||
next_log_return: f32,
|
||
vol_proxy: f32,
|
||
isv_neg_cap: f32,
|
||
isv_pos_cap: f32,
|
||
shaping_scale: f32,
|
||
) -> f32 {
|
||
if dir_idx != DIR_HOLD_GPU {
|
||
return 0.0;
|
||
}
|
||
let vnr = next_log_return / (vol_proxy + 1e-12_f32).sqrt();
|
||
let base = 2.0_f32 * vnr;
|
||
let raw = -base;
|
||
let capped = isv_neg_cap.max(raw.min(isv_pos_cap));
|
||
capped * shaping_scale
|
||
}
|
||
|
||
/// Single-shot GPU oracle launch: writes one f32 to a 1-element
|
||
/// mapped-pinned output buffer; reads back via `read_volatile` after
|
||
/// stream sync.
|
||
fn launch_sp18_hold_opp(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
dir_idx: i32,
|
||
next_log_return: f32,
|
||
vol_proxy: f32,
|
||
isv_neg_cap: f32,
|
||
isv_pos_cap: f32,
|
||
shaping_scale: f32,
|
||
) -> f32 {
|
||
let out_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc out buf");
|
||
// Pre-fill with NaN so any unwritten output trips a finite-check
|
||
// failure in the caller (locks the kernel actually wrote).
|
||
out_buf.write_from_slice(&[f32::NAN]);
|
||
|
||
let dir_hold = DIR_HOLD_GPU;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&dir_idx)
|
||
.arg(&dir_hold)
|
||
.arg(&next_log_return)
|
||
.arg(&vol_proxy)
|
||
.arg(&isv_neg_cap)
|
||
.arg(&isv_pos_cap)
|
||
.arg(&shaping_scale)
|
||
.arg(&out_buf.dev_ptr)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("launch sp18_hold_opp_test_kernel");
|
||
}
|
||
stream.synchronize().expect("sync after sp18_hold_opp_test_kernel");
|
||
|
||
out_buf.read_all()[0]
|
||
}
|
||
|
||
/// T2 (spec §"Behavioral Test Predictions"): GPU oracle parity with
|
||
/// CPU oracle. Drives every Case A-F from the CPU oracle test through
|
||
/// the production device function via the test wrapper kernel and
|
||
/// asserts bit-equality to ε=1e-5 (f32 rounding budget — `expf`,
|
||
/// `sqrtf`, and the asymmetric clamp are deterministic to within a
|
||
/// few ulps on Ampere/Ada).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn compute_sp18_hold_opportunity_cost_gpu_oracle() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_sp18_hold_opp_test(&stream);
|
||
|
||
let eps = 1e-5_f32;
|
||
|
||
// Case A: positive next_log_return, dir=Hold → ~-1.0 (in [-10, 0]).
|
||
let inputs_a = (DIR_HOLD_GPU, 0.005_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 1.0_f32);
|
||
let cpu_a = cpu_oracle(inputs_a.0, inputs_a.1, inputs_a.2, inputs_a.3, inputs_a.4, inputs_a.5);
|
||
let gpu_a = launch_sp18_hold_opp(
|
||
&stream, &kernel, inputs_a.0, inputs_a.1, inputs_a.2, inputs_a.3, inputs_a.4, inputs_a.5,
|
||
);
|
||
assert!(gpu_a.is_finite(), "Case A: GPU output non-finite ({gpu_a})");
|
||
assert!((gpu_a - cpu_a).abs() < eps,
|
||
"Case A (pos log-return): GPU {gpu_a} vs CPU {cpu_a}");
|
||
|
||
// Case B: negative next_log_return → +1.0 (in [0, +5]).
|
||
let inputs_b = (DIR_HOLD_GPU, -0.005_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 1.0_f32);
|
||
let cpu_b = cpu_oracle(inputs_b.0, inputs_b.1, inputs_b.2, inputs_b.3, inputs_b.4, inputs_b.5);
|
||
let gpu_b = launch_sp18_hold_opp(
|
||
&stream, &kernel, inputs_b.0, inputs_b.1, inputs_b.2, inputs_b.3, inputs_b.4, inputs_b.5,
|
||
);
|
||
assert!(gpu_b.is_finite(), "Case B: GPU output non-finite ({gpu_b})");
|
||
assert!((gpu_b - cpu_b).abs() < eps,
|
||
"Case B (neg log-return): GPU {gpu_b} vs CPU {cpu_b}");
|
||
|
||
// Case C: dir != HOLD → 0.0 exactly (early-exit gate). Test all
|
||
// three non-HOLD directions.
|
||
for dir in [0_i32, 2, 3] {
|
||
let cpu_c = cpu_oracle(dir, 0.005, 0.0001, -10.0, 5.0, 1.0);
|
||
let gpu_c = launch_sp18_hold_opp(
|
||
&stream, &kernel, dir, 0.005, 0.0001, -10.0, 5.0, 1.0,
|
||
);
|
||
assert_eq!(gpu_c, 0.0,
|
||
"Case C dir={dir} (non-HOLD): GPU expected 0.0 exactly, got {gpu_c}");
|
||
assert_eq!(cpu_c, 0.0,
|
||
"Case C dir={dir} (non-HOLD): CPU expected 0.0 exactly, got {cpu_c}");
|
||
}
|
||
|
||
// Case D: positive saturation (+pos_cap).
|
||
let inputs_d = (DIR_HOLD_GPU, -0.5_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 1.0_f32);
|
||
let cpu_d = cpu_oracle(inputs_d.0, inputs_d.1, inputs_d.2, inputs_d.3, inputs_d.4, inputs_d.5);
|
||
let gpu_d = launch_sp18_hold_opp(
|
||
&stream, &kernel, inputs_d.0, inputs_d.1, inputs_d.2, inputs_d.3, inputs_d.4, inputs_d.5,
|
||
);
|
||
assert!((gpu_d - cpu_d).abs() < eps,
|
||
"Case D (+pos saturation): GPU {gpu_d} vs CPU {cpu_d}");
|
||
assert!((gpu_d - 5.0).abs() < eps,
|
||
"Case D: expected exact +pos_cap clamp at +5.0, got {gpu_d}");
|
||
|
||
// Case E: negative saturation (-neg_cap).
|
||
let inputs_e = (DIR_HOLD_GPU, 0.05_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 1.0_f32);
|
||
let cpu_e = cpu_oracle(inputs_e.0, inputs_e.1, inputs_e.2, inputs_e.3, inputs_e.4, inputs_e.5);
|
||
let gpu_e = launch_sp18_hold_opp(
|
||
&stream, &kernel, inputs_e.0, inputs_e.1, inputs_e.2, inputs_e.3, inputs_e.4, inputs_e.5,
|
||
);
|
||
assert!((gpu_e - cpu_e).abs() < eps,
|
||
"Case E (-neg saturation): GPU {gpu_e} vs CPU {cpu_e}");
|
||
assert!((gpu_e - (-10.0)).abs() < eps,
|
||
"Case E: expected exact -neg_cap clamp at -10.0, got {gpu_e}");
|
||
|
||
// Case F: shaping_scale=0 (validation mode) → 0.0 regardless.
|
||
let inputs_f = (DIR_HOLD_GPU, 0.005_f32, 0.0001_f32, -10.0_f32, 5.0_f32, 0.0_f32);
|
||
let gpu_f = launch_sp18_hold_opp(
|
||
&stream, &kernel, inputs_f.0, inputs_f.1, inputs_f.2, inputs_f.3, inputs_f.4, inputs_f.5,
|
||
);
|
||
assert_eq!(gpu_f, 0.0,
|
||
"Case F (shaping_scale=0): expected 0.0 exactly, got {gpu_f}");
|
||
}
|
||
|
||
// ── SP18 Phase 3 — D-leg adaptive HOLD_REWARD_POS/NEG_CAP producer ──
|
||
//
|
||
// Tests for `hold_reward_cap_update_kernel.cu`. Mirrors the SP14 P0-A
|
||
// `reward_cap_update_kernel` test pattern (sp14_oracle_tests.rs) with
|
||
// three structural differences:
|
||
// 1. Filter: (is_close && step_ret != 0) — both winners AND losers
|
||
// contribute, since the consumer needs the magnitude scale of
|
||
// *all* Long/Short closes.
|
||
// 2. Welford accumulators in slots [487..493) feed Wiener-optimal α
|
||
// — sample_count < 3 → α=1.0 (REPLACE), else
|
||
// α ∈ [WELFORD_ALPHA_MIN=0.4, WELFORD_ALPHA_MAX=0.95].
|
||
// 3. Bounds [HOLD_REWARD_POS_CAP_MIN_BOUND=0.5,
|
||
// HOLD_REWARD_POS_CAP_MAX_BOUND=50.0] (vs. [1.0, 50.0] for the
|
||
// position-side cap).
|
||
//
|
||
// All tests are #[ignore = "requires GPU"]; gated under
|
||
// `#[cfg(feature = "cuda")]` (we are already inside that gate).
|
||
|
||
/// Cubin handle for the SP18 D-leg adaptive Hold-reward-cap producer.
|
||
const SP18_HOLD_REWARD_CAP_UPDATE_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/hold_reward_cap_update_kernel.cubin"));
|
||
|
||
fn load_hold_reward_cap_update(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(SP18_HOLD_REWARD_CAP_UPDATE_CUBIN.to_vec())
|
||
.expect("load hold_reward_cap_update_kernel cubin");
|
||
module
|
||
.load_function("hold_reward_cap_update")
|
||
.expect("load hold_reward_cap_update function")
|
||
}
|
||
|
||
/// 4 arrays × 256 × 4 bytes = 4096 bytes shmem (matches kernel).
|
||
const HRC_BLK_DIM: u32 = 256;
|
||
const HRC_SMEM_BYTES: u32 = 4 * HRC_BLK_DIM * std::mem::size_of::<f32>() as u32;
|
||
|
||
/// Helper: launch the SP18 hold-reward-cap producer with a fixed
|
||
/// config. Mirrors the position-side `launch_reward_cap` helper but
|
||
/// passes the 6 Welford slot indices instead of a single fixed α.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn launch_hold_reward_cap(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
step_ret_ptr: u64,
|
||
trade_close_ptr: u64,
|
||
total_samples: i32,
|
||
isv_ptr: u64,
|
||
pos_idx: i32,
|
||
neg_idx: i32,
|
||
target_mean_idx: i32,
|
||
target_m2_idx: i32,
|
||
diff_mean_idx: i32,
|
||
diff_m2_idx: i32,
|
||
prev_target_idx: i32,
|
||
sample_count_idx: i32,
|
||
sentinel_pos: f32,
|
||
sentinel_neg: f32,
|
||
safety_factor: f32,
|
||
neg_to_pos_ratio: f32,
|
||
pos_min: f32,
|
||
pos_max: f32,
|
||
) {
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&step_ret_ptr)
|
||
.arg(&trade_close_ptr)
|
||
.arg(&total_samples)
|
||
.arg(&isv_ptr)
|
||
.arg(&pos_idx)
|
||
.arg(&neg_idx)
|
||
.arg(&target_mean_idx)
|
||
.arg(&target_m2_idx)
|
||
.arg(&diff_mean_idx)
|
||
.arg(&diff_m2_idx)
|
||
.arg(&prev_target_idx)
|
||
.arg(&sample_count_idx)
|
||
.arg(&sentinel_pos)
|
||
.arg(&sentinel_neg)
|
||
.arg(&safety_factor)
|
||
.arg(&neg_to_pos_ratio)
|
||
.arg(&pos_min)
|
||
.arg(&pos_max)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (HRC_BLK_DIM, 1, 1),
|
||
shared_mem_bytes: HRC_SMEM_BYTES,
|
||
})
|
||
.expect("launch hold_reward_cap_update");
|
||
}
|
||
stream.synchronize().expect("sync after hold_reward_cap_update");
|
||
}
|
||
|
||
/// SP18 Phase 3 T5 — adaptive Hold-reward-cap producer drives slots
|
||
/// [483]/[484] from Long/Short trade-close p99.
|
||
///
|
||
/// Construct 100 simulated Long/Short close events with `step_ret`
|
||
/// drawn from a centred Gaussian-ish distribution scaled to ~0.01
|
||
/// (per the spec's T5 anchor: `step_ret ∈ N(0, 0.01)`). Mix winners
|
||
/// (positive) and losers (negative) so the kernel's
|
||
/// `(is_close && step_ret != 0)` filter takes both. Also include
|
||
/// non-close samples (trade_close=0) with non-zero step_ret to
|
||
/// verify they're FILTERED OUT (close events only contribute).
|
||
///
|
||
/// Per the spec's T5 acceptance: ISV[483] ≈ p99(|step_ret|) × 1.5
|
||
/// (small, ~0.04 for σ ≈ 0.01); ISV[484] = −2 × ISV[483]
|
||
/// (mirrored); both within [HOLD_REWARD_POS_CAP_MIN_BOUND=0.5,
|
||
/// HOLD_REWARD_POS_CAP_MAX_BOUND=50.0]. The lower-bound clamp
|
||
/// at 0.5 is the dominating constraint at this magnitude scale —
|
||
/// verifying the bilateral clamp activation per
|
||
/// `pearl_symmetric_clamp_audit.md`.
|
||
///
|
||
/// Jitter: per `pearl_sp4_histogram_warp_tile_undercount`, synthetic
|
||
/// uniform test data can trip warp-tile undercount in
|
||
/// `sp4_histogram_p99`. This producer doesn't use `sp4_histogram_p99`
|
||
/// (it uses Welford `mean + Z_99 × sigma` like the position-side
|
||
/// kernel), but the input is jittered anyway as defensive practice.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn hold_reward_cap_producer_drives_slots() {
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
HOLD_REWARD_CAP_SAFETY_FACTOR, HOLD_REWARD_NEG_CAP_INDEX, HOLD_REWARD_POS_CAP_INDEX,
|
||
HOLD_REWARD_POS_CAP_MAX_BOUND, HOLD_REWARD_POS_CAP_MIN_BOUND, HRC_DIFF_M2_INDEX,
|
||
HRC_DIFF_MEAN_INDEX, HRC_PREV_TARGET_INDEX, HRC_SAMPLE_COUNT_INDEX,
|
||
HRC_TARGET_M2_INDEX, HRC_TARGET_MEAN_INDEX, REWARD_NEG_TO_POS_RATIO,
|
||
SENTINEL_HOLD_REWARD_NEG_CAP, SENTINEL_HOLD_REWARD_POS_CAP, SENTINEL_WELFORD_ZERO,
|
||
};
|
||
|
||
let stream = make_test_stream();
|
||
let kernel = load_hold_reward_cap_update(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
|
||
// Build 100 Long/Short close events with jittered ~N(0, 0.01)
|
||
// step_ret. Mix winners and losers; sprinkle in non-close
|
||
// samples that should be filtered out.
|
||
let n_close = 100usize;
|
||
let n_filler = 50usize;
|
||
let total = n_close + n_filler;
|
||
let mut step_ret = vec![0.0_f32; total];
|
||
let mut trade_close = vec![0_i32; total];
|
||
|
||
// Deterministic pseudo-random: alternating signs + slight phase
|
||
// jitter so the histogram (had we used one) wouldn't undercount.
|
||
for i in 0..n_close {
|
||
let sign = if i % 2 == 0 { 1.0_f32 } else { -1.0_f32 };
|
||
// Stretch the magnitude across [0.001, 0.025] so p99 ≈ 0.025.
|
||
let mag = 0.001 + (i as f32 / n_close as f32) * 0.024 + (i as f32 * 0.0001);
|
||
step_ret[i] = sign * mag;
|
||
trade_close[i] = 1;
|
||
}
|
||
// Filler: non-close samples with non-zero step_ret. The kernel
|
||
// MUST filter these out via `is_close == 0`.
|
||
for i in 0..n_filler {
|
||
step_ret[n_close + i] = 1000.0; // outlier magnitude
|
||
trade_close[n_close + i] = 0; // not a close
|
||
}
|
||
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// Cold-start: sentinels match SP18 PP.2 sentinel allocations.
|
||
isv[HOLD_REWARD_POS_CAP_INDEX] = SENTINEL_HOLD_REWARD_POS_CAP;
|
||
isv[HOLD_REWARD_NEG_CAP_INDEX] = SENTINEL_HOLD_REWARD_NEG_CAP;
|
||
isv[HRC_TARGET_MEAN_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_TARGET_M2_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_DIFF_MEAN_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_DIFF_M2_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_PREV_TARGET_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_SAMPLE_COUNT_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
|
||
let n = total as i32;
|
||
let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
|
||
.expect("alloc step_ret");
|
||
ret_buf.write_from_slice(&step_ret);
|
||
let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
|
||
.expect("alloc trade_close");
|
||
close_buf.write_from_slice(&trade_close);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_hold_reward_cap(
|
||
&stream, &kernel,
|
||
ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
|
||
HOLD_REWARD_POS_CAP_INDEX as i32,
|
||
HOLD_REWARD_NEG_CAP_INDEX as i32,
|
||
HRC_TARGET_MEAN_INDEX as i32,
|
||
HRC_TARGET_M2_INDEX as i32,
|
||
HRC_DIFF_MEAN_INDEX as i32,
|
||
HRC_DIFF_M2_INDEX as i32,
|
||
HRC_PREV_TARGET_INDEX as i32,
|
||
HRC_SAMPLE_COUNT_INDEX as i32,
|
||
SENTINEL_HOLD_REWARD_POS_CAP,
|
||
SENTINEL_HOLD_REWARD_NEG_CAP,
|
||
HOLD_REWARD_CAP_SAFETY_FACTOR,
|
||
REWARD_NEG_TO_POS_RATIO,
|
||
HOLD_REWARD_POS_CAP_MIN_BOUND,
|
||
HOLD_REWARD_POS_CAP_MAX_BOUND,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let pos = result[HOLD_REWARD_POS_CAP_INDEX];
|
||
let neg = result[HOLD_REWARD_NEG_CAP_INDEX];
|
||
|
||
// The unclamped target_cap at this scale is ~p99 × 1.5 ≈
|
||
// 0.025 × 1.5 ≈ 0.04, well below HOLD_REWARD_POS_CAP_MIN_BOUND
|
||
// = 0.5. The bilateral clamp pre-blend pulls it up to the
|
||
// floor. Pearl-A then REPLACES the sentinel with the clamped
|
||
// value → POS = 0.5 exactly.
|
||
assert!(
|
||
(pos - HOLD_REWARD_POS_CAP_MIN_BOUND).abs() < 1e-5,
|
||
"Pearl-A bootstrap: small p99 → bilateral clamp pulls POS to MIN_BOUND. \
|
||
Expected POS={HOLD_REWARD_POS_CAP_MIN_BOUND}, got {pos}",
|
||
);
|
||
|
||
// POS must be within bounds.
|
||
assert!(
|
||
pos >= HOLD_REWARD_POS_CAP_MIN_BOUND && pos <= HOLD_REWARD_POS_CAP_MAX_BOUND,
|
||
"POS out of bounds: {pos} not in [{HOLD_REWARD_POS_CAP_MIN_BOUND}, {HOLD_REWARD_POS_CAP_MAX_BOUND}]",
|
||
);
|
||
|
||
// NEG = −2 × POS (DD5=b mirrored asymmetry, single source of
|
||
// truth at producer time).
|
||
assert!(
|
||
(neg - (-REWARD_NEG_TO_POS_RATIO * pos)).abs() < 1e-4,
|
||
"NEG must equal −{REWARD_NEG_TO_POS_RATIO} × POS at producer-time \
|
||
(Kahneman 2:1 single source of truth); got POS={pos}, NEG={neg}",
|
||
);
|
||
|
||
// NEG within derived range [−ratio × pos_max, −ratio × pos_min] = [−100, −1].
|
||
let neg_lo = -REWARD_NEG_TO_POS_RATIO * HOLD_REWARD_POS_CAP_MAX_BOUND;
|
||
let neg_hi = -REWARD_NEG_TO_POS_RATIO * HOLD_REWARD_POS_CAP_MIN_BOUND;
|
||
assert!(
|
||
neg >= neg_lo && neg <= neg_hi,
|
||
"NEG out of bounds: {neg} not in [{neg_lo}, {neg_hi}]",
|
||
);
|
||
|
||
// Welford state updated: sample_count == 1 (first observation).
|
||
let count = result[HRC_SAMPLE_COUNT_INDEX];
|
||
assert!((count - 1.0).abs() < 1e-5,
|
||
"First-observation Welford: sample_count expected 1.0, got {count}");
|
||
|
||
// Filler outliers MUST NOT have contributed (they were
|
||
// is_close=0). We can't read max directly from ISV, but we
|
||
// can verify the cap stayed clamped to 0.5 — if the filler
|
||
// outliers had leaked, target_cap would be ≈ 1000 × 1.5 = 1500,
|
||
// clamped to MAX_BOUND=50.0. The fact POS == 0.5 proves the
|
||
// filter worked.
|
||
assert!(
|
||
pos < 1.0,
|
||
"Filter regression: filler outliers (close=0) leaked into accumulator; \
|
||
cap should be clamped to MIN_BOUND=0.5, got {pos}",
|
||
);
|
||
}
|
||
|
||
/// SP18 Phase 3 — Pearl-A first-observation bootstrap test.
|
||
///
|
||
/// Sentinel state → first observation REPLACES (no blend). Verify
|
||
/// ISV[483] equals `target_cap × 1.5` post-clamp exactly, NOT
|
||
/// blended with the sentinel 5.0. Per
|
||
/// `pearl_first_observation_bootstrap.md` the kernel must detect
|
||
/// `|isv[pos_idx] − sentinel_pos| < EPS_F` and route through
|
||
/// REPLACE (α=1.0), not the standard EMA blend.
|
||
///
|
||
/// Construction: 4 closes with `step_ret = [+10, +20, +30, +40]`
|
||
/// (large magnitudes — ensures the unclamped target_cap is well
|
||
/// above MIN_BOUND so Pearl-A's REPLACE is observable). Welford
|
||
/// rule on |step_ret|: mean=25, var = (100+400+900+1600)/4 − 625
|
||
/// = 750 − 625 = 125, sigma ≈ 11.18, p99 ≈ 25 + 2.326 × 11.18 ≈
|
||
/// 51.0. max=40. Estimator picks `max(p99, max) = 51.0`. ×1.5 =
|
||
/// 76.5. Clamped to MAX_BOUND=50.0 → POS = 50.0.
|
||
///
|
||
/// With sample_count < WELFORD_MIN_SAMPLE_COUNT_F=3, α=1.0
|
||
/// (cold-start REPLACE per `pearl_wiener_alpha_floor_for_nonstationary`
|
||
/// — Welford variance not yet defined). Combined with Pearl-A
|
||
/// sentinel detection, the result is bit-identical to a direct
|
||
/// REPLACE: blended = 50.0, NOT (1−α)×5.0 + α×50.0 if α had been
|
||
/// some EMA value.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn hold_reward_cap_pearl_a_first_observation_replace() {
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
HOLD_REWARD_CAP_SAFETY_FACTOR, HOLD_REWARD_NEG_CAP_INDEX, HOLD_REWARD_POS_CAP_INDEX,
|
||
HOLD_REWARD_POS_CAP_MAX_BOUND, HOLD_REWARD_POS_CAP_MIN_BOUND, HRC_DIFF_M2_INDEX,
|
||
HRC_DIFF_MEAN_INDEX, HRC_PREV_TARGET_INDEX, HRC_SAMPLE_COUNT_INDEX,
|
||
HRC_TARGET_M2_INDEX, HRC_TARGET_MEAN_INDEX, REWARD_NEG_TO_POS_RATIO,
|
||
SENTINEL_HOLD_REWARD_NEG_CAP, SENTINEL_HOLD_REWARD_POS_CAP, SENTINEL_WELFORD_ZERO,
|
||
};
|
||
|
||
let stream = make_test_stream();
|
||
let kernel = load_hold_reward_cap_update(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
|
||
// 4 closes with mixed-sign large step_ret values. The kernel's
|
||
// |step_ret| filter takes both winners and losers.
|
||
let step_ret: Vec<f32> = vec![10.0, -20.0, 30.0, -40.0];
|
||
let trade_close: Vec<i32> = vec![1, 1, 1, 1];
|
||
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[HOLD_REWARD_POS_CAP_INDEX] = SENTINEL_HOLD_REWARD_POS_CAP; // 5.0
|
||
isv[HOLD_REWARD_NEG_CAP_INDEX] = SENTINEL_HOLD_REWARD_NEG_CAP; // -10.0
|
||
isv[HRC_TARGET_MEAN_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_TARGET_M2_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_DIFF_MEAN_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_DIFF_M2_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_PREV_TARGET_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
isv[HRC_SAMPLE_COUNT_INDEX] = SENTINEL_WELFORD_ZERO;
|
||
|
||
let n = step_ret.len() as i32;
|
||
let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
|
||
.expect("alloc step_ret");
|
||
ret_buf.write_from_slice(&step_ret);
|
||
let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
|
||
.expect("alloc trade_close");
|
||
close_buf.write_from_slice(&trade_close);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_hold_reward_cap(
|
||
&stream, &kernel,
|
||
ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
|
||
HOLD_REWARD_POS_CAP_INDEX as i32,
|
||
HOLD_REWARD_NEG_CAP_INDEX as i32,
|
||
HRC_TARGET_MEAN_INDEX as i32,
|
||
HRC_TARGET_M2_INDEX as i32,
|
||
HRC_DIFF_MEAN_INDEX as i32,
|
||
HRC_DIFF_M2_INDEX as i32,
|
||
HRC_PREV_TARGET_INDEX as i32,
|
||
HRC_SAMPLE_COUNT_INDEX as i32,
|
||
SENTINEL_HOLD_REWARD_POS_CAP,
|
||
SENTINEL_HOLD_REWARD_NEG_CAP,
|
||
HOLD_REWARD_CAP_SAFETY_FACTOR,
|
||
REWARD_NEG_TO_POS_RATIO,
|
||
HOLD_REWARD_POS_CAP_MIN_BOUND,
|
||
HOLD_REWARD_POS_CAP_MAX_BOUND,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let pos = result[HOLD_REWARD_POS_CAP_INDEX];
|
||
let neg = result[HOLD_REWARD_NEG_CAP_INDEX];
|
||
|
||
// Pearl-A REPLACE: target_cap = max(p99, max) × 1.5 ≈ max(51, 40)
|
||
// × 1.5 = 76.5. Bilateral clamp to [0.5, 50.0] → 50.0. The
|
||
// sentinel-detection branch in the kernel writes blended = 50.0
|
||
// directly (not (1−α)×5.0 + α×50.0). With α=1.0 these would
|
||
// collapse to the same value, but the test pins behaviour:
|
||
// the kernel MUST take the `if (|current − sentinel| < EPS)`
|
||
// branch and write target_cap unblended.
|
||
assert!(
|
||
(pos - HOLD_REWARD_POS_CAP_MAX_BOUND).abs() < 1e-4,
|
||
"Pearl-A REPLACE on sentinel: expected POS exactly at MAX_BOUND={HOLD_REWARD_POS_CAP_MAX_BOUND}, \
|
||
got {pos}",
|
||
);
|
||
|
||
// Confirm we did NOT blend with sentinel: a blended result with
|
||
// any α < 1 would land between 5.0 and 50.0, never exactly at
|
||
// the clamp boundary.
|
||
assert!(pos > 49.99 && pos <= 50.01,
|
||
"Pearl-A signature: POS ≈ MAX_BOUND exactly (no sentinel blend), got {pos}");
|
||
|
||
// NEG mirrors at producer time.
|
||
assert!(
|
||
(neg - (-REWARD_NEG_TO_POS_RATIO * pos)).abs() < 1e-4,
|
||
"NEG must equal −{REWARD_NEG_TO_POS_RATIO} × POS; got POS={pos}, NEG={neg}",
|
||
);
|
||
assert!(
|
||
(neg - (-100.0)).abs() < 1e-3,
|
||
"NEG = −2 × 50.0 = −100; got {neg}",
|
||
);
|
||
|
||
// sample_count == 1 (first observation).
|
||
let count = result[HRC_SAMPLE_COUNT_INDEX];
|
||
assert!((count - 1.0).abs() < 1e-5,
|
||
"Welford sample_count after 1 observation: expected 1.0, got {count}");
|
||
|
||
// prev_target stores the unclamped target_cap (= max(p99, max)
|
||
// × safety_factor) for the next launch's diff. Useful sanity:
|
||
// proves the Welford state is being updated even though the
|
||
// CAP write was REPLACED.
|
||
let prev = result[HRC_PREV_TARGET_INDEX];
|
||
assert!(prev > 1.0,
|
||
"prev_target should be the unclamped target_cap (~76.5), got {prev}");
|
||
}
|
||
|
||
/// SP18 Phase 3 — no Long/Short closes ⇒ ISV slots preserved bit-exactly.
|
||
///
|
||
/// All trade_close=0 (no closes) AND all step_ret=0. The kernel
|
||
/// must skip the EMA update (count==0 guard) and leave POS, NEG,
|
||
/// AND every Welford accumulator at their seeded values.
|
||
///
|
||
/// Models the cold-start "Hold-heavy phase with no real trade
|
||
/// activity" regime — exactly the case where the producer SHOULD
|
||
/// keep the cap at sentinel and let the consumer fall back to the
|
||
/// structural cold-start sentinel value (5.0/-10.0).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn hold_reward_cap_no_closes_preserves_isv() {
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
HOLD_REWARD_CAP_SAFETY_FACTOR, HOLD_REWARD_NEG_CAP_INDEX, HOLD_REWARD_POS_CAP_INDEX,
|
||
HOLD_REWARD_POS_CAP_MAX_BOUND, HOLD_REWARD_POS_CAP_MIN_BOUND, HRC_DIFF_M2_INDEX,
|
||
HRC_DIFF_MEAN_INDEX, HRC_PREV_TARGET_INDEX, HRC_SAMPLE_COUNT_INDEX,
|
||
HRC_TARGET_M2_INDEX, HRC_TARGET_MEAN_INDEX, REWARD_NEG_TO_POS_RATIO,
|
||
SENTINEL_HOLD_REWARD_NEG_CAP, SENTINEL_HOLD_REWARD_POS_CAP,
|
||
};
|
||
|
||
let stream = make_test_stream();
|
||
let kernel = load_hold_reward_cap_update(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
// Seed with deliberately non-sentinel values to verify
|
||
// bit-exact preservation (NOT just "sentinel was preserved").
|
||
const SEED_POS: f32 = 12.5;
|
||
const SEED_NEG: f32 = -25.0;
|
||
const SEED_TARGET_MEAN: f32 = 7.7;
|
||
const SEED_TARGET_M2: f32 = 0.123;
|
||
const SEED_DIFF_MEAN: f32 = 0.05;
|
||
const SEED_DIFF_M2: f32 = 0.0001;
|
||
const SEED_PREV_TARGET: f32 = 7.6;
|
||
const SEED_SAMPLE_COUNT: f32 = 5.0;
|
||
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[HOLD_REWARD_POS_CAP_INDEX] = SEED_POS;
|
||
isv[HOLD_REWARD_NEG_CAP_INDEX] = SEED_NEG;
|
||
isv[HRC_TARGET_MEAN_INDEX] = SEED_TARGET_MEAN;
|
||
isv[HRC_TARGET_M2_INDEX] = SEED_TARGET_M2;
|
||
isv[HRC_DIFF_MEAN_INDEX] = SEED_DIFF_MEAN;
|
||
isv[HRC_DIFF_M2_INDEX] = SEED_DIFF_M2;
|
||
isv[HRC_PREV_TARGET_INDEX] = SEED_PREV_TARGET;
|
||
isv[HRC_SAMPLE_COUNT_INDEX] = SEED_SAMPLE_COUNT;
|
||
|
||
// Step returns present but no closes — none should contribute.
|
||
let step_ret: Vec<f32> = vec![3.0, 5.0, -2.0];
|
||
let trade_close: Vec<i32> = vec![0, 0, 0];
|
||
|
||
let n = step_ret.len() as i32;
|
||
let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
|
||
.expect("alloc step_ret");
|
||
ret_buf.write_from_slice(&step_ret);
|
||
let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
|
||
.expect("alloc trade_close");
|
||
close_buf.write_from_slice(&trade_close);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_hold_reward_cap(
|
||
&stream, &kernel,
|
||
ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
|
||
HOLD_REWARD_POS_CAP_INDEX as i32,
|
||
HOLD_REWARD_NEG_CAP_INDEX as i32,
|
||
HRC_TARGET_MEAN_INDEX as i32,
|
||
HRC_TARGET_M2_INDEX as i32,
|
||
HRC_DIFF_MEAN_INDEX as i32,
|
||
HRC_DIFF_M2_INDEX as i32,
|
||
HRC_PREV_TARGET_INDEX as i32,
|
||
HRC_SAMPLE_COUNT_INDEX as i32,
|
||
SENTINEL_HOLD_REWARD_POS_CAP,
|
||
SENTINEL_HOLD_REWARD_NEG_CAP,
|
||
HOLD_REWARD_CAP_SAFETY_FACTOR,
|
||
REWARD_NEG_TO_POS_RATIO,
|
||
HOLD_REWARD_POS_CAP_MIN_BOUND,
|
||
HOLD_REWARD_POS_CAP_MAX_BOUND,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
// Every slot must be preserved bit-exactly.
|
||
assert_eq!(result[HOLD_REWARD_POS_CAP_INDEX], SEED_POS,
|
||
"POS preserved bit-exactly when no closes");
|
||
assert_eq!(result[HOLD_REWARD_NEG_CAP_INDEX], SEED_NEG,
|
||
"NEG preserved bit-exactly when no closes");
|
||
assert_eq!(result[HRC_TARGET_MEAN_INDEX], SEED_TARGET_MEAN,
|
||
"target_mean preserved");
|
||
assert_eq!(result[HRC_TARGET_M2_INDEX], SEED_TARGET_M2,
|
||
"target_M2 preserved");
|
||
assert_eq!(result[HRC_DIFF_MEAN_INDEX], SEED_DIFF_MEAN,
|
||
"diff_mean preserved");
|
||
assert_eq!(result[HRC_DIFF_M2_INDEX], SEED_DIFF_M2,
|
||
"diff_M2 preserved");
|
||
assert_eq!(result[HRC_PREV_TARGET_INDEX], SEED_PREV_TARGET,
|
||
"prev_target preserved");
|
||
assert_eq!(result[HRC_SAMPLE_COUNT_INDEX], SEED_SAMPLE_COUNT,
|
||
"sample_count preserved");
|
||
}
|
||
}
|