Files
foxhunt/crates/ml/tests/sp5_producer_unit_tests.rs
jgrusewski baf971ba54 diag(rl): emit v9 eval_warmup state to JSONL + cleanup lints
Diag: surfaces `risk_stack.eval_warmup.{remaining,active,blend,
floor_*,target_*}` so the v9 defensive-warmup window is observable
in diag.jsonl. `remaining` is the counter; `blend` is the
defensive-vs-normal mix coefficient (1.0 = full defensive, 0.0 =
normal); `floor_*` reflect the LIVE override values (read AFTER the
warmup kernel ran). Pre-warmup the kernel is a no-op (remaining=-1),
so v9 train-phase diag is bit-identical to v8.

Cleanup: tightens unreachable_pub items in tests/behavioral/* and
tests/sp5_producer_unit_tests.rs (pub → pub(crate)), removes
unused_mut on 6 sp5 scratch buffers, renames unused `step` loop
counter in alpha_baseline example, and explicitly discards an
intentionally-no-op `Command::assert` in cli_integration_test.
Reduces lint count by ~25; remaining 3 dead_code warnings flag
SP15 Phase 2A behavioral scaffolding (Phase 2B never landed —
deliberate signal, not noise).

Pre-existing pearl per feedback_no_hiding: do NOT suppress these
with #[allow]; the warnings ARE the design call surface.
2026-05-31 02:12:04 +02:00

2869 lines
123 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 Layer A producer kernel unit tests.
//!
//! Tests cover the SP5 Layer A CUDA kernels across Tasks A1A5:
//!
//! Task A1 (Pearl 1 atom-span + Q-stats source):
//! 1. `sp5_q_branch_stats_uniform_input_matches_analytical_ground_truth`
//! 2. `pearl_1_atom_update_v_half_tracks_max_abs_dev_with_bootstrap_headroom`
//! Task A2 (Pearl 3 NoisyNet σ):
//! 3. `pearl_3_sigma_bootstrap_at_target_entropy`
//! 4. `pearl_3_sigma_fraction_increases_when_entropy_below_target`
//! Task A3 (Pearl 2 loss budget):
//! 5. `pearl_2_budget_flat_regime_iqn_floor_c51_takes_remainder`
//! 6. `pearl_2_budget_sharp_regime_iqn_dominates`
//! Task A4 (Pearl 4 per-group Adam β/β/ε + auxiliary cosine-sim):
//! 7. `pearl_4_adam_hparams_high_stability_yields_long_memory`
//! 8. `pearl_4_adam_hparams_low_stability_yields_short_memory`
//! 9. `grad_cosine_sim_per_group_dot_norm_and_writeback`
//! Task A5 (Pearl 5 per-branch IQN τ schedule):
//! 10. `pearl_5_iqn_tau_symmetric_q_yields_uniform_default_schedule`
//! 11. `pearl_5_iqn_tau_left_skew_shifts_quantiles_negatively`
//!
//! Task A6 (Pearl 6 cross-fold-persistent Kelly):
//! 12. `pearl_6_kelly_within_fold_ewma_blend`
//! 13. `pearl_6_kelly_cross_fold_sample_count_persists_via_max`
//!
//! Task A7 (Pearl 8 per-direction trail distance):
//! 14. `pearl_8_trail_per_direction_factors`
//! 15. `pearl_8_trail_atr_floor_at_quiet_market`
//!
//! Task A8 (Pearl 1-ext per-branch num_atoms):
//! 16. `pearl_1_ext_num_atoms_threshold_cascade`
//! 17. `pearl_1_ext_num_atoms_exact_threshold_falls_to_next_branch`
//!
//! Layer D Task D1 (PnL aggregation):
//! 18. `pnl_aggregation_kernel_correctness`
//!
//! Layer D Task D2 (Health composition):
//! 19. `health_composition_kernel_correctness`
//!
//! Layer D Task D3 (Training metrics EMA):
//! 20. `training_metrics_ema_kernel_correctness`
//!
//! All 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"));
const SP5_GRAD_COSINE_SIM_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/grad_cosine_sim_kernel.cubin"));
const SP5_Q_SKEW_KURTOSIS_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/q_skew_kurtosis_kernel.cubin"));
const SP5_PEARL_5_IQN_TAU_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pearl_5_iqn_tau_kernel.cubin"));
const SP5_PEARL_6_KELLY_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pearl_6_kelly_kernel.cubin"));
const SP5_PEARL_8_TRAIL_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pearl_8_trail_kernel.cubin"));
const SP5_PEARL_1_EXT_NUM_ATOMS_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pearl_1_ext_num_atoms_kernel.cubin"));
const SP5_PNL_AGGREGATION_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pnl_aggregation_kernel.cubin"));
const SP5_HEALTH_COMPOSITION_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/health_composition_kernel.cubin"));
const SP5_TRAINING_METRICS_EMA_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/training_metrics_ema_kernel.cubin"));
const SP7_LOSS_BALANCE_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/loss_balance_controller_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 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()
}
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"
);
}
}
// ── 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]
);
}
}
// ── Test 10 + 11 helpers (Task A5) ────────────────────────────────────────────
fn load_q_skew_kurtosis_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_Q_SKEW_KURTOSIS_CUBIN.to_vec())
.expect("load q_skew_kurtosis_kernel cubin");
module
.load_function("q_skew_kurtosis_update")
.expect("load q_skew_kurtosis_update function")
}
fn load_pearl_5_iqn_tau_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_PEARL_5_IQN_TAU_CUBIN.to_vec())
.expect("load pearl_5_iqn_tau_kernel cubin");
module
.load_function("pearl_5_iqn_tau_update")
.expect("load pearl_5_iqn_tau_update function")
}
// ── Test 10: zero skew → symmetric default τ schedule ─────────────────────────
/// SP5 Task A5 unit test: constant Q-values (zero variance → zero skew) produce
/// the symmetric default τ schedule {0.05, 0.25, 0.5, 0.75, 0.95} on all branches.
///
/// Synthetic input: B=4 batch, q_out_buf [4 × 13] all set to constant 3.0.
///
/// Analytical ground truth for constant input:
/// mean = 3.0, all deviations = 0 → m2 = m3 = m4 = 0
/// m2_15 = 0 + EPS_DIV = 1e-12
/// m3 / m2_15 = 0 / 1e-12 = 0 → skew = 0 (before clamp, still 0)
/// τ[q] = tau_defaults[q] + 0 × SKEW_SHIFT = tau_defaults[q]
/// Expected τ per branch: {0.05, 0.25, 0.5, 0.75, 0.95}
///
/// This test chains q_skew_kurtosis_update → pearl_5_iqn_tau_update exactly as
/// the production launcher does (scratch[171..175) is the skew bridge slot).
/// No CPU reference computation per `feedback_no_cpu_test_fallbacks.md`.
#[test]
#[ignore = "requires GPU"]
fn pearl_5_iqn_tau_symmetric_q_yields_uniform_default_schedule() {
const BATCH_SIZE: usize = 4;
const TOTAL_ACTIONS: usize = 13;
const SKEW_IDX_BASE: usize = 171; // SCRATCH_PEARL_5_SKEW
const EX_KURT_IDX_BASE: usize = 175; // SCRATCH_PEARL_5_EX_KURT
const TAU_IDX_BASE: usize = 179; // SCRATCH_PEARL_5_TAU
const SCRATCH_SIZE: usize = TAU_IDX_BASE + 20; // 199
let stream = make_test_stream();
let skew_kernel = load_q_skew_kurtosis_kernel(&stream);
let tau_kernel = load_pearl_5_iqn_tau_kernel(&stream);
// q_out_buf [B × 13]: all 3.0 — constant Q across all branches.
let q_buf = unsafe { MappedF32Buffer::new(BATCH_SIZE * TOTAL_ACTIONS) }
.expect("alloc q_buf");
let q_data = vec![3.0_f32; BATCH_SIZE * TOTAL_ACTIONS];
q_buf.write_from_slice(&q_data);
// action_counts = {4, 3, 3, 3}
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);
// action_offsets = {0, 4, 7, 10}
let action_offsets = [0_i32, 4, 7, 10];
let action_offsets_buf = unsafe { MappedI32Buffer::new(4) }
.expect("alloc action_offsets_buf");
action_offsets_buf.write_from_slice(&action_offsets);
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_SIZE) }
.expect("alloc scratch_buf");
let batch_size_i32 = BATCH_SIZE as i32;
let skew_idx_i32 = SKEW_IDX_BASE as i32;
let ex_kurt_idx_i32 = EX_KURT_IDX_BASE as i32;
let tau_idx_i32 = TAU_IDX_BASE as i32;
let q_dev = q_buf.dev_ptr;
let counts_dev = action_counts_buf.dev_ptr;
let offsets_dev = action_offsets_buf.dev_ptr;
let scratch_dev = scratch_buf.dev_ptr;
// Step 1: q_skew_kurtosis_update
unsafe {
stream
.launch_builder(&skew_kernel)
.arg(&q_dev)
.arg(&batch_size_i32)
.arg(&counts_dev)
.arg(&offsets_dev)
.arg(&scratch_dev)
.arg(&skew_idx_i32)
.arg(&ex_kurt_idx_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch q_skew_kurtosis_update (zero-skew test)");
}
stream.synchronize().expect("sync after q_skew_kurtosis_update (zero-skew test)");
// Step 2: pearl_5_iqn_tau_update
unsafe {
stream
.launch_builder(&tau_kernel)
.arg(&scratch_dev)
.arg(&skew_idx_i32)
.arg(&scratch_dev)
.arg(&tau_idx_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_5_iqn_tau_update (zero-skew test)");
}
stream.synchronize().expect("sync after pearl_5_iqn_tau_update (zero-skew test)");
let scratch = scratch_buf.read_all();
// Verify skew[4] ≈ 0 (constant input → zero skew)
for b in 0..4_usize {
let skew = scratch[SKEW_IDX_BASE + b];
println!("branch={b} skew={skew:.8}");
assert!(
skew.abs() < 1e-5,
"branch={b}: expected skew≈0 for constant Q, got {skew}"
);
}
// Verify τ[4×5] ≈ symmetric defaults {0.05, 0.25, 0.5, 0.75, 0.95}
let defaults = [0.05_f32, 0.25, 0.5, 0.75, 0.95];
for b in 0..4_usize {
for q in 0..5_usize {
let tau = scratch[TAU_IDX_BASE + b * 5 + q];
let expected = defaults[q];
println!("branch={b} q={q} tau={tau:.6} expected={expected:.6}");
assert!(
(tau - expected).abs() < 1e-5,
"branch={b} q={q}: tau={tau:.6} expected={expected:.6}"
);
}
}
}
// ── Test 11: negative skew shifts τ schedule down ─────────────────────────────
/// SP5 Task A5 unit test: negative Q-distribution skew shifts the τ schedule
/// downward (toward lower quantiles), verifying the SKEW_SHIFT=0.05 mechanism.
///
/// Synthetic input: inject skew = -1.0 directly into the scratch buffer
/// (SCRATCH_PEARL_5_SKEW slot) and launch only pearl_5_iqn_tau_update.
/// This isolates the τ-schedule kernel from the skew-computation kernel.
///
/// Analytical ground truth for skew = -1.0:
/// τ[q] = tau_defaults[q] + (-1.0) × 0.05 = tau_defaults[q] - 0.05
/// Expected τ: {0.0, 0.20, 0.45, 0.70, 0.90}
/// Clamp check: 0.05 - 0.05 = 0.00 → clamped to 0.01 (structural envelope [0.01, 0.99])
/// So expected τ[0] = 0.01, τ[1..5] = {0.20, 0.45, 0.70, 0.90}
///
/// Checks that τ values are strictly less than the symmetric defaults (shifted down)
/// and that the 0.01 floor clamp fires on the first quantile.
/// No CPU reference computation per `feedback_no_cpu_test_fallbacks.md`.
#[test]
#[ignore = "requires GPU"]
fn pearl_5_iqn_tau_left_skew_shifts_quantiles_negatively() {
const SKEW_IDX_BASE: usize = 171; // SCRATCH_PEARL_5_SKEW
const TAU_IDX_BASE: usize = 179; // SCRATCH_PEARL_5_TAU
const SCRATCH_SIZE: usize = TAU_IDX_BASE + 20; // 199
const INJECTED_SKEW: f32 = -1.0;
let stream = make_test_stream();
let tau_kernel = load_pearl_5_iqn_tau_kernel(&stream);
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_SIZE) }
.expect("alloc scratch_buf");
// Inject skew = -1.0 for all 4 branches directly into the scratch slot.
let mut scratch_init = vec![0.0_f32; SCRATCH_SIZE];
for b in 0..4_usize {
scratch_init[SKEW_IDX_BASE + b] = INJECTED_SKEW;
}
scratch_buf.write_from_slice(&scratch_init);
let scratch_dev = scratch_buf.dev_ptr;
let skew_idx_i32 = SKEW_IDX_BASE as i32;
let tau_idx_i32 = TAU_IDX_BASE as i32;
unsafe {
stream
.launch_builder(&tau_kernel)
.arg(&scratch_dev)
.arg(&skew_idx_i32)
.arg(&scratch_dev)
.arg(&tau_idx_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_5_iqn_tau_update (left-skew test)");
}
stream.synchronize().expect("sync after pearl_5_iqn_tau_update (left-skew test)");
let scratch = scratch_buf.read_all();
// Analytical expected τ for skew = -1.0:
// tau_defaults[q] + (-1.0) × 0.05, clamped to [0.01, 0.99]
let expected = [0.01_f32, 0.20, 0.45, 0.70, 0.90];
for b in 0..4_usize {
for q in 0..5_usize {
let tau = scratch[TAU_IDX_BASE + b * 5 + q];
let exp = expected[q];
println!("branch={b} q={q} tau={tau:.6} expected={exp:.6}");
assert!(
(tau - exp).abs() < 1e-5,
"branch={b} q={q}: tau={tau:.6} expected={exp:.6}"
);
}
// τ[1..5] must be strictly below their symmetric defaults (skew shifted them down).
let defaults = [0.05_f32, 0.25, 0.5, 0.75, 0.95];
for q in 1..5_usize {
let tau = scratch[TAU_IDX_BASE + b * 5 + q];
assert!(
tau < defaults[q],
"branch={b} q={q}: tau={tau:.6} should be below default={:.6}",
defaults[q]
);
}
// τ[0] must be at the floor (clamp fired: 0.05 - 0.05 = 0.00 → clamped to 0.01).
let tau0 = scratch[TAU_IDX_BASE + b * 5];
assert!(
(tau0 - 0.01_f32).abs() < 1e-5,
"branch={b} q=0: expected clamp floor 0.01, got {tau0:.6}"
);
}
}
// ── Pearl 6 helpers ───────────────────────────────────────────────────────────
fn load_pearl_6_kelly_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let ctx = stream.context();
let module = ctx.load_cubin(SP5_PEARL_6_KELLY_CUBIN.to_vec())
.expect("load pearl_6_kelly_kernel cubin");
module.load_function("pearl_6_kelly_update")
.expect("load pearl_6_kelly_update function")
}
/// Allocate a MappedF32Buffer for portfolio_state [n_envs * ps_stride] and ISV.
/// Returns (portfolio_buf, isv_buf). Both are mapped-pinned (no DtoH).
/// PS_STRIDE=43 matches state_layout.cuh.
const PS_STRIDE: usize = 43;
const PS_KELLY_WIN_COUNT: usize = 14;
const PS_KELLY_LOSS_COUNT: usize = 15;
const PS_KELLY_SUM_WINS: usize = 16;
const PS_KELLY_SUM_LOSSES: usize = 17;
// ISV slot indices for Pearl 6 outputs (match sp5_isv_slots.rs).
const KELLY_F_SMOOTH_IDX: usize = 280;
const CONVICTION_SMOOTH_IDX: usize = 281;
const TRADE_VAR_SMOOTH_IDX: usize = 282;
const KELLY_SAMPLE_COUNT_IDX: usize = 283;
const WIN_RATE_SMOOTH_IDX: usize = 284;
const LOSS_RATE_SMOOTH_IDX: usize = 285;
fn launch_pearl_6(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
ps_buf: &MappedF32Buffer,
isv_buf: &MappedF32Buffer,
n_envs: i32,
) {
let ps_dev = ps_buf.dev_ptr;
let isv_dev = isv_buf.dev_ptr;
let ps_stride_i32 = PS_STRIDE as i32;
let kelly_f_i32 = KELLY_F_SMOOTH_IDX as i32;
let conv_i32 = CONVICTION_SMOOTH_IDX as i32;
let tvar_i32 = TRADE_VAR_SMOOTH_IDX as i32;
let scount_i32 = KELLY_SAMPLE_COUNT_IDX as i32;
let wr_i32 = WIN_RATE_SMOOTH_IDX as i32;
let lr_i32 = LOSS_RATE_SMOOTH_IDX as i32;
unsafe {
stream
.launch_builder(kernel)
.arg(&ps_dev)
.arg(&n_envs)
.arg(&ps_stride_i32)
.arg(&isv_dev)
.arg(&kelly_f_i32)
.arg(&conv_i32)
.arg(&tvar_i32)
.arg(&scount_i32)
.arg(&wr_i32)
.arg(&lr_i32)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (6, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_6_kelly_update");
}
stream.synchronize().expect("sync after pearl_6_kelly_update");
}
/// SP5 Task A6 Test 12: within-fold EWMA blend.
///
/// Analytical derivation:
/// Single env with win_count=10, loss_count=5, sum_wins=0.5, sum_losses=0.25.
/// Bayesian priors: prior_wins=2, prior_losses=2, prior_sum_wins=0.01, prior_sum_losses=0.01.
/// eff_w=12, eff_l=7, wr=12/19≈0.6316, avg_w=(0.5+0.01)/12≈0.0425, avg_l=(0.25+0.01)/7≈0.0371
/// payoff=0.0425/0.0371≈1.1456, kelly_f=(1.1456×0.6316 - 0.3684)/1.1456≈(0.7234-0.3684)/1.1456≈0.3100
/// EWMA: new_isv = 0.99 × 0.5 + 0.01 × computed_kelly_f
/// ISV initial = 0.5 for all slots.
///
/// For sample_count (s==3): max(0.5, 15.0) = 15.0 (new_obs = total_trades = 15 > current=0.5).
/// For win_rate (s==4): new_obs = 10/15 ≈ 0.6667, new_isv = 0.99×0.5 + 0.01×0.6667 ≈ 0.5017.
/// For loss_rate (s==5): new_obs = 5/15 ≈ 0.3333, new_isv = 0.99×0.5 + 0.01×0.3333 ≈ 0.4983.
#[test]
#[ignore = "requires GPU"]
fn pearl_6_kelly_within_fold_ewma_blend() {
let ctx = CudaContext::new(0).expect("CUDA context");
let stream = ctx.new_stream().expect("stream");
let kernel = load_pearl_6_kelly_kernel(&stream);
let n_envs = 1_i32;
let isv_size = 290_usize; // covers slots [280..286)
let mut ps_buf = unsafe { MappedF32Buffer::new(n_envs as usize * PS_STRIDE) }.unwrap();
let mut isv_buf = unsafe { MappedF32Buffer::new(isv_size) }.unwrap();
// Set portfolio_state for env 0.
{
let ps_slice = ps_buf.host_slice_mut();
ps_slice.fill(0.0_f32);
ps_slice[PS_KELLY_WIN_COUNT] = 10.0;
ps_slice[PS_KELLY_LOSS_COUNT] = 5.0;
ps_slice[PS_KELLY_SUM_WINS] = 0.5;
ps_slice[PS_KELLY_SUM_LOSSES] = 0.25;
}
// Initialise ISV to 0.5 for all Pearl 6 slots.
{
let isv_slice = isv_buf.host_slice_mut();
isv_slice.fill(0.0_f32);
isv_slice[KELLY_F_SMOOTH_IDX] = 0.5;
isv_slice[CONVICTION_SMOOTH_IDX] = 0.5;
isv_slice[TRADE_VAR_SMOOTH_IDX] = 0.5;
isv_slice[KELLY_SAMPLE_COUNT_IDX] = 0.5;
isv_slice[WIN_RATE_SMOOTH_IDX] = 0.5;
isv_slice[LOSS_RATE_SMOOTH_IDX] = 0.5;
}
launch_pearl_6(&stream, &kernel, &ps_buf, &isv_buf, n_envs);
let isv = isv_buf.read_all();
// slot 280 — kelly_f_smooth: EWMA blend of computed kelly_f.
// Computed kelly_f: eff_w=12, eff_l=7, wr=12/19
let eff_w = 12.0_f32; let eff_l = 7.0_f32;
let wr = eff_w / (eff_w + eff_l);
let avg_w = (0.5_f32 + 0.01) / eff_w;
let avg_l = (0.25_f32 + 0.01) / eff_l;
let pf = avg_w / avg_l.max(0.0001);
let kf = ((pf * wr - (1.0 - wr)) / pf.max(0.0001)).clamp(0.0, 1.0);
let expected_kelly_f = 0.99 * 0.5 + 0.01 * kf;
println!("kf={kf:.6} expected_kelly_f={expected_kelly_f:.6} got={:.6}", isv[KELLY_F_SMOOTH_IDX]);
assert!((isv[KELLY_F_SMOOTH_IDX] - expected_kelly_f).abs() < 2e-5,
"kelly_f: expected {expected_kelly_f:.6}, got {:.6}", isv[KELLY_F_SMOOTH_IDX]);
// slot 281 — conviction_smooth: identity (no-op update, current=0.5).
// EWMA of (new_obs=current=0.5) with current=0.5 → still 0.5.
assert!((isv[CONVICTION_SMOOTH_IDX] - 0.5).abs() < 1e-6,
"conviction: expected 0.5 (identity), got {:.6}", isv[CONVICTION_SMOOTH_IDX]);
// slot 283 — sample_count: cumulative via max(current=0.5, new_obs=15.0) = 15.0.
assert!((isv[KELLY_SAMPLE_COUNT_IDX] - 15.0).abs() < 1e-4,
"sample_count: expected 15.0, got {:.6}", isv[KELLY_SAMPLE_COUNT_IDX]);
// slot 284 — win_rate_smooth: EWMA blend.
let expected_wr = 0.99 * 0.5 + 0.01 * (10.0_f32 / 15.0);
assert!((isv[WIN_RATE_SMOOTH_IDX] - expected_wr).abs() < 2e-5,
"win_rate: expected {expected_wr:.6}, got {:.6}", isv[WIN_RATE_SMOOTH_IDX]);
// slot 285 — loss_rate_smooth: EWMA blend.
let expected_lr = 0.99 * 0.5 + 0.01 * (5.0_f32 / 15.0);
assert!((isv[LOSS_RATE_SMOOTH_IDX] - expected_lr).abs() < 2e-5,
"loss_rate: expected {expected_lr:.6}, got {:.6}", isv[LOSS_RATE_SMOOTH_IDX]);
// slot 282 — trade_var_smooth: variance of per-env Kelly fractions across envs.
// With n_envs=1, kelly_count=1, the kernel's `(kelly_count > 1) ? var : 0.0f`
// branch returns 0.0 (single-env can't have cross-env variance).
// EWMA blend: 0.99 × 0.5 + 0.01 × 0.0 = 0.495.
let expected_tvar = 0.99 * 0.5 + 0.01 * 0.0_f32;
assert!((isv[TRADE_VAR_SMOOTH_IDX] - expected_tvar).abs() < 2e-5,
"trade_var: expected {expected_tvar:.6} (n_envs=1 → variance=0; EWMA blend with prior 0.5), got {:.6}",
isv[TRADE_VAR_SMOOTH_IDX]);
}
/// SP5 Task A6 Test 13: cross-fold sample_count persists via max().
///
/// This test verifies the core cross-fold-persistence mechanism:
/// Step 1: portfolio_state total_trades=100. Run → ISV[283] = max(0, 100) = 100.
/// Step 2: Simulate fold boundary — reset portfolio_state to 0 (total_trades=0).
/// Run → new_obs=0, max(100, 0) = 100. ISV[283] preserved.
/// Step 3: portfolio_state total_trades=10 (recovery, below prior max).
/// Run → new_obs=10, max(100, 10) = 100. Still preserved.
/// Step 4: portfolio_state total_trades=150 (surpasses old max).
/// Run → new_obs=150, max(100, 150) = 150. Grows to new max.
#[test]
#[ignore = "requires GPU"]
fn pearl_6_kelly_cross_fold_sample_count_persists_via_max() {
let ctx = CudaContext::new(0).expect("CUDA context");
let stream = ctx.new_stream().expect("stream");
let kernel = load_pearl_6_kelly_kernel(&stream);
let n_envs = 1_i32;
let isv_size = 290_usize;
let mut ps_buf = unsafe { MappedF32Buffer::new(n_envs as usize * PS_STRIDE) }.unwrap();
let mut isv_buf = unsafe { MappedF32Buffer::new(isv_size) }.unwrap();
// Helper: set portfolio_state wins/losses for env 0.
let set_ps = |ps_buf: &mut MappedF32Buffer, wins: f32, losses: f32| {
let sl = ps_buf.host_slice_mut();
sl.fill(0.0_f32);
sl[PS_KELLY_WIN_COUNT] = wins;
sl[PS_KELLY_LOSS_COUNT] = losses;
sl[PS_KELLY_SUM_WINS] = wins * 0.05; // arbitrary; only count matters for this test
sl[PS_KELLY_SUM_LOSSES] = losses * 0.05;
};
// Zero-init ISV (MappedF32Buffer::new already zeroes, but be explicit).
isv_buf.host_slice_mut().fill(0.0_f32);
// Step 1: total_trades=100, ISV[283] starts at 0.
set_ps(&mut ps_buf, 60.0, 40.0); // total=100
launch_pearl_6(&stream, &kernel, &ps_buf, &isv_buf, n_envs);
let v1 = isv_buf.read_all()[KELLY_SAMPLE_COUNT_IDX];
println!("step1 ISV[283]={v1:.1} (expected 100)");
assert!((v1 - 100.0).abs() < 0.5, "step1: expected 100.0, got {v1:.2}");
// Step 2: fold boundary simulation — portfolio_state reset to 0.
set_ps(&mut ps_buf, 0.0, 0.0); // total=0
launch_pearl_6(&stream, &kernel, &ps_buf, &isv_buf, n_envs);
let v2 = isv_buf.read_all()[KELLY_SAMPLE_COUNT_IDX];
println!("step2 ISV[283]={v2:.1} (expected 100 — preserved via max)");
assert!((v2 - 100.0).abs() < 0.5,
"step2: cross-fold persistence failed — expected 100.0, got {v2:.2}");
// Step 3: portfolio_state recovering, total=10 (below old max).
set_ps(&mut ps_buf, 6.0, 4.0); // total=10
launch_pearl_6(&stream, &kernel, &ps_buf, &isv_buf, n_envs);
let v3 = isv_buf.read_all()[KELLY_SAMPLE_COUNT_IDX];
println!("step3 ISV[283]={v3:.1} (expected 100 — still preserved)");
assert!((v3 - 100.0).abs() < 0.5,
"step3: max should still hold 100.0, got {v3:.2}");
// Step 4: total_trades=150 — surpasses old max, should grow.
set_ps(&mut ps_buf, 90.0, 60.0); // total=150
launch_pearl_6(&stream, &kernel, &ps_buf, &isv_buf, n_envs);
let v4 = isv_buf.read_all()[KELLY_SAMPLE_COUNT_IDX];
println!("step4 ISV[283]={v4:.1} (expected 150 — grew to new max)");
assert!((v4 - 150.0).abs() < 0.5,
"step4: expected 150.0 (new max), got {v4:.2}");
}
// ── Pearl 8 trail helpers ─────────────────────────────────────────────────────
fn load_pearl_8_trail_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_PEARL_8_TRAIL_CUBIN.to_vec())
.expect("load pearl_8_trail_kernel cubin");
module
.load_function("pearl_8_trail_update")
.expect("load pearl_8_trail_update function")
}
/// Launch helper: writes 4 trail-distance floats to scratch_buf[base..base+4).
fn launch_pearl_8_trail(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
features: &MappedF32Buffer, // features[bar_idx * market_dim + 9] = atr_norm
bar_idx: i32,
market_dim: i32,
scratch_out: &MappedF32Buffer,
base: i32,
) {
let feat_ptr = features.dev_ptr;
let scr_ptr = scratch_out.dev_ptr;
unsafe {
stream
.launch_builder(kernel)
.arg(&feat_ptr)
.arg(&bar_idx)
.arg(&market_dim)
.arg(&scr_ptr)
.arg(&base)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_8_trail_update");
}
stream.synchronize().expect("sync after pearl_8_trail_update");
}
/// SP5 Task A7 Test 14: per-direction ATR factor (2× vs floor=1.0).
///
/// Analytical derivation:
/// atr_norm = 0.5 → log_atr = 0.5 × 16 - 7 = 1.0 → atr_abs = e^1 ≈ 2.7183
/// Short[270] = Long[272] = atr_abs × 2.0 ≈ 5.4366
/// Hold[271] = Flat[273] = EPS_CLAMP_FLOOR = 1.0
///
/// No CPU reference implementation (feedback_no_cpu_test_fallbacks.md).
/// Analytically-known inputs only — atr_norm=0.5 is chosen so that
/// log_atr=1.0 makes exp() trivial.
#[test]
#[ignore = "requires GPU"]
fn pearl_8_trail_per_direction_factors() {
let ctx = CudaContext::new(0).expect("CUDA context");
let stream = ctx.new_stream().expect("stream");
let kernel = load_pearl_8_trail_kernel(&stream);
// atr_norm = 0.5 → atr_abs = exp(0.5 × 16 7) = exp(1.0) ≈ 2.7183
let atr_norm: f32 = 0.5;
let atr_abs_expected: f32 = (1.0_f32).exp(); // e^1
// feature buffer: 1 bar × market_dim=10 (col 9 = atr_norm)
let market_dim: i32 = 10;
let bar_idx: i32 = 0;
let mut feat_buf = unsafe { MappedF32Buffer::new((bar_idx as usize + 1) * market_dim as usize) }
.expect("feat_buf alloc");
feat_buf.host_slice_mut().fill(0.0_f32);
feat_buf.host_slice_mut()[(bar_idx as usize) * (market_dim as usize) + 9] = atr_norm;
let scratch_size: usize = 4;
let base: i32 = 0;
let mut scratch = unsafe { MappedF32Buffer::new(scratch_size) }.expect("scratch alloc");
launch_pearl_8_trail(&stream, &kernel, &feat_buf, bar_idx, market_dim, &mut scratch, base);
let out = scratch.read_all();
let trail_long_short = atr_abs_expected * 2.0_f32;
let trail_hold_flat = 1.0_f32;
println!("Short[0]={:.4} (expected {trail_long_short:.4})", out[0]);
println!("Hold [1]={:.4} (expected {trail_hold_flat:.4})", out[1]);
println!("Long [2]={:.4} (expected {trail_long_short:.4})", out[2]);
println!("Flat [3]={:.4} (expected {trail_hold_flat:.4})", out[3]);
let tol = 1e-4_f32;
assert!((out[0] - trail_long_short).abs() < tol,
"Short: expected {trail_long_short:.4}, got {:.4}", out[0]);
assert!((out[1] - trail_hold_flat).abs() < tol,
"Hold: expected {trail_hold_flat:.4}, got {:.4}", out[1]);
assert!((out[2] - trail_long_short).abs() < tol,
"Long: expected {trail_long_short:.4}, got {:.4}", out[2]);
assert!((out[3] - trail_hold_flat).abs() < tol,
"Flat: expected {trail_hold_flat:.4}, got {:.4}", out[3]);
}
/// SP5 Task A7 Test 15: ATR floor at quiet market (atr_norm very negative).
///
/// Analytical derivation:
/// atr_norm = -10.0 → log_atr = -10.0 × 16 - 7 = -167.0 → exp(-167) ≈ 0 << 0.01
/// atr_abs = max(exp(-167), 0.01) = 0.01 (Invariant 1 anchor: atr_abs floor)
/// Short[270] = Long[272] = 0.01 × 2.0 = 0.02
/// Hold[271] = Flat[273] = 1.0
///
/// This tests the `fmaxf(expf(log_atr), 0.01f)` floor in the kernel — essential
/// for log-space underflow protection when the ATR feature is at very low values.
#[test]
#[ignore = "requires GPU"]
fn pearl_8_trail_atr_floor_at_quiet_market() {
let ctx = CudaContext::new(0).expect("CUDA context");
let stream = ctx.new_stream().expect("stream");
let kernel = load_pearl_8_trail_kernel(&stream);
// atr_norm = -10.0 → exp(-167) ≈ 0 → clamped to 0.01 floor
let atr_norm: f32 = -10.0;
let market_dim: i32 = 10;
let bar_idx: i32 = 0;
let mut feat_buf = unsafe { MappedF32Buffer::new((bar_idx as usize + 1) * market_dim as usize) }
.expect("feat_buf alloc");
feat_buf.host_slice_mut().fill(0.0_f32);
feat_buf.host_slice_mut()[(bar_idx as usize) * (market_dim as usize) + 9] = atr_norm;
let scratch_size: usize = 4;
let base: i32 = 0;
let mut scratch = unsafe { MappedF32Buffer::new(scratch_size) }.expect("scratch alloc");
launch_pearl_8_trail(&stream, &kernel, &feat_buf, bar_idx, market_dim, &mut scratch, base);
let out = scratch.read_all();
// atr_abs = 0.01 (floor), trail = 0.01 × 2.0 = 0.02 for Short and Long.
let trail_long_short = 0.01_f32 * 2.0_f32; // = 0.02
let trail_hold_flat = 1.0_f32;
println!("Short[0]={:.4} (expected {trail_long_short:.4} — ATR floor 0.01)", out[0]);
println!("Hold [1]={:.4} (expected {trail_hold_flat:.4})", out[1]);
println!("Long [2]={:.4} (expected {trail_long_short:.4} — ATR floor 0.01)", out[2]);
println!("Flat [3]={:.4} (expected {trail_hold_flat:.4})", out[3]);
let tol = 1e-5_f32;
assert!((out[0] - trail_long_short).abs() < tol,
"Short: ATR floor test failed — expected {trail_long_short:.5}, got {:.5}", out[0]);
assert!((out[1] - trail_hold_flat).abs() < tol,
"Hold: expected {trail_hold_flat:.5}, got {:.5}", out[1]);
assert!((out[2] - trail_long_short).abs() < tol,
"Long: ATR floor test failed — expected {trail_long_short:.5}, got {:.5}", out[2]);
assert!((out[3] - trail_hold_flat).abs() < tol,
"Flat: expected {trail_hold_flat:.5}, got {:.5}", out[3]);
}
// ── Task A8 (Pearl 1-ext num_atoms) ──────────────────────────────────────────
fn load_pearl_1_ext_num_atoms_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_PEARL_1_EXT_NUM_ATOMS_CUBIN.to_vec())
.expect("load pearl_1_ext_num_atoms_kernel cubin");
module
.load_function("pearl_1_ext_num_atoms_update")
.expect("load pearl_1_ext_num_atoms_update function")
}
/// SP5 Task A8 Test 16: pearl_1_ext_num_atoms threshold cascade.
///
/// Synthetic ISV[ATOM_V_HALF_BASE + b] for 4 branches:
/// b=0: v_half=0.05 → strictly less than 0.1 → 64 atoms (narrow)
/// b=1: v_half=0.5 → not < 0.1, but < 1.0 → 32 atoms (moderate)
/// b=2: v_half=2.0 → not < 1.0 → 16 atoms (wide)
/// b=3: v_half=0.099 → just below 0.1 boundary → 64 atoms (narrow)
///
/// Verifies the threshold cascade boundaries.
/// No CPU reference implementation (feedback_no_cpu_test_fallbacks.md).
/// Analytically-known: all expected values are structural constants
/// ATOMS_HIGH=64, ATOMS_MID=32, ATOMS_LOW=16 (Invariant 1 anchors).
#[test]
#[ignore = "requires GPU"]
fn pearl_1_ext_num_atoms_threshold_cascade() {
let ctx = CudaContext::new(0).expect("CUDA context");
let stream = ctx.new_stream().expect("stream");
let kernel = load_pearl_1_ext_num_atoms_kernel(&stream);
// ISV buffer: place v_half values at ATOM_V_HALF_BASE=178..182.
// Allocate enough slots to cover ISV[182) (4 slots from base 178 = indices 178..182).
let isv_size: usize = 182;
let mut isv_buf = unsafe { MappedF32Buffer::new(isv_size) }.expect("isv_buf alloc");
isv_buf.host_slice_mut().fill(0.0_f32);
// ATOM_V_HALF_BASE = 178
isv_buf.host_slice_mut()[178] = 0.05_f32; // b=0: < 0.1 → 64 atoms
isv_buf.host_slice_mut()[179] = 0.5_f32; // b=1: < 1.0 → 32 atoms
isv_buf.host_slice_mut()[180] = 2.0_f32; // b=2: >= 1.0 → 16 atoms
isv_buf.host_slice_mut()[181] = 0.099_f32; // b=3: < 0.1 → 64 atoms
let scratch_size: usize = 4;
let base: i32 = 0;
let scratch = unsafe { MappedF32Buffer::new(scratch_size) }.expect("scratch alloc");
let isv_dev = isv_buf.dev_ptr;
let scr_dev = scratch.dev_ptr;
let v_half_base_i32: i32 = 178;
unsafe {
stream
.launch_builder(&kernel)
.arg(&isv_dev)
.arg(&v_half_base_i32)
.arg(&scr_dev)
.arg(&base)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_1_ext_num_atoms_update");
}
stream.synchronize().expect("sync after pearl_1_ext_num_atoms_update");
let out = scratch.read_all();
println!("b=0 (v_half=0.05): got {:.0} (expected 64 — narrow)", out[0]);
println!("b=1 (v_half=0.5): got {:.0} (expected 32 — moderate)", out[1]);
println!("b=2 (v_half=2.0): got {:.0} (expected 16 — wide)", out[2]);
println!("b=3 (v_half=0.099): got {:.0} (expected 64 — just-narrow)", out[3]);
let tol = 0.5_f32; // integer comparison via f32 — tolerate no rounding
assert!((out[0] - 64.0_f32).abs() < tol,
"b=0: expected 64 atoms (v_half=0.05 < 0.1), got {:.0}", out[0]);
assert!((out[1] - 32.0_f32).abs() < tol,
"b=1: expected 32 atoms (0.1 <= v_half=0.5 < 1.0), got {:.0}", out[1]);
assert!((out[2] - 16.0_f32).abs() < tol,
"b=2: expected 16 atoms (v_half=2.0 >= 1.0), got {:.0}", out[2]);
assert!((out[3] - 64.0_f32).abs() < tol,
"b=3: expected 64 atoms (v_half=0.099 < 0.1, just below narrow boundary), got {:.0}", out[3]);
}
/// SP5 Task A8 Test 17: pearl_1_ext_num_atoms exact-threshold boundary semantics.
///
/// At v_half = 0.1 exactly: `0.1 < 0.1` is false → falls to next branch → 32 atoms.
/// At v_half = 1.0 exactly: `1.0 < 1.0` is false → falls to else branch → 16 atoms.
///
/// Verifies the strictly-less-than threshold semantics (NOT <=).
/// No CPU reference implementation (feedback_no_cpu_test_fallbacks.md).
/// The two branches exercise: THRESHOLD_NARROW boundary (b=0) and
/// THRESHOLD_MODERATE boundary (b=1); the other two branches (b=2, b=3) are
/// set to unambiguous interior values to confirm they are not affected.
#[test]
#[ignore = "requires GPU"]
fn pearl_1_ext_num_atoms_exact_threshold_falls_to_next_branch() {
let ctx = CudaContext::new(0).expect("CUDA context");
let stream = ctx.new_stream().expect("stream");
let kernel = load_pearl_1_ext_num_atoms_kernel(&stream);
let isv_size: usize = 182;
let mut isv_buf = unsafe { MappedF32Buffer::new(isv_size) }.expect("isv_buf alloc");
isv_buf.host_slice_mut().fill(0.0_f32);
// ATOM_V_HALF_BASE = 178
isv_buf.host_slice_mut()[178] = 0.1_f32; // b=0: exactly 0.1 — NOT < 0.1 → 32 atoms
isv_buf.host_slice_mut()[179] = 1.0_f32; // b=1: exactly 1.0 — NOT < 1.0 → 16 atoms
isv_buf.host_slice_mut()[180] = 0.05_f32; // b=2: clearly narrow → 64 atoms (sanity)
isv_buf.host_slice_mut()[181] = 5.0_f32; // b=3: clearly wide → 16 atoms (sanity)
let scratch_size: usize = 4;
let base: i32 = 0;
let scratch = unsafe { MappedF32Buffer::new(scratch_size) }.expect("scratch alloc");
let isv_dev = isv_buf.dev_ptr;
let scr_dev = scratch.dev_ptr;
let v_half_base_i32: i32 = 178;
unsafe {
stream
.launch_builder(&kernel)
.arg(&isv_dev)
.arg(&v_half_base_i32)
.arg(&scr_dev)
.arg(&base)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (4, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch pearl_1_ext_num_atoms_update (exact threshold)");
}
stream.synchronize().expect("sync after pearl_1_ext_num_atoms_update (exact threshold)");
let out = scratch.read_all();
println!("b=0 (v_half=0.1 exact): got {:.0} (expected 32 — NOT < 0.1)", out[0]);
println!("b=1 (v_half=1.0 exact): got {:.0} (expected 16 — NOT < 1.0)", out[1]);
println!("b=2 (v_half=0.05): got {:.0} (expected 64 — sanity)", out[2]);
println!("b=3 (v_half=5.0): got {:.0} (expected 16 — sanity)", out[3]);
let tol = 0.5_f32;
assert!((out[0] - 32.0_f32).abs() < tol,
"b=0 (v_half=0.1): expected 32 atoms (threshold is strictly <, not <=), got {:.0}", out[0]);
assert!((out[1] - 16.0_f32).abs() < tol,
"b=1 (v_half=1.0): expected 16 atoms (threshold is strictly <, not <=), got {:.0}", out[1]);
assert!((out[2] - 64.0_f32).abs() < tol,
"b=2 (v_half=0.05): expected 64 atoms (sanity check), got {:.0}", out[2]);
assert!((out[3] - 16.0_f32).abs() < tol,
"b=3 (v_half=5.0): expected 16 atoms (sanity check), got {:.0}", out[3]);
}
// ── Layer D Task D1 (PnL aggregation) ─────────────────────────────────────────
fn load_pnl_aggregation_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_PNL_AGGREGATION_CUBIN.to_vec())
.expect("load pnl_aggregation_kernel cubin");
module
.load_function("pnl_aggregation_update")
.expect("load pnl_aggregation_update function")
}
/// SP5 Layer D Task D1 (rewrite): `pnl_aggregation_update` correctness on a
/// synthetic per-bar return tape with one episode boundary, with
/// analytically-known total / mean / variance / max drawdown.
///
/// **Rewrite.** The original test (commit `5ee795f14`) targeted a per-trade
/// signature that doesn't exist in production; this test exercises the
/// rewritten kernel against the actual `compute_epoch_financials` (financials.rs)
/// formulas. Per `feedback_no_cpu_test_fallbacks.md` the expected outputs are
/// pre-computed analytical literals — no CPU reference implementation in the
/// test process.
///
/// Synthetic inputs:
/// step_returns = [+0.01, 0.005, +0.003, 0.002, +0.001]
/// done_flags = [0, 0, 1, 0, 0]
/// num_bars = 5
/// n_trades = 5 (per-trade event count for mean / variance)
/// sum_returns = 0.007 (per-trade Σ returns; arbitrary distinct from
/// the per-bar sum so the kernel can't accidentally
/// use the per-bar slice for the mean)
/// sum_sq_returns = 1.39e-4 (per-trade Σ returns²)
/// initial_capital = 100_000.0
///
/// Analytical ground truth (computed offline from the financials.rs formulas):
///
/// pnl_total = exp(Σ ln(max(1+r, 1e-10))) 1
/// = exp( ln(1.01) + ln(0.995) + ln(1.003) + ln(0.998) + ln(1.001) ) 1
/// = exp(0.0069307957) 1 ≈ 0.0069548692
///
/// pnl_mean = sum_returns / n_trades = 0.007 / 5 = 0.0014
///
/// pnl_var = max(0, sum_sq_returns/n_trades mean²)
/// = max(0, 1.39e-4 / 5 1.96e-6)
/// = max(0, 2.78e-5 1.96e-6)
/// = 2.584e-5
///
/// pnl_max_dd: per-bar equity walk with episode-boundary reset.
/// Start: equity = peak = 100_000.
/// bar 0 (r=+0.0100): equity=101_000, peak=101_000, dd=0, max_dd=0
/// bar 1 (r=0.0050): equity=100_495, peak=101_000, dd=0.005, max_dd=0.005
/// bar 2 (r=+0.0030): equity=100_796.485, peak=101_000, then done=1 →
/// equity=peak=100_000, dd=0, max_dd=0.005
/// bar 3 (r=0.0020): equity=99_800, peak=100_000, dd=0.002, max_dd=0.005
/// bar 4 (r=+0.0010): equity=99_899.8, peak=100_000, dd=0.001002, max_dd=0.005
/// max_dd = 0.005, capped at 1.0 ⇒ 0.005.
///
/// **Episode boundary reset semantics**: financials.rs:175-183 resets equity
/// and peak AFTER processing the done-bar's return — the loss on the done
/// bar belongs to the just-ending episode; the next bar starts fresh. This
/// test's bar 2 hits the boundary; without the reset the cumulative product
/// would continue to compound and bar 3/4's drawdowns would reference the
/// pre-reset peak of 101_000 instead of 100_000, changing max_dd.
#[test]
#[ignore = "requires GPU"]
fn pnl_aggregation_kernel_correctness() {
let ctx = CudaContext::new(0).expect("CUDA context");
let stream = ctx.new_stream().expect("stream");
let kernel = load_pnl_aggregation_kernel(&stream);
// ── Synthetic input arrays ───────────────────────────────────────
let returns_host: [f32; 5] = [0.01, -0.005, 0.003, -0.002, 0.001];
let dones_host: [f32; 5] = [0.0, 0.0, 1.0, 0.0, 0.0 ];
let num_bars: i32 = 5;
let n_trades: i32 = 5;
let sum_returns: f32 = 0.007;
let sum_sq_returns: f32 = 1.39e-4;
let initial_capital: f32 = 100_000.0;
let mut returns_buf =
unsafe { MappedF32Buffer::new(num_bars as usize) }.expect("step_returns alloc");
let mut dones_buf =
unsafe { MappedF32Buffer::new(num_bars as usize) }.expect("done_flags alloc");
returns_buf.host_slice_mut().copy_from_slice(&returns_host);
dones_buf.host_slice_mut().copy_from_slice(&dones_host);
// ── Output scratch (4 slots) ─────────────────────────────────────
let scratch = unsafe { MappedF32Buffer::new(4) }.expect("scratch alloc");
// ── Launch ───────────────────────────────────────────────────────
let returns_dev = returns_buf.dev_ptr;
let dones_dev = dones_buf.dev_ptr;
let scratch_dev = scratch.dev_ptr;
let total_idx: i32 = 0;
let mean_idx: i32 = 1;
let var_idx: i32 = 2;
let max_dd_idx: i32 = 3;
let block_dim: u32 = 256;
let shared_mem_bytes: u32 = block_dim * std::mem::size_of::<f32>() as u32;
unsafe {
stream
.launch_builder(&kernel)
.arg(&returns_dev)
.arg(&dones_dev)
.arg(&num_bars)
.arg(&n_trades)
.arg(&sum_returns)
.arg(&sum_sq_returns)
.arg(&initial_capital)
.arg(&scratch_dev)
.arg(&total_idx)
.arg(&mean_idx)
.arg(&var_idx)
.arg(&max_dd_idx)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (block_dim, 1, 1),
shared_mem_bytes,
})
.expect("launch pnl_aggregation_update");
}
stream.synchronize().expect("sync after pnl_aggregation_update");
let out = scratch.read_all();
// ── Analytical ground truth (literals; per feedback_no_cpu_test_fallbacks) ─
let exp_total: f32 = 0.0069548692;
let exp_mean: f32 = 0.0014;
let exp_var: f32 = 2.584e-5;
let exp_max_dd: f32 = 0.005;
println!("pnl_total = {:.10} (expected {exp_total:.10})", out[0]);
println!("pnl_mean = {:.10} (expected {exp_mean:.10})", out[1]);
println!("pnl_var = {:.10} (expected {exp_var:.10})", out[2]);
println!("pnl_max_dd = {:.10} (expected {exp_max_dd:.10})", out[3]);
// FP tolerances:
// - pnl_total: log/exp roundtrip in f32 over 5 small returns; ~5 ulps.
// - pnl_mean: division by 5 of an exact f32; effectively exact.
// - pnl_var: E[X²] E[X]² catastrophic-cancellation prone; result is
// ~2.5e-5 so 1e-7 absolute = ~0.4% relative — comfortable.
// - pnl_max_dd: integer-arithmetic equity walk; effectively exact.
let tol_total: f32 = 1e-7;
let tol_mean: f32 = 1e-9;
let tol_var: f32 = 1e-7;
let tol_max_dd: f32 = 1e-7;
assert!((out[0] - exp_total).abs() < tol_total,
"pnl_total: expected {exp_total:.10}, got {:.10} (delta {:.3e})",
out[0], (out[0] - exp_total).abs());
assert!((out[1] - exp_mean).abs() < tol_mean,
"pnl_mean: expected {exp_mean:.10}, got {:.10}", out[1]);
assert!((out[2] - exp_var).abs() < tol_var,
"pnl_var: expected {exp_var:.10} (per-trade E[X²]E[X]² form), got {:.10}",
out[2]);
assert!((out[3] - exp_max_dd).abs() < tol_max_dd,
"pnl_max_dd: expected {exp_max_dd:.10} (episode reset at bar 2), got {:.10}",
out[3]);
}
// ── Layer D Task D2 (Health composition) ──────────────────────────────────────
fn load_health_composition_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_HEALTH_COMPOSITION_CUBIN.to_vec())
.expect("load health_composition_kernel cubin");
module
.load_function("health_composition_update")
.expect("load health_composition_update function")
}
/// SP5 Layer D Task D2: `health_composition_update` formula-fidelity test.
///
/// Asserts the kernel reproduces `learning_health.rs::NormalizedComponents::
/// from_raw` + `compose()` bit-for-bit (within float precision). Per
/// `feedback_no_cpu_test_fallbacks.md` we do **not** programmatically
/// re-implement the formula in the test; instead the synthetic inputs are
/// chosen so each normalised component evaluates to a hand-computable
/// closed-form rational, and the expected outputs are checked against
/// literal pre-computed constants.
///
/// Synthetic raw inputs:
///
/// q_gap = 0.05 → smoothstep(0.01, 0.5)
/// t = 0.04 / 0.49 = 0.08163265306...
/// ss = t²·(3 2t) = 0.018901683...
/// q_var = 0.10 → smoothstep(0.001, 0.1)
/// t = (0.099)/(0.099) = 1 → ss = 1.0
/// (intentional saturation at upper edge)
/// atom_util = 0.45 → smoothstep(0.2, 0.7) at the **midpoint**
/// t = 0.25/0.5 = 0.5 → ss = 0.5²·2 = 0.5
/// grad_norm = 30.0 → 1 smoothstep(10, 100)
/// t = 20/90 = 0.2̄
/// ss = t²·(3 2t) = (4/81)·(73/27)·…
/// ≈ 0.126200274...
/// grad_stable = 1 ss ≈ 0.873799725...
/// ens_disagreement = 0.20 → ens_agree = 1 0.20 = 0.80 (clamp pass-through)
/// grad_consistency = 0.15 → smoothstep(0.2, 0.5) at the midpoint
/// t = 0.35/0.7 = 0.5 → ss = 0.5
/// spectral_gap = 5.0 → 1 smoothstep(2, 10)
/// t = 3/8 = 0.375
/// ss = t²·(3 2t) = 0.140625·2.25 = 0.316406250
/// spectral_gap_norm = 1 0.316406250 = 0.683593750
///
/// Two of the seven normalisations land at exact rational midpoints (output
/// 0.5) and one saturates exactly (output 1.0); the other four are
/// pre-computed analytically and pinned as literal constants below. The
/// composed score is the weighted sum.
///
/// health_score = 0.25 · 0.018901683
/// + 0.15 · 1.000000000
/// + 0.15 · 0.500000000
/// + 0.15 · 0.873799725
/// + 0.10 · 0.800000000
/// + 0.10 · 0.500000000
/// + 0.10 · 0.683593750
/// ≈ 0.559153878
///
/// **Formula fidelity is the load-bearing invariant** for D2: the kernel
/// performs a verbatim structural migration of the host-side composition,
/// no algorithmic change. The test pins both the cubic interpolation
/// (q_gap_norm interior point) and the composition arithmetic.
#[test]
#[ignore = "requires GPU"]
fn health_composition_kernel_correctness() {
let ctx = CudaContext::new(0).expect("CUDA context");
let stream = ctx.new_stream().expect("stream");
let kernel = load_health_composition_kernel(&stream);
// ── Synthetic raw inputs ────────────────────────────────────────
let q_gap: f32 = 0.05;
let q_var: f32 = 0.10;
let atom_util: f32 = 0.45;
let grad_norm: f32 = 30.0;
let ens_disagreement: f32 = 0.20;
let grad_consistency: f32 = 0.15;
let spectral_gap: f32 = 5.0;
// ── Output scratch (4 slots) ─────────────────────────────────────
let scratch = unsafe { MappedF32Buffer::new(4) }.expect("scratch alloc");
let scratch_dev = scratch.dev_ptr;
let health_score_idx: i32 = 0;
let q_gap_norm_idx: i32 = 1;
let q_var_norm_idx: i32 = 2;
let grad_norm_norm_idx: i32 = 3;
unsafe {
stream
.launch_builder(&kernel)
.arg(&q_gap)
.arg(&q_var)
.arg(&atom_util)
.arg(&grad_norm)
.arg(&ens_disagreement)
.arg(&grad_consistency)
.arg(&spectral_gap)
.arg(&scratch_dev)
.arg(&health_score_idx)
.arg(&q_gap_norm_idx)
.arg(&q_var_norm_idx)
.arg(&grad_norm_norm_idx)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch health_composition_update");
}
stream.synchronize().expect("sync after health_composition_update");
let out = scratch.read_all();
// ── Analytical ground truth (literals; per feedback_no_cpu_test_fallbacks) ─
// q_gap_norm closed-form (exact rational):
// t = 4/49, t²(3 2t) = (16·139) / (49²·49) = 2224 / 117649 ≈ 0.0189036880
let exp_q_gap_norm: f32 = 0.018903688;
// q_var_norm: smoothstep saturates at edge1 (0.10 == upper edge).
let exp_q_var_norm: f32 = 1.0;
// grad_norm_norm = 1 smoothstep(10, 100, 30) = 1 (20/90)² · (3 40/90).
// t = 2/9, t²(3 2t) = (4/81) · (23/9) = 92 / 729 ≈ 0.1262002743
// grad_stable = 1 92/729 = 637 / 729 ≈ 0.8737997257
let exp_grad_stable: f32 = 0.873799725;
// health_score: weighted sum of the 7 normalised values listed in the
// docstring above. Using exact closed forms where possible:
// 0.25 · (2224/117649) ≈ 0.0047259220
// 0.15 · 1.0 = 0.15
// 0.15 · 0.5 = 0.075 (atom_util_norm midpoint)
// 0.15 · (637/729) ≈ 0.131069959
// 0.10 · 0.80 = 0.08 (ens_agree)
// 0.10 · 0.5 = 0.05 (grad_consistency_norm midpoint)
// 0.10 · 0.68359375 = 0.068359375
// ────────────────────────────────────
// sum ≈ 0.5591552560
let exp_health_score: f32 = 0.559155256;
println!("health_score = {:.7} (expected {:.7})", out[0], exp_health_score);
println!("q_gap_norm = {:.7} (expected {:.7})", out[1], exp_q_gap_norm);
println!("q_var_norm = {:.7} (expected {:.7})", out[2], exp_q_var_norm);
println!("grad_norm_norm = {:.7} (expected {:.7})", out[3], exp_grad_stable);
// FP tolerances: composed score has 7 multiply-adds of small (≤1.0)
// values; q_var_norm saturates exactly. q_gap_norm comes from cubic
// interpolation of an irrational `t` in f32 — the literal we pinned is
// truncated at the 9th digit, so the divergence between f32 evaluation
// of the closed form and the literal is bounded by ~5e-9 * 3 (cubic).
// 1e-6 absolute is safely above that.
let tol_strict: f32 = 1e-6;
let tol_score: f32 = 1e-6;
assert!((out[0] - exp_health_score).abs() < tol_score,
"health_score: expected {exp_health_score:.7}, got {:.7}", out[0]);
assert!((out[1] - exp_q_gap_norm).abs() < tol_strict,
"q_gap_norm: expected {exp_q_gap_norm:.7}, got {:.7}", out[1]);
assert!((out[2] - exp_q_var_norm).abs() < tol_strict,
"q_var_norm: expected {exp_q_var_norm:.7}, got {:.7}", out[2]);
assert!((out[3] - exp_grad_stable).abs() < tol_strict,
"grad_norm_norm (= grad_stable): expected {exp_grad_stable:.7}, got {:.7}", out[3]);
}
// ── Layer D Task D3 (Training metrics EMA) ────────────────────────────────────
fn load_training_metrics_ema_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP5_TRAINING_METRICS_EMA_CUBIN.to_vec())
.expect("load training_metrics_ema_kernel cubin");
module
.load_function("training_metrics_ema_update")
.expect("load training_metrics_ema_update function")
}
/// SP5 Layer D Task D3: `training_metrics_ema_update` formula-fidelity test.
///
/// Asserts the kernel reproduces the host-side EMA arithmetic at
/// `training_loop.rs:5039-5113` bit-for-bit (within float precision). Per
/// `feedback_no_cpu_test_fallbacks.md` the test uses synthetic inputs whose
/// closed-form expected outputs are pre-computed and pinned as literals —
/// no programmatic re-implementation of the formula in the test body.
///
/// Synthetic inputs (deliberately exercise the steady-state branch of all
/// three threads — first-observation paths are tested in the second case
/// below by setting `sharpe_initialized=0` and `prev_max_dd_ema=0`):
///
/// epoch_sharpe = 1.0
/// epoch_max_dd = 0.05 (5% drawdown)
/// prev_sharpe_ema = 0.5 (sharpe diverged from prev by |err|=0.5)
/// prev_max_dd_ema = 0.10
/// prev_low_dd_ratio = 0.4
/// sharpe_initialized = true
///
/// Closed-form expected outputs:
///
/// tid=0 (training_sharpe_ema, adaptive α):
/// err = |1.0 0.5| = 0.5
/// α = clamp(0.05 + 0.3 · 0.5, 0, 0.5) = clamp(0.20, 0, 0.5) = 0.20
/// ema = (1 0.20) · 0.5 + 0.20 · 1.0 = 0.40 + 0.20 = 0.60
///
/// tid=1 (max_dd_ema, fixed α=0.1, prev > 1e-12 so steady-state):
/// ema = 0.9 · 0.10 + 0.1 · 0.05 = 0.090 + 0.005 = 0.095
///
/// tid=2 (low_dd_ratio, fixed α=0.15 over binary `is_low`):
/// thresh = new_max_dd_ema · 0.5 = 0.095 · 0.5 = 0.0475
/// is_low = (epoch_max_dd=0.05 < thresh=0.0475) ? 1.0 : 0.0 = 0.0
/// (epoch_max_dd is *just* above half the new EMA)
/// ema = 0.85 · 0.4 + 0.15 · 0.0 = 0.340
///
/// **Formula fidelity is the load-bearing invariant** for D3: the kernel
/// performs a verbatim structural migration of the host-side EMA update,
/// no algorithmic change. The test pins the adaptive-α arithmetic, the
/// fixed-α recurrence, and the cross-thread `is_low` derivation
/// (tid=2 must read tid=1's *new* output via __syncthreads()).
#[test]
#[ignore = "requires GPU"]
fn training_metrics_ema_kernel_correctness() {
let ctx = CudaContext::new(0).expect("CUDA context");
let stream = ctx.new_stream().expect("stream");
let kernel = load_training_metrics_ema_kernel(&stream);
// ── Case 1: steady-state branch on all three threads ────────────────
{
let epoch_sharpe: f32 = 1.0;
let epoch_max_dd: f32 = 0.05;
let prev_sharpe_ema: f32 = 0.5;
let prev_max_dd_ema: f32 = 0.10;
let prev_low_dd_ratio: f32 = 0.4;
let sharpe_initialized: i32 = 1;
let scratch = unsafe { MappedF32Buffer::new(3) }.expect("scratch alloc");
let scratch_dev = scratch.dev_ptr;
let sharpe_idx: i32 = 0;
let max_dd_idx: i32 = 1;
let low_dd_ratio_idx: i32 = 2;
unsafe {
stream
.launch_builder(&kernel)
.arg(&epoch_sharpe)
.arg(&epoch_max_dd)
.arg(&prev_sharpe_ema)
.arg(&prev_max_dd_ema)
.arg(&prev_low_dd_ratio)
.arg(&sharpe_initialized)
.arg(&scratch_dev)
.arg(&sharpe_idx)
.arg(&max_dd_idx)
.arg(&low_dd_ratio_idx)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (3, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch training_metrics_ema_update steady-state");
}
stream.synchronize().expect("sync after training_metrics_ema_update steady-state");
let out = scratch.read_all();
// Analytical ground truth (closed-form literals):
let exp_sharpe_ema: f32 = 0.60;
let exp_max_dd_ema: f32 = 0.095;
let exp_low_dd_ratio: f32 = 0.340;
println!("[steady] training_sharpe_ema = {:.6} (expected {:.6})", out[0], exp_sharpe_ema);
println!("[steady] max_dd_ema = {:.6} (expected {:.6})", out[1], exp_max_dd_ema);
println!("[steady] low_dd_ratio = {:.6} (expected {:.6})", out[2], exp_low_dd_ratio);
// FP tolerances: each output is 2-3 multiply-adds of small (≤1.0)
// values; 1e-6 absolute is comfortable.
let tol: f32 = 1e-6;
assert!((out[0] - exp_sharpe_ema).abs() < tol,
"training_sharpe_ema [steady]: expected {exp_sharpe_ema:.6}, got {:.6}", out[0]);
assert!((out[1] - exp_max_dd_ema).abs() < tol,
"max_dd_ema [steady]: expected {exp_max_dd_ema:.6}, got {:.6}", out[1]);
assert!((out[2] - exp_low_dd_ratio).abs() < tol,
"low_dd_ratio [steady]: expected {exp_low_dd_ratio:.6}, got {:.6}", out[2]);
}
// ── Case 2: cold-start branches on tid=0 (sharpe_initialized=0)
// and tid=1 (prev_max_dd_ema < 1e-12). tid=2 has no
// host-side sentinel; it always uses the EMA recurrence.
{
let epoch_sharpe: f32 = 0.7;
let epoch_max_dd: f32 = 0.08;
let prev_sharpe_ema: f32 = 99.0; // ignored when initialized=0
let prev_max_dd_ema: f32 = 0.0; // < 1e-12 sentinel triggers replace
let prev_low_dd_ratio: f32 = 0.0; // EMA from 0 is recurrence-safe
let sharpe_initialized: i32 = 0;
let scratch = unsafe { MappedF32Buffer::new(3) }.expect("scratch alloc");
let scratch_dev = scratch.dev_ptr;
let sharpe_idx: i32 = 0;
let max_dd_idx: i32 = 1;
let low_dd_ratio_idx: i32 = 2;
unsafe {
stream
.launch_builder(&kernel)
.arg(&epoch_sharpe)
.arg(&epoch_max_dd)
.arg(&prev_sharpe_ema)
.arg(&prev_max_dd_ema)
.arg(&prev_low_dd_ratio)
.arg(&sharpe_initialized)
.arg(&scratch_dev)
.arg(&sharpe_idx)
.arg(&max_dd_idx)
.arg(&low_dd_ratio_idx)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (3, 1, 1),
shared_mem_bytes: 0,
})
.expect("launch training_metrics_ema_update cold-start");
}
stream.synchronize().expect("sync after training_metrics_ema_update cold-start");
let out = scratch.read_all();
// Closed-form for cold-start:
// tid=0: sharpe_initialized==0 ⇒ output replaces with epoch_sharpe = 0.7.
// tid=1: prev_max_dd_ema<1e-12 ⇒ output replaces with epoch_max_dd = 0.08.
// tid=2: thresh=0.04, is_low=(0.08<0.04)?1:0 = 0; ema = 0.85·0 + 0.15·0 = 0.
let exp_sharpe_ema: f32 = 0.7;
let exp_max_dd_ema: f32 = 0.08;
let exp_low_dd_ratio: f32 = 0.0;
println!("[cold] training_sharpe_ema = {:.6} (expected {:.6})", out[0], exp_sharpe_ema);
println!("[cold] max_dd_ema = {:.6} (expected {:.6})", out[1], exp_max_dd_ema);
println!("[cold] low_dd_ratio = {:.6} (expected {:.6})", out[2], exp_low_dd_ratio);
let tol: f32 = 1e-6;
assert!((out[0] - exp_sharpe_ema).abs() < tol,
"training_sharpe_ema [cold]: expected {exp_sharpe_ema:.6}, got {:.6}", out[0]);
assert!((out[1] - exp_max_dd_ema).abs() < tol,
"max_dd_ema [cold]: expected {exp_max_dd_ema:.6}, got {:.6}", out[1]);
assert!((out[2] - exp_low_dd_ratio).abs() < tol,
"low_dd_ratio [cold]: expected {exp_low_dd_ratio:.6}, got {:.6}", out[2]);
}
}
// ── SP7 activation-flag fix unit test ─────────────────────────────────────────
//
// Verifies the activation-flag transitions in `loss_balance_controller_update`:
// 1. Cold start (zero ISV, zero grad_decomp_result): kernel takes the genuine
// cold-start branch (h_n < EPS_DIV), writes 0.0 to scratch_active. The
// apply_pearls_ad_kernel short-circuit on step_obs == 0.0 is observable
// via the scratch buffer directly (we don't run apply_pearls in this test).
// 2. Active (non-zero IQN/CQL/C51 norms): kernel takes the active path,
// writes 1.0 to scratch_active and a real budget to scratch_budget.
// 3. Resulting budget reflects controller compute (NOT bootstrap constant).
//
// Per `feedback_no_cpu_test_fallbacks.md`: assertions are on analytically-known
// kernel behaviour (active flag binary, budget structurally `cold_start_basis ·
// correction` from the seed-from-cold-start branch since old_budget=0).
fn load_loss_balance_controller_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP7_LOSS_BALANCE_CONTROLLER_CUBIN.to_vec())
.expect("load loss_balance_controller_kernel cubin");
module
.load_function("loss_balance_controller_update")
.expect("load loss_balance_controller_update function")
}
#[test]
#[ignore = "requires GPU"]
fn sp7_loss_balance_controller_activation_flag_transitions() {
use ml::cuda_pipeline::sp5_isv_slots::{
BUDGET_CQL_BASE, BUDGET_C51_BASE, FLATNESS_BASE,
LB_DIFF_VAR_CQL_BASE, LB_SAMPLE_VAR_CQL_BASE,
LB_DIFF_VAR_C51_BASE, LB_SAMPLE_VAR_C51_BASE,
LB_CQL_ACTIVE_BASE, LB_C51_ACTIVE_BASE,
};
// ISV must cover up to SP5_SLOT_END (321) AND EPOCH_IDX_INDEX (39).
const ISV_SIZE: usize = 321;
const EPOCH_IDX_INDEX: usize = 39;
// grad_decomp_result_pinned: 27 floats (9 components × 3 [mag, dir, trunk]).
// Component element offsets per kernel header: iqn=0, cql_sx=6, c51=9.
// We allocate the full 27-float layout to mirror production.
const GRAD_DECOMP_TOTAL: usize = 27;
const GRAD_OFFSET_IQN: usize = 0;
const GRAD_OFFSET_CQL_SX: usize = 6;
const GRAD_OFFSET_C51: usize = 9;
// Scratch layout: 8 output slot-blocks × 4 branches = 32 floats.
// We use small-but-distinct base offsets for the test.
const SCRATCH_BUDGET_CQL: usize = 0; // [0..4)
const SCRATCH_BUDGET_C51: usize = 4; // [4..8)
const SCRATCH_DIFF_CQL: usize = 8; // [8..12)
const SCRATCH_SAMPLE_CQL: usize = 12; // [12..16)
const SCRATCH_DIFF_C51: usize = 16; // [16..20)
const SCRATCH_SAMPLE_C51: usize = 20; // [20..24)
const SCRATCH_ACTIVE_CQL: usize = 24; // [24..28)
const SCRATCH_ACTIVE_C51: usize = 28; // [28..32)
const SCRATCH_SIZE: usize = 32;
let stream = make_test_stream();
let kernel = load_loss_balance_controller_kernel(&stream);
let isv_buf = unsafe { MappedF32Buffer::new(ISV_SIZE) }.expect("alloc isv_buf");
let grad_buf = unsafe { MappedF32Buffer::new(GRAD_DECOMP_TOTAL) }.expect("alloc grad_buf");
let scratch_buf = unsafe { MappedF32Buffer::new(SCRATCH_SIZE) }.expect("alloc scratch_buf");
let isv_dev = isv_buf.dev_ptr;
let scratch_dev = scratch_buf.dev_ptr;
let f32_size = std::mem::size_of::<f32>() as u64;
let iqn_dev = grad_buf.dev_ptr + (GRAD_OFFSET_IQN as u64) * f32_size;
let cql_sx_dev = grad_buf.dev_ptr + (GRAD_OFFSET_CQL_SX as u64) * f32_size;
let c51_dev = grad_buf.dev_ptr + (GRAD_OFFSET_C51 as u64) * f32_size;
let flatness_isv_base_i32 = FLATNESS_BASE as i32;
let budget_cql_isv_base_i32 = BUDGET_CQL_BASE as i32;
let budget_c51_isv_base_i32 = BUDGET_C51_BASE as i32;
let diff_var_cql_isv_base_i32 = LB_DIFF_VAR_CQL_BASE as i32;
let sample_var_cql_isv_base_i32 = LB_SAMPLE_VAR_CQL_BASE as i32;
let diff_var_c51_isv_base_i32 = LB_DIFF_VAR_C51_BASE as i32;
let sample_var_c51_isv_base_i32 = LB_SAMPLE_VAR_C51_BASE as i32;
let active_cql_isv_base_i32 = LB_CQL_ACTIVE_BASE as i32;
let active_c51_isv_base_i32 = LB_C51_ACTIVE_BASE as i32;
let epoch_idx_isv_index_i32 = EPOCH_IDX_INDEX as i32;
let sb_budget_cql_i32 = SCRATCH_BUDGET_CQL as i32;
let sb_budget_c51_i32 = SCRATCH_BUDGET_C51 as i32;
let sb_diff_var_cql_i32 = SCRATCH_DIFF_CQL as i32;
let sb_sample_var_cql_i32 = SCRATCH_SAMPLE_CQL as i32;
let sb_diff_var_c51_i32 = SCRATCH_DIFF_C51 as i32;
let sb_sample_var_c51_i32 = SCRATCH_SAMPLE_C51 as i32;
let sb_active_cql_i32 = SCRATCH_ACTIVE_CQL as i32;
let sb_active_c51_i32 = SCRATCH_ACTIVE_C51 as i32;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (8, 1, 1),
shared_mem_bytes: 0,
};
// Helper: launch the controller kernel once with current ISV/grad state.
let launch_once = |isv_data: &[f32], grad_data: &[f32], scratch_seed: f32| {
isv_buf.write_from_slice(isv_data);
grad_buf.write_from_slice(grad_data);
// Seed scratch with a marker so we can detect "kernel didn't write".
let scratch_init = vec![scratch_seed; SCRATCH_SIZE];
scratch_buf.write_from_slice(&scratch_init);
unsafe {
stream
.launch_builder(&kernel)
.arg(&iqn_dev)
.arg(&cql_sx_dev)
.arg(&c51_dev)
.arg(&isv_dev)
.arg(&flatness_isv_base_i32)
.arg(&budget_cql_isv_base_i32)
.arg(&budget_c51_isv_base_i32)
.arg(&diff_var_cql_isv_base_i32)
.arg(&sample_var_cql_isv_base_i32)
.arg(&diff_var_c51_isv_base_i32)
.arg(&sample_var_c51_isv_base_i32)
.arg(&active_cql_isv_base_i32)
.arg(&active_c51_isv_base_i32)
.arg(&epoch_idx_isv_index_i32)
.arg(&scratch_dev)
.arg(&sb_budget_cql_i32)
.arg(&sb_budget_c51_i32)
.arg(&sb_diff_var_cql_i32)
.arg(&sb_sample_var_cql_i32)
.arg(&sb_diff_var_c51_i32)
.arg(&sb_sample_var_c51_i32)
.arg(&sb_active_cql_i32)
.arg(&sb_active_c51_i32)
.launch(cfg)
.expect("launch loss_balance_controller_update");
}
stream.synchronize().expect("sync after loss_balance_controller_update");
scratch_buf.read_all()
};
// ── Step 1: cold start (all-zero ISV, all-zero grads). ───────────────────
// Kernel hits cold-start branch: h_n=0 < EPS_DIV. was_active=0 (ISV is zero).
// Expect: scratch_active = 0.0 for all 4 branches × 2 heads.
// (apply_pearls_ad short-circuit on 0.0 means the consumer sees the
// bootstrap value, but we test the scratch directly here.)
let isv_cold = vec![0.0_f32; ISV_SIZE];
let grad_cold = vec![0.0_f32; GRAD_DECOMP_TOTAL];
let scratch_after_cold = launch_once(&isv_cold, &grad_cold, /* seed = */ -1.0);
for b in 0..4_usize {
let cql_active = scratch_after_cold[SCRATCH_ACTIVE_CQL + b];
let c51_active = scratch_after_cold[SCRATCH_ACTIVE_C51 + b];
assert!(
cql_active.abs() < 1e-7,
"cold start branch={b}: cql_active={cql_active:.6} should be 0.0 (genuine cold start)"
);
assert!(
c51_active.abs() < 1e-7,
"cold start branch={b}: c51_active={c51_active:.6} should be 0.0 (genuine cold start)"
);
// Budget slots also written 0.0 (no-op via apply_pearls_ad short-circuit).
assert!(
scratch_after_cold[SCRATCH_BUDGET_CQL + b].abs() < 1e-7,
"cold start branch={b}: budget_cql={:.6} should be 0.0 (no-op marker)",
scratch_after_cold[SCRATCH_BUDGET_CQL + b]
);
assert!(
scratch_after_cold[SCRATCH_BUDGET_C51 + b].abs() < 1e-7,
"cold start branch={b}: budget_c51={:.6} should be 0.0 (no-op marker)",
scratch_after_cold[SCRATCH_BUDGET_C51 + b]
);
}
// ── Step 2: active (synthetic non-zero IQN/CQL/C51 norms). ───────────────
// grad_decomp pinned layout per component: [mag, dir, trunk]. We populate
// all 3 slices so every branch has a non-zero h_n / iqn_n.
// IQN slice norms: [1.0, 1.0, 1.0]
// CQL_SX slice norms: [0.5, 0.5, 0.5]
// C51 slice norms: [0.3, 0.3, 0.3]
// ISV: epoch_idx = 0 (first step) → welford_alpha = 1/max(1,0) = 1.0 → full
// first-step update. Flatness defaults to 0 (clamped) → target_ratio for
// CQL = 2.0, target_ratio for C51 = 0.0. With actual_ratio_CQL = 0.5/1.0 =
// 0.5 → correction_CQL = 2.0/0.5 = 4.0. Old budget = 0 → seed-from-cold-start
// branch: new_budget = COLD_START_FLOOR_CQL · correction = 0.02 · 4.0 = 0.08.
// For C51: target=0 → correction=0 → new_budget = COLD_START_FLOOR_C51 · 0 = 0
// (clamped at EPS_DIV = 1e-8).
let mut grad_active = vec![0.0_f32; GRAD_DECOMP_TOTAL];
for slice in 0..3_usize {
grad_active[GRAD_OFFSET_IQN + slice] = 1.0;
grad_active[GRAD_OFFSET_CQL_SX + slice] = 0.5;
grad_active[GRAD_OFFSET_C51 + slice] = 0.3;
}
let scratch_after_active = launch_once(&isv_cold, &grad_active, /* seed = */ -1.0);
const EPS_DIV: f32 = 1e-8;
const COLD_START_FLOOR_CQL: f32 = 0.02;
for b in 0..4_usize {
let cql_active = scratch_after_active[SCRATCH_ACTIVE_CQL + b];
let c51_active = scratch_after_active[SCRATCH_ACTIVE_C51 + b];
assert!(
cql_active >= 0.5,
"active branch={b}: cql_active={cql_active:.6} should be 1.0 after grads populated"
);
assert!(
c51_active >= 0.5,
"active branch={b}: c51_active={c51_active:.6} should be 1.0 after grads populated"
);
// Budget slots reflect controller's seed-from-cold-start compute, not
// the bootstrap value. CQL: target_ratio=2.0, actual=0.5,
// correction=4.0, seed = 0.02·4.0 = 0.08. C51: target_ratio=0.0
// (flatness=0), so seed = 0.05·0 = 0 → clamped at EPS_DIV.
let budget_cql = scratch_after_active[SCRATCH_BUDGET_CQL + b];
let budget_c51 = scratch_after_active[SCRATCH_BUDGET_C51 + b];
let expected_cql = COLD_START_FLOOR_CQL * 4.0;
assert!(
(budget_cql - expected_cql).abs() / expected_cql < 0.01,
"active branch={b}: budget_cql={budget_cql:.6} expected {expected_cql:.6} (cold_start_basis · correction; not the {COLD_START_FLOOR_CQL:.6} bootstrap)"
);
// C51 target_ratio = ANCHOR_C51_RATIO · flatness = 1.0 · 0 = 0; the
// seed becomes COLD_START_FLOOR_C51 · 0 = 0 → clamped at EPS_DIV.
// The load-bearing assertion: budget != bootstrap (0.05).
assert!(
budget_c51 < 0.04,
"active branch={b}: budget_c51={budget_c51:.6} should be ~EPS_DIV (target_ratio=0), not bootstrap 0.05"
);
assert!(
budget_c51 >= EPS_DIV * 0.99,
"active branch={b}: budget_c51={budget_c51:.6} should be at least EPS_DIV={EPS_DIV:.1e}"
);
}
// ── Step 3: re-launch with prior was_active flags simulated by writing 1.0
// into LB_*_ACTIVE_BASE. Even with grads gated off (back to zero),
// transient cold-start should hold prior budget verbatim and re-assert
// active=1, NOT collapse back to consumer bootstrap.
let mut isv_active = vec![0.0_f32; ISV_SIZE];
for b in 0..4_usize {
isv_active[LB_CQL_ACTIVE_BASE + b] = 1.0; // was_active = 1
isv_active[LB_C51_ACTIVE_BASE + b] = 1.0;
isv_active[BUDGET_CQL_BASE + b] = 0.07; // prior budget != bootstrap
isv_active[BUDGET_C51_BASE + b] = 0.04; // prior budget != bootstrap
}
let scratch_after_transient = launch_once(&isv_active, &grad_cold, /* seed = */ -1.0);
for b in 0..4_usize {
// Active flag held at 1.
assert!(
scratch_after_transient[SCRATCH_ACTIVE_CQL + b] >= 0.5,
"transient grad-gate branch={b}: cql_active={:.6} should stay 1.0 (was_active=1)",
scratch_after_transient[SCRATCH_ACTIVE_CQL + b]
);
assert!(
scratch_after_transient[SCRATCH_ACTIVE_C51 + b] >= 0.5,
"transient grad-gate branch={b}: c51_active={:.6} should stay 1.0 (was_active=1)",
scratch_after_transient[SCRATCH_ACTIVE_C51 + b]
);
// Budget held verbatim at prior value (no drift on a measurement gap).
assert!(
(scratch_after_transient[SCRATCH_BUDGET_CQL + b] - 0.07).abs() < 1e-5,
"transient grad-gate branch={b}: budget_cql={:.6} should hold prior 0.07",
scratch_after_transient[SCRATCH_BUDGET_CQL + b]
);
assert!(
(scratch_after_transient[SCRATCH_BUDGET_C51 + b] - 0.04).abs() < 1e-5,
"transient grad-gate branch={b}: budget_c51={:.6} should hold prior 0.04",
scratch_after_transient[SCRATCH_BUDGET_C51 + b]
);
}
}