Lands the per-bar Hold opportunity-cost dual emission at
experience_env_step's per-bar branches (positioned-non-event ~3650 +
flat-non-event ~3737), atomically with the trade-close consumer
wiring at the segment_complete branch (~3289 — replaces the Phase 2
Task 2.2 forward-reference placeholder `hold_baseline = 0.0f`) per
`feedback_no_partial_refactor`.
Spec §4.2 dual-emission contract:
per_bar_opp_cost = -aux_conf × cost_scale
Path 1 (Hold-only): r_micro/r_opp_cost += per_bar_opp_cost
- ISV[HOLD_REWARD_EMA]
(centered for Q-target stability)
Path 2 (always): hold_baseline_buffer[env, t % 30] = per_bar_opp_cost
(uncentered, consumed at trade close)
At trade close:
hold_baseline = sp20_sum_hold_baseline_over_trade(buf, env, 30,
current_t,
segment_hold_time)
alpha = R_event - hold_baseline # replaces Phase 2 placeholder
The summation walks backwards `min(30, segment_hold_time)` slots from
`(current_t - 1) % 30` in env i's row of the per-env circular buffer.
The trade-close bar's segment_complete branch does NOT write to the
buffer, so the most recent per-bar write is at current_t - 1.
Layout decision (plan errata Gap 9): per-env stride
`[N_envs × HOLD_BASELINE_BUFFER_SIZE = 30]` row-major. The kernel is
per-env-parallel; a single global ring would interleave bars across
envs and break trade-attribution semantic.
Trade-open tracking decision (plan errata Gap 10): NO new state slot
needed. The existing `segment_hold_time` (= saved_hold_time captured
before PS_HOLD_TIME reset at experience_kernels.cu:2618) plus
current_t suffice — walk backwards in the buffer.
Replaces SP18 D-leg `compute_sp18_hold_opportunity_cost` calls at
experience_kernels.cu:3672 and :3783. The device function in
trade_physics.cuh:655 is RETAINED — still called by
sp18_hold_opp_test_kernel.cu (oracle test surface). Per
`feedback_no_stubs`: only production callers migrate; the helper
stays for the test surface.
New device helpers (sp20_hold_baseline.cuh):
- sp20_compute_per_bar_aux_conf_k2(logits) — bit-identical to the
formula in sp20_stats_compute_kernel.cu Pass A.
- sp20_sum_hold_baseline_over_trade(buf, env, size, current_t,
segment_hold_time) — walks backwards with modulo wrap-around.
New buffer + reset infrastructure:
- GpuExperienceCollector.hold_baseline_buffer field
- HOLD_BASELINE_BUFFER_SIZE = 30 constant (re-exported)
- StateResetRegistry FoldReset entry + invariant test
- reset_named_state dispatch arm
Six new kernel args appended to experience_env_step signature:
aux_logits_per_env, hold_baseline_buffer, hold_buffer_size, aux_k,
hold_cost_scale_idx, hold_reward_ema_idx. All NULL-tolerant.
HOLD_REWARD_EMA forward-reference: ISV[516] is updated by
sp20_emas_compute_kernel reading per_bar_hold_reward from
sp20_aggregate_inputs_kernel which currently emits 0.0f as a Phase
3.2 forward-ref placeholder (line 292). The parallel `sp20-phase-2-fix`
agent owns wiring the real producer; Task 3.2's centering math
references the ISV slot (not a hardcoded 0), so when the parallel
branch lands, EMA starts updating and centering becomes load-bearing
automatically. No additional Task 3.2 work needed post-merge.
Tests (sp20_hold_baseline_test.rs, 4 GPU oracle tests):
1. aux_conf_k2_bounds_and_fixed_points — bounds, uniform/saturated/
spec-warmup/spec-confident/symmetry fixed points.
2. sum_hold_baseline_over_trade_indexing — basic indexing,
hold_time>size clamp, defensive guards, modulo wrap-around.
3. sum_hold_baseline_per_env_stride — env-stride correctness across
row-major buffer.
4. dual_emission_hold_vs_non_hold — full Path 1 + Path 2 contract,
mirrors plan §3.2 reference fixture.
Verification:
SQLX_OFFLINE=true cargo check -p ml --tests --features cuda # green
SQLX_OFFLINE=true cargo test -p ml --lib sp20 # 21/21 pass
SQLX_OFFLINE=true cargo build -p ml # cubin compile
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
413 lines
17 KiB
Rust
413 lines
17 KiB
Rust
//! SP20 Phase 3 Task 3.2 (2026-05-10) — `sp20_hold_baseline.cuh` GPU oracle tests.
|
||
//!
|
||
//! Verifies the two device helpers in `sp20_hold_baseline.cuh`
|
||
//! (Component 2 / Hold opportunity-cost dual emission per spec §4.2):
|
||
//!
|
||
//! - `sp20_compute_per_bar_aux_conf_k2(logits)`:
|
||
//! Per-row K=2 peak-confidence above the K=2 uniform mean.
|
||
//! `aux_conf = max(softmax(logits)) - 0.5` ∈ [0, 0.5].
|
||
//!
|
||
//! - `sp20_sum_hold_baseline_over_trade(buf, env, size, current_t,
|
||
//! segment_hold_time)`:
|
||
//! Sum the per-env circular buffer over the most recent
|
||
//! `min(size, segment_hold_time)` slots BEFORE `current_t`.
|
||
//!
|
||
//! - The full dual-emission shape (mirrors the production per-bar
|
||
//! emission site at `experience_env_step` per-bar branches): Path 1
|
||
//! centered Hold reward AND Path 2 uncentered buffer write, with
|
||
//! the Hold-only / always action gating contract.
|
||
//!
|
||
//! Tests assert invariants per
|
||
//! `pearl_tests_must_prove_not_lock_observations` (boundedness,
|
||
//! formula correctness, fixed points), not observed-from-a-regression-
|
||
//! run values.
|
||
//!
|
||
//! Run on a GPU host:
|
||
//!
|
||
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
|
||
//! cargo test -p ml --test sp20_hold_baseline_test --features cuda \
|
||
//! -- --ignored --nocapture
|
||
|
||
#![allow(clippy::tests_outside_test_module)]
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)]
|
||
mod gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, PushKernelArg};
|
||
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
|
||
|
||
const TEST_CUBIN: &[u8] = include_bytes!(concat!(
|
||
env!("OUT_DIR"),
|
||
"/sp20_hold_baseline_test_kernel.cubin"
|
||
));
|
||
|
||
fn make_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_fn(stream: &Arc<CudaStream>, name: &str) -> CudaFunction {
|
||
let m = stream
|
||
.context()
|
||
.load_cubin(TEST_CUBIN.to_vec())
|
||
.expect("load sp20_hold_baseline_test_kernel cubin");
|
||
m.load_function(name)
|
||
.unwrap_or_else(|e| panic!("load {name}: {e}"))
|
||
}
|
||
|
||
/// Helper: run `sp20_per_bar_aux_conf_k2_test_kernel` once with the
|
||
/// given logits and return the resulting aux_conf.
|
||
fn run_per_bar_aux_conf_k2(logits: [f32; 2]) -> f32 {
|
||
let stream = make_stream();
|
||
let kernel = load_fn(&stream, "sp20_per_bar_aux_conf_k2_test_kernel");
|
||
|
||
let logits_buf = unsafe { MappedF32Buffer::new(2) }.expect("alloc logits");
|
||
logits_buf.write_from_slice(&logits);
|
||
let out_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc out");
|
||
out_buf.write_from_slice(&[0.0_f32]);
|
||
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&logits_buf.dev_ptr)
|
||
.arg(&out_buf.dev_ptr)
|
||
.launch(cudarc::driver::LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("launch sp20_per_bar_aux_conf_k2_test_kernel");
|
||
}
|
||
stream
|
||
.synchronize()
|
||
.expect("sync after sp20_per_bar_aux_conf_k2_test_kernel");
|
||
out_buf.read_all()[0]
|
||
}
|
||
|
||
/// Helper: run `sp20_sum_hold_baseline_over_trade_test_kernel` once
|
||
/// with the given buffer + indexing args and return the resulting sum.
|
||
fn run_sum_hold_baseline(
|
||
buf: &[f32],
|
||
env: i32,
|
||
hold_buffer_size: i32,
|
||
current_t: i32,
|
||
segment_hold_time: f32,
|
||
) -> f32 {
|
||
let stream = make_stream();
|
||
let kernel = load_fn(
|
||
&stream,
|
||
"sp20_sum_hold_baseline_over_trade_test_kernel",
|
||
);
|
||
|
||
let buf_dev = unsafe { MappedF32Buffer::new(buf.len()) }.expect("alloc buf");
|
||
buf_dev.write_from_slice(buf);
|
||
let out_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc out");
|
||
out_buf.write_from_slice(&[0.0_f32]);
|
||
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&buf_dev.dev_ptr)
|
||
.arg(&env)
|
||
.arg(&hold_buffer_size)
|
||
.arg(¤t_t)
|
||
.arg(&segment_hold_time)
|
||
.arg(&out_buf.dev_ptr)
|
||
.launch(cudarc::driver::LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("launch sp20_sum_hold_baseline_over_trade_test_kernel");
|
||
}
|
||
stream
|
||
.synchronize()
|
||
.expect("sync after sum_hold_baseline_over_trade");
|
||
out_buf.read_all()[0]
|
||
}
|
||
|
||
/// ── Test 1: aux_conf bounds + uniform/peak fixed points ──────────
|
||
///
|
||
/// `aux_conf = max(softmax(logits)) - 0.5` ∈ [0, 0.5].
|
||
///
|
||
/// Fixed points:
|
||
/// - Equal logits ⇒ peak_softmax = 0.5 ⇒ aux_conf = 0.0 (uniform)
|
||
/// - Δlogit → ∞ ⇒ peak_softmax → 1.0 ⇒ aux_conf → 0.5 (saturated)
|
||
/// - Δlogit ≈ 0.20067 (= ln(0.55/0.45)) ⇒ aux_conf = 0.05
|
||
/// - Δlogit ≈ 2.19722 (= ln(0.90/0.10)) ⇒ aux_conf = 0.40
|
||
///
|
||
/// These match the spec §4.2 self-stabilizing-property operating
|
||
/// points exercised in
|
||
/// `sp20_phase1_4_wireup_test::target_hold_pct_behavioral_spot_checks`.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn aux_conf_k2_bounds_and_fixed_points() {
|
||
let tol = 1e-4_f32;
|
||
|
||
// Uniform logits ⇒ aux_conf = 0.0.
|
||
let aux_conf = run_per_bar_aux_conf_k2([1.0, 1.0]);
|
||
assert!(aux_conf.abs() < tol,
|
||
"uniform logits ⇒ aux_conf = 0.0; got {aux_conf}");
|
||
|
||
// Saturated (Δ = 20 ⇒ peak_softmax ≈ 1.0).
|
||
let aux_conf = run_per_bar_aux_conf_k2([0.0, 20.0]);
|
||
assert!((aux_conf - 0.5).abs() < tol,
|
||
"saturated logits ⇒ aux_conf = 0.5; got {aux_conf}");
|
||
|
||
// Spec-warmup operating point.
|
||
let delta_low = (0.55_f32 / 0.45_f32).ln();
|
||
let aux_conf = run_per_bar_aux_conf_k2([0.0, delta_low]);
|
||
assert!((aux_conf - 0.05).abs() < 1e-3,
|
||
"Δlogit=ln(0.55/0.45) ⇒ aux_conf = 0.05; got {aux_conf}");
|
||
|
||
// Spec-confident operating point.
|
||
let delta_high = (0.90_f32 / 0.10_f32).ln();
|
||
let aux_conf = run_per_bar_aux_conf_k2([0.0, delta_high]);
|
||
assert!((aux_conf - 0.40).abs() < 1e-3,
|
||
"Δlogit=ln(0.90/0.10) ⇒ aux_conf = 0.40; got {aux_conf}");
|
||
|
||
// Symmetry: order-reversed logits give the same aux_conf.
|
||
let a1 = run_per_bar_aux_conf_k2([0.0, delta_high]);
|
||
let a2 = run_per_bar_aux_conf_k2([delta_high, 0.0]);
|
||
assert!((a1 - a2).abs() < tol,
|
||
"symmetric logits ⇒ same aux_conf; got {a1} vs {a2}");
|
||
|
||
// Non-stable inputs: large abs values with Δ=0 still uniform.
|
||
let aux_conf = run_per_bar_aux_conf_k2([1000.0, 1000.0]);
|
||
assert!(aux_conf.abs() < tol,
|
||
"large equal logits ⇒ aux_conf = 0.0; got {aux_conf}");
|
||
}
|
||
|
||
/// ── Test 2: sum_hold_baseline_over_trade indexing ────────────────
|
||
///
|
||
/// Buffer = [N=1 env × hold_buffer_size = 5] for clarity. Slots
|
||
/// pre-loaded with values matching their slot index for easy
|
||
/// arithmetic verification.
|
||
///
|
||
/// At current_t = 5, segment_hold_time = 3:
|
||
/// - Walks backwards from idx (current_t - 1) % 5 = 4.
|
||
/// - Reads slots [4, 3, 2] ⇒ sum = 4 + 3 + 2 = 9.
|
||
///
|
||
/// At current_t = 5, segment_hold_time = 10 (> hold_buffer_size):
|
||
/// - Walks backwards but bounded by hold_buffer_size = 5.
|
||
/// - Reads slots [4, 3, 2, 1, 0] ⇒ sum = 0 + 1 + 2 + 3 + 4 = 10.
|
||
///
|
||
/// At segment_hold_time = 0:
|
||
/// - Returns 0.0f (defensive guard for empty trade).
|
||
///
|
||
/// At hold_buffer_size = 0:
|
||
/// - Returns 0.0f (defensive guard for empty buffer).
|
||
///
|
||
/// Wrap-around: current_t = 7, hold_buffer_size = 5 ⇒ start idx =
|
||
/// (7 - 1) % 5 = 1; segment_hold_time = 4 walks 1, 0, 4, 3 (wrap).
|
||
/// Sum = 1 + 0 + 4 + 3 = 8.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn sum_hold_baseline_over_trade_indexing() {
|
||
let tol = 1e-5_f32;
|
||
|
||
// 1 env × 5-slot buffer. Slot[k] = k.
|
||
let buf: Vec<f32> = (0..5).map(|k| k as f32).collect();
|
||
|
||
// Trade of 3 bars closing at t=5.
|
||
let s = run_sum_hold_baseline(&buf, 0, 5, 5, 3.0);
|
||
assert!((s - 9.0).abs() < tol,
|
||
"current_t=5, segment_hold_time=3 ⇒ sum slots [4,3,2] = 9; got {s}");
|
||
|
||
// Trade of 10 bars (clamped by buffer size).
|
||
let s = run_sum_hold_baseline(&buf, 0, 5, 5, 10.0);
|
||
assert!((s - 10.0).abs() < tol,
|
||
"current_t=5, segment_hold_time=10 ⇒ all 5 slots = 0+1+2+3+4=10; got {s}");
|
||
|
||
// Empty trade.
|
||
let s = run_sum_hold_baseline(&buf, 0, 5, 5, 0.0);
|
||
assert!(s.abs() < tol,
|
||
"segment_hold_time=0 ⇒ defensive guard returns 0; got {s}");
|
||
|
||
// Negative trade (defensive).
|
||
let s = run_sum_hold_baseline(&buf, 0, 5, 5, -1.0);
|
||
assert!(s.abs() < tol,
|
||
"segment_hold_time<0 ⇒ defensive guard returns 0; got {s}");
|
||
|
||
// Empty buffer.
|
||
let s = run_sum_hold_baseline(&buf, 0, 0, 5, 3.0);
|
||
assert!(s.abs() < tol,
|
||
"hold_buffer_size=0 ⇒ defensive guard returns 0; got {s}");
|
||
|
||
// Wrap-around test: current_t=7, size=5, hold_time=4.
|
||
// Start idx = (7-1) % 5 = 1; walks 1, 0, 4, 3.
|
||
let s = run_sum_hold_baseline(&buf, 0, 5, 7, 4.0);
|
||
assert!((s - (1.0 + 0.0 + 4.0 + 3.0)).abs() < tol,
|
||
"current_t=7, hold_time=4 ⇒ sum slots [1,0,4,3] = 8; got {s}");
|
||
}
|
||
|
||
/// ── Test 3: per-env stride (multiple envs in row-major buffer) ───
|
||
///
|
||
/// Buffer = [N=3 envs × 4-slot]. Slot[env, k] = env * 100 + k.
|
||
/// Layout (row-major):
|
||
/// env=0: [ 0, 1, 2, 3]
|
||
/// env=1: [100, 101, 102, 103]
|
||
/// env=2: [200, 201, 202, 203]
|
||
///
|
||
/// At env=1, current_t=4, segment_hold_time=2:
|
||
/// - Walks backwards from idx (4-1) % 4 = 3.
|
||
/// - Reads buf[1*4 + 3] = 103, buf[1*4 + 2] = 102 ⇒ sum = 205.
|
||
///
|
||
/// Crucially: the buffer reader MUST use the env stride (`env *
|
||
/// hold_buffer_size`) to land in env=1's row, not env=0's. A
|
||
/// missing stride would return 1+2=3 (env=0 contents), failing
|
||
/// this assertion clearly.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn sum_hold_baseline_per_env_stride() {
|
||
let tol = 1e-5_f32;
|
||
let n_envs = 3;
|
||
let size = 4;
|
||
let mut buf = vec![0.0_f32; n_envs * size];
|
||
for env in 0..n_envs {
|
||
for k in 0..size {
|
||
buf[env * size + k] = (env * 100 + k) as f32;
|
||
}
|
||
}
|
||
|
||
let s = run_sum_hold_baseline(&buf, 1, size as i32, 4, 2.0);
|
||
assert!((s - 205.0).abs() < tol,
|
||
"env=1, t=4, hold=2 ⇒ buf[1,3]+buf[1,2] = 103+102 = 205; got {s}");
|
||
|
||
let s = run_sum_hold_baseline(&buf, 2, size as i32, 4, 2.0);
|
||
assert!((s - 405.0).abs() < tol,
|
||
"env=2, t=4, hold=2 ⇒ buf[2,3]+buf[2,2] = 203+202 = 405; got {s}");
|
||
}
|
||
|
||
/// ── Test 4: full dual-emission shape (Hold action AND non-Hold) ──
|
||
///
|
||
/// Mirrors the production per-bar emission site (positioned-non-
|
||
/// event branch ~line 3650 of experience_kernels.cu).
|
||
///
|
||
/// Per spec §4.2:
|
||
/// per_bar_opp_cost = -aux_conf × cost_scale
|
||
/// Path 1 (Hold-only): centered = per_bar_opp_cost - hold_reward_ema
|
||
/// Path 2 (always): buf[..] = per_bar_opp_cost (UNcentered)
|
||
///
|
||
/// Plan §3.2 reference fixture (line ~1306):
|
||
/// Fix aux_conf=0.3, cost_scale=0.1, hold_reward_ema=-0.02
|
||
/// Bar with action=Hold:
|
||
/// per_bar_centered = -0.3*0.1 - (-0.02) = -0.03 + 0.02 = -0.01
|
||
/// hold_baseline_buffer = -0.3*0.1 = -0.03 (uncentered)
|
||
/// Bar with action=Long:
|
||
/// per_bar_centered = 0
|
||
/// hold_baseline_buffer = -0.3*0.1 = -0.03 (always written)
|
||
///
|
||
/// To engineer aux_conf=0.3 ⇒ peak_softmax = 0.8 ⇒ Δlogit =
|
||
/// ln(0.8/0.2) = ln(4) ≈ 1.3862944.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn dual_emission_hold_vs_non_hold() {
|
||
let tol = 1e-4_f32;
|
||
const HOLD_BUFFER_SIZE: i32 = 30;
|
||
const HOLD_ACTION_ID: i32 = 1; // DIR_HOLD
|
||
const LONG_ACTION_ID: i32 = 2; // DIR_LONG
|
||
|
||
// Δlogit = ln(0.8/0.2) ⇒ aux_conf = 0.3.
|
||
let delta = (0.8_f32 / 0.2_f32).ln();
|
||
let logits = [0.0_f32, delta];
|
||
let cost_scale = 0.1_f32;
|
||
let hold_reward_ema = -0.02_f32;
|
||
let current_t = 7_i32;
|
||
|
||
// Expected:
|
||
// per_bar_opp_cost = -0.3 × 0.1 = -0.03
|
||
// Hold: centered = -0.03 - (-0.02) = -0.01
|
||
// buf[7 % 30 = 7] = -0.03
|
||
// Long: centered = 0.0 (Hold-only path)
|
||
// buf[7 % 30 = 7] = -0.03 (still written)
|
||
let expected_per_bar = -0.03_f32;
|
||
let expected_hold_centered = -0.01_f32;
|
||
|
||
let stream = make_stream();
|
||
let kernel = load_fn(&stream, "sp20_dual_emission_test_kernel");
|
||
|
||
let logits_buf = unsafe { MappedF32Buffer::new(2) }.expect("alloc logits");
|
||
logits_buf.write_from_slice(&logits);
|
||
|
||
let centered_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc centered");
|
||
let buf_buf = unsafe { MappedF32Buffer::new(HOLD_BUFFER_SIZE as usize) }
|
||
.expect("alloc hold_baseline_buffer");
|
||
|
||
// ── Sub-test A: action = Hold ─────────────────────────────────
|
||
centered_buf.write_from_slice(&[0.0_f32]);
|
||
buf_buf.write_from_slice(&vec![0.0_f32; HOLD_BUFFER_SIZE as usize]);
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&logits_buf.dev_ptr)
|
||
.arg(&cost_scale)
|
||
.arg(&hold_reward_ema)
|
||
.arg(&HOLD_ACTION_ID)
|
||
.arg(&HOLD_ACTION_ID) // hold_action_id
|
||
.arg(¤t_t)
|
||
.arg(&HOLD_BUFFER_SIZE)
|
||
.arg(¢ered_buf.dev_ptr)
|
||
.arg(&buf_buf.dev_ptr)
|
||
.launch(cudarc::driver::LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("launch dual_emission Hold");
|
||
}
|
||
stream.synchronize().expect("sync");
|
||
|
||
let centered = centered_buf.read_all()[0];
|
||
let buf_full = buf_buf.read_all();
|
||
let buf_slot = buf_full[(current_t % HOLD_BUFFER_SIZE) as usize];
|
||
|
||
assert!((centered - expected_hold_centered).abs() < tol,
|
||
"Hold-action centered: expected {expected_hold_centered}, got {centered}");
|
||
assert!((buf_slot - expected_per_bar).abs() < tol,
|
||
"Hold-action Path 2 buffer slot: expected {expected_per_bar}, got {buf_slot}");
|
||
|
||
// Non-target slots untouched (still 0.0).
|
||
for k in 0..(HOLD_BUFFER_SIZE as usize) {
|
||
if k as i32 == current_t % HOLD_BUFFER_SIZE { continue; }
|
||
assert!(buf_full[k].abs() < tol,
|
||
"Hold: buffer slot {k} should be untouched 0.0, got {}", buf_full[k]);
|
||
}
|
||
|
||
// ── Sub-test B: action = Long ─────────────────────────────────
|
||
centered_buf.write_from_slice(&[999.0_f32]); // sentinel != 0
|
||
buf_buf.write_from_slice(&vec![0.0_f32; HOLD_BUFFER_SIZE as usize]);
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&logits_buf.dev_ptr)
|
||
.arg(&cost_scale)
|
||
.arg(&hold_reward_ema)
|
||
.arg(&LONG_ACTION_ID)
|
||
.arg(&HOLD_ACTION_ID)
|
||
.arg(¤t_t)
|
||
.arg(&HOLD_BUFFER_SIZE)
|
||
.arg(¢ered_buf.dev_ptr)
|
||
.arg(&buf_buf.dev_ptr)
|
||
.launch(cudarc::driver::LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("launch dual_emission Long");
|
||
}
|
||
stream.synchronize().expect("sync");
|
||
|
||
let centered = centered_buf.read_all()[0];
|
||
let buf_full = buf_buf.read_all();
|
||
let buf_slot = buf_full[(current_t % HOLD_BUFFER_SIZE) as usize];
|
||
|
||
assert!(centered.abs() < tol,
|
||
"Long-action Path 1 (Hold-only) ⇒ centered = 0; got {centered}");
|
||
assert!((buf_slot - expected_per_bar).abs() < tol,
|
||
"Long-action Path 2 ⇒ buffer ALWAYS written; expected {expected_per_bar}, got {buf_slot}");
|
||
}
|
||
}
|