//! 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 { let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?"); ctx.default_stream() } fn load_kernel(stream: &Arc, 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 { 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::() 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."); } }