Files
foxhunt/crates/ml/tests/sp20_hold_baseline_test.rs
jgrusewski 06dbb78ffc feat(sp20): Phase 3 Task 3.2 — Hold opportunity-cost dual emission (Component 2)
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>
2026-05-10 08:56:19 +02:00

413 lines
17 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SP20 Phase 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(&current_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(&current_t)
.arg(&HOLD_BUFFER_SIZE)
.arg(&centered_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(&current_t)
.arg(&HOLD_BUFFER_SIZE)
.arg(&centered_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}");
}
}