SP21 Tier-3 foundation: defrost two production-frozen ISV slots that
pinned at 0 across every observed training epoch (d7bj7, xmd6b). Both
bugs in the same aggregator kernel, same structural shape: per-step
binary majority-vote indicators feeding fractional EMAs.
T3.1 — WR_EMA defrost (sp20_aggregate_inputs_kernel.cu):
- was: is_win_out = (2 * wins_count >= closed_count) ? 1 : 0
binary "majority won this step" indicator
- now: win_fraction_out = (float)wins_count / (float)closed_count
fractional in [0, 1]
- With actual val WR≈0.46, the binary signal was 0 most of the time,
pinning WR_EMA at 0 across 30+ epochs in xmd6b. EMA now converges
to population win rate.
T3.2 — HOLD_PCT_EMA defrost (same kernel, same pattern):
- was: action_is_hold_out = (hold_count * 2 > n_envs) ? 1 : 0
strict-majority indicator
- now: hold_fraction_out = (float)hold_count / (float)n_envs
- Cascade: HOLD_COST_SCALE controller compared hold_pct_ema=0 to
tgt±0.05, always saw < lower, ramped × 0.95 → clamped at floor
0.01 (the hold_cost_scale=0.0100 observation in d7bj7 logs).
T3.5 expected to cascade-fix in next training run.
- HOLD_REWARD_EMA gate preserves strict-majority semantic via
hold_fraction > 0.5f test in the consumer kernel.
Atomic across struct fields, kernel logic, Rust mirror, byte
serialization, doc tables, and 4 test files (per
feedback_no_partial_refactor):
- sp20_aggregate_inputs_kernel.cu (struct + 2 computations)
- sp20_emas_compute_kernel.cu (struct + reader + gate)
- sp20_aggregate_inputs.rs (doc table)
- sp20_emas_compute.rs (Rust struct + serialize + 3 tests)
- sp20_aggregate_inputs_test.rs (reader sig + 4 test assertions)
- sp20_emas_compute_test.rs (5 struct literal updates)
- sp20_phase1_4_wireup_test.rs (HOLD_PCT_EMA expected 0.625)
Verification:
- cargo check -p ml --tests: passes (warnings only)
- cargo test -p ml --lib sp20_emas: 6/6 unit tests pass
Pearl candidate: binary-majority aggregator over a fractional
underlying signal cannot serve as input to a fractional-target EMA.
The previous fix (commit 64bbbe418) addressed the per-bar vs segment
predicate at the producer site, but didn't notice the aggregator's
binarization step still collapsed the fraction to {0, 1}. Two bugs
in series, both now resolved.
Plan reference: docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md
Tiers 3.3-3.6 remaining; T3.5 expected to cascade-fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
378 lines
15 KiB
Rust
378 lines
15 KiB
Rust
//! SP20 Phase 1.2 (2026-05-09) — `sp20_emas_compute_kernel` GPU oracle tests.
|
||
//!
|
||
//! Verifies the 8 Wiener-α EMA producer kernel (Component 5 / Kernel 1
|
||
//! of the SP20 design) updates the ISV slots [511, 512, 515, 516] and
|
||
//! 4 internal scratch slots correctly under the
|
||
//! `pearl_first_observation_bootstrap` + `pearl_wiener_alpha_floor_for_
|
||
//! nonstationary` discipline.
|
||
//!
|
||
//! ## Path C kernel-arg refactor (2026-05-09, Phase 1.4)
|
||
//!
|
||
//! These tests exercise the post-Path-C kernel signature: the kernel
|
||
//! reads its 9 fields from a device struct pointer (`SP20EmaInputs*`),
|
||
//! not 9 scalar value-args. The tests use a mapped-pinned f32 buffer
|
||
//! sized to [`SP20_EMA_INPUTS_F32_LEN`] (= 9), pack the inputs via
|
||
//! [`pack_inputs_into_f32_view`], and pass the buffer's `dev_ptr` to
|
||
//! the kernel. Math semantics are bit-identical to the pre-Path-C
|
||
//! 9-scalar-arg version — every blend formula reads the same fields
|
||
//! by name.
|
||
//!
|
||
//! Tests cover:
|
||
//!
|
||
//! 1. **First-observation bootstrap** — sentinel (count==0) replaces
|
||
//! directly without blend, even when the observed value happens
|
||
//! to be 0 (e.g., first-loss WR=0). Exercises the per-EMA
|
||
//! observation counter mechanism.
|
||
//! 2. **Wiener-α convergence with alternating wins** — 100 trade-
|
||
//! close events with alternating is_win 1/0/1/0 land WR_EMA
|
||
//! within tolerance of 0.5 (the long-run mean of the indicator).
|
||
//! 3. **Hold-reward gating** — `HOLD_REWARD_EMA` only updates on
|
||
//! Hold-state bars (`action_is_hold == 1`); non-Hold steps leave
|
||
//! it at sentinel.
|
||
//! 4. **Per-step EMAs always fire** — `HOLD_PCT_EMA`, the three
|
||
//! internal aux EMAs (p50, std, dir_acc) update every step
|
||
//! regardless of `is_close` / `action_is_hold` gates.
|
||
//!
|
||
//! Run on a GPU host:
|
||
//!
|
||
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
|
||
//! cargo test -p ml --test sp20_emas_compute_test --features cuda \
|
||
//! -- --ignored --nocapture
|
||
|
||
#![allow(clippy::tests_outside_test_module)]
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
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, HOLD_PCT_EMA_INDEX, HOLD_REWARD_EMA_INDEX, WR_EMA_INDEX,
|
||
};
|
||
use ml::cuda_pipeline::sp20_emas_compute::{
|
||
launch_sp20_emas_compute, pack_inputs_into_f32_view, EmaInputs,
|
||
EMA_INTERNAL_AUX_DIR_ACC, EMA_INTERNAL_AUX_P50, EMA_INTERNAL_AUX_STD,
|
||
EMA_INTERNAL_SLOT_COUNT, EMA_INTERNAL_TRADE_DURATION, OBS_COUNT_SLOTS,
|
||
SP20_EMA_INPUTS_F32_LEN, WIENER_ALPHA_FLOOR,
|
||
};
|
||
|
||
/// Total ISV slot count is the trainer's contract; for tests we
|
||
/// allocate just enough to cover the highest SP20 EMA slot (516).
|
||
const ISV_TEST_LEN: usize = 520;
|
||
|
||
const SP20_EMAS_COMPUTE_CUBIN: &[u8] = include_bytes!(concat!(
|
||
env!("OUT_DIR"),
|
||
"/sp20_emas_compute_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_sp20_emas_compute(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(SP20_EMAS_COMPUTE_CUBIN.to_vec())
|
||
.expect("load sp20_emas_compute_kernel cubin");
|
||
module
|
||
.load_function("sp20_emas_compute_kernel")
|
||
.expect("load sp20_emas_compute_kernel function")
|
||
}
|
||
|
||
/// Test harness: allocates ISV + internal + obs_count + ema_inputs
|
||
/// buffers, then fires `inputs.len()` sequential kernel invocations
|
||
/// on the same stream. Returns final ISV + internal + obs_count
|
||
/// state for assertions.
|
||
///
|
||
/// Per Path C: the `ema_inputs` buffer is a 9-f32 mapped-pinned
|
||
/// scratch byte-aliased to `SP20EmaInputs`. We rewrite it before
|
||
/// each launch via `pack_inputs_into_f32_view`. The kernel reads
|
||
/// it as `SP20EmaInputs*` — the f32-aliased writes land at the
|
||
/// same byte offsets the device struct's fields do.
|
||
fn run_kernel_sequence(inputs: &[EmaInputs]) -> (Vec<f32>, Vec<f32>, Vec<i32>) {
|
||
let stream = make_test_stream();
|
||
let kernel = load_sp20_emas_compute(&stream);
|
||
|
||
let isv = unsafe { MappedF32Buffer::new(ISV_TEST_LEN) }.expect("alloc isv");
|
||
isv.write_from_slice(&vec![0.0_f32; ISV_TEST_LEN]);
|
||
|
||
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]);
|
||
|
||
let mut ema_inputs_buf =
|
||
unsafe { MappedF32Buffer::new(SP20_EMA_INPUTS_F32_LEN) }
|
||
.expect("alloc ema_inputs");
|
||
|
||
for inp in inputs {
|
||
// Re-pack the 9-field struct view before each launch.
|
||
pack_inputs_into_f32_view(ema_inputs_buf.host_slice_mut(), inp);
|
||
unsafe {
|
||
launch_sp20_emas_compute(
|
||
&stream,
|
||
&kernel,
|
||
isv.dev_ptr,
|
||
ema_inputs_buf.dev_ptr,
|
||
internal.dev_ptr,
|
||
obs_count.dev_ptr,
|
||
)
|
||
.expect("launch sp20_emas_compute_kernel");
|
||
}
|
||
// Synchronise after each launch so the kernel finishes
|
||
// reading the input buffer before we overwrite it for the
|
||
// next iteration. (Pre-Path-C the inputs travelled as
|
||
// kernel-arg by-value; under Path C the kernel reads the
|
||
// buffer in-flight, so we must sync between writes.)
|
||
stream.synchronize().expect("sync between sp20_emas launches");
|
||
}
|
||
|
||
(isv.read_all(), internal.read_all(), obs_count.read_all())
|
||
}
|
||
|
||
/// Test 1 — first-observation bootstrap on a trade-close event with
|
||
/// `is_win=1, alpha=0.5`. Per `pearl_first_observation_bootstrap`,
|
||
/// the sentinel (count==0) MUST be replaced directly without blend.
|
||
/// WR_EMA → 1.0, ALPHA_EMA → 0.5 (not 0.4 × 1 or 0.4 × 0.5 from
|
||
/// any blend formula).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn first_observation_replaces_sentinel() {
|
||
let inp = EmaInputs {
|
||
is_close: 1,
|
||
win_fraction: 1.0,
|
||
trade_duration: 7,
|
||
hold_fraction: 0.0,
|
||
alpha: 0.5,
|
||
per_bar_hold_reward: 0.0,
|
||
aux_logits_p50: 0.0,
|
||
aux_logits_std: 0.0,
|
||
aux_dir_acc: 0.0,
|
||
};
|
||
let (isv, internal, obs_count) = run_kernel_sequence(&[inp]);
|
||
|
||
// Per-trade EMAs fired by is_close=1.
|
||
assert_eq!(
|
||
isv[WR_EMA_INDEX], 1.0,
|
||
"WR_EMA: first observation must REPLACE sentinel directly (got {})",
|
||
isv[WR_EMA_INDEX]
|
||
);
|
||
assert_eq!(
|
||
isv[ALPHA_EMA_INDEX], 0.5,
|
||
"ALPHA_EMA: first observation must REPLACE sentinel directly (got {})",
|
||
isv[ALPHA_EMA_INDEX]
|
||
);
|
||
assert_eq!(
|
||
internal[EMA_INTERNAL_TRADE_DURATION], 7.0,
|
||
"trade_duration_ema: first observation must REPLACE sentinel directly",
|
||
);
|
||
|
||
// Per-step EMAs that fired (action_is_hold=0 ⇒ HOLD_PCT first
|
||
// observation = 0.0 directly, so we read it as 0).
|
||
assert_eq!(
|
||
isv[HOLD_PCT_EMA_INDEX], 0.0,
|
||
"HOLD_PCT_EMA: first observation = 0.0 (action_is_hold=0)",
|
||
);
|
||
|
||
// HOLD_REWARD_EMA was NOT fired (action_is_hold=0) — sentinel
|
||
// remains at 0.0 with obs_count == 0.
|
||
assert_eq!(
|
||
isv[HOLD_REWARD_EMA_INDEX], 0.0,
|
||
"HOLD_REWARD_EMA must remain at sentinel when action_is_hold=0",
|
||
);
|
||
|
||
// Observation counters: WR, ALPHA, TRADE_DURATION, HOLD_PCT,
|
||
// AUX_P50, AUX_STD, AUX_DIR_ACC fired = 1; HOLD_REWARD did not.
|
||
// The counter layout is fixed by the launcher's contract; verify
|
||
// HOLD_REWARD's slot stayed at 0.
|
||
// (Slot indices come from launcher constants; we just check a
|
||
// non-zero count for at least one fired EMA and zero for HOLD_REWARD.)
|
||
let hold_reward_count = obs_count[4]; // HOLD_REWARD slot
|
||
let wr_count = obs_count[1];
|
||
assert_eq!(hold_reward_count, 0, "HOLD_REWARD obs_count must stay 0");
|
||
assert_eq!(wr_count, 1, "WR obs_count must be 1 after one trade");
|
||
}
|
||
|
||
/// Test 2 — Wiener-α convergence with alternating wins.
|
||
///
|
||
/// Fire 100 trade-close events with alternating `is_win` 1/0/1/0...
|
||
/// Expected: WR_EMA → 0.5 (long-run mean of the indicator).
|
||
/// Tolerance: at α=0.4 the EMA tracks within ≈ √(α/(2-α)) of the
|
||
/// mean for an alternating signal — about 0.5 amplitude × √0.25 ≈
|
||
/// 0.25, so we widen to 0.30 to absorb the alternation noise of
|
||
/// the last few samples.
|
||
///
|
||
/// First observation is a win (is_win=1) → WR_EMA = 1.0 directly,
|
||
/// then alternation drives it to ≈ 0.5 long-run.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn wiener_alpha_converges_to_long_run_mean() {
|
||
let inputs: Vec<EmaInputs> = (0..100)
|
||
.map(|i| EmaInputs {
|
||
is_close: 1,
|
||
win_fraction: if i % 2 == 0 { 1.0 } else { 0.0 },
|
||
trade_duration: 1,
|
||
hold_fraction: 0.0,
|
||
alpha: 0.0,
|
||
per_bar_hold_reward: 0.0,
|
||
aux_logits_p50: 0.0,
|
||
aux_logits_std: 0.0,
|
||
aux_dir_acc: 0.0,
|
||
})
|
||
.collect();
|
||
let (isv, _internal, _obs_count) = run_kernel_sequence(&inputs);
|
||
|
||
let wr_ema = isv[WR_EMA_INDEX];
|
||
let amplitude = 0.30_f32; // generous bound, absorbs alternation
|
||
assert!(
|
||
(wr_ema - 0.5).abs() < amplitude,
|
||
"WR_EMA after 100 alternating events should be near 0.5; got {wr_ema}",
|
||
);
|
||
}
|
||
|
||
/// Test 3 — HOLD_REWARD_EMA only updates on Hold-state bars.
|
||
///
|
||
/// Fire 5 non-Hold bars (action_is_hold=0, r_per_bar=−0.05) then
|
||
/// 5 Hold bars (action_is_hold=1, r_per_bar=−0.05). Expected:
|
||
/// after all 10 calls, HOLD_REWARD_EMA == −0.05 (only the 5 Hold
|
||
/// bars updated; the first Hold bar bootstrapped to −0.05, and
|
||
/// subsequent identical observations leave it at −0.05).
|
||
///
|
||
/// Per the spec: "Update only on Hold-state bars (gate on
|
||
/// action_is_hold == 1)."
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn hold_reward_ema_gated_on_hold_bars() {
|
||
let mut inputs: Vec<EmaInputs> = Vec::new();
|
||
for _ in 0..5 {
|
||
inputs.push(EmaInputs {
|
||
is_close: 0,
|
||
win_fraction: 0.0,
|
||
trade_duration: 0,
|
||
hold_fraction: 0.0, // gated OFF
|
||
alpha: 0.0,
|
||
per_bar_hold_reward: -0.05,
|
||
aux_logits_p50: 0.0,
|
||
aux_logits_std: 0.0,
|
||
aux_dir_acc: 0.0,
|
||
});
|
||
}
|
||
for _ in 0..5 {
|
||
inputs.push(EmaInputs {
|
||
is_close: 0,
|
||
win_fraction: 0.0,
|
||
trade_duration: 0,
|
||
hold_fraction: 1.0, // > 0.5 gate ON (SP21 T3.2)
|
||
alpha: 0.0,
|
||
per_bar_hold_reward: -0.05,
|
||
aux_logits_p50: 0.0,
|
||
aux_logits_std: 0.0,
|
||
aux_dir_acc: 0.0,
|
||
});
|
||
}
|
||
let (isv, _internal, obs_count) = run_kernel_sequence(&inputs);
|
||
|
||
// After 5 non-Hold bars the EMA is at sentinel (0.0) and
|
||
// counter is 0; the first Hold bar (step 6) bootstraps to
|
||
// −0.05; the next 4 Hold bars Wiener-blend toward −0.05 (all
|
||
// identical) — the EMA stays at −0.05 to fp32 precision.
|
||
let hold_reward = isv[HOLD_REWARD_EMA_INDEX];
|
||
let tol = 1e-6_f32;
|
||
assert!(
|
||
(hold_reward - (-0.05)).abs() < tol,
|
||
"HOLD_REWARD_EMA: after 5 non-Hold + 5 Hold bars (all r_per_bar=−0.05) \
|
||
expected −0.05, got {hold_reward}",
|
||
);
|
||
|
||
// Counter must reflect ONLY the 5 Hold bars.
|
||
let hold_reward_count = obs_count[4];
|
||
assert_eq!(
|
||
hold_reward_count, 5,
|
||
"HOLD_REWARD obs_count must equal Hold-bar count (5), got {hold_reward_count}",
|
||
);
|
||
}
|
||
|
||
/// Test 4 — per-step always-fire EMAs (HOLD_PCT, aux_p50, aux_std,
|
||
/// aux_dir_acc) update every step regardless of trade-close /
|
||
/// hold-state gates.
|
||
///
|
||
/// Fire 10 steps with `is_close=0, action_is_hold=0`, so the
|
||
/// per-trade and Hold-reward EMAs never fire, but the per-step
|
||
/// EMAs receive consistent inputs (aux_p50=0.2, aux_std=0.1,
|
||
/// aux_dir_acc=1.0) and must converge there.
|
||
///
|
||
/// First observation bootstraps directly (count==0); subsequent
|
||
/// 9 observations Wiener-blend toward the same value. With
|
||
/// identical inputs after bootstrap, the EMA stays put — the
|
||
/// blend `(1-α)·prev + α·curr = curr` when prev == curr.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn per_step_emas_fire_unconditionally() {
|
||
let inputs: Vec<EmaInputs> = (0..10)
|
||
.map(|_| EmaInputs {
|
||
is_close: 0,
|
||
win_fraction: 0.0,
|
||
trade_duration: 0,
|
||
hold_fraction: 0.0,
|
||
alpha: 0.0,
|
||
per_bar_hold_reward: 0.0,
|
||
aux_logits_p50: 0.2,
|
||
aux_logits_std: 0.1,
|
||
aux_dir_acc: 1.0,
|
||
})
|
||
.collect();
|
||
let (isv, internal, obs_count) = run_kernel_sequence(&inputs);
|
||
|
||
let tol = 1e-6_f32;
|
||
assert!(
|
||
(isv[HOLD_PCT_EMA_INDEX] - 0.0).abs() < tol,
|
||
"HOLD_PCT_EMA: 10 non-Hold steps should land at 0.0, got {}",
|
||
isv[HOLD_PCT_EMA_INDEX]
|
||
);
|
||
assert!(
|
||
(internal[EMA_INTERNAL_AUX_P50] - 0.2).abs() < tol,
|
||
"aux_conf_p50_ema: 10 identical steps should land at 0.2, got {}",
|
||
internal[EMA_INTERNAL_AUX_P50]
|
||
);
|
||
assert!(
|
||
(internal[EMA_INTERNAL_AUX_STD] - 0.1).abs() < tol,
|
||
"aux_conf_std_ema: 10 identical steps should land at 0.1, got {}",
|
||
internal[EMA_INTERNAL_AUX_STD]
|
||
);
|
||
assert!(
|
||
(internal[EMA_INTERNAL_AUX_DIR_ACC] - 1.0).abs() < tol,
|
||
"aux_dir_acc_ema: 10 identical steps should land at 1.0, got {}",
|
||
internal[EMA_INTERNAL_AUX_DIR_ACC]
|
||
);
|
||
|
||
// Per-step EMAs fired all 10 times.
|
||
let hold_pct_count = obs_count[3];
|
||
let aux_p50_count = obs_count[5];
|
||
assert_eq!(hold_pct_count, 10, "HOLD_PCT obs_count must be 10");
|
||
assert_eq!(aux_p50_count, 10, "AUX_P50 obs_count must be 10");
|
||
|
||
// Per-trade EMAs never fired (is_close=0).
|
||
let wr_count = obs_count[1];
|
||
assert_eq!(wr_count, 0, "WR obs_count must remain 0");
|
||
}
|
||
|
||
/// Test 5 — Wiener-α floor 0.4 contract.
|
||
///
|
||
/// The launcher exports `WIENER_ALPHA_FLOOR = 0.4` per
|
||
/// `pearl_wiener_alpha_floor_for_nonstationary`. Lock the value
|
||
/// against silent drift.
|
||
#[test]
|
||
fn wiener_alpha_floor_locked_at_0_4() {
|
||
assert!(
|
||
(WIENER_ALPHA_FLOOR - 0.4).abs() < 1e-9,
|
||
"Wiener-α floor must be 0.4 per pearl_wiener_alpha_floor_for_nonstationary",
|
||
);
|
||
}
|
||
}
|