Files
foxhunt/crates/ml/tests/sp5_producer_unit_tests.rs
jgrusewski 67dd414cb3 feat(sp5): Task A4 — Pearl 4 per-group Adam β1/β2/ε
Per-param-group Adam hyperparameters from per-group gradient
direction-stability EMA + L2 norm. 8 SP4 param groups × 3 hparams = 24
ISV slots [226..250).

Two-kernel chain:
  grad_cosine_sim_update — per-group cosine_sim of curr vs prev grads
                          + L2 norm; writes back grad_curr → grad_prev
                          for next step's comparison.
  pearl_4_adam_hparams_update — β1/β2/ε from cosine + l2_norm.

Structural envelopes (Invariant 1 anchors per spec line 88):
  β1 ∈ [0.85, 0.95]; β2 ∈ [0.99, 0.9995]; ε ∈ [1e-10, 1e-6]

ALPHA_META migration (Option A — atomic shared-contract migration per
feedback_no_partial_refactor): apply_pearls_ad_kernel.cu and the
launch_apply_pearls Rust wrapper now take alpha_meta as a parameter.
All 45 existing call sites (SP4 + SP5 producers) pass the prior default
1.0e-3 explicitly via crate::cuda_pipeline::sp4_wiener_ema::ALPHA_META.
Pearl 4's apply_pearls calls pass 5.0e-4 (half the default per spec
line 89) to limit β change rate.

Theoretical caveat: adaptive β breaks Adam's constant-β convergence
proof. Mitigations: structural envelopes + halved ALPHA_META + Pearls
A+D smoothing. Fall-back path defined: revert + ε-only adaptive
variant if Layer C destabilization observed.

New mapped-pinned buffer: grad_prev_buf_per_group [TOTAL_PARAMS] for
cosine-sim previous-step direction storage. Mapped-pinned i32 mirror
of grad_buf layout.

producer_step_scratch_buf grew 131 → 171 (40 new outputs: 8 cosine_sim
+ 8 l2_norm + 24 β1/β2/ε). wiener_state_buf already at 543 (A1 sized
for entire SP5 block).

StateResetRegistry: 4 new FoldReset entries: sp5_adam_beta1,
sp5_adam_beta2, sp5_adam_eps, sp5_grad_prev_buf. All sentinel 0 →
bootstrap to envelope midpoint via Pearls A+D + cosine_sim=0 fall-back
on first step of new fold.

Adam-launcher consumer migration deferred to Layer B.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 22:18:10 +02:00

1108 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
//! SP5 Task A1 producer kernel unit tests.
//!
//! Tests cover the two SP5 Task A1 CUDA kernels:
//! 1. `q_branch_stats_update`: per-branch Q-statistics reduction.
//! 2. `pearl_1_atom_update`: Pearl 1 atom-span headroom controller.
//!
//! Both tests use analytically-known synthetic inputs with no CPU reference
//! implementation, per `feedback_no_cpu_test_fallbacks.md`. All mapped-pinned
//! memory (no DtoH), all `#[ignore]`-gated for GPU.
//!
//! Run with:
//!
//! cargo test -p ml --tests sp5_producer_unit_tests -- --ignored --nocapture
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
// ── Cubins ────────────────────────────────────────────────────────────────────
const SP5_Q_BRANCH_STATS_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/q_branch_stats_kernel.cubin"));
const SP5_PEARL_1_ATOM_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pearl_1_atom_kernel.cubin"));
const SP5_PEARL_3_SIGMA_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pearl_3_sigma_kernel.cubin"));
const SP5_PEARL_2_BUDGET_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pearl_2_budget_kernel.cubin"));
const SP5_PEARL_4_ADAM_HPARAMS_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pearl_4_adam_hparams_kernel.cubin"));
// ── Helpers ───────────────────────────────────────────────────────────────────
fn load_pearl_3_sigma_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_PEARL_3_SIGMA_CUBIN.to_vec())
.expect("load pearl_3_sigma_kernel cubin");
module
.load_function("pearl_3_sigma_update")
.expect("load pearl_3_sigma_update function")
}
fn load_pearl_2_budget_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_PEARL_2_BUDGET_CUBIN.to_vec())
.expect("load pearl_2_budget_kernel cubin");
module
.load_function("pearl_2_budget_update")
.expect("load pearl_2_budget_update function")
}
fn load_pearl_4_adam_hparams_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_PEARL_4_ADAM_HPARAMS_CUBIN.to_vec())
.expect("load pearl_4_adam_hparams_kernel cubin");
module
.load_function("pearl_4_adam_hparams_update")
.expect("load pearl_4_adam_hparams_update function")
}
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
fn load_q_branch_stats_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_Q_BRANCH_STATS_CUBIN.to_vec())
.expect("load q_branch_stats_kernel cubin");
module
.load_function("q_branch_stats_update")
.expect("load q_branch_stats_update function")
}
fn load_pearl_1_atom_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_PEARL_1_ATOM_CUBIN.to_vec())
.expect("load pearl_1_atom_kernel cubin");
module
.load_function("pearl_1_atom_update")
.expect("load pearl_1_atom_update function")
}
// ── Test 1: q_branch_stats_update ─────────────────────────────────────────────
/// SP5 Task A1 unit test: `q_branch_stats_update` correctly computes per-branch
/// statistics on a synthetic uniform batch.
///
/// Synthetic input: B=4 batch, q_out_buf [4, 13] with all values = constant c,
/// where c = 2.0.
///
/// Analytical ground truth for constant input (c = 2.0):
/// - mean = 2.0 (trivially)
/// - max_abs_dev = 0.0 (all deviations from mean are 0)
/// - var = 0.0 (no spread)
/// - entropy ≈ log(n_actions) (uniform distribution after softmax)
///
/// For Direction branch (n_actions=4): entropy = log(4) ≈ 1.3862944
/// For Magnitude/Order/Urgency branches (n_actions=3): entropy = log(3) ≈ 1.0986123
///
/// The uniform softmax arises because all Q-values are equal (after subtracting
/// the max, all differences are 0, yielding exp(0)/exp(0) = 1/n for each action).
/// This is an analytically-known result that does NOT require any CPU computation
/// per `feedback_no_cpu_test_fallbacks.md`.
#[test]
#[ignore = "requires GPU"]
fn sp5_q_branch_stats_uniform_input_matches_analytical_ground_truth() {
const BATCH_SIZE: usize = 4;
const TOTAL_ACTIONS: usize = 13;
const Q_CONST: f32 = 2.0_f32;
const SCRATCH_IDX_BASE: usize = 71; // SCRATCH_Q_STATS
let stream = make_test_stream();
let kernel = load_q_branch_stats_kernel(&stream);
// q_out_buf [B, total_actions=13] all = Q_CONST.
let q_buf = unsafe { MappedF32Buffer::new(BATCH_SIZE * TOTAL_ACTIONS) }
.expect("alloc q_out_buf");
q_buf.write_from_slice(&vec![Q_CONST; BATCH_SIZE * TOTAL_ACTIONS]);
// action_counts = {4, 3, 3, 3}
let counts_buf = unsafe { MappedI32Buffer::new(4) }
.expect("alloc action_counts_buf");
counts_buf.write_from_slice(&[4_i32, 3, 3, 3]);
// action_offsets = {0, 4, 7, 10}
let offsets_buf = unsafe { MappedI32Buffer::new(4) }
.expect("alloc action_offsets_buf");
offsets_buf.write_from_slice(&[0_i32, 4, 7, 10]);
// scratch [71..87) for 16 outputs (4 per branch × 4 branches).
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_IDX_BASE + 16) }
.expect("alloc scratch_buf");
let q_ptr = q_buf.dev_ptr;
let b_i32 = BATCH_SIZE as i32;
let counts_dev = counts_buf.dev_ptr;
let offsets_dev = offsets_buf.dev_ptr;
let scratch_dev = scratch_buf.dev_ptr;
let scratch_base_i32 = SCRATCH_IDX_BASE as i32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&q_ptr)
.arg(&b_i32)
.arg(&counts_dev)
.arg(&offsets_dev)
.arg(&scratch_dev)
.arg(&scratch_base_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch q_branch_stats_update");
}
stream.synchronize().expect("sync after q_branch_stats_update");
let scratch = scratch_buf.read_all();
// Analytical values:
let action_counts = [4_usize, 3, 3, 3];
for branch in 0..4_usize {
let base = SCRATCH_IDX_BASE + branch * 4;
let mean = scratch[base + 0];
let max_abs_dev = scratch[base + 1];
let var = scratch[base + 2];
let entropy = scratch[base + 3];
let n = action_counts[branch] as f32;
let expected_entropy = n.ln(); // log(n) for uniform distribution
println!(
"branch={branch} n={n:.0} mean={mean:.5} max_abs_dev={max_abs_dev:.5} \
var={var:.5} entropy={entropy:.5} (expected entropy={expected_entropy:.5})"
);
// Mean = Q_CONST exactly (constant input).
assert!(
(mean - Q_CONST).abs() < 1e-5,
"branch={branch}: mean={mean} != Q_CONST={Q_CONST}"
);
// max_abs_dev = 0 for constant input.
assert!(
max_abs_dev.abs() < 1e-5,
"branch={branch}: max_abs_dev={max_abs_dev} should be 0 for constant input"
);
// var = 0 for constant input.
assert!(
var.abs() < 1e-5,
"branch={branch}: var={var} should be 0 for constant input"
);
// entropy = log(n_actions) for uniform distribution, within 1% relative.
let rel_err = ((entropy - expected_entropy) / expected_entropy).abs();
assert!(
rel_err < 0.01,
"branch={branch}: entropy={entropy:.5} expected={expected_entropy:.5} rel_err={rel_err:.5}"
);
}
}
// ── Test 2: pearl_1_atom_update ────────────────────────────────────────────────
/// SP5 Task A1 unit test: `pearl_1_atom_update` correctly computes the
/// atom-span headroom controller on known synthetic inputs.
///
/// Synthetic input:
/// - mean = 1.0 (all branches), max_abs_dev = 0.5 (all branches)
/// - atoms_clip_count = [0, 0, 0, 0] (Layer B not wired yet)
/// - headroom from ISV = 0.0 (Pearl A sentinel → bootstrap to 6.0)
///
/// Analytical ground truth:
/// - clip_rate = 0 / (B × 13) = 0.0
/// - new_headroom = 6.0 + 0.01 × (0.0 - 0.01) = 6.0 - 0.0001 = 5.9999
/// (clamped to [2.0, 20.0] → 5.9999)
/// - v_half = max_abs_dev × new_headroom = 0.5 × 5.9999 ≈ 2.99995
/// - v_center = mean = 1.0
///
/// This verifies:
/// 1. Pearl A sentinel detection (headroom ≤ 0 → bootstrap 6.0).
/// 2. Headroom controller arithmetic (one step from bootstrap).
/// 3. v_half = max_abs_dev × new_headroom.
/// 4. Output slot layout (v_center/v_half/headroom/clip_rate in SCRATCH_ATOM_OUT groups).
#[test]
#[ignore = "requires GPU"]
fn sp5_pearl_1_atom_headroom_bootstrap_and_controller_step() {
const BATCH_SIZE: usize = 4;
const SCRATCH_Q_STATS: usize = 71;
const SCRATCH_ATOM_OUT: usize = 87;
let stream = make_test_stream();
let kernel = load_pearl_1_atom_kernel(&stream);
// q_branch_stats scratch: 16 floats at [71..87).
// Layout: branch b at scratch[71 + b*4 + {0,1,2,3}] = {mean, max_abs_dev, var, entropy}
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_ATOM_OUT + 16) }
.expect("alloc scratch_buf");
let scratch_data: Vec<f32> = (0..scratch_buf.len)
.map(|i| {
let rel = i as i32 - SCRATCH_Q_STATS as i32;
if rel >= 0 && rel < 16 {
// branch b at offset b*4: mean=1.0, max_abs_dev=0.5, var=0.1, entropy=1.0
let field = (rel % 4) as usize;
match field {
0 => 1.0_f32, // mean
1 => 0.5_f32, // max_abs_dev
2 => 0.1_f32, // var
3 => 1.0_f32, // entropy
_ => unreachable!(),
}
} else {
0.0_f32
}
})
.collect();
scratch_buf.write_from_slice(&scratch_data);
// atoms_clip_count = [0, 0, 0, 0] (Layer B not wired).
let clip_count_buf = unsafe { MappedI32Buffer::new(4) }
.expect("alloc atoms_clip_count_buf");
// Already zero from alloc; write explicitly for clarity.
clip_count_buf.write_from_slice(&[0_i32; 4]);
// action_counts = {4, 3, 3, 3} — per-branch Q-value counts. The clip_rate
// denominator is batch_size × action_counts[b], not a global sum.
let action_counts_buf = unsafe { MappedI32Buffer::new(4) }
.expect("alloc action_counts_buf");
action_counts_buf.write_from_slice(&[4_i32, 3, 3, 3]);
// ISV signals: all zero (headroom sentinel → bootstrap to 6.0).
// Need at least ATOM_HEADROOM_BASE+4 = 182+4 = 186 slots.
const ISV_SIZE: usize = 200;
let isv_buf = unsafe { MappedF32Buffer::new(ISV_SIZE) }
.expect("alloc isv_signals");
// All zeros — headroom ≤ 0 triggers Pearl A bootstrap in kernel.
let scratch_dev = scratch_buf.dev_ptr;
let scratch_stats_i32 = SCRATCH_Q_STATS as i32;
let clip_dev = clip_count_buf.dev_ptr;
let action_counts_dev = action_counts_buf.dev_ptr;
let b_i32 = BATCH_SIZE as i32;
// SCRATCH_ATOM_OUT layout: [87..91)=v_center, [91..95)=v_half, [95..99)=headroom, [99..103)=clip_rate
let v_center_base_i32 = SCRATCH_ATOM_OUT as i32;
let v_half_base_i32 = (SCRATCH_ATOM_OUT + 4) as i32;
let headroom_base_i32 = (SCRATCH_ATOM_OUT + 8) as i32;
let clip_rate_base_i32 = (SCRATCH_ATOM_OUT + 12) as i32;
let isv_dev = isv_buf.dev_ptr;
let isv_headroom_i32 = 182_i32; // ATOM_HEADROOM_BASE
unsafe {
stream
.launch_builder(&kernel)
.arg(&scratch_dev)
.arg(&scratch_stats_i32)
.arg(&clip_dev)
.arg(&action_counts_dev)
.arg(&b_i32)
.arg(&scratch_dev)
.arg(&v_center_base_i32)
.arg(&v_half_base_i32)
.arg(&headroom_base_i32)
.arg(&clip_rate_base_i32)
.arg(&isv_dev)
.arg(&isv_headroom_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_1_atom_update");
}
stream.synchronize().expect("sync after pearl_1_atom_update");
let scratch = scratch_buf.read_all();
// Analytical expected values.
let expected_clip_rate = 0.0_f32;
// new_headroom = 6.0 + 0.01 × (0.0 - 0.01) = 5.9999
let expected_headroom = 6.0_f32 + 0.01 * (expected_clip_rate - 0.01);
let expected_v_half = 0.5_f32 * expected_headroom;
let expected_v_center = 1.0_f32;
for b in 0..4_usize {
let v_center = scratch[SCRATCH_ATOM_OUT + b];
let v_half = scratch[SCRATCH_ATOM_OUT + 4 + b];
let headroom = scratch[SCRATCH_ATOM_OUT + 8 + b];
let clip_rate = scratch[SCRATCH_ATOM_OUT + 12 + b];
println!(
"branch={b} v_center={v_center:.5} v_half={v_half:.5} \
headroom={headroom:.5} clip_rate={clip_rate:.5}"
);
// v_center = mean = 1.0
assert!(
(v_center - expected_v_center).abs() < 1e-4,
"branch={b}: v_center={v_center} expected={expected_v_center}"
);
// v_half = 0.5 × 5.9999 ≈ 2.99995 (1e-4 tolerance for fp32 rounding)
assert!(
(v_half - expected_v_half).abs() < 1e-3,
"branch={b}: v_half={v_half} expected={expected_v_half:.5}"
);
// headroom = 5.9999 (bootstrap 6.0 minus one step)
assert!(
(headroom - expected_headroom).abs() < 1e-4,
"branch={b}: headroom={headroom} expected={expected_headroom:.5}"
);
// clip_rate = 0 (no clips yet)
assert!(
clip_rate.abs() < 1e-6,
"branch={b}: clip_rate={clip_rate} expected=0.0"
);
}
}
// ── Test 3: pearl_3_sigma_update bootstrap at target entropy ──────────────────
/// SP5 Task A2 unit test: `pearl_3_sigma_update` bootstraps SF=0.1 when ISV
/// sigma_fraction=0 (Pearl A sentinel), computes new_sigma = SF * v_half.
///
/// Synthetic input:
/// - ISV[ATOM_V_HALF_BASE+b] = 2.0 (all branches)
/// - ISV[BRANCH_ENTROPY_BASE+b] = log(n_actions[b]) * 0.7 (target entropy exactly)
/// - ISV[SIGMA_FRACTION_BASE+b] = 0.0 (Pearl A sentinel → bootstrap to 0.1)
/// - action_counts = {4, 3, 3, 3}
///
/// Analytical ground truth (target == actual → no SF update step, only bootstrap):
/// - sigma_fraction bootstraps to 0.1 (sentinel 0 detection)
/// - new_sf = 0.1 + 0.005 * (target - actual) = 0.1 + 0.005 * 0 = 0.1 (exact)
/// - new_sigma = 0.1 * max(2.0, 1.0) = 0.1 * 2.0 = 0.2 for each branch
///
/// This verifies Pearl A sentinel detection, bootstrap value, and SF * v_half formula.
/// No CPU reference oracle per `feedback_no_cpu_test_fallbacks.md`.
#[test]
#[ignore = "requires GPU"]
fn pearl_3_sigma_bootstrap_at_target_entropy() {
const SCRATCH_SIGMA: usize = 103;
const SCRATCH_SF: usize = 107;
// Need enough ISV slots: ATOM_V_HALF_BASE=178, SIGMA_FRACTION_BASE=214,
// BRANCH_ENTROPY_BASE=218. Max = 218+4 = 222.
const ISV_SIZE: usize = 230;
let stream = make_test_stream();
let kernel = load_pearl_3_sigma_kernel(&stream);
let action_counts = [4_i32, 3, 3, 3];
let action_counts_buf = unsafe { MappedI32Buffer::new(4) }
.expect("alloc action_counts_buf");
action_counts_buf.write_from_slice(&action_counts);
// Build ISV: v_half=2.0 at [178..182), SF=0.0 at [214..218) (sentinel),
// entropy=log(n)*0.7 at [218..222).
let isv_buf = unsafe { MappedF32Buffer::new(ISV_SIZE) }
.expect("alloc isv_buf");
let mut isv_data = vec![0.0_f32; ISV_SIZE];
for b in 0..4_usize {
isv_data[178 + b] = 2.0_f32; // ATOM_V_HALF_BASE = 178
// SIGMA_FRACTION_BASE = 214: leave at 0.0 (sentinel)
// BRANCH_ENTROPY_BASE = 218: set to target_entropy = log(n) * 0.7
let n = action_counts[b] as f32;
isv_data[218 + b] = n.ln() * 0.7_f32;
}
isv_buf.write_from_slice(&isv_data);
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_SF + 4) }
.expect("alloc scratch_buf");
let isv_dev = isv_buf.dev_ptr;
let v_half_base_i32 = 178_i32; // ATOM_V_HALF_BASE
let branch_entropy_base_i32 = 218_i32; // BRANCH_ENTROPY_BASE
let sigma_fraction_base_i32 = 214_i32; // SIGMA_FRACTION_BASE
let counts_dev = action_counts_buf.dev_ptr;
let scratch_dev = scratch_buf.dev_ptr;
let sigma_idx_i32 = SCRATCH_SIGMA as i32;
let sf_idx_i32 = SCRATCH_SF as i32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&isv_dev)
.arg(&v_half_base_i32)
.arg(&branch_entropy_base_i32)
.arg(&sigma_fraction_base_i32)
.arg(&counts_dev)
.arg(&scratch_dev)
.arg(&sigma_idx_i32)
.arg(&sf_idx_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_3_sigma_update (bootstrap test)");
}
stream.synchronize().expect("sync after pearl_3_sigma_update (bootstrap test)");
let scratch = scratch_buf.read_all();
for b in 0..4_usize {
let sigma = scratch[SCRATCH_SIGMA + b];
let sf = scratch[SCRATCH_SF + b];
println!("branch={b} sigma={sigma:.6} sf={sf:.6}");
// SF bootstraps to 0.1 (sentinel 0 → 0.1), no update because target=actual.
let rel_err_sf = (sf - 0.1_f32).abs() / 0.1_f32;
assert!(
rel_err_sf < 0.01,
"branch={b}: sf={sf:.6} expected=0.1 rel_err={rel_err_sf:.5}"
);
// sigma = sf * v_half = 0.1 * 2.0 = 0.2
let expected_sigma = 0.1_f32 * 2.0_f32;
let rel_err_sigma = (sigma - expected_sigma).abs() / expected_sigma;
assert!(
rel_err_sigma < 0.01,
"branch={b}: sigma={sigma:.6} expected={expected_sigma:.6} rel_err={rel_err_sigma:.5}"
);
}
}
// ── Test 4: pearl_3_sigma_update fraction increases when entropy below target ──
/// SP5 Task A2 unit test: when actual entropy is BELOW target, the entropy-deficit
/// controller increases SF.
///
/// Synthetic input for branch 0 (Direction, n_actions=4):
/// - ISV[ATOM_V_HALF_BASE+0] = 2.0
/// - ISV[BRANCH_ENTROPY_BASE+0] = 0.0 (extreme case: zero entropy)
/// - ISV[SIGMA_FRACTION_BASE+0] = 0.1 (already bootstrapped)
/// - target_entropy = log(4) * 0.7 = 1.3862944 * 0.7 = 0.97040609
///
/// Analytical ground truth for branch 0:
/// new_sf = 0.1 + 0.005 * (0.97040609 - 0.0) = 0.1 + 0.00485203 = 0.10485203
/// new_sigma = 0.10485203 * 2.0 = 0.20970406
///
/// Same inputs for branches 1/2/3 (n=3, entropy=0, sf=0.1):
/// target_entropy = log(3) * 0.7 = 1.0986123 * 0.7 = 0.76902860
/// new_sf = 0.1 + 0.005 * 0.76902860 = 0.10384514
/// new_sigma = 0.20769028
///
/// Checks SF strictly increases when entropy is below target. 1% rel-err tolerance.
#[test]
#[ignore = "requires GPU"]
fn pearl_3_sigma_fraction_increases_when_entropy_below_target() {
const SCRATCH_SIGMA: usize = 103;
const SCRATCH_SF: usize = 107;
const ISV_SIZE: usize = 230;
let stream = make_test_stream();
let kernel = load_pearl_3_sigma_kernel(&stream);
let action_counts = [4_i32, 3, 3, 3];
let action_counts_buf = unsafe { MappedI32Buffer::new(4) }
.expect("alloc action_counts_buf");
action_counts_buf.write_from_slice(&action_counts);
// ISV: v_half=2.0, SF=0.1 (already bootstrapped), entropy=0 (below target).
let isv_buf = unsafe { MappedF32Buffer::new(ISV_SIZE) }
.expect("alloc isv_buf");
let mut isv_data = vec![0.0_f32; ISV_SIZE];
for b in 0..4_usize {
isv_data[178 + b] = 2.0_f32; // ATOM_V_HALF_BASE = 178
isv_data[214 + b] = 0.1_f32; // SIGMA_FRACTION_BASE = 214 (already bootstrapped)
// BRANCH_ENTROPY_BASE = 218: leave at 0.0 (zero entropy)
}
isv_buf.write_from_slice(&isv_data);
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_SF + 4) }
.expect("alloc scratch_buf");
let isv_dev = isv_buf.dev_ptr;
let v_half_base_i32 = 178_i32;
let branch_entropy_base_i32 = 218_i32;
let sigma_fraction_base_i32 = 214_i32;
let counts_dev = action_counts_buf.dev_ptr;
let scratch_dev = scratch_buf.dev_ptr;
let sigma_idx_i32 = SCRATCH_SIGMA as i32;
let sf_idx_i32 = SCRATCH_SF as i32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&isv_dev)
.arg(&v_half_base_i32)
.arg(&branch_entropy_base_i32)
.arg(&sigma_fraction_base_i32)
.arg(&counts_dev)
.arg(&scratch_dev)
.arg(&sigma_idx_i32)
.arg(&sf_idx_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_3_sigma_update (entropy-below-target test)");
}
stream.synchronize().expect("sync after pearl_3_sigma_update (entropy-below-target test)");
let scratch = scratch_buf.read_all();
// Branch 0: n=4, target=log(4)*0.7
{
let b = 0_usize;
let sigma = scratch[SCRATCH_SIGMA + b];
let sf = scratch[SCRATCH_SF + b];
let target_entropy = (4.0_f32).ln() * 0.7_f32;
let expected_sf = 0.1_f32 + 0.005_f32 * target_entropy;
let expected_sigma = expected_sf * 2.0_f32;
println!("branch={b} n=4 sf={sf:.7} expected_sf={expected_sf:.7} \
sigma={sigma:.7} expected_sigma={expected_sigma:.7}");
let rel_sf = (sf - expected_sf).abs() / expected_sf;
assert!(rel_sf < 0.01, "branch={b}: sf={sf:.7} expected={expected_sf:.7} rel={rel_sf:.5}");
let rel_sig = (sigma - expected_sigma).abs() / expected_sigma;
assert!(rel_sig < 0.01, "branch={b}: sigma={sigma:.7} expected={expected_sigma:.7} rel={rel_sig:.5}");
// SF strictly increased from 0.1
assert!(sf > 0.1_f32, "branch={b}: SF should have increased from 0.1, got {sf}");
}
// Branches 1-3: n=3, target=log(3)*0.7
for b in 1..4_usize {
let sigma = scratch[SCRATCH_SIGMA + b];
let sf = scratch[SCRATCH_SF + b];
let target_entropy = (3.0_f32).ln() * 0.7_f32;
let expected_sf = 0.1_f32 + 0.005_f32 * target_entropy;
let expected_sigma = expected_sf * 2.0_f32;
println!("branch={b} n=3 sf={sf:.7} expected_sf={expected_sf:.7} \
sigma={sigma:.7} expected_sigma={expected_sigma:.7}");
let rel_sf = (sf - expected_sf).abs() / expected_sf;
assert!(rel_sf < 0.01, "branch={b}: sf={sf:.7} expected={expected_sf:.7} rel={rel_sf:.5}");
let rel_sig = (sigma - expected_sigma).abs() / expected_sigma;
assert!(rel_sig < 0.01, "branch={b}: sigma={sigma:.7} expected={expected_sigma:.7} rel={rel_sig:.5}");
assert!(sf > 0.1_f32, "branch={b}: SF should have increased from 0.1, got {sf}");
}
}
// ── Tests 5 + 6: pearl_2_budget_update ────────────────────────────────────────
/// SP5 Task A3 unit test: `pearl_2_budget_update` on flat-Q regime.
///
/// Synthetic ISV: σ=1.0 (all branches), var_q=2.0 (all branches).
/// flatness = clamp(2.0 / (1.0² + 1e-8), 0, 1) = clamp(~2.0, 0, 1) = 1.0
///
/// Analytical expected (flatness = 1.0):
/// BASE_IQN = 0.11
/// BASE_C51_SHARE = 0.84 / (1 - 0.11) = 0.84 / 0.89 ≈ 0.94382...
/// budget_iqn = 0.11 + (1 - 1.0) * 0.89 = 0.11
/// budget_c51 = 1.0 * 0.89 * 0.94382 ≈ 0.84
/// budget_cql = 0
/// budget_ens = max(0, 1 - 0.11 - 0.84 - 0) = 0.05
///
/// All 4 branches receive identical inputs, so identical outputs.
/// No CPU reference oracle per `feedback_no_cpu_test_fallbacks.md`.
#[test]
#[ignore = "requires GPU"]
fn pearl_2_budget_flat_regime_iqn_floor_c51_takes_remainder() {
// Scratch layout mirrors SCRATCH_PEARL_2_* constants:
// [111..115) = budget_c51[4]
// [115..119) = budget_iqn[4]
// [119..123) = budget_cql[4]
// [123..127) = budget_ens[4]
// [127..131) = flatness[4]
const SCRATCH_C51: usize = 111;
const SCRATCH_IQN: usize = 115;
const SCRATCH_CQL: usize = 119;
const SCRATCH_ENS: usize = 123;
const SCRATCH_FLATNESS: usize = 127;
const SCRATCH_SIZE: usize = 131;
// ISV: Q_VAR_PER_BRANCH_BASE=222, NOISY_SIGMA_BASE=210.
// Need ISV size >= 222 + 4 = 226.
const ISV_SIZE: usize = 230;
// Q_VAR_PER_BRANCH_BASE = 222, NOISY_SIGMA_BASE = 210.
const Q_VAR_BASE: usize = 222;
const SIGMA_BASE: usize = 210;
let stream = make_test_stream();
let kernel = load_pearl_2_budget_kernel(&stream);
let isv_buf = unsafe { MappedF32Buffer::new(ISV_SIZE) }
.expect("alloc isv_buf");
let mut isv_data = vec![0.0_f32; ISV_SIZE];
for b in 0..4_usize {
isv_data[Q_VAR_BASE + b] = 2.0_f32; // var_q = 2.0 (above σ² → clamp to 1.0 flatness)
isv_data[SIGMA_BASE + b] = 1.0_f32; // σ = 1.0
}
isv_buf.write_from_slice(&isv_data);
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_SIZE) }
.expect("alloc scratch_buf");
let isv_dev = isv_buf.dev_ptr;
let q_var_base_i32 = Q_VAR_BASE as i32;
let sigma_base_i32 = SIGMA_BASE as i32;
let scratch_dev = scratch_buf.dev_ptr;
let c51_base_i32 = SCRATCH_C51 as i32;
let iqn_base_i32 = SCRATCH_IQN as i32;
let cql_base_i32 = SCRATCH_CQL as i32;
let ens_base_i32 = SCRATCH_ENS as i32;
let flatness_base_i32 = SCRATCH_FLATNESS as i32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&isv_dev)
.arg(&q_var_base_i32)
.arg(&sigma_base_i32)
.arg(&scratch_dev)
.arg(&c51_base_i32)
.arg(&iqn_base_i32)
.arg(&cql_base_i32)
.arg(&ens_base_i32)
.arg(&flatness_base_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_2_budget_update (flat regime)");
}
stream.synchronize().expect("sync after pearl_2_budget_update (flat regime)");
let scratch = scratch_buf.read_all();
// Analytical values for flatness = 1.0 (saturated ceiling):
// BASE_IQN = 0.11, BASE_C51_SHARE = 0.84 / 0.89
const BASE_IQN: f32 = 0.11_f32;
const BASE_C51_SHARE: f32 = 0.84_f32 / (1.0_f32 - BASE_IQN);
let expected_flatness = 1.0_f32;
let expected_iqn = BASE_IQN + (1.0_f32 - expected_flatness) * (1.0_f32 - BASE_IQN);
let expected_c51 = expected_flatness * (1.0_f32 - BASE_IQN) * BASE_C51_SHARE;
let expected_cql = 0.0_f32;
let expected_ens = (1.0_f32 - expected_iqn - expected_c51 - expected_cql).max(0.0_f32);
for b in 0..4_usize {
let c51 = scratch[SCRATCH_C51 + b];
let iqn = scratch[SCRATCH_IQN + b];
let cql = scratch[SCRATCH_CQL + b];
let ens = scratch[SCRATCH_ENS + b];
let flatness = scratch[SCRATCH_FLATNESS + b];
println!(
"branch={b} flatness={flatness:.6} iqn={iqn:.6} c51={c51:.6} \
cql={cql:.6} ens={ens:.6}"
);
let rel_flatness = (flatness - expected_flatness).abs();
assert!(
rel_flatness < 1e-5,
"branch={b}: flatness={flatness:.6} expected={expected_flatness:.6}"
);
let rel_iqn = (iqn - expected_iqn).abs() / expected_iqn.max(1e-9);
assert!(
rel_iqn < 0.01,
"branch={b}: iqn={iqn:.6} expected={expected_iqn:.6} rel={rel_iqn:.5}"
);
let rel_c51 = (c51 - expected_c51).abs() / expected_c51.max(1e-9);
assert!(
rel_c51 < 0.01,
"branch={b}: c51={c51:.6} expected={expected_c51:.6} rel={rel_c51:.5}"
);
assert!(
cql.abs() < 1e-7,
"branch={b}: cql={cql:.6} expected=0"
);
let rel_ens = (ens - expected_ens).abs() / expected_ens.max(1e-9);
assert!(
rel_ens < 0.01,
"branch={b}: ens={ens:.6} expected={expected_ens:.6} rel={rel_ens:.5}"
);
// Normalization invariant: budgets sum to 1.0 (ens is computed as the
// residual `max(0, 1 - iqn - c51 - cql)`, so the structural claim is
// sum ≈ 1.0). A coefficient typo (e.g. wrong BASE_IQN) would still
// pass the per-component relative checks but break this invariant.
let sum = c51 + iqn + cql + ens;
assert!(
(sum - 1.0_f32).abs() < 1e-4,
"branch={b}: budget sum={sum:.6} should be ≈ 1.0 (c51={c51:.6} iqn={iqn:.6} cql={cql:.6} ens={ens:.6})"
);
}
}
/// SP5 Task A3 unit test: `pearl_2_budget_update` on sharp-Q regime.
///
/// Synthetic ISV: σ=2.0 (all branches), var_q=0.04 (all branches).
/// flatness = clamp(0.04 / (4.0 + 1e-8), 0, 1) = 0.04/4.0 = 0.01
///
/// Analytical expected (flatness = 0.01):
/// BASE_IQN = 0.11
/// BASE_C51_SHARE = 0.84 / 0.89 ≈ 0.94382
/// budget_iqn = 0.11 + (1 - 0.01) * 0.89 = 0.11 + 0.8811 = 0.9911
/// budget_c51 = 0.01 * 0.89 * 0.94382 ≈ 0.0084
/// budget_cql = 0
/// budget_ens = max(0, 1 - 0.9911 - 0.0084 - 0) ≈ 0.0005 (rounds to ~0)
///
/// IQN dominates in the sharp-Q regime, C51 share is minimal.
/// No CPU reference oracle per `feedback_no_cpu_test_fallbacks.md`.
#[test]
#[ignore = "requires GPU"]
fn pearl_2_budget_sharp_regime_iqn_dominates() {
const SCRATCH_C51: usize = 111;
const SCRATCH_IQN: usize = 115;
const SCRATCH_CQL: usize = 119;
const SCRATCH_ENS: usize = 123;
const SCRATCH_FLATNESS: usize = 127;
const SCRATCH_SIZE: usize = 131;
const ISV_SIZE: usize = 230;
const Q_VAR_BASE: usize = 222;
const SIGMA_BASE: usize = 210;
let stream = make_test_stream();
let kernel = load_pearl_2_budget_kernel(&stream);
let isv_buf = unsafe { MappedF32Buffer::new(ISV_SIZE) }
.expect("alloc isv_buf");
let mut isv_data = vec![0.0_f32; ISV_SIZE];
for b in 0..4_usize {
isv_data[Q_VAR_BASE + b] = 0.04_f32; // var_q small
isv_data[SIGMA_BASE + b] = 2.0_f32; // σ large → flatness small
}
isv_buf.write_from_slice(&isv_data);
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_SIZE) }
.expect("alloc scratch_buf");
let isv_dev = isv_buf.dev_ptr;
let q_var_base_i32 = Q_VAR_BASE as i32;
let sigma_base_i32 = SIGMA_BASE as i32;
let scratch_dev = scratch_buf.dev_ptr;
let c51_base_i32 = SCRATCH_C51 as i32;
let iqn_base_i32 = SCRATCH_IQN as i32;
let cql_base_i32 = SCRATCH_CQL as i32;
let ens_base_i32 = SCRATCH_ENS as i32;
let flatness_base_i32 = SCRATCH_FLATNESS as i32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&isv_dev)
.arg(&q_var_base_i32)
.arg(&sigma_base_i32)
.arg(&scratch_dev)
.arg(&c51_base_i32)
.arg(&iqn_base_i32)
.arg(&cql_base_i32)
.arg(&ens_base_i32)
.arg(&flatness_base_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_2_budget_update (sharp regime)");
}
stream.synchronize().expect("sync after pearl_2_budget_update (sharp regime)");
let scratch = scratch_buf.read_all();
// Analytical values for flatness = 0.04 / (4.0 + 1e-8) ≈ 0.01:
let expected_sigma_sq = 2.0_f32 * 2.0_f32 + 1e-8_f32;
let expected_flatness = (0.04_f32 / expected_sigma_sq).clamp(0.0, 1.0);
const BASE_IQN: f32 = 0.11_f32;
const BASE_C51_SHARE: f32 = 0.84_f32 / (1.0_f32 - BASE_IQN);
let expected_iqn = BASE_IQN + (1.0_f32 - expected_flatness) * (1.0_f32 - BASE_IQN);
let expected_c51 = expected_flatness * (1.0_f32 - BASE_IQN) * BASE_C51_SHARE;
let expected_cql = 0.0_f32;
let expected_ens = (1.0_f32 - expected_iqn - expected_c51 - expected_cql).max(0.0_f32);
for b in 0..4_usize {
let c51 = scratch[SCRATCH_C51 + b];
let iqn = scratch[SCRATCH_IQN + b];
let cql = scratch[SCRATCH_CQL + b];
let ens = scratch[SCRATCH_ENS + b];
let flatness = scratch[SCRATCH_FLATNESS + b];
println!(
"branch={b} flatness={flatness:.6} iqn={iqn:.6} c51={c51:.6} \
cql={cql:.6} ens={ens:.6}"
);
let rel_flatness = (flatness - expected_flatness).abs() / expected_flatness.max(1e-9);
assert!(
rel_flatness < 0.01,
"branch={b}: flatness={flatness:.6} expected={expected_flatness:.6} rel={rel_flatness:.5}"
);
let rel_iqn = (iqn - expected_iqn).abs() / expected_iqn.max(1e-9);
assert!(
rel_iqn < 0.01,
"branch={b}: iqn={iqn:.6} expected={expected_iqn:.6} rel={rel_iqn:.5}"
);
// IQN must dominate: iqn >> c51
assert!(
iqn > 0.9_f32,
"branch={b}: iqn={iqn:.6} should exceed 0.9 in sharp regime"
);
let rel_c51 = (c51 - expected_c51).abs() / expected_c51.max(1e-9);
assert!(
rel_c51 < 0.01,
"branch={b}: c51={c51:.6} expected={expected_c51:.6} rel={rel_c51:.5}"
);
assert!(
cql.abs() < 1e-7,
"branch={b}: cql={cql:.6} expected=0"
);
// ens is near-zero in sharp regime; just verify it is non-negative
assert!(
ens >= 0.0_f32,
"branch={b}: ens={ens:.6} must be non-negative"
);
let _ = expected_ens; // expected is ~0.0005, just verify non-negative above
// Normalization invariant: budgets sum to 1.0 (ens is computed as
// residual). Catches coefficient typos that would still pass the
// per-component dominance + relative-tolerance checks.
let sum = c51 + iqn + cql + ens;
assert!(
(sum - 1.0_f32).abs() < 1e-4,
"branch={b}: budget sum={sum:.6} should be ≈ 1.0 (c51={c51:.6} iqn={iqn:.6} cql={cql:.6} ens={ens:.6})"
);
}
}
// ── Tests 7 + 8: pearl_4_adam_hparams_update ──────────────────────────────────
/// SP5 Task A4 unit test: `pearl_4_adam_hparams_update` with high stability input.
///
/// Synthetic input: cosine_sim=[1.0; 8] (maximum stability), grad_norm=[1.0; 8].
///
/// Analytical expected:
/// stability = fmaxf(0, fminf(1, cosine_sim)) = 1.0
/// β1 = 0.85 + 0.10 × 1.0 = 0.95 (long-memory ceiling)
/// β2 = 0.99 + 0.0095 × 1.0 = 0.9995 (long-memory ceiling)
/// ε = clamp(1.0 × 1e-7, 1e-10, 1e-6) = 1e-7 → clamped to 1e-7 (within [1e-10, 1e-6])
///
/// Structural envelopes (Invariant 1 anchors): β1∈[0.85,0.95], β2∈[0.99,0.9995].
/// No CPU reference oracle per `feedback_no_cpu_test_fallbacks.md`.
#[test]
#[ignore = "requires GPU"]
fn pearl_4_adam_hparams_high_stability_yields_long_memory() {
// Scratch layout (Pearl 4 outputs):
// [0..8) = β1[8] (beta1_idx_base=0)
// [8..16) = β2[8] (beta2_idx_base=8)
// [16..24) = ε[8] (eps_idx_base=16)
const BETA1_BASE: usize = 0;
const BETA2_BASE: usize = 8;
const EPS_BASE: usize = 16;
const SCRATCH_SIZE: usize = 24;
const N_GROUPS: usize = 8;
let stream = make_test_stream();
let kernel = load_pearl_4_adam_hparams_kernel(&stream);
// cosine_sim = 1.0 for all 8 groups → maximum stability.
let cosine_buf = unsafe { MappedF32Buffer::new(N_GROUPS) }
.expect("alloc cosine_buf");
cosine_buf.write_from_slice(&[1.0_f32; N_GROUPS]);
// grad_norm = 1.0 for all 8 groups → ε = 1e-7 (within envelope).
let l2_norm_buf = unsafe { MappedF32Buffer::new(N_GROUPS) }
.expect("alloc l2_norm_buf");
l2_norm_buf.write_from_slice(&[1.0_f32; N_GROUPS]);
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_SIZE) }
.expect("alloc scratch_buf");
let cosine_dev = cosine_buf.dev_ptr;
let l2_dev = l2_norm_buf.dev_ptr;
let scratch_dev = scratch_buf.dev_ptr;
let beta1_base_i32 = BETA1_BASE as i32;
let beta2_base_i32 = BETA2_BASE as i32;
let eps_base_i32 = EPS_BASE as i32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&cosine_dev)
.arg(&l2_dev)
.arg(&scratch_dev)
.arg(&beta1_base_i32)
.arg(&beta2_base_i32)
.arg(&eps_base_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (8, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_4_adam_hparams_update (high stability)");
}
stream.synchronize().expect("sync after pearl_4_adam_hparams_update (high stability)");
let scratch = scratch_buf.read_all();
// Analytical values for stability = 1.0:
let expected_beta1: f32 = 0.85 + 0.10 * 1.0; // = 0.95
let expected_beta2: f32 = 0.99 + 0.0095 * 1.0; // = 0.9995
// ε = clamp(1.0 × 1e-7, 1e-10, 1e-6) = 1e-7
let expected_eps: f32 = 1.0_f32 * 1e-7_f32; // within [1e-10, 1e-6]
for g in 0..N_GROUPS {
let beta1 = scratch[BETA1_BASE + g];
let beta2 = scratch[BETA2_BASE + g];
let eps = scratch[EPS_BASE + g];
println!("group={g} beta1={beta1:.6} beta2={beta6:.7} eps={eps:.2e}",
beta6 = beta2);
// β1 ceiling: 0.95 ± 1 ULP.
let rel_b1 = (beta1 - expected_beta1).abs() / expected_beta1.max(1e-9);
assert!(
rel_b1 < 1e-5,
"group={g}: beta1={beta1:.6} expected={expected_beta1:.6} rel={rel_b1:.2e}"
);
// β2 ceiling: 0.9995 ± 1 ULP.
let rel_b2 = (beta2 - expected_beta2).abs() / expected_beta2.max(1e-9);
assert!(
rel_b2 < 1e-5,
"group={g}: beta2={beta2:.7} expected={expected_beta2:.7} rel={rel_b2:.2e}"
);
// ε must be within structural envelope.
assert!(
eps >= 1e-10_f32 && eps <= 1e-6_f32,
"group={g}: eps={eps:.2e} outside structural envelope [1e-10, 1e-6]"
);
let rel_eps = (eps - expected_eps).abs() / expected_eps.max(1e-15);
assert!(
rel_eps < 1e-4,
"group={g}: eps={eps:.2e} expected={expected_eps:.2e} rel={rel_eps:.2e}"
);
// β1 must be at structural ceiling for maximum stability.
assert!(
(beta1 - 0.95_f32).abs() < 1e-5,
"group={g}: beta1={beta1:.6} should be at ceiling 0.95 for stability=1.0"
);
// β2 must be at structural ceiling for maximum stability.
assert!(
(beta2 - 0.9995_f32).abs() < 1e-5,
"group={g}: beta2={beta2:.7} should be at ceiling 0.9995 for stability=1.0"
);
}
}
/// SP5 Task A4 unit test: `pearl_4_adam_hparams_update` with low stability input.
///
/// Synthetic input: cosine_sim=[0.0; 8] (minimum stability), grad_norm=[0.01; 8].
///
/// Analytical expected:
/// stability = fmaxf(0, fminf(1, 0.0)) = 0.0
/// β1 = 0.85 + 0.10 × 0.0 = 0.85 (low-stability floor)
/// β2 = 0.99 + 0.0095 × 0.0 = 0.99 (low-stability floor)
/// ε = clamp(0.01 × 1e-7, 1e-10, 1e-6) = 1e-9 → clamped to 1e-10
///
/// No CPU reference oracle per `feedback_no_cpu_test_fallbacks.md`.
#[test]
#[ignore = "requires GPU"]
fn pearl_4_adam_hparams_low_stability_yields_short_memory() {
const BETA1_BASE: usize = 0;
const BETA2_BASE: usize = 8;
const EPS_BASE: usize = 16;
const SCRATCH_SIZE: usize = 24;
const N_GROUPS: usize = 8;
let stream = make_test_stream();
let kernel = load_pearl_4_adam_hparams_kernel(&stream);
// cosine_sim = 0.0 for all 8 groups → minimum stability.
let cosine_buf = unsafe { MappedF32Buffer::new(N_GROUPS) }
.expect("alloc cosine_buf");
cosine_buf.write_from_slice(&[0.0_f32; N_GROUPS]);
// grad_norm = 0.01 → ε = 0.01 × 1e-7 = 1e-9 → clamped to 1e-10.
let l2_norm_buf = unsafe { MappedF32Buffer::new(N_GROUPS) }
.expect("alloc l2_norm_buf");
l2_norm_buf.write_from_slice(&[0.01_f32; N_GROUPS]);
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_SIZE) }
.expect("alloc scratch_buf");
let cosine_dev = cosine_buf.dev_ptr;
let l2_dev = l2_norm_buf.dev_ptr;
let scratch_dev = scratch_buf.dev_ptr;
let beta1_base_i32 = BETA1_BASE as i32;
let beta2_base_i32 = BETA2_BASE as i32;
let eps_base_i32 = EPS_BASE as i32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&cosine_dev)
.arg(&l2_dev)
.arg(&scratch_dev)
.arg(&beta1_base_i32)
.arg(&beta2_base_i32)
.arg(&eps_base_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (8, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_4_adam_hparams_update (low stability)");
}
stream.synchronize().expect("sync after pearl_4_adam_hparams_update (low stability)");
let scratch = scratch_buf.read_all();
// Analytical values for stability = 0.0:
let expected_beta1: f32 = 0.85; // low-stability floor
let expected_beta2: f32 = 0.99; // low-stability floor
// ε = clamp(0.01 × 1e-7, 1e-10, 1e-6) = clamp(1e-9, 1e-10, 1e-6) = 1e-9
// Actually 1e-9 is within [1e-10, 1e-6], so no clamping needed.
let expected_eps: f32 = (0.01_f32 * 1e-7_f32).clamp(1e-10, 1e-6); // = 1e-9
for g in 0..N_GROUPS {
let beta1 = scratch[BETA1_BASE + g];
let beta2 = scratch[BETA2_BASE + g];
let eps = scratch[EPS_BASE + g];
println!("group={g} beta1={beta1:.6} beta2={beta2:.7} eps={eps:.2e}");
// β1 floor: 0.85 ± 1 ULP.
let rel_b1 = (beta1 - expected_beta1).abs() / expected_beta1.max(1e-9);
assert!(
rel_b1 < 1e-5,
"group={g}: beta1={beta1:.6} expected={expected_beta1:.6} rel={rel_b1:.2e}"
);
// β2 floor: 0.99 ± 1 ULP.
let rel_b2 = (beta2 - expected_beta2).abs() / expected_beta2.max(1e-9);
assert!(
rel_b2 < 1e-5,
"group={g}: beta2={beta2:.7} expected={expected_beta2:.7} rel={rel_b2:.2e}"
);
// ε must be within structural envelope.
assert!(
eps >= 1e-10_f32 && eps <= 1e-6_f32,
"group={g}: eps={eps:.2e} outside structural envelope [1e-10, 1e-6]"
);
let rel_eps = (eps - expected_eps).abs() / expected_eps.max(1e-15);
assert!(
rel_eps < 1e-4,
"group={g}: eps={eps:.2e} expected={expected_eps:.2e} rel={rel_eps:.2e}"
);
// β1 must be at structural floor for zero stability.
assert!(
(beta1 - 0.85_f32).abs() < 1e-5,
"group={g}: beta1={beta1:.6} should be at floor 0.85 for stability=0.0"
);
// β2 must be at structural floor for zero stability.
assert!(
(beta2 - 0.99_f32).abs() < 1e-5,
"group={g}: beta2={beta2:.7} should be at floor 0.99 for stability=0.0"
);
}
}