Files
foxhunt/crates/ml/tests/sp20_emas_compute_test.rs
jgrusewski 1790a31b66 feat(sp21): T3.1+T3.2 ISV defrost — wr_ema + hold_pct_ema (atomic)
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>
2026-05-10 20:13:13 +02:00

378 lines
15 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 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",
);
}
}