From ebbd28437ad153914929164fd24ef16d928f77ed Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Fri, 15 May 2026 15:14:56 +0200 Subject: [PATCH] =?UTF-8?q?test(alpha):=20chained=20pipeline=20smoke=20?= =?UTF-8?q?=E2=80=94=20Task=2012=20wiring=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end test for the kernel COMPOSITION that the H=600 DQN smoke (Task 12 proper) will use at each rollout boundary: t+0 alpha_kill_criteria_compute_kernel → scratch[0..4] t+1 apply_pearls_ad_kernel(n_slots=4) → ISV[539..543] The two prior alpha_kernels smokes (munchausen + kill_criteria) validated kernels in isolation. This smoke validates the COMPOSITION on the same stream — failures here are different (stream-ordering, Pearls index base, Wiener offset base, scratch visibility) and would silently break Task 12. Two iterations with stationary synthetic inputs: Iter 1 (Pearl A bootstrap) prev_x_mean=0 AND x_lag=0 → ISV[539..542] populated with raw scratch observations = [0.2041, 0.8980, 1.0670, 0.1] within 0.01 tolerance. Anchor slots 547/548 remain at Task 7c values (-5185, 4953). Iter 2 (Pearl D stationary) dx_mean = dx_step = 0 → α* = 0 → ISV unchanged from iter 1 within 1e-4. Stationary signal stays at the bootstrap value. Test would catch: - Producer's scratch write not visible to applicator (stream-ordering) - Wrong Pearls isv_idx_base / wiener_offset_base - Pearl A sentinel detection broken (formula yields 0 at t=0) - Wiener state corruption (iter 2 drifts from iter 1) Reuses launch_apply_pearls from sp4_wiener_ema.rs (pub(crate)). Helper fn run_chained_iter factors the producer→applicator sequence so the two iterations are byte-identical apart from the Wiener state's evolution. `cargo test -p ml --lib alpha_kernels`: 4 pass (compile witness + 2 prior GPU smokes + this chained smoke) on RTX 3050 Ti in 1.89s total. Audit doc docs/isv-slots.md updated per Invariant 7. Remaining Task 12 work (full H=600 DQN trainer integration with this pipeline at rollout boundaries) is queued for a dedicated session. --- crates/ml/src/cuda_pipeline/alpha_kernels.rs | 248 +++++++++++++++++++ docs/isv-slots.md | 27 ++ 2 files changed, 275 insertions(+) diff --git a/crates/ml/src/cuda_pipeline/alpha_kernels.rs b/crates/ml/src/cuda_pipeline/alpha_kernels.rs index 56a7b839f..40a368cef 100644 --- a/crates/ml/src/cuda_pipeline/alpha_kernels.rs +++ b/crates/ml/src/cuda_pipeline/alpha_kernels.rs @@ -175,6 +175,12 @@ static ALPHA_MUNCHAUSEN_TARGET_CUBIN: &[u8] = static ALPHA_KILL_CRITERIA_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/alpha_kill_criteria.cubin")); +/// Precompiled canonical Pearls A+D applicator cubin (test-only access; the +/// production path loads this through the trainer's module init). +#[cfg(test)] +static APPLY_PEARLS_CUBIN: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/apply_pearls_kernel.cubin")); + #[cfg(test)] mod tests { use super::*; @@ -406,4 +412,246 @@ mod tests { ); } } + + /// End-to-end **chained pipeline** smoke for Task 12 wiring (2026-05-15). + /// + /// Validates that the kill-criteria producer correctly composes with the + /// canonical SP4 `apply_pearls_ad_kernel` on the same stream: + /// + /// t+0 alpha_kill_criteria_compute_kernel → scratch[0..4] + /// t+1 apply_pearls_ad_kernel(n_slots=4) → ISV[539..543] + /// + /// This is the gating composition for Task 12 — the H=600 DQN smoke + /// will use this exact pattern at each rollout boundary. Failures here + /// would manifest as bogus ISV values (e.g., scratch read before write + /// completes due to stream-ordering violation, or wrong Pearls index + /// base). + /// + /// Hand-math (same synthetic inputs as the standalone kill-criteria + /// smoke, run twice with stationary inputs): + /// + /// Iter 1: Pearl A bootstrap (sentinel detect: prev_x_mean==0 AND + /// x_lag==0) → ISV[539..542] = [0.2041, 0.8980, 1.0670, 0.1] + /// (raw observations). + /// + /// Iter 2: Pearl D update with same inputs. dx_mean = dx_step = 0 + /// → new_sample_var = new_diff_var = 0 → α* = 0 → ISV + /// unchanged. Stationary signal stays at the bootstrap value. + /// + /// If iteration 2 drifted from iteration 1's value, the Wiener state + /// would be wrong; if iteration 1 produced 0 instead of the + /// observations, Pearl A's sentinel bootstrap would be broken; + /// if iteration 2 produced 0, the producer's scratch write wouldn't be + /// visible to the applicator (stream-ordering bug). + #[test] + fn kill_criteria_chained_with_pearls_populates_isv_slots() { + use crate::cuda_pipeline::alpha_isv_slots::{ + ACTION_ENTROPY_EMA_INDEX, ALPHA_ISV_BLOCK_LO, EARLY_Q_MOVEMENT_EMA_INDEX, + Q_SPREAD_EMA_INDEX, RANDOM_BASELINE_MEAN_INDEX, RANDOM_BASELINE_STD_INDEX, + RETURN_VS_RANDOM_EMA_INDEX, + }; + // (launch_apply_pearls is invoked inside the run_chained_iter helper + // so we don't re-import it at the top-level test scope.) + + let ctx = match CudaContext::new(0) { + Ok(c) => c, + Err(e) => { + eprintln!("Skipping GPU pipeline smoke — no CUDA device 0: {e}"); + return; + } + }; + let stream = ctx.default_stream(); + + let kc_module = ctx + .load_cubin(ALPHA_KILL_CRITERIA_CUBIN.to_vec()) + .expect("kill_criteria cubin load"); + let kc_kernel = kc_module + .load_function("alpha_kill_criteria_compute_kernel") + .expect("kill_criteria kernel load"); + let pearls_module = ctx + .load_cubin(APPLY_PEARLS_CUBIN.to_vec()) + .expect("apply_pearls cubin load"); + let pearls_kernel = pearls_module + .load_function("apply_pearls_ad_kernel") + .expect("apply_pearls_ad kernel load"); + + let batch: i32 = 2; + let n_actions: i32 = 3; + + // Same synthetic inputs as the standalone smoke for hand-math reuse. + let q_values: Vec = vec![1.0, 2.0, 3.0, 5.0, 5.0, 5.0]; + let action_counts: Vec = vec![10, 30, 60]; + let scalar_inputs: Vec = vec![100.0, 50.0, 55.0]; + + // ISV buffer with the Task 7c baseline anchors at slots 547/548. + // Sized to 552 so it covers RANDOM_BASELINE_STD_INDEX (548) plus the + // 4 diagnostic slots 539..542 that Pearls will write into. + let mut isv_host: Vec = vec![0.0; 552]; + isv_host[RANDOM_BASELINE_MEAN_INDEX] = -5185.0; + isv_host[RANDOM_BASELINE_STD_INDEX] = 4953.0; + // Slots 539..542 start at sentinel 0 so Pearl A triggers on iter 1. + + let q_values_dev = stream.clone_htod(&q_values).expect("upload q_values"); + let action_counts_dev = stream + .clone_htod(&action_counts) + .expect("upload action_counts"); + let scalar_inputs_dev = stream + .clone_htod(&scalar_inputs) + .expect("upload scalar_inputs"); + let mut isv_dev = stream.clone_htod(&isv_host).expect("upload isv"); + let mut scratch_dev = stream.alloc_zeros::(4).expect("alloc scratch"); + // Wiener state: 4 slots × [sample_var, diff_var, x_lag] = 12 floats. + let mut wiener_dev = stream.alloc_zeros::(12).expect("alloc wiener"); + + // --- Iter 1: Pearl A bootstrap --- + run_chained_iter( + &stream, + &kc_kernel, + &pearls_kernel, + &q_values_dev, + &action_counts_dev, + &scalar_inputs_dev, + &mut isv_dev, + &mut scratch_dev, + &mut wiener_dev, + batch, + n_actions, + ); + stream.synchronize().expect("sync iter 1"); + + let isv_after_1 = stream.clone_dtoh(&isv_dev).expect("download isv iter 1"); + let iter_1_obs = [ + isv_after_1[Q_SPREAD_EMA_INDEX], + isv_after_1[ACTION_ENTROPY_EMA_INDEX], + isv_after_1[RETURN_VS_RANDOM_EMA_INDEX], + isv_after_1[EARLY_Q_MOVEMENT_EMA_INDEX], + ]; + let expected_iter_1 = [0.2041_f32, 0.8980_f32, 1.0670_f32, 0.1_f32]; + let labels = ["q_spread", "action_entropy", "return_vs_random", "early_movement"]; + for (i, ((got, exp), label)) in iter_1_obs + .iter() + .zip(expected_iter_1.iter()) + .zip(labels.iter()) + .enumerate() + { + assert!( + (got - exp).abs() < 0.01, + "Iter 1 (Pearl A bootstrap) ISV[{}] = {label}: expected {exp:.4}, got {got:.4}", + ALPHA_ISV_BLOCK_LO + i + ); + } + + // --- Iter 2: Pearl D stationary update (same inputs → ISV unchanged) --- + run_chained_iter( + &stream, + &kc_kernel, + &pearls_kernel, + &q_values_dev, + &action_counts_dev, + &scalar_inputs_dev, + &mut isv_dev, + &mut scratch_dev, + &mut wiener_dev, + batch, + n_actions, + ); + stream.synchronize().expect("sync iter 2"); + + let isv_after_2 = stream.clone_dtoh(&isv_dev).expect("download isv iter 2"); + let iter_2_obs = [ + isv_after_2[Q_SPREAD_EMA_INDEX], + isv_after_2[ACTION_ENTROPY_EMA_INDEX], + isv_after_2[RETURN_VS_RANDOM_EMA_INDEX], + isv_after_2[EARLY_Q_MOVEMENT_EMA_INDEX], + ]; + for (i, (g1, g2)) in iter_1_obs.iter().zip(iter_2_obs.iter()).enumerate() { + assert!( + (g1 - g2).abs() < 1.0e-4, + "Iter 2 (Pearl D stationary) ISV[{}] = {}: expected ~{:.4} (unchanged), got {:.4}", + ALPHA_ISV_BLOCK_LO + i, + labels[i], + g1, + g2 + ); + } + // Anchor slots (547/548) must remain at Task 7c values throughout — + // Pearls only writes 539..542 so these should be untouched. + assert_eq!( + isv_after_2[RANDOM_BASELINE_MEAN_INDEX], -5185.0, + "anchor slot 547 was unexpectedly modified" + ); + assert_eq!( + isv_after_2[RANDOM_BASELINE_STD_INDEX], 4953.0, + "anchor slot 548 was unexpectedly modified" + ); + + } + + /// Helper: one iteration of the chained pipeline. Producer → Pearls + /// applicator on the same stream. Factored out so the two iterations + /// in the test above are byte-identical apart from the Wiener state's + /// evolution. + #[allow(clippy::too_many_arguments)] + fn run_chained_iter( + stream: &cudarc::driver::CudaStream, + kc_kernel: &cudarc::driver::CudaFunction, + pearls_kernel: &cudarc::driver::CudaFunction, + q_values_dev: &cudarc::driver::CudaSlice, + action_counts_dev: &cudarc::driver::CudaSlice, + scalar_inputs_dev: &cudarc::driver::CudaSlice, + isv_dev: &mut cudarc::driver::CudaSlice, + scratch_dev: &mut cudarc::driver::CudaSlice, + wiener_dev: &mut cudarc::driver::CudaSlice, + batch: i32, + n_actions: i32, + ) { + use crate::cuda_pipeline::alpha_isv_slots::ALPHA_ISV_BLOCK_LO; + use crate::cuda_pipeline::sp4_wiener_ema::{launch_apply_pearls, ALPHA_META}; + + let (q_values_ptr, _g0) = q_values_dev.device_ptr(stream); + let (action_counts_ptr, _g1) = action_counts_dev.device_ptr(stream); + let (scalar_inputs_ptr, _g2) = scalar_inputs_dev.device_ptr(stream); + let (isv_ptr_ro, _g3) = isv_dev.device_ptr(stream); + let (scratch_ptr_mut, _g4) = scratch_dev.device_ptr_mut(stream); + + unsafe { + launch_alpha_kill_criteria( + stream, + kc_kernel, + q_values_ptr, + action_counts_ptr, + scalar_inputs_ptr, + isv_ptr_ro, + batch, + n_actions, + scratch_ptr_mut, + ) + .expect("kill_criteria launch"); + } + drop(_g0); + drop(_g1); + drop(_g2); + drop(_g3); + drop(_g4); + + let (scratch_ptr_ro, _g5) = scratch_dev.device_ptr(stream); + let (isv_ptr_mut, _g6) = isv_dev.device_ptr_mut(stream); + let (wiener_ptr_mut, _g7) = wiener_dev.device_ptr_mut(stream); + + unsafe { + launch_apply_pearls( + stream, + pearls_kernel, + scratch_ptr_ro, + 0, // scratch_idx_base + isv_ptr_mut, + ALPHA_ISV_BLOCK_LO as i32, // 539 + wiener_ptr_mut, + 0, // wiener_offset_base + 4, // n_slots — Q_SPREAD..EARLY_Q_MOVEMENT + ALPHA_META, + ) + .expect("apply_pearls launch"); + } + } } diff --git a/docs/isv-slots.md b/docs/isv-slots.md index d1b697ab0..ada05b164 100644 --- a/docs/isv-slots.md +++ b/docs/isv-slots.md @@ -618,3 +618,30 @@ Both launchers follow `launch_apply_pearls` (in `sp4_wiener_ema.rs`) — pre-loa ISV buffer is sized to 552 floats with the production slot indices populated using the committed Task 7c baseline values; this exercises the slot-indexing path through `isv[random_baseline_mean_slot]` / `isv[random_baseline_std_slot]` reads. Companion to the `munchausen_target_smoke_matches_hand_computation` test (91d1a52b9). Both pass on RTX 3050 Ti. + +## Phase E.1 Task 12 wiring — chained pipeline smoke (2026-05-15) + +`crates/ml/src/cuda_pipeline/alpha_kernels.rs::tests::kill_criteria_chained_with_pearls_populates_isv_slots` is the gating composition smoke for the H=600 DQN smoke (Task 12 proper). Validates that `alpha_kill_criteria_compute_kernel` correctly composes with the canonical `apply_pearls_ad_kernel` on the same stream: + +``` +t+0 alpha_kill_criteria_compute_kernel → scratch[0..4] +t+1 apply_pearls_ad_kernel(n_slots=4) → ISV[539..543] +``` + +Two iterations with stationary synthetic inputs: + +- **Iter 1 (Pearl A bootstrap)**: prev_x_mean=0 AND x_lag=0 → ISV[539..542] = raw observations = [0.2041, 0.8980, 1.0670, 0.1] within 0.01 tolerance. Anchor slots 547/548 must remain at Task 7c values (-5185, 4953) — Pearls only writes 539..542. + +- **Iter 2 (Pearl D stationary)**: dx_mean = dx_step = 0 → α* = 0 → ISV unchanged from iter 1 within 1e-4. Stationary signal stays at the bootstrap value. + +Tests it would catch: +- Producer's scratch write not visible to applicator (stream-ordering bug) → iter 2 produces 0 +- Wrong Pearls `isv_idx_base` → ISV slots wrong +- Wrong `wiener_offset_base` → Wiener state corruption visible at iter 2 +- Pearl A sentinel detection broken → iter 1 produces 0 (formula yields 0 at t=0 without sentinel branch) + +Reuses `launch_apply_pearls` from `sp4_wiener_ema.rs` (pub(crate)); both kernels load directly from cubins via `include_bytes!`. + +Companion to `munchausen_target_smoke_matches_hand_computation` and `kill_criteria_smoke_matches_hand_computation`. All three pass on RTX 3050 Ti in ~2s total. + +The full H=600 DQN training smoke (real trainer integration with this pipeline at rollout boundaries) is the remaining Task 12 work, queued for a dedicated session.