test(ml-alpha): per-horizon pipeline end-to-end smoke + α-gate (C23)

Composes the C21 + C22 kernels with a host-side learnable α-gate to
prove the full per-horizon contribution path works end-to-end without
yet doing the captured-graph integration in PerceptionTrainer.

Pipeline:
  LNb [B, K, HIDDEN_DIM]
   → per_horizon_attention_pool_fwd  → context_h [B, N_HORIZONS, HIDDEN_DIM]
   → per_horizon_residual_head_fwd   → residual  [B, N_HORIZONS]
   → final[b, h] = baseline[b, h] + tanh(α[h]) * residual[b, h]

Two tests cover the critical invariants for adoption-safety:

  alpha_zero_init_is_identity_to_baseline
    With α = [0, 0, 0, 0, 0] and any random Q_h / w_res / bias_res,
    final_logit MUST be bit-identical to baseline_logit (because
    tanh(0) = 0). Verified via to_bits() byte equality. Proves that
    initialising the new variant with α=0 makes it a strict superset
    of the existing path — switching to AttentionPoolVariant::PerHorizon
    cannot regress before any training has happened.

  alpha_nonzero_changes_output_and_grads_flow_end_to_end
    With α = [0.5, -0.3, 0.2, -0.1, 0.4]:
      * final ≠ baseline (residual contributing) ✓
      * all final logits finite ✓
      * full backward chain (residual_head_bwd → attention_pool_bwd)
        produces finite d_Q_h_scratch + finite d_LNb with at least
        one non-zero entry in each → gradients flow back to both the
        attention queries and the LN_b input ✓

This closes the kernel-side correctness story. The remaining
integration commits (C24+) are operational:

  C24: extend CheckpointV1 → V2 (add q_h, w_res, bias_res, alpha
       fields; V1 files load as Variant::SharedQuery)
  C25: PerceptionTrainer wiring — allocate the device buffers, fold
       attention + residual + gate into the captured graph, plumb
       gradients into AdamW's param list
  C26: 1-epoch smoke (assert no NaN, loss decreases vs baseline) —
       needs real training data + multi-GPU time
  C27: 30-epoch × 3-fold A/B (task #204) — decision gate per
       docs/superpowers/specs/2026-05-18-per-horizon-attention-pool-design.md
       §0 falsifiable claim

C24-C25 are 1-2 day work even when carefully scoped; C26-C27 need
real GPU-hours + result analysis. C21-C23 land the validatable kernel
correctness piece without committing to that time investment yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-18 10:38:24 +02:00
parent 8c335caef7
commit 69c64f2266

View File

@@ -0,0 +1,201 @@
//! End-to-end smoke test for the per-horizon attention pool +
//! residual head + α-gate composition (C23).
//!
//! Pipeline:
//! LNb [B, K, HIDDEN_DIM]
//! → per_horizon_attention_pool_fwd → context_h [B, N_HORIZONS, HIDDEN_DIM]
//! → per_horizon_residual_head_fwd → residual [B, N_HORIZONS]
//! → final_logit[h] = baseline_logit[h] + tanh(α[h]) * residual[h]
//!
//! Verifies:
//! 1. α = 0 → final_logit == baseline_logit (bit-equal) — proves the
//! additive integration is identity at zero-init, so adopting this
//! path can't regress the existing baseline.
//! 2. α ≠ 0 → final_logit ≠ baseline_logit AND no NaN/Inf leaks.
//! 3. Backward: residual_head_bwd's d_context_h feeds the attention
//! pool bwd; full gradient chain finite + non-zero where expected.
use anyhow::Result;
use cudarc::driver::CudaSlice;
use ml_alpha::per_horizon_attention_pool::{
PerHorizonAttentionPool, PHA_HIDDEN_DIM, PHA_N_HORIZONS,
};
use ml_alpha::per_horizon_residual_head::PerHorizonResidualHead;
use ml_core::device::MlDevice;
use rand::Rng;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
const B: usize = 4;
const K: usize = 16;
fn try_dev() -> Option<MlDevice> {
match MlDevice::cuda(0) {
Ok(d) => Some(d),
Err(e) => {
eprintln!("skipping: cuda device unavailable ({e})");
None
}
}
}
fn alloc_upload(
stream: &std::sync::Arc<cudarc::driver::CudaStream>,
host: &[f32],
) -> CudaSlice<f32> {
let mut buf = stream.alloc_zeros::<f32>(host.len()).expect("alloc");
stream.memcpy_htod(host, &mut buf).expect("htod");
buf
}
fn download(stream: &std::sync::Arc<cudarc::driver::CudaStream>, src: &CudaSlice<f32>) -> Vec<f32> {
let mut out = vec![0.0f32; src.len()];
stream.memcpy_dtoh(src, out.as_mut_slice()).expect("dtoh");
out
}
#[test]
#[ignore = "requires CUDA"]
fn alpha_zero_init_is_identity_to_baseline() -> Result<()> {
let Some(dev) = try_dev() else { return Ok(()); };
let ctx = dev.cuda_context()?.clone();
let stream = dev.cuda_stream()?.clone();
let pool = PerHorizonAttentionPool::new(&ctx, stream.clone())?;
let head = PerHorizonResidualHead::new(&ctx, stream.clone())?;
let mut rng = ChaCha8Rng::seed_from_u64(0xA11A_0BEEF);
let ln_host: Vec<f32> = (0..B * K * PHA_HIDDEN_DIM).map(|_| rng.gen_range(-0.5..0.5)).collect();
let q_h_host: Vec<f32> = (0..PHA_N_HORIZONS * PHA_HIDDEN_DIM).map(|_| rng.gen_range(-0.1..0.1)).collect();
let w_res_host: Vec<f32> = (0..PHA_N_HORIZONS * PHA_HIDDEN_DIM).map(|_| rng.gen_range(-0.1..0.1)).collect();
let bias_res_host: Vec<f32> = (0..PHA_N_HORIZONS).map(|_| rng.gen_range(-0.05..0.05)).collect();
let baseline_logit_host: Vec<f32> = (0..B * PHA_N_HORIZONS).map(|_| rng.gen_range(-2.0..2.0)).collect();
// α = 0 — final logit MUST equal baseline.
let alpha_zero = vec![0.0f32; PHA_N_HORIZONS];
let ln_d = alloc_upload(&stream, &ln_host);
let q_h_d = alloc_upload(&stream, &q_h_host);
let w_res_d = alloc_upload(&stream, &w_res_host);
let b_res_d = alloc_upload(&stream, &bias_res_host);
let mut ctx_d = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS * PHA_HIDDEN_DIM)?;
let mut attn_d = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS * K)?;
pool.forward(&q_h_d, &ln_d, B as i32, K as i32, &mut ctx_d, &mut attn_d)?;
let mut residual_d = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS)?;
head.forward(&ctx_d, &w_res_d, &b_res_d, B as i32, &mut residual_d)?;
// Host-side gate combination: final[b,h] = baseline[b,h] + tanh(α[h]) * residual[b,h].
let residual_host = download(&stream, &residual_d);
let mut final_logit = vec![0.0f32; B * PHA_N_HORIZONS];
for b in 0..B {
for h in 0..PHA_N_HORIZONS {
let idx = b * PHA_N_HORIZONS + h;
let gate = alpha_zero[h].tanh();
final_logit[idx] = baseline_logit_host[idx] + gate * residual_host[idx];
}
}
for i in 0..B * PHA_N_HORIZONS {
assert_eq!(
final_logit[i].to_bits(),
baseline_logit_host[i].to_bits(),
"alpha=0 at idx {i}: final {} ≠ baseline {}",
final_logit[i], baseline_logit_host[i]
);
}
Ok(())
}
#[test]
#[ignore = "requires CUDA"]
fn alpha_nonzero_changes_output_and_grads_flow_end_to_end() -> Result<()> {
let Some(dev) = try_dev() else { return Ok(()); };
let ctx = dev.cuda_context()?.clone();
let stream = dev.cuda_stream()?.clone();
let pool = PerHorizonAttentionPool::new(&ctx, stream.clone())?;
let head = PerHorizonResidualHead::new(&ctx, stream.clone())?;
let mut rng = ChaCha8Rng::seed_from_u64(0xBABE_F00D);
let ln_host: Vec<f32> = (0..B * K * PHA_HIDDEN_DIM).map(|_| rng.gen_range(-0.5..0.5)).collect();
let q_h_host: Vec<f32> = (0..PHA_N_HORIZONS * PHA_HIDDEN_DIM).map(|_| rng.gen_range(-0.1..0.1)).collect();
let w_res_host: Vec<f32> = (0..PHA_N_HORIZONS * PHA_HIDDEN_DIM).map(|_| rng.gen_range(-0.1..0.1)).collect();
let bias_res_host: Vec<f32> = (0..PHA_N_HORIZONS).map(|_| rng.gen_range(-0.05..0.05)).collect();
let baseline_logit_host: Vec<f32> = (0..B * PHA_N_HORIZONS).map(|_| rng.gen_range(-2.0..2.0)).collect();
// Non-zero alpha — different per horizon.
let alpha = [0.5f32, -0.3, 0.2, -0.1, 0.4];
let ln_d = alloc_upload(&stream, &ln_host);
let q_h_d = alloc_upload(&stream, &q_h_host);
let w_res_d = alloc_upload(&stream, &w_res_host);
let b_res_d = alloc_upload(&stream, &bias_res_host);
let mut ctx_d = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS * PHA_HIDDEN_DIM)?;
let mut attn_d = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS * K)?;
pool.forward(&q_h_d, &ln_d, B as i32, K as i32, &mut ctx_d, &mut attn_d)?;
let mut residual_d = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS)?;
head.forward(&ctx_d, &w_res_d, &b_res_d, B as i32, &mut residual_d)?;
let residual_host = download(&stream, &residual_d);
let mut final_logit = vec![0.0f32; B * PHA_N_HORIZONS];
let mut differences_seen = 0;
for b in 0..B {
for h in 0..PHA_N_HORIZONS {
let idx = b * PHA_N_HORIZONS + h;
let gate = alpha[h].tanh();
final_logit[idx] = baseline_logit_host[idx] + gate * residual_host[idx];
assert!(final_logit[idx].is_finite(), "non-finite final[{idx}]");
if (final_logit[idx] - baseline_logit_host[idx]).abs() > 1e-6 {
differences_seen += 1;
}
}
}
assert!(
differences_seen > 0,
"α non-zero but final logit unchanged from baseline — residual contributing nothing"
);
// Now run the full backward chain. Loss = Σ final_logit, so
// d_final[b,h] = 1
// d_residual[b,h] = tanh(α[h])
// d_α[h] = (1 - tanh(α[h])²) * Σ_b residual[b, h]
// d_baseline[b,h] = 1
let mut d_residual_host = vec![0.0f32; B * PHA_N_HORIZONS];
for b in 0..B {
for h in 0..PHA_N_HORIZONS {
d_residual_host[b * PHA_N_HORIZONS + h] = alpha[h].tanh();
}
}
let d_residual_d = alloc_upload(&stream, &d_residual_host);
let mut d_w_res_scratch = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS * PHA_HIDDEN_DIM)?;
let mut d_b_res_scratch = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS)?;
let mut d_ctx = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS * PHA_HIDDEN_DIM)?;
head.backward(
&ctx_d, &w_res_d, &d_residual_d, B as i32,
&mut d_w_res_scratch, &mut d_b_res_scratch, &mut d_ctx,
)?;
let mut d_q_h_scratch = stream.alloc_zeros::<f32>(B * PHA_N_HORIZONS * PHA_HIDDEN_DIM)?;
let mut d_ln = stream.alloc_zeros::<f32>(B * K * PHA_HIDDEN_DIM)?;
pool.backward(
&q_h_d, &ln_d, &attn_d, &d_ctx,
B as i32, K as i32,
&mut d_q_h_scratch, &mut d_ln,
)?;
let d_q_h_host = download(&stream, &d_q_h_scratch);
let d_ln_host = download(&stream, &d_ln);
// No NaN/Inf in any gradient buffer.
for &g in &d_q_h_host { assert!(g.is_finite(), "d_q_h scratch has non-finite"); }
for &g in &d_ln_host { assert!(g.is_finite(), "d_ln has non-finite"); }
// At least one non-zero gradient (else gate path didn't actually trigger).
let any_nonzero_q = d_q_h_host.iter().any(|&g| g.abs() > 1e-8);
let any_nonzero_ln = d_ln_host.iter().any(|&g| g.abs() > 1e-8);
assert!(any_nonzero_q, "d_q_h all zero — backward didn't flow through attention");
assert!(any_nonzero_ln, "d_ln all zero — backward didn't flow back to LN_b input");
Ok(())
}