Files
foxhunt/crates/ml/tests/sp18_hold_reward_oracle_tests.rs
jgrusewski 5ea5aa9b8e feat(sp18 v2 P3.T1-T5): adaptive HOLD_REWARD_POS/NEG_CAP producer kernel
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>
2026-05-09 11:05:03 +02:00

1255 lines
55 KiB
Rust
Raw 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.
//! 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");
}
}