Atomic Phase 1.4 commit per `feedback_no_partial_refactor`. Wires the
SP20 fused-producer chain (Stats → Aggregate → EMAs → Controllers) into
GpuExperienceCollector's per-rollout-step path with one new aggregation
kernel + Phase 1.2 EMA kernel-signature refactor + production wire-up
+ tests + audit/spec/plan amendments, all in one commit.
## Path C decision rationale
The Phase 1.2 EMA kernel originally took 9 scalar value-args. The Phase
1.4 wire-up site (per-rollout-step in GpuExperienceCollector) needs to
feed per-env GPU-resident trade signals (trade_close_per_sample,
step_ret_per_sample, hold_at_exit_per_sample, packed factored
actions_out) into the kernel — forbidden via host sync
(`feedback_cpu_is_read_only`) and via memcpy_dtoh/htod
(`feedback_no_htod_htoh_only_mapped_pinned`). Path C refactors the EMA
kernel to take a device struct pointer (`SP20EmaInputs* ema_inputs`) +
adds an aggregation kernel that writes the struct on the GPU. Phase 1.2
had zero production consumers yet, so the sig change + Phase 1.4 wire-up
land atomically.
## What this commit contains
1. **EMA kernel signature refactor** (Phase 1.2 → Path C):
`sp20_emas_compute_kernel.cu` swaps 9 scalar args for a single
`const SP20EmaInputs* __restrict__ ema_inputs` device pointer.
Math semantics bit-identical. Rust `EmaInputs` mirrored as
`#[repr(C)]` byte-for-byte; new `pack_inputs_into_f32_view` helper
for tests + collectors that fill the struct via a mapped-pinned
f32-aliased buffer.
2. **New aggregation kernel** (`sp20_aggregate_inputs_kernel.cu`):
Per-env arrays (sliced to current rollout step) + sp20_stats outputs
(p50/std) + aux_dir_acc_reduce_kernel output [0] → SP20EmaInputs
struct. Block tree-reduce (4 stripes × bdim × 4 bytes shmem); no
atomicAdd. Aggregation rules per spec §4.5:
- is_close = OR over envs
- is_win = (≥ 0.5 of closed envs were wins)
- trade_duration = round(mean(hold_at_exit) over closed envs)
- action_is_hold = strict majority (count*2 > n_envs) HOLD
- alpha = 0.0 (Phase 2 forward ref — reward kernel)
- per_bar_hold_reward = 0.0 (Phase 3.2 forward ref — Hold-cost dual)
- aux_logits_p50/std/dir_acc forwarded from upstream
3. **Production wire-up in GpuExperienceCollector**:
4 kernel handles + 4 mapped-pinned buffers (struct fields, alloc,
init); per-rollout-step launch sequence after env_step (step 5c):
`Stats → Aggregate → EMAs → Controllers`. All on the same stream;
stream-implicit producer→consumer ordering. Gated on
`isv_signals_dev_ptr != 0 && trainer_params_ptr != 0` (matches the
existing SP14-β EGF chain pattern).
4. **Fold-boundary reset**:
3 new RegistryEntry records (sp20_ema_inputs_buf,
sp20_emas_internal_buf, sp20_emas_obs_count_buf) + 13 new dispatch
arms in training_loop.rs (10 SP20 ISV slot resets + 3 buffer resets).
Closes the pre-existing `every_fold_and_soft_reset_entry_has_dispatch_arm`
regression (was failing on fresh check after the SP20 ISV slot
registrations landed in commit 4249ebc96).
5. **HEALTH_DIAG emit**:
Per-epoch `HEALTH_DIAG[N]: sp20_isv [loss_cap=… alpha_ema=…
wr_ema=… hold_cost_scale=… target_hold_pct=… hold_pct_ema=…
hold_reward_ema=… n_step=… aux_conf_threshold=… aux_gate_temp=…]`
right after the existing q_disagreement_diag emit.
6. **Tests** (5 test files, all green on RTX 3050 Ti sm_86):
- sp20_emas_compute_test.rs (4 GPU oracle): updated to use the
post-Path-C buffer-arg API (mapped-pinned f32-aliased struct).
- sp20_aggregate_inputs_test.rs (NEW, 6 GPU oracle): aggregation
rule coverage including the Phase 2 / Phase 3.2 placeholder
contract.
- sp20_phase1_4_wireup_test.rs (NEW, 2 GPU oracle): end-to-end
4-kernel chain integration test.
- sp20_stats_compute_test.rs (4 GPU oracle): unchanged, regression.
- sp20_controllers_compute_test.rs (7 GPU oracle): unchanged,
regression.
- 18 lib unit tests across the SP20 launchers.
7. **Phase 2 / Phase 3.2 forward references**:
The `alpha` (Phase 2) and `per_bar_hold_reward` (Phase 3.2) fields
are emitted as 0.0 placeholders and documented at:
- `sp20_aggregate_inputs_kernel.cu:46-52` (in-kernel docstring)
- `sp20_aggregate_inputs.rs:46-49` (launcher docstring)
- `dqn-wire-up-audit.md` "Phase 2 / Phase 3.2 forward references"
These are NOT stubs — Phase 2 / 3.2 will replace the kernel's 0.0
writes with real signals atomically with their respective producers.
The original Task 2.3 (host-side aggregation) is **subsumed** by
the GPU-side aggregation kernel.
## Hard rules
- `feedback_no_partial_refactor` — kernel sig change + aggregation
kernel + production wire-up + tests + audit/spec/plan amendments
in one atomic commit
- `feedback_no_atomicadd` — aggregation kernel uses block tree-reduce
- `feedback_no_cpu_compute_strict` — every aggregation lives on GPU
- `feedback_no_htod_htoh_only_mapped_pinned` — every Phase 1.4 buffer
is mapped-pinned with `cuMemHostAlloc(DEVICEMAP)` reachable via
`dev_ptr`; no memcpy_dtoh/htod
- `pearl_first_observation_bootstrap` — counter-based bootstrap
preserved; placeholder writes (0.0) keep the ALPHA / HOLD_REWARD
EMAs at 0.0 sentinel until Phase 2 / 3.2 wire real producers
- `pearl_no_host_branches_in_captured_graph` — every kernel is single-
block; no host branches; safe to capture in the per-step CUDA Graph
- `pearl_tests_must_prove_not_lock_observations` — integration test
asserts invariants (slots populate, bounds respected) rather than
locked observed values
## Verification
- `cargo check -p ml --features cuda` clean
- 18 lib unit tests for SP20 launchers pass
- 23 GPU oracle tests across 5 test files pass on RTX 3050 Ti (sm_86)
- `every_fold_and_soft_reset_entry_has_dispatch_arm` regression test
now passes (was failing pre-change)
- 14 baseline lib test failures unchanged (none introduced by this
commit)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
12 KiB
Rust
290 lines
12 KiB
Rust
//! SP20 Phase 1.4 (2026-05-09) — `sp20_aggregate_inputs_kernel` GPU oracle tests.
|
|
//!
|
|
//! Validates the per-env → SP20EmaInputs aggregation rules atomically
|
|
//! with the Path C buffer-arg refactor of `sp20_emas_compute_kernel`.
|
|
//! The aggregation kernel is the GPU-side replacement for what would
|
|
//! otherwise need a host-side reduction over `trade_close_per_sample`,
|
|
//! `step_ret_per_sample`, etc. — forbidden by `feedback_cpu_is_read_only`
|
|
//! + `feedback_no_htod_htoh_only_mapped_pinned`.
|
|
//!
|
|
//! Each test constructs synthetic per-env arrays in mapped-pinned f32/i32
|
|
//! buffers, primes the upstream stats / dir_acc inputs, launches the
|
|
//! aggregation kernel, then reads back the resulting `SP20EmaInputs`
|
|
//! struct via the same f32-aliased buffer trick the EMA tests use.
|
|
//!
|
|
//! Run on a GPU host:
|
|
//!
|
|
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
|
|
//! cargo test -p ml --test sp20_aggregate_inputs_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::sp20_aggregate_inputs::{
|
|
launch_sp20_aggregate_inputs, SP20_EMA_INPUTS_F32_LEN,
|
|
};
|
|
|
|
const SP20_AGGREGATE_INPUTS_CUBIN: &[u8] = include_bytes!(concat!(
|
|
env!("OUT_DIR"),
|
|
"/sp20_aggregate_inputs_kernel.cubin"
|
|
));
|
|
|
|
/// Production direction divisor (`NUM_MAGNITUDES * NUM_ORD * NUM_URG = 27`)
|
|
/// matching `state_layout.cuh` and `experience_action_select`.
|
|
const DIR_DIVISOR: i32 = 27;
|
|
|
|
/// `DIR_HOLD = 1` per `state_layout.cuh`. The aggregation kernel
|
|
/// flags `action_is_hold` when the majority of envs decode to this
|
|
/// direction.
|
|
const HOLD_ACTION_ID: i32 = 1;
|
|
|
|
/// Encode a packed factored action with the given direction.
|
|
/// `dir * 27 + 0` lands at the `mag=0, ord=0, urg=0` slot of the
|
|
/// chosen direction — the aggregation kernel only cares about the
|
|
/// direction component.
|
|
fn encode_dir(dir: i32) -> i32 {
|
|
dir * DIR_DIVISOR
|
|
}
|
|
|
|
fn make_test_stream() -> Arc<CudaStream> {
|
|
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
|
ctx.default_stream()
|
|
}
|
|
|
|
fn load_aggregation_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
|
|
let module = stream
|
|
.context()
|
|
.load_cubin(SP20_AGGREGATE_INPUTS_CUBIN.to_vec())
|
|
.expect("load sp20_aggregate_inputs_kernel cubin");
|
|
module
|
|
.load_function("sp20_aggregate_inputs_kernel")
|
|
.expect("load sp20_aggregate_inputs_kernel function")
|
|
}
|
|
|
|
/// Read the post-launch `SP20EmaInputs` struct out of the f32-aliased
|
|
/// buffer. Returns `(is_close, is_win, trade_duration, action_is_hold,
|
|
/// alpha, per_bar_hold_reward, p50, std, dir_acc)`.
|
|
fn read_ema_inputs(buf: &MappedF32Buffer) -> (i32, i32, i32, i32, f32, f32, f32, f32, f32) {
|
|
let v = buf.read_all();
|
|
assert_eq!(v.len(), SP20_EMA_INPUTS_F32_LEN);
|
|
// The kernel writes int fields in the first 4 slots and float
|
|
// fields in the last 5. We cast back from f32::to_bits.
|
|
let is_close = v[0].to_bits() as i32;
|
|
let is_win = v[1].to_bits() as i32;
|
|
let trade_duration = v[2].to_bits() as i32;
|
|
let action_is_hold = v[3].to_bits() as i32;
|
|
let alpha = v[4];
|
|
let per_bar_hr = v[5];
|
|
let p50 = v[6];
|
|
let std = v[7];
|
|
let dir_acc = v[8];
|
|
(is_close, is_win, trade_duration, action_is_hold,
|
|
alpha, per_bar_hr, p50, std, dir_acc)
|
|
}
|
|
|
|
/// Construct a 1-stride arrangement (one slice per env at offset
|
|
/// `env_id * 1`). This mirrors a per-step slice that has already
|
|
/// been offset to the current timestep — the production wire-up
|
|
/// passes `base + current_t` so the kernel reads `[env*L + 0]`.
|
|
/// In tests we use stride=1 and pack envs contiguously.
|
|
fn run_kernel(
|
|
trade_close: &[i32],
|
|
step_ret: &[f32],
|
|
hold_at_exit: &[f32],
|
|
actions: &[i32],
|
|
aux_logits_p50: f32,
|
|
aux_logits_std: f32,
|
|
aux_dir_acc: f32,
|
|
) -> (i32, i32, i32, i32, f32, f32, f32, f32, f32) {
|
|
let stream = make_test_stream();
|
|
let kernel = load_aggregation_kernel(&stream);
|
|
let n = trade_close.len();
|
|
assert!(n > 0, "test n_envs must be > 0");
|
|
assert_eq!(step_ret.len(), n);
|
|
assert_eq!(hold_at_exit.len(), n);
|
|
assert_eq!(actions.len(), n);
|
|
|
|
let tc_buf = unsafe { MappedI32Buffer::new(n) }.expect("alloc tc");
|
|
tc_buf.write_from_slice(trade_close);
|
|
let sr_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc sr");
|
|
sr_buf.write_from_slice(step_ret);
|
|
let he_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc he");
|
|
he_buf.write_from_slice(hold_at_exit);
|
|
let act_buf = unsafe { MappedI32Buffer::new(n) }.expect("alloc act");
|
|
act_buf.write_from_slice(actions);
|
|
|
|
let p50_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc p50");
|
|
p50_buf.write_from_slice(&[aux_logits_p50]);
|
|
let std_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc std");
|
|
std_buf.write_from_slice(&[aux_logits_std]);
|
|
let dir_buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc dir");
|
|
dir_buf.write_from_slice(&[aux_dir_acc]);
|
|
|
|
let out_buf = unsafe { MappedF32Buffer::new(SP20_EMA_INPUTS_F32_LEN) }
|
|
.expect("alloc out");
|
|
// Pre-fill with sentinel so we can detect a kernel that fails to write.
|
|
out_buf.write_from_slice(&[f32::NAN; SP20_EMA_INPUTS_F32_LEN]);
|
|
|
|
unsafe {
|
|
launch_sp20_aggregate_inputs(
|
|
&stream,
|
|
&kernel,
|
|
tc_buf.dev_ptr,
|
|
sr_buf.dev_ptr,
|
|
he_buf.dev_ptr,
|
|
act_buf.dev_ptr,
|
|
/* env_stride = */ 1,
|
|
p50_buf.dev_ptr,
|
|
std_buf.dev_ptr,
|
|
dir_buf.dev_ptr,
|
|
/* n_envs = */ n as i32,
|
|
DIR_DIVISOR,
|
|
HOLD_ACTION_ID,
|
|
out_buf.dev_ptr,
|
|
).expect("launch aggregate kernel");
|
|
}
|
|
stream.synchronize().expect("sync after aggregate");
|
|
read_ema_inputs(&out_buf)
|
|
}
|
|
|
|
/// All envs flat (no trade close), all picking Long. Expected:
|
|
/// is_close = 0, action_is_hold = 0, trade_duration = 0,
|
|
/// alpha = per_bar_hold_reward = 0 (Phase 2 / 3.2 placeholders),
|
|
/// upstream stats forwarded.
|
|
#[test]
|
|
#[ignore = "requires GPU"]
|
|
fn no_trades_no_holds_emits_sentinel_state() {
|
|
let trade_close = vec![0_i32; 8];
|
|
let step_ret = vec![0.0_f32; 8];
|
|
let hold_at_exit = vec![0.0_f32; 8];
|
|
let actions = vec![encode_dir(2 /* DIR_LONG */); 8];
|
|
let (ic, iw, td, ah, alpha, hr, p50, std, dir) =
|
|
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
|
0.3, 0.1, 0.55);
|
|
assert_eq!(ic, 0, "is_close must be 0 (no trades closed)");
|
|
assert_eq!(iw, 0, "is_win must be 0 when is_close=0");
|
|
assert_eq!(td, 0, "trade_duration must be 0 when is_close=0");
|
|
assert_eq!(ah, 0, "action_is_hold must be 0 (all envs picked Long)");
|
|
assert_eq!(alpha, 0.0, "alpha is Phase 2 forward ref placeholder = 0.0");
|
|
assert_eq!(hr, 0.0, "per_bar_hold_reward is Phase 3.2 forward ref = 0.0");
|
|
assert!((p50 - 0.3).abs() < 1e-6, "aux_logits_p50 forwarded");
|
|
assert!((std - 0.1).abs() < 1e-6, "aux_logits_std forwarded");
|
|
assert!((dir - 0.55).abs() < 1e-6, "aux_dir_acc forwarded");
|
|
}
|
|
|
|
/// Half envs win, half lose; all close. Expected:
|
|
/// is_close = 1, is_win = 1 (4 wins / 8 closed = 0.5, threshold).
|
|
/// trade_duration = round(mean(hold_at_exit)) = round(7.5) = 8
|
|
/// (round-to-nearest-even at 7.5 ⇒ 8).
|
|
#[test]
|
|
#[ignore = "requires GPU"]
|
|
fn half_wins_meets_threshold_and_duration_rounds() {
|
|
// 4 wins, 4 losses. Hold-at-exit mean = (5+6+7+8+7+8+9+10)/8 = 7.5.
|
|
let trade_close = vec![1; 8];
|
|
let step_ret = vec![ 0.5, 0.3, 0.2, 0.1, -0.5, -0.3, -0.2, -0.1];
|
|
let hold_at_exit = vec![5.0, 6.0, 7.0, 8.0, 7.0, 8.0, 9.0, 10.0];
|
|
let actions = vec![encode_dir(0); 8]; // all DIR_SHORT — not Hold
|
|
let (ic, iw, td, ah, _, _, _, _, _) =
|
|
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
|
0.0, 0.0, 0.0);
|
|
assert_eq!(ic, 1);
|
|
assert_eq!(iw, 1, "fraction wins (4/8 = 0.5) meets >= 0.5 threshold");
|
|
// round-to-nearest-even at 7.5 = 8 (banker's rounding); CUDA
|
|
// __float2int_rn uses round-to-nearest-even. Either 7 or 8 are
|
|
// structurally acceptable; the kernel uses CUDA's RN so we
|
|
// accept both as a bound test.
|
|
assert!(
|
|
td == 7 || td == 8,
|
|
"trade_duration must round 7.5 to 7 (down) or 8 (RN-even); got {td}",
|
|
);
|
|
assert_eq!(ah, 0);
|
|
}
|
|
|
|
/// Strict majority Hold over n_envs. With 5 of 8 picking Hold,
|
|
/// 5 > 8/2 = 4 ⇒ action_is_hold = 1.
|
|
#[test]
|
|
#[ignore = "requires GPU"]
|
|
fn majority_hold_sets_action_is_hold() {
|
|
let trade_close = vec![0; 8];
|
|
let step_ret = vec![0.0; 8];
|
|
let hold_at_exit = vec![0.0; 8];
|
|
let mut actions = Vec::with_capacity(8);
|
|
for i in 0..8 {
|
|
actions.push(if i < 5 { encode_dir(HOLD_ACTION_ID) } else { encode_dir(0) });
|
|
}
|
|
let (_, _, _, ah, _, _, _, _, _) =
|
|
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
|
0.0, 0.0, 0.0);
|
|
assert_eq!(ah, 1, "5 of 8 = strict majority Hold => action_is_hold=1");
|
|
}
|
|
|
|
/// Tie at exactly half Hold. 4 of 8 = 4 = n_envs/2; the kernel uses
|
|
/// `count * 2 > n_envs` (strict majority), so a tie ⇒ 0.
|
|
#[test]
|
|
#[ignore = "requires GPU"]
|
|
fn tie_hold_count_does_not_set_action_is_hold() {
|
|
let trade_close = vec![0; 8];
|
|
let step_ret = vec![0.0; 8];
|
|
let hold_at_exit = vec![0.0; 8];
|
|
let mut actions = Vec::with_capacity(8);
|
|
for i in 0..8 {
|
|
actions.push(if i < 4 { encode_dir(HOLD_ACTION_ID) } else { encode_dir(2) });
|
|
}
|
|
let (_, _, _, ah, _, _, _, _, _) =
|
|
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
|
0.0, 0.0, 0.0);
|
|
assert_eq!(ah, 0, "tie (4 of 8 Hold) => action_is_hold=0 (strict majority)");
|
|
}
|
|
|
|
/// Single env closed with a loss: is_close=1, is_win=0
|
|
/// (0/1 < 0.5), trade_duration = round(3.0) = 3.
|
|
#[test]
|
|
#[ignore = "requires GPU"]
|
|
fn single_env_loss_close() {
|
|
let trade_close = vec![1, 0, 0, 0];
|
|
let step_ret = vec![-0.2, 0.0, 0.0, 0.0];
|
|
let hold_at_exit = vec![3.0, 0.0, 0.0, 0.0];
|
|
let actions = vec![encode_dir(0); 4];
|
|
let (ic, iw, td, _, _, _, _, _, _) =
|
|
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
|
0.0, 0.0, 0.0);
|
|
assert_eq!(ic, 1);
|
|
assert_eq!(iw, 0, "loss (0 wins out of 1 closed) => is_win=0");
|
|
assert_eq!(td, 3, "trade_duration = round(3.0) = 3");
|
|
}
|
|
|
|
/// Phase 2 / Phase 3.2 forward-reference placeholders are emitted
|
|
/// as 0.0 regardless of any input combinations. This test
|
|
/// **verifies the documented contract** — the kernel does NOT
|
|
/// surface any per-env signal into `alpha` / `per_bar_hold_reward`
|
|
/// in Phase 1.4; that wiring lands in Phase 2 / 3.2.
|
|
#[test]
|
|
#[ignore = "requires GPU"]
|
|
fn alpha_and_per_bar_hold_reward_are_phase_2_and_3_2_placeholders() {
|
|
let trade_close = vec![1, 1, 1];
|
|
let step_ret = vec![0.5, 0.4, 0.3];
|
|
let hold_at_exit = vec![10.0, 10.0, 10.0];
|
|
let actions = vec![encode_dir(HOLD_ACTION_ID); 3];
|
|
let (_, _, _, _, alpha, hr, _, _, _) =
|
|
run_kernel(&trade_close, &step_ret, &hold_at_exit, &actions,
|
|
0.5, 0.5, 0.99);
|
|
assert_eq!(
|
|
alpha, 0.0,
|
|
"alpha is Phase 2 placeholder; aggregation kernel must NOT \
|
|
surface a per-env signal here regardless of inputs",
|
|
);
|
|
assert_eq!(
|
|
hr, 0.0,
|
|
"per_bar_hold_reward is Phase 3.2 placeholder; aggregation \
|
|
kernel must NOT surface a per-env signal here",
|
|
);
|
|
}
|
|
}
|