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>
1108 lines
44 KiB
Rust
1108 lines
44 KiB
Rust
#![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"
|
||
);
|
||
}
|
||
}
|