test(alpha): chained pipeline smoke — Task 12 wiring validation
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.
This commit is contained in:
@@ -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<f32> = vec![1.0, 2.0, 3.0, 5.0, 5.0, 5.0];
|
||||
let action_counts: Vec<i32> = vec![10, 30, 60];
|
||||
let scalar_inputs: Vec<f32> = 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<f32> = 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::<f32>(4).expect("alloc scratch");
|
||||
// Wiener state: 4 slots × [sample_var, diff_var, x_lag] = 12 floats.
|
||||
let mut wiener_dev = stream.alloc_zeros::<f32>(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<f32>,
|
||||
action_counts_dev: &cudarc::driver::CudaSlice<i32>,
|
||||
scalar_inputs_dev: &cudarc::driver::CudaSlice<f32>,
|
||||
isv_dev: &mut cudarc::driver::CudaSlice<f32>,
|
||||
scratch_dev: &mut cudarc::driver::CudaSlice<f32>,
|
||||
wiener_dev: &mut cudarc::driver::CudaSlice<f32>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user