Files
foxhunt/crates/ml/tests/sp20_phase1_4_wireup_test.rs
jgrusewski 4d4cd996db feat(sp21): T3.3 ISV defrost — hold_reward_ema (atomic Phase 3.2 wireup)
Closes the Phase 3.2 forward-reference loop in the SP20 aggregator.
Previously `out_inputs->per_bar_hold_reward = 0.0f` was hardcoded; the
per-bar Hold opportunity-cost producer existed
(experience_kernels.cu:3823 — `per_bar_opp_cost = -aux_conf × cost_scale`)
and wrote to `hold_baseline_buffer` and `r_micro` directly, but never
reached HOLD_REWARD_EMA. Result: HOLD_REWARD_EMA frozen at sentinel 0.0
across all observed epochs; the SP20 reward centering loop
(`r_micro += per_bar_opp_cost - HOLD_REWARD_EMA`) stayed uncentered,
biasing the policy's reward signal away from zero-mean.

T3.3 wireup mirrors the T2.2 alpha refactor: a per-env scratch buffer
that the producer writes at every step (alongside the existing
`hold_baseline_buffer` write), and the aggregator reads at the same
step. Sums opp_cost over Hold-direction envs only; emits the mean as
`per_bar_hold_reward`. The HOLD_REWARD_EMA's gate (`hold_fraction > 0.5f`
from T3.2) preserves the strict-majority semantic from before.

Atomic across producer site, kernel signature, aggregator, launcher,
collector, and tests (per feedback_no_partial_refactor):

  - experience_kernels.cu — new `float* per_bar_opp_cost_per_env`
    kernel arg, NULL-tolerant write at line ~3823.
  - sp20_aggregate_inputs_kernel.cu — new arg, 6th shmem stripe
    (`sh_opp_cost_sum`), per-thread Hold-gated accumulation, tree
    reduction extended to 6 stripes, output `per_bar_hold_reward =
    opp_cost_sum / hold_count` when hold_count > 0 else 0.
  - sp20_aggregate_inputs.rs — launcher signature, dynamic_shmem_bytes
    5→6 stripes, doc table, internal test.
  - gpu_experience_collector.rs — new `per_bar_opp_cost_per_env:
    CudaSlice<f32>` field, alloc, struct construction, kernel-arg
    pass at both experience_env_step and sp20_aggregate_inputs
    launches (raw_ptr).
  - sp20_aggregate_inputs_test.rs — helper renamed
    `run_kernel_with_is_win_and_opp_cost`, 4 existing sites pass
    NULL, 2 NEW oracle tests verifying real producer + NULL fallback.
  - sp20_phase1_4_wireup_test.rs — NULL fallback at the wireup site;
    HOLD_REWARD_EMA-stays-at-zero assertion remains valid.

Verification:
  - cargo check -p ml --tests: passes (warnings only)
  - cargo test -p ml --test sp20_aggregate_inputs_test --features cuda
    -- --ignored: 12/12 GPU oracle tests pass on RTX 3050 Ti, including
    both new T3.3 tests (per_bar_hold_reward_means_over_hold_envs_only,
    null_per_bar_opp_cost_emits_zero).
  - cargo test -p ml --test sp20_phase1_4_wireup_test --features cuda
    -- --ignored: 2/2 pass under the new NULL-tolerant contract.

Plan reference: docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md
Tier 3 status: T3.1 ✓, T3.2 ✓, T3.3 ✓ (this commit), T3.4 withdrawn,
T3.5 cascade-pending, T3.6 withdrawn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:35:39 +02:00

379 lines
17 KiB
Rust

//! SP20 Phase 1.4 (2026-05-09, Path C) — fused-producer chain integration test.
//!
//! Direct end-to-end exercise of the 4-kernel chain that the
//! `GpuExperienceCollector` invokes per rollout step:
//!
//! Stats (Kernel 3) → Aggregate (Path C) → EMAs (Kernel 1) → Controllers (Kernel 2)
//!
//! Constructs synthetic per-env arrays + aux-head outputs in mapped-pinned
//! buffers, fires the 4 launches in sequence on a single stream, and
//! asserts that the 10 SP20 ISV slots populate with sensible
//! formula-derived values.
//!
//! Per `pearl_tests_must_prove_not_lock_observations` the assertions are
//! invariants (slot is non-sentinel, bound-respecting, monotonic in the
//! expected direction) rather than locked numerical values.
//!
//! Run on a GPU host:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test sp20_phase1_4_wireup_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};
use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
use ml::cuda_pipeline::sp14_isv_slots::{
ALPHA_EMA_INDEX, AUX_CONF_THRESHOLD_INDEX, AUX_GATE_TEMP_INDEX,
HOLD_COST_SCALE_INDEX, HOLD_PCT_EMA_INDEX, HOLD_REWARD_EMA_INDEX,
LOSS_CAP_INDEX, N_STEP_INDEX, TARGET_HOLD_PCT_INDEX, WR_EMA_INDEX,
};
use ml::cuda_pipeline::sp20_aggregate_inputs::launch_sp20_aggregate_inputs;
use ml::cuda_pipeline::sp20_controllers_compute::launch_sp20_controllers_compute;
use ml::cuda_pipeline::sp20_emas_compute::{
launch_sp20_emas_compute, EMA_INTERNAL_SLOT_COUNT, OBS_COUNT_SLOTS,
SP20_EMA_INPUTS_F32_LEN,
};
use ml::cuda_pipeline::sp20_stats_compute::{
launch_sp20_stats_compute, AUX_K_CLASSES,
};
/// ISV slot count: cover up to slot 519.
const ISV_LEN: usize = 520;
/// Production action encoding constants (mirror `state_layout.cuh`).
const DIR_DIVISOR: i32 = 27; // 3 * 3 * 3 (mag * ord * urg)
const HOLD_ACTION_ID: i32 = 1;
fn encode_dir(dir: i32) -> i32 {
dir * DIR_DIVISOR
}
const SP20_STATS_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/sp20_stats_compute_kernel.cubin"
));
const SP20_AGGREGATE_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/sp20_aggregate_inputs_kernel.cubin"
));
const SP20_EMAS_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/sp20_emas_compute_kernel.cubin"
));
const SP20_CONTROLLERS_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/sp20_controllers_compute_kernel.cubin"
));
fn make_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
fn load_kernel(stream: &Arc<CudaStream>, cubin: &[u8], name: &str) -> CudaFunction {
let m = stream
.context()
.load_cubin(cubin.to_vec())
.unwrap_or_else(|e| panic!("load {name} cubin: {e}"));
m.load_function(name)
.unwrap_or_else(|e| panic!("load {name} function: {e}"))
}
/// Run the full 4-kernel chain once with the given synthetic inputs.
/// Returns the final ISV state after the chain settles.
fn run_chain_once(
n_envs: usize,
aux_logits: &[f32],
trade_close: &[i32],
step_ret: &[f32],
hold_at_exit: &[f32],
actions: &[i32],
aux_dir_acc: f32,
) -> Vec<f32> {
let stream = make_stream();
let stats_kernel = load_kernel(&stream, SP20_STATS_CUBIN, "sp20_stats_compute_kernel");
let agg_kernel = load_kernel(&stream, SP20_AGGREGATE_CUBIN, "sp20_aggregate_inputs_kernel");
let emas_kernel = load_kernel(&stream, SP20_EMAS_CUBIN, "sp20_emas_compute_kernel");
let ctrl_kernel = load_kernel(&stream, SP20_CONTROLLERS_CUBIN, "sp20_controllers_compute_kernel");
// ── ISV bus ──
let isv = unsafe { MappedF32Buffer::new(ISV_LEN) }.expect("alloc isv");
isv.write_from_slice(&vec![0.0_f32; ISV_LEN]);
// ── Stats inputs/outputs ──
assert_eq!(aux_logits.len(), n_envs * AUX_K_CLASSES,
"aux_logits must be [n_envs * K=2]");
let aux_logits_buf = unsafe {
MappedF32Buffer::new(n_envs * AUX_K_CLASSES)
}.expect("alloc aux_logits");
aux_logits_buf.write_from_slice(aux_logits);
let stats_out = unsafe { MappedF32Buffer::new(2) }.expect("alloc stats_out");
stats_out.write_from_slice(&[0.0_f32, 0.0_f32]);
// ── Aggregation inputs ──
let tc_buf = unsafe { MappedI32Buffer::new(n_envs) }.expect("alloc tc");
tc_buf.write_from_slice(trade_close);
let sr_buf = unsafe { MappedF32Buffer::new(n_envs) }.expect("alloc sr");
sr_buf.write_from_slice(step_ret);
let he_buf = unsafe { MappedF32Buffer::new(n_envs) }.expect("alloc he");
he_buf.write_from_slice(hold_at_exit);
let act_buf = unsafe { MappedI32Buffer::new(n_envs) }.expect("alloc act");
act_buf.write_from_slice(actions);
let dir_acc_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc dir_acc");
dir_acc_buf.write_from_slice(&[aux_dir_acc]);
// ── Aggregation output / EMA input struct (9 f32 byte-aliased) ──
let ema_inputs = unsafe { MappedF32Buffer::new(SP20_EMA_INPUTS_F32_LEN) }
.expect("alloc ema_inputs");
ema_inputs.write_from_slice(&vec![0.0_f32; SP20_EMA_INPUTS_F32_LEN]);
// ── EMA scratch ──
let internal = unsafe { MappedF32Buffer::new(EMA_INTERNAL_SLOT_COUNT) }
.expect("alloc internal");
internal.write_from_slice(&vec![0.0_f32; EMA_INTERNAL_SLOT_COUNT]);
let obs_count = unsafe { MappedI32Buffer::new(OBS_COUNT_SLOTS) }
.expect("alloc obs_count");
obs_count.write_from_slice(&vec![0_i32; OBS_COUNT_SLOTS]);
// ── Launch chain ──
unsafe {
launch_sp20_stats_compute(
&stream, &stats_kernel,
aux_logits_buf.dev_ptr,
stats_out.dev_ptr,
n_envs as i32,
).expect("launch sp20_stats_compute");
let stats_p50 = stats_out.dev_ptr;
let stats_std = stats_out.dev_ptr + std::mem::size_of::<f32>() as u64;
launch_sp20_aggregate_inputs(
&stream, &agg_kernel,
tc_buf.dev_ptr,
sr_buf.dev_ptr,
he_buf.dev_ptr,
act_buf.dev_ptr,
/* env_stride = */ 1,
stats_p50,
stats_std,
dir_acc_buf.dev_ptr,
/* alpha_per_env_dev = */ 0, // SP20 Phase 2 Task 2.2 — Phase 1.4
// wireup test stays NULL; the alpha
// producer is exercised at the
// segment_complete site in the full
// collector path. Tests in this file
// assert the placeholder contract
// (alpha_ema stays at sentinel 0.0).
/* is_win_per_env_dev = */ 0, // SP20 Phase 2 Task 2.2-fix
// (2026-05-10) — wireup test stays
// NULL; the kernel falls back to
// the `sr > 0` legacy predicate so
// existing wireup-test assertions
// (e.g. WR_EMA = 1.0 with one
// winning trade at step_ret > 0)
// remain valid.
/* per_bar_opp_cost_per_env_dev = */ 0, // SP21 T3.3 — wireup test stays
// NULL; consumer falls back to
// per_bar_hold_reward = 0.0f
// matching pre-T3.3 contract.
n_envs as i32,
DIR_DIVISOR,
HOLD_ACTION_ID,
ema_inputs.dev_ptr,
).expect("launch sp20_aggregate_inputs");
launch_sp20_emas_compute(
&stream, &emas_kernel,
isv.dev_ptr,
ema_inputs.dev_ptr,
internal.dev_ptr,
obs_count.dev_ptr,
).expect("launch sp20_emas_compute");
launch_sp20_controllers_compute(
&stream, &ctrl_kernel,
isv.dev_ptr,
internal.dev_ptr,
).expect("launch sp20_controllers_compute");
}
stream.synchronize().expect("sync after sp20 chain");
isv.read_all()
}
/// Smoke: one rollout step with mid-confidence aux + a winning trade
/// closure at one env should populate every SP20 ISV slot with
/// non-sentinel, bound-respecting values.
#[test]
#[ignore = "requires GPU"]
fn one_step_chain_populates_all_10_isv_slots() {
let n_envs = 8;
// Mid-confidence aux logits: each row [logit_down, logit_up] with
// a clear winner (e.g. up=1.0, down=0.0 ⇒ softmax up ≈ 0.73).
// peak_softmax ≈ 0.73 ⇒ aux_conf ≈ 0.73 - 0.5 = 0.23.
let mut aux_logits = Vec::with_capacity(n_envs * AUX_K_CLASSES);
for _ in 0..n_envs {
aux_logits.push(0.0); // logit_down
aux_logits.push(1.0); // logit_up
}
// One env closes a winning trade at duration 5.
let mut trade_close = vec![0; n_envs];
let mut step_ret = vec![0.0_f32; n_envs];
let mut hold_at_exit = vec![0.0_f32; n_envs];
trade_close[3] = 1;
step_ret[3] = 0.4;
hold_at_exit[3] = 5.0;
// Half the envs picked Hold (4 of 8 = tie ⇒ action_is_hold = 0).
// Bump to 5 to cross strict-majority threshold and exercise the
// HOLD_PCT_EMA fire-on-Hold path.
let mut actions = vec![encode_dir(2); n_envs]; // most envs Long
for i in 0..5 {
actions[i] = encode_dir(HOLD_ACTION_ID);
}
let isv = run_chain_once(
n_envs, &aux_logits, &trade_close, &step_ret, &hold_at_exit,
&actions, /* aux_dir_acc = */ 0.62,
);
// ── ISV invariants ──
// ALPHA_EMA: aggregation kernel sees `alpha_per_env_dev = 0`
// (NULL in this test scaffold — the full SP20 reward producer
// is exercised at the `experience_env_step::segment_complete`
// site, not in this Phase 1.4 wireup test). The kernel falls
// back to `alpha = 0.0`, first-observation REPLACE on a closed
// trade ⇒ ALPHA_EMA stays at 0.0. **This validates the SP20
// Phase 2 Task 2.2 NULL-tolerance contract** — the
// aggregation kernel still emits 0.0 when the alpha producer
// is unwired, preserving the Phase 1.4 placeholder behavior
// for tests that don't exercise the reward producer.
assert_eq!(isv[ALPHA_EMA_INDEX], 0.0,
"ALPHA_EMA stays at 0.0 with NULL alpha_per_env producer (Task 2.2 NULL contract)");
// WR_EMA: 1 win out of 1 close ⇒ win_fraction = 1.0, first-obs
// replace ⇒ WR_EMA = 1.0. SP21 T3.1: aggregator emits exact
// fraction (was binary majority).
assert_eq!(isv[WR_EMA_INDEX], 1.0,
"WR_EMA = 1.0 after one winning close (1 win / 1 closed = 1.0)");
// HOLD_PCT_EMA: 5 of 8 envs picked Hold ⇒ hold_fraction = 5/8 =
// 0.625, first-obs replace ⇒ HOLD_PCT_EMA = 0.625. SP21 T3.2:
// aggregator emits exact fraction (was binary majority indicator
// → 1.0). The fractional value lets the downstream HOLD_COST_SCALE
// controller make graded decisions instead of the cascade-
// pinning-at-floor that the binary signal produced.
let hold_pct = isv[HOLD_PCT_EMA_INDEX];
assert!(
(hold_pct - 0.625).abs() < 1e-6,
"HOLD_PCT_EMA = 0.625 (5/8 of envs Hold); got {hold_pct}",
);
// HOLD_REWARD_EMA: per_bar_hold_reward = 0.0 (Phase 3.2 fwd
// ref); fired on Hold ⇒ EMA = 0.0.
assert_eq!(isv[HOLD_REWARD_EMA_INDEX], 0.0,
"HOLD_REWARD_EMA stays at 0.0 (Phase 3.2 forward ref)");
// LOSS_CAP: WR_EMA = 1.0 > 0.55 ⇒ ramp clamped at 1.0 ⇒
// LOSS_CAP = -1 - 1 = -2.0.
let lc = isv[LOSS_CAP_INDEX];
assert!((lc - (-2.0)).abs() < 1e-6,
"LOSS_CAP = -2.0 with WR_EMA = 1.0; got {lc}");
// N_STEP: trade_duration_ema = 5 (first-obs replace), then
// round to 5; clamp [1, 30] ⇒ N_STEP = 5.
let n = isv[N_STEP_INDEX];
assert!((n - 5.0).abs() < 1e-6,
"N_STEP = 5 with trade_duration = 5; got {n}");
// AUX_CONF_THRESHOLD: aux_dir_acc_ema = 0.62 (first-obs
// replace) ⇒ raw = 0.62 - 0.50 = 0.12 ⇒ clamp [0.01, 0.20] ⇒
// 0.12.
let act = isv[AUX_CONF_THRESHOLD_INDEX];
assert!((act - 0.12).abs() < 1e-5,
"AUX_CONF_THRESHOLD = 0.12 with dir_acc = 0.62; got {act}");
// AUX_GATE_TEMP: aux_std_ema is the std of aux_conf rows; with
// identical logits per row, aux_conf is identical so std = 0;
// floor at 0.01.
let agt = isv[AUX_GATE_TEMP_INDEX];
assert!((agt - 0.01).abs() < 1e-6,
"AUX_GATE_TEMP floor = 0.01 with identical aux logits; got {agt}");
// TARGET_HOLD_PCT: aux_p50_ema is the kernel's median of
// aux_conf rows (sp4-histogram quantization). With identical
// logits per row it lands somewhere in [0, 0.5]; the controller
// formula then maps that to TARGET_HOLD_PCT in [0.1, 0.8].
// We assert bound-respecting per
// `pearl_tests_must_prove_not_lock_observations` rather than
// an observed numerical value (the histogram bin granularity
// makes the exact value implementation-specific).
let thp = isv[TARGET_HOLD_PCT_INDEX];
assert!(thp >= 0.1 - 1e-6 && thp <= 0.8 + 1e-6,
"TARGET_HOLD_PCT must be in [0.1, 0.8]; got {thp}");
// HOLD_COST_SCALE: prev = 0 (constructor), hold_pct_ema = 1.0 >
// tgt + 0.05 ⇒ ramp up: 0 * 1.05 = 0; final clamp [0.01, 0.5]
// ⇒ 0.01 (floor). Verifies the deadband / final-clamp wiring
// even at sentinel-zero start.
let hcs = isv[HOLD_COST_SCALE_INDEX];
assert!(hcs >= 0.01 - 1e-6 && hcs <= 0.5 + 1e-6,
"HOLD_COST_SCALE in [0.01, 0.5]; got {hcs}");
}
/// SP20 Phase 2 Task 2.2 contract — when the aggregation kernel is
/// invoked with `alpha_per_env_dev = 0` (NULL, this test scaffold's
/// configuration) AND the per-bar-hold-reward producer is still in
/// Phase 3.2 forward-reference, both EMAs stay at sentinel 0.0
/// regardless of any input combinations. Validates the
/// NULL-tolerance contract of the aggregation kernel post-Task-2.2:
/// downstream tests that don't wire the SP20 reward producer (e.g.
/// this Phase 1.4 wireup smoke) preserve the Phase 1.4 ALPHA_EMA
/// placeholder behavior.
///
/// HOLD_REWARD_EMA stays at 0.0 because Phase 3.2 lands the per-bar
/// `r_per_bar = -aux_conf * cost_scale` producer; the aggregation
/// kernel writes 0.0 for `per_bar_hold_reward` until then.
///
/// **Pinned forward refs locked here**:
/// - ALPHA_EMA bound to NULL-alpha-producer scaffold contract.
/// - HOLD_REWARD_EMA bound to Phase 3.2 placeholder.
#[test]
#[ignore = "requires GPU"]
fn alpha_and_hold_reward_emas_stay_at_zero_with_null_producers() {
let n_envs = 4;
// 4 closing wins per call, 4 Hold actions per call
let trade_close = vec![1; n_envs];
let step_ret = vec![0.5; n_envs];
let hold_at_exit = vec![10.0; n_envs];
let actions = vec![encode_dir(HOLD_ACTION_ID); n_envs];
let aux_logits = vec![0.5_f32; n_envs * AUX_K_CLASSES];
// Single chain run is enough — `run_chain_once` passes
// alpha_per_env_dev=0 (NULL), so the aggregation kernel falls
// back to `alpha = 0.0`; first-obs REPLACE on 0.0 lands the
// EMA at 0.0.
let isv = run_chain_once(
n_envs, &aux_logits, &trade_close, &step_ret, &hold_at_exit,
&actions, 0.99,
);
assert_eq!(isv[ALPHA_EMA_INDEX], 0.0,
"ALPHA_EMA stays at 0.0 with NULL alpha_per_env producer \
(Task 2.2 NULL-tolerance contract). The full SP20 reward \
producer at experience_env_step::segment_complete is NOT \
exercised in this Phase 1.4 wireup test.");
assert_eq!(isv[HOLD_REWARD_EMA_INDEX], 0.0,
"HOLD_REWARD_EMA must be 0.0 — Phase 3.2 forward ref \
placeholder. Aggregation kernel writes 0.0 until \
Component 2 lands the per-bar producer.");
}
}