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:
jgrusewski
2026-05-15 15:14:56 +02:00
parent 697bb586d0
commit ebbd28437a
2 changed files with 275 additions and 0 deletions

View File

@@ -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");
}
}
}

View File

@@ -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.