test(sp5): Task A4 — add grad_cosine_sim_update unit test

Code-quality review caught that pearl_4_adam_hparams_kernel had direct
GPU tests but the auxiliary grad_cosine_sim_kernel had none. Tests 7
and 8 launched the hparams kernel with pre-baked cosine inputs,
never exercising the per-group reduction (dot + 2× L2 norm sums) or
the writeback (grad_curr → grad_prev for next step's comparison).
The writeback is load-bearing for cross-step cosine evolution — if
broken, every subsequent step's cosine_sim is computed against a stale
or mis-aligned previous gradient.

Adds Test 9 `grad_cosine_sim_per_group_dot_norm_and_writeback` with
8 analytically-known synthetic group cases:
  group 0: curr=prev=e1               → cos=+1, |c|=1
  group 1: curr=e1, prev=e2           → cos= 0 (orthogonal)
  group 2: curr=e1, prev=-e1          → cos=-1 (antiparallel; raw)
  group 3: curr=prev=(3,4,0,0)        → cos=+1, |c|=5
  group 4: curr=0, prev=e1            → cos= 0, |c|=0 (cold-start curr)
  group 5: curr=e1, prev=0            → cos= 0, |c|=1 (Pearl A sentinel)
  group 6,7: same as group 0

NO CPU oracle — expected values are unit-vector cosines from the
kernel formula. Writeback verified by reading grad_prev_buf after
the kernel runs and asserting bit-identity with grad_curr_buf for
all 32 elements.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-01 22:30:02 +02:00
parent 67dd414cb3
commit 4da6b34d78
2 changed files with 145 additions and 0 deletions

View File

@@ -36,6 +36,9 @@ const SP5_PEARL_2_BUDGET_CUBIN: &[u8] =
const SP5_PEARL_4_ADAM_HPARAMS_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pearl_4_adam_hparams_kernel.cubin"));
const SP5_GRAD_COSINE_SIM_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/grad_cosine_sim_kernel.cubin"));
// ── Helpers ───────────────────────────────────────────────────────────────────
fn load_pearl_3_sigma_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
@@ -68,6 +71,16 @@ fn load_pearl_4_adam_hparams_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
.expect("load pearl_4_adam_hparams_update function")
}
fn load_grad_cosine_sim_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_GRAD_COSINE_SIM_CUBIN.to_vec())
.expect("load grad_cosine_sim_kernel cubin");
module
.load_function("grad_cosine_sim_update")
.expect("load grad_cosine_sim_update function")
}
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
@@ -1105,3 +1118,133 @@ fn pearl_4_adam_hparams_low_stability_yields_short_memory() {
);
}
}
// ── Test 9: grad_cosine_sim_update ────────────────────────────────────────────
/// SP5 Task A4 unit test: `grad_cosine_sim_update` correctly computes per-group
/// cosine similarity, L2 norm, and writes back grad_curr → grad_prev.
///
/// Synthetic input: 8 groups × 4 params = 32 total params.
/// group 0: grad_curr = [1, 0, 0, 0], grad_prev = [1, 0, 0, 0] → cos = +1.0
/// group 1: grad_curr = [1, 0, 0, 0], grad_prev = [0, 1, 0, 0] → cos = 0.0 (orthogonal)
/// group 2: grad_curr = [1, 0, 0, 0], grad_prev = [-1, 0, 0, 0] → cos = -1.0 (antiparallel; raw, NOT clamped here)
/// group 3: grad_curr = [3, 4, 0, 0], grad_prev = [3, 4, 0, 0] → cos = +1.0; |g_curr|=5
/// group 4: grad_curr = [0, 0, 0, 0], grad_prev = [1, 0, 0, 0] → cos = 0/(0×1+EPS) = 0.0; |g_curr|=0 (cold-start curr)
/// group 5: grad_curr = [1, 0, 0, 0], grad_prev = [0, 0, 0, 0] → cos = 0/(1×0+EPS) = 0.0; |g_curr|=1 (cold-start prev)
/// group 6..7: identical to group 0 for repetition.
///
/// Then verify writeback: after the kernel runs, grad_prev_buf should equal
/// grad_curr_buf for all 32 elements (writeback is the load-bearing state
/// machinery for cross-step cosine evolution).
///
/// NO CPU reference oracle — all expected values are analytically known unit-vector cosines.
#[test]
#[ignore = "requires GPU"]
fn grad_cosine_sim_per_group_dot_norm_and_writeback() {
const N_GROUPS: usize = 8;
const PARAMS_PER_GROUP: usize = 4;
const TOTAL_PARAMS: usize = N_GROUPS * PARAMS_PER_GROUP;
const SCRATCH_BASE_COSINE: usize = 0;
const SCRATCH_BASE_L2: usize = 8;
let stream = make_test_stream();
let kernel = load_grad_cosine_sim_kernel(&stream);
let grad_curr_buf = unsafe { MappedF32Buffer::new(TOTAL_PARAMS) }
.expect("alloc grad_curr_buf");
let grad_prev_buf = unsafe { MappedF32Buffer::new(TOTAL_PARAMS) }
.expect("alloc grad_prev_buf");
let offsets_buf = unsafe { MappedI32Buffer::new(N_GROUPS + 1) }
.expect("alloc group_param_offsets_buf");
let scratch_buf = unsafe { MappedF32Buffer::new(16) } // 8 cosine + 8 L2
.expect("alloc scratch_buf");
// Group offsets: [0, 4, 8, 12, 16, 20, 24, 28, 32]
let offsets: Vec<i32> = (0..=N_GROUPS as i32).map(|i| i * PARAMS_PER_GROUP as i32).collect();
offsets_buf.write_from_slice(&offsets);
// Per-group synthetic vectors (analytical cos_sim and L2 norms).
let mut grad_curr = vec![0.0_f32; TOTAL_PARAMS];
let mut grad_prev = vec![0.0_f32; TOTAL_PARAMS];
// group 0: curr=prev=e1 → cos=+1, |c|=1
grad_curr[0] = 1.0; grad_prev[0] = 1.0;
// group 1: curr=e1, prev=e2 → cos=0, |c|=1
grad_curr[4] = 1.0; grad_prev[5] = 1.0;
// group 2: curr=e1, prev=-e1 → cos=-1, |c|=1
grad_curr[8] = 1.0; grad_prev[8] = -1.0;
// group 3: curr=prev=(3,4,0,0) → cos=+1, |c|=5
grad_curr[12] = 3.0; grad_curr[13] = 4.0;
grad_prev[12] = 3.0; grad_prev[13] = 4.0;
// group 4: curr=0, prev=e1 → cos = 0/(0×1+EPS) = 0, |c|=0 (cold-start curr)
grad_prev[17] = 1.0;
// group 5: curr=e1, prev=0 → cos = 0/(1×0+EPS) = 0, |c|=1 (cold-start prev — Pearl A sentinel state)
grad_curr[20] = 1.0;
// group 6: same as group 0
grad_curr[24] = 1.0; grad_prev[24] = 1.0;
// group 7: same as group 0
grad_curr[28] = 1.0; grad_prev[28] = 1.0;
grad_curr_buf.write_from_slice(&grad_curr);
grad_prev_buf.write_from_slice(&grad_prev);
let curr_ptr = grad_curr_buf.dev_ptr;
let prev_ptr = grad_prev_buf.dev_ptr;
let offsets_dev = offsets_buf.dev_ptr;
let scratch_dev = scratch_buf.dev_ptr;
let cosine_base_i32 = SCRATCH_BASE_COSINE as i32;
let l2_base_i32 = SCRATCH_BASE_L2 as i32;
let writeback_ptr = grad_prev_buf.dev_ptr; // writeback target = grad_prev_buf itself
unsafe {
stream
.launch_builder(&kernel)
.arg(&curr_ptr)
.arg(&prev_ptr)
.arg(&offsets_dev)
.arg(&scratch_dev)
.arg(&cosine_base_i32)
.arg(&l2_base_i32)
.arg(&writeback_ptr)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (8, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch grad_cosine_sim_update");
}
stream.synchronize().expect("sync after grad_cosine_sim_update");
let scratch = scratch_buf.read_all();
let prev_after = grad_prev_buf.read_all();
let expected_cos = [1.0_f32, 0.0, -1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
let expected_l2 = [1.0_f32, 1.0, 1.0, 5.0, 0.0, 1.0, 1.0, 1.0];
for g in 0..N_GROUPS {
let cos = scratch[SCRATCH_BASE_COSINE + g];
let l2 = scratch[SCRATCH_BASE_L2 + g];
println!("group={g} cos={cos:.5} (expected {:.5}) l2={l2:.5} (expected {:.5})",
expected_cos[g], expected_l2[g]);
assert!(
(cos - expected_cos[g]).abs() < 1e-5,
"group={g}: cos={cos:.5} expected={:.5}", expected_cos[g]
);
assert!(
(l2 - expected_l2[g]).abs() < 1e-5,
"group={g}: l2={l2:.5} expected={:.5}", expected_l2[g]
);
}
// Writeback verification: grad_prev_buf must now equal grad_curr_buf.
// This is load-bearing for cross-step cosine evolution — if writeback
// is wrong, every subsequent step's cosine_sim is computed against a
// stale or mis-aligned previous gradient.
for i in 0..TOTAL_PARAMS {
assert!(
(prev_after[i] - grad_curr[i]).abs() < 1e-7,
"writeback mismatch at i={i}: prev_after={} != grad_curr={}",
prev_after[i], grad_curr[i]
);
}
}

View File

@@ -2,6 +2,8 @@
**Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7.
SP5 Task A4 fix-up — grad_cosine_sim_update unit test added (2026-05-01): code-quality review caught that `pearl_4_adam_hparams_kernel` had direct GPU tests but the auxiliary `grad_cosine_sim_kernel` had none. Test 7 and Test 8 launched the hparams kernel directly with pre-baked cosine inputs, never exercising the per-group reduction (dot + 2× L2 norm sums) or the writeback (grad_curr → grad_prev for next step's comparison). The writeback in particular is load-bearing for cross-step cosine evolution — if broken, every subsequent step's cosine_sim is computed against a stale or mis-aligned previous gradient. Fix adds Test 9 `grad_cosine_sim_per_group_dot_norm_and_writeback` with 8 analytically-known synthetic group cases: identical (cos=+1), orthogonal (0), antiparallel (-1), Pythagorean (cos=+1, |c|=5), cold-start curr (zero grad → cos=0, |c|=0), cold-start prev (Pearl A sentinel state → cos=0, |c|=1), and 2 repeats. NO CPU oracle — expected values are unit-vector cosines from the kernel formula. Writeback verified by reading grad_prev_buf after the kernel runs and asserting bit-identity with grad_curr_buf for all 32 elements. Touched: `crates/ml/tests/sp5_producer_unit_tests.rs` (+1 cubin const + 1 loader + 120 LOC test). cargo test --no-run clean.
SP5 Task A4 — Pearl 4 per-group Adam β1/β2/ε GPU producers (Layer A, 2026-05-01): two new CUDA kernels (`grad_cosine_sim_kernel.cu`, `pearl_4_adam_hparams_kernel.cu`) and one `MappedF32Buffer` (`grad_prev_buf_per_group`) land as Layer A additive producers that feed ISV slots [226..250) with per-param-group Adam hyperparameters. `grad_cosine_sim_update`: single-block 8-thread kernel (one thread per SP4 param group: DqnTrunk/Value/Branches/IQN/IqlHigh/IqlLow/Attn/Curiosity), reads `grad_buf[total_params]` + `grad_prev_buf[total_params]` + `group_param_offsets[9]` (prefix sums in element units); computes `dot = Σ gc·gp`, `norm_curr_sq = Σ gc²`, `norm_prev_sq = Σ gp²` per group; writes `cos_sim[8]→scratch[131..139)` and `l2_norm[8]→scratch[139..147)`; writebacks `grad_curr → grad_prev_buf` for the next step's comparison (same thread — no race). `pearl_4_adam_hparams_update`: single-block 8-thread kernel, reads `cos_sim[8]` + `l2_norm[8]` from scratch; clips stability to `[0, 1]`; computes `β1 = 0.85 + 0.10×stability ∈ [0.85, 0.95]`, `β2 = 0.99 + 0.0095×stability ∈ [0.99, 0.9995]`, `ε = clamp(grad_norm × 1e-7, 1e-10, 1e-6)`; writes `β1[8]→scratch[147..155)`, `β2[8]→scratch[155..163)`, `ε[8]→scratch[163..171)`. Structural envelopes (Invariant 1 anchors per spec): β1∈[0.85, 0.95], β2∈[0.99, 0.9995], ε∈[1e-10, 1e-6]. `launch_sp5_pearl_4_adam_hparams` fires 24 `launch_apply_pearls` calls: β1[8] → ISV[226..234), β2[8] → ISV[234..242), ε[8] → ISV[242..250). Uses `ALPHA_META_PEARL_4=5e-4` (half SP4 default 1e-3) per theoretical-caveat mitigation — slow β change rate reduces instability risk from adaptive β breaking Adam's constant-β convergence proof. Pearl A sentinel 0 at fold boundary fires first-observation replacement (cosine_sim=0 → low-stability envelope endpoints). Aux groups 3-7 (IQN/IqlHigh/IqlLow/Attn/Curiosity) have non-contiguous grad allocations; zero-width sentinel offsets `[tp, tp, tp, tp, tp]` in `group_param_offsets_buf` prevent the kernel inner loop from executing for those groups, yielding cosine_sim=0 → β1/β2 start at low-stability floors. `apply_pearls_kernel.cu` signature gained `float alpha_meta` parameter (migration of all 18 call sites atomic per `feedback_no_partial_refactor.md`: 1 in `gpu_experience_collector.rs`, 17 in `gpu_dqn_trainer.rs`). Buffer growth: `producer_step_scratch_buf` 131→171 slots (SP5_SCRATCH_TOTAL); 5 new scratch constants `SCRATCH_PEARL_4_{COSINE=131, L2=139, BETA1=147, BETA2=155, EPS=163}`. StateResetRegistry gains 4 new FoldReset entries: `sp5_adam_beta1` (ISV[226..234)), `sp5_adam_beta2` (ISV[234..242)), `sp5_adam_eps` (ISV[242..250)), `sp5_grad_prev_buf` (grad_prev_buf_per_group — zero at fold boundary so cosine_sim=0 fires Pearl A bootstrap). Wire-up in training_loop.rs: `launch_sp5_pearl_4_adam_hparams()` called immediately after `launch_sp5_pearl_2_budget()` with `tracing::warn!` on error. Two GPU-only `#[ignore = "requires GPU"]` unit tests: (7) high-stability (cosine_sim=1.0 → β1=0.95, β2=0.9995, ε within envelope); (8) low-stability (cosine_sim=0.0 → β1=0.85, β2=0.99, ε within envelope). No CPU compute, no HtoD/HtoH, no atomicAdd, no stubs, no consumer migration. Touched: `cuda_pipeline/grad_cosine_sim_kernel.cu` (new), `cuda_pipeline/pearl_4_adam_hparams_kernel.cu` (new), `cuda_pipeline/apply_pearls_kernel.cu` (+alpha_meta param), `cuda_pipeline/sp4_wiener_ema.rs` (+alpha_meta arg), `cuda_pipeline/gpu_experience_collector.rs` (+1 call site ALPHA_META arg), `build.rs` (+2 cubin entries), `cuda_pipeline/gpu_dqn_trainer.rs` (SP5_SCRATCH_TOTAL 131→171 + 5 new constants + 2 static cubins + 4 struct fields + constructor allocs + struct initializer + 17 call-site ALPHA_META args + launch method), `trainers/dqn/state_reset_registry.rs` (+4 FoldReset entries), `trainers/dqn/trainer/training_loop.rs` (+5 LOC after Pearl 2 launch), `tests/sp5_producer_unit_tests.rs` (+2 GPU-only tests + cubin constant + loader helper). Hard rules: feedback_no_cpu_compute_strict, feedback_no_atomicadd, feedback_no_htod_htoh_only_mapped_pinned, feedback_no_stubs, feedback_no_partial_refactor (apply_pearls 18 call sites all migrated).
SP5 Task A3 fix-up — Pearl 2 budget tests now assert sum-to-1 normalization invariant (2026-05-01): code-quality review caught that the two A3 unit tests verified each output (c51, iqn, cql, ens) against its analytical expected value within 1% relative tolerance, but did NOT assert the structural invariant `c51 + iqn + cql + ens ≈ 1.0` that the kernel maintains by construction (`ens = max(0, 1 - iqn - c51 - cql)`). A coefficient typo such as `BASE_IQN = 0.111` instead of `0.11` would produce individually-plausible per-component values that all still pass the relative checks while quietly summing to 0.97 or 1.04. Fix adds `assert!((c51+iqn+cql+ens - 1.0).abs() < 1e-4)` inside both per-branch loops. Touched: `crates/ml/tests/sp5_producer_unit_tests.rs` (+12 LOC). cargo test --no-run clean.