Pure-comment follow-up to SP18 Phase 1 atomic deletion (3c318953a) and Phase 2 device-fn introduction (8da8e2e58). Strips the "DELETED by SP18 Phase 1" markers + "RETIRED" docstrings + per-slot SP3→SP18 archaeology that survived the focused deletion diff. Per feedback_no_legacy_aliases ("avoid backwards-compatibility hacks like removed-comment markers") and CLAUDE.md ("don't add comments that explain what the code does"), git history is the historical record — code shouldn't narrate its own deletion. Three categories cleaned: 1. ISV_TOTAL_DIM giant docstring in gpu_dqn_trainer.rs (Category 1): collapsed ~5000-char slot-by-slot SP3→SP18 history block into a 13-line conceptual definition pointing readers at sp14_isv_slots.rs for the actual range definitions. Layout fingerprint seed (RESERVED_GAP_461_TO_468=SP18_P1_RETIRED;) is load-bearing and preserved. 2. RETIRED markers in sp14_isv_slots.rs + state_layout.cuh (Category 2): deleted "SP16 Phase 2 RETIRED 2026-05-09" header blocks, "HCS_* slots [462..468) RETIRED" markers, "SP13 [380..383)/ SP16 [461..474) ALLOCATED but RETIRED per PP.4" prose, and the test-removal note before the surviving MHT block lock test. Tightened the SP16 T3 Wiener-α comment block to describe only the surviving MIN_HOLD_TEMPERATURE producer. 3. Stale doc-refs across the SP18 audit consumer set (Category 3): updated gpu_experience_collector.rs prose, sp5_isv_slots.rs SP13 v3 description, gpu_dqn_trainer.rs slot-380 constructor block, state_reset_registry.rs HRC_*/MHT_*/TDB_* "mirrors slot N" refs + the registry-block introduction comment + the deleted-test marker at end of file, training_loop.rs launcher-call/diag-emit/ dispatch-arms "DELETED" markers, build.rs cubin manifest "DELETED" marker, gpu_aux_trunk.rs trailing "HoldCostScaleUpdateOps DELETED" block, and tests/sp14_oracle_tests.rs leading "tests DELETED" marker. Production-source grep verification (post-cleanup): zero matches for HOLD_COST_SCALE_INDEX | HCS_TARGET | HCS_DIFF | HCS_PREV | HCS_SAMPLE | HOLD_COST_CONTROLLER_GAIN | HOLD_COST_FLOOR_RATIO | HOLD_COST_CEIL_RATIO | hold_cost_scale_update across crates/ml/src/ *.rs/*.cu/*.cuh. Audit doc + script preserved per design: - scripts/audit_sp18_consumers.sh grep patterns retain HOLD_COST_SCALE / HCS_* — those are the diagnostic tool. - docs/sp18-wireup-audit.md historical sections retain references (audit doc IS the design-history record); fingerprint section regenerated to reflect post-cleanup hit counts (audit --check passes). - docs/superpowers/specs/* + docs/superpowers/plans/* unchanged (spec/plan files retain HOLD_COST_SCALE references unchanged per feedback_trust_code_not_docs corollary — those are the past design-decision record). - docs/dqn-wire-up-audit.md current-state header section gains a 2026-05-09 archaeology-cleanup-pass entry citing the affected files + verification (per Pre-commit Invariant 7). Verification: - cargo check --workspace: clean (warnings only — pre-existing). - cargo test -p ml --lib --no-run: compiles clean. - cargo test --doc -p ml: 21 failures pre-existing (verified via stash; identical 21 failures on parent commit8da8e2e58— unrelated to this cleanup). - bash scripts/audit_sp18_consumers.sh --check: passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2310 lines
99 KiB
Rust
2310 lines
99 KiB
Rust
//! SP14 Layer A + B oracle tests (post-Layer C Phase C.1 deletion).
|
||
//!
|
||
//! Layer A is the pre-Smoke-A stability sweep: small fixes addressing the
|
||
//! aux_w controller pathologies surfaced by Smoke A. Tests here run on CPU —
|
||
//! they exercise pure-math constants and the host-side `compute_aux_w_p0b`
|
||
//! controller without GPU dependencies.
|
||
//!
|
||
//! - **A.2**: `set_aux_weight` clamp lift from SP11-era `[0.05, 0.3]` to the
|
||
//! SP13 P0b design range `[base × floor_ratio, base × ceil_ratio]` =
|
||
//! `[0.15, 1.5]`. Verified here as a constants check; the trainer-side
|
||
//! wiring is verified by Smoke A2-A end-to-end behaviour.
|
||
//!
|
||
//! Layer B retained tests (post-Phase C.1 atomic α-machinery deletion,
|
||
//! 2026-05-08): only the q_disagreement diagnostic chain (B.3) and the
|
||
//! Coupling A forward feature wire (B.6) survive. The α-machinery oracle
|
||
//! tests (alpha_grad_schmitt_hysteresis, alpha_grad_adaptive_beta,
|
||
//! gradient_hack_circuit_breaker_fires) were deleted atomically with the
|
||
//! kernel files — see
|
||
//! `docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md` §C.1.
|
||
//!
|
||
//! - **B.3**: `q_disagreement_update_kernel` — K=4↔K=2 mapped argmax mismatch
|
||
//! between Q-head direction (Short/Hold/Long/Flat) and aux head's K=2
|
||
//! prediction (down/up); Hold/Flat masked. Pearl-A first-observation
|
||
//! bootstrap; Welford variance EMA in same kernel. No atomicAdd per
|
||
//! `feedback_no_atomicadd.md`. Post-C.1 the producer survives as a
|
||
//! HEALTH_DIAG diagnostic only — no consumer reads slots 383/384/389.
|
||
//!
|
||
//! - **B.6**: `dir_concat_qaux_kernel` — Coupling A forward feature wire.
|
||
//! Concatenates `[h_s2 ; aux_softmax_diff]` into `[B, SH2 + 1]` for the
|
||
//! direction Q-head's first FC SGEMM input. Preserved through Phase C.1.
|
||
//!
|
||
//! Run GPU tests on a GPU host:
|
||
//!
|
||
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
|
||
//! cargo test -p ml --test sp14_oracle_tests --features cuda \
|
||
//! -- --ignored --nocapture
|
||
|
||
/// Test A.2: SP13 P0b aux_w bounds resolve to [0.15, 1.5] from the documented
|
||
/// constants. The pre-fix `set_aux_weight` clamp at gpu_dqn_trainer.rs:14722
|
||
/// was `[0.05, 0.3]` — the SP11-era cap that silently chopped the entire
|
||
/// upper half of the controller's intended range. This test verifies the
|
||
/// constants used by the post-fix clamp resolve to the design range.
|
||
#[test]
|
||
fn set_aux_weight_clamp_range() {
|
||
use ml::cuda_pipeline::sp13_isv_slots::{
|
||
AUX_W_BASE, AUX_W_HARD_FLOOR_RATIO, AUX_W_HARD_CEIL_RATIO,
|
||
};
|
||
// Direct math check; not a GPU test.
|
||
let floor = AUX_W_BASE * AUX_W_HARD_FLOOR_RATIO;
|
||
let ceil = AUX_W_BASE * AUX_W_HARD_CEIL_RATIO;
|
||
assert_eq!(floor, 0.15);
|
||
assert_eq!(ceil, 1.5);
|
||
|
||
// Verify the trainer struct's clamp uses these bounds. We use a direct
|
||
// f32 clamp here mirroring the production code; the test ensures the
|
||
// production constants resolve to the expected values.
|
||
let test_value: f32 = 1.0;
|
||
let clamped = test_value.clamp(floor, ceil);
|
||
assert_eq!(clamped, 1.0,
|
||
"1.0 should NOT be clamped under [0.15, 1.5]; got {}", clamped);
|
||
|
||
let too_low: f32 = 0.05;
|
||
let clamped_low = too_low.clamp(floor, ceil);
|
||
assert_eq!(clamped_low, 0.15);
|
||
|
||
let too_high: f32 = 2.0;
|
||
let clamped_high = too_high.clamp(floor, ceil);
|
||
assert_eq!(clamped_high, 1.5);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// SP14 Layer B GPU oracle tests — `#[cfg(feature = "cuda")]` gated.
|
||
//
|
||
// Each test is `#[ignore = "requires GPU"]` per the codebase convention so
|
||
// non-GPU runners skip them without `--ignored`. All CPU↔GPU buffers are
|
||
// `MappedF32Buffer` per `feedback_no_htod_htoh_only_mapped_pinned.md` (tests
|
||
// are not exempt from the rule). Cubins are `include_bytes!`'d from
|
||
// `OUT_DIR`, mirroring `sp13_layer_b_oracle_tests.rs`.
|
||
//
|
||
// SP14 Layer C Phase C.1 (2026-05-08): the α-machinery cubin loaders
|
||
// (`load_alpha_grad`, `load_grad_hack`) and tests
|
||
// (`alpha_grad_schmitt_hysteresis`, `alpha_grad_adaptive_beta`,
|
||
// `gradient_hack_circuit_breaker_fires`) were deleted atomically with
|
||
// the kernel files per `feedback_no_legacy_aliases`. Surviving:
|
||
// q_disagreement_* (3 tests) + dir_concat_qaux_correct (1 test).
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
mod gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
|
||
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
Q_DISAGREEMENT_LONG_EMA_INDEX,
|
||
Q_DISAGREEMENT_SHORT_EMA_INDEX,
|
||
Q_DISAGREEMENT_VARIANCE_EMA_INDEX,
|
||
};
|
||
|
||
// ── B.3 cubin handle ────────────────────────────────────────────────────
|
||
|
||
const SP14_Q_DISAGREEMENT_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/q_disagreement_update_kernel.cubin"));
|
||
|
||
fn make_test_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_q_disagreement(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(SP14_Q_DISAGREEMENT_CUBIN.to_vec())
|
||
.expect("load q_disagreement_update_kernel cubin");
|
||
module
|
||
.load_function("q_disagreement_update_kernel")
|
||
.expect("load q_disagreement_update_kernel function")
|
||
}
|
||
|
||
/// Shared-memory bytes for the kernel: `2 * blockDim.x * sizeof(float)`
|
||
/// (the kernel uses `extern __shared__ float smem[]` split into
|
||
/// `num_smem` and `cnt_smem` arrays of `blockDim.x` floats each).
|
||
/// blockDim.x = 256 → 2 × 256 × 4 = 2048 bytes.
|
||
const Q_DIS_BLOCK: u32 = 256;
|
||
const Q_DIS_SMEM_BYTES: u32 = 2 * Q_DIS_BLOCK * std::mem::size_of::<f32>() as u32;
|
||
|
||
// ── Test B.3a: K=4 → K=2 mapping correctness ───────────────────────────
|
||
|
||
/// 8-row batch with deliberate mix of agreements, disagreements, and
|
||
/// masked rows (Hold/Flat). With Q_DISAGREEMENT_SHORT_EMA at sentinel
|
||
/// 0.5, Pearl-A first-observation replaces the EMA directly with the
|
||
/// batch mean disagreement (2 disagreements / 4 contributing rows = 0.5).
|
||
/// Both short and long EMAs land on 0.5 because the Pearl-A bootstrap
|
||
/// applies to both EMAs simultaneously when prev = sentinel.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn q_disagreement_k4_k2_mapping() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_q_disagreement(&stream);
|
||
|
||
const B: usize = 8;
|
||
const K_AUX: usize = 2;
|
||
const K_DIR: usize = 4;
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// Pearl-A sentinels.
|
||
isv[Q_DISAGREEMENT_SHORT_EMA_INDEX] = 0.5;
|
||
isv[Q_DISAGREEMENT_LONG_EMA_INDEX] = 0.5;
|
||
isv[Q_DISAGREEMENT_VARIANCE_EMA_INDEX] = 0.0;
|
||
|
||
// 8-row batch: rows 0,1,2,3 contributing (Short/Long), rows 4,5 are
|
||
// Hold (masked), rows 6,7 are Flat (masked).
|
||
// Agreements (aux matches Q): rows 0 (Short→down), 2 (Long→up).
|
||
// Disagreements: rows 1 (Short→up), 3 (Long→down). 2 dis / 4 = 0.5.
|
||
let mut aux_softmax = vec![0.0_f32; B * K_AUX];
|
||
let mut q_logits = vec![0.0_f32; B * K_DIR];
|
||
// Row 0: Q=Short(0), aux=down(0) → agreement
|
||
aux_softmax[0 * K_AUX + 0] = 0.9; aux_softmax[0 * K_AUX + 1] = 0.1;
|
||
q_logits[0 * K_DIR + 0] = 5.0;
|
||
// Row 1: Q=Short(0), aux=up(1) → disagreement
|
||
aux_softmax[1 * K_AUX + 0] = 0.1; aux_softmax[1 * K_AUX + 1] = 0.9;
|
||
q_logits[1 * K_DIR + 0] = 5.0;
|
||
// Row 2: Q=Long(2), aux=up(1) → agreement
|
||
aux_softmax[2 * K_AUX + 0] = 0.1; aux_softmax[2 * K_AUX + 1] = 0.9;
|
||
q_logits[2 * K_DIR + 2] = 5.0;
|
||
// Row 3: Q=Long(2), aux=down(0) → disagreement
|
||
aux_softmax[3 * K_AUX + 0] = 0.9; aux_softmax[3 * K_AUX + 1] = 0.1;
|
||
q_logits[3 * K_DIR + 2] = 5.0;
|
||
// Row 4: Q=Hold(1), masked
|
||
q_logits[4 * K_DIR + 1] = 5.0;
|
||
// Row 5: Q=Hold(1), masked
|
||
q_logits[5 * K_DIR + 1] = 5.0;
|
||
// Row 6: Q=Flat(3), masked
|
||
q_logits[6 * K_DIR + 3] = 5.0;
|
||
// Row 7: Q=Flat(3), masked
|
||
q_logits[7 * K_DIR + 3] = 5.0;
|
||
|
||
let aux_buf = unsafe { MappedF32Buffer::new(B * K_AUX) }
|
||
.expect("alloc aux softmax buf");
|
||
aux_buf.write_from_slice(&aux_softmax);
|
||
let q_buf = unsafe { MappedF32Buffer::new(B * K_DIR) }
|
||
.expect("alloc q_logits buf");
|
||
q_buf.write_from_slice(&q_logits);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }
|
||
.expect("alloc isv buf");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
let b_i32: i32 = B as i32;
|
||
let q_stride: i32 = K_DIR as i32;
|
||
let alpha_short: f32 = 0.3;
|
||
let alpha_long: f32 = 0.05;
|
||
let alpha_var: f32 = 0.05;
|
||
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&aux_buf.dev_ptr)
|
||
.arg(&q_buf.dev_ptr)
|
||
.arg(&isv_buf.dev_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&q_stride)
|
||
.arg(&alpha_short)
|
||
.arg(&alpha_long)
|
||
.arg(&alpha_var)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (Q_DIS_BLOCK, 1, 1),
|
||
shared_mem_bytes: Q_DIS_SMEM_BYTES,
|
||
})
|
||
.expect("launch q_disagreement_update_kernel (k4_k2)");
|
||
}
|
||
stream.synchronize().expect("sync after q_disagreement");
|
||
|
||
let result = isv_buf.read_all();
|
||
let short = result[Q_DISAGREEMENT_SHORT_EMA_INDEX];
|
||
let long = result[Q_DISAGREEMENT_LONG_EMA_INDEX];
|
||
// Pearl-A: sentinel=0.5 + batch_mean=0.5 → exactly 0.5.
|
||
assert!((short - 0.5).abs() < 1e-5, "short EMA: expected ~0.5, got {short}");
|
||
assert!((long - 0.5).abs() < 1e-5, "long EMA: expected ~0.5, got {long}");
|
||
}
|
||
|
||
/// All-Hold batch: every row is Hold (action=1), so every row is
|
||
/// masked, contributing 0 to numerator AND 0 to count. Kernel must
|
||
/// preserve the prior EMA bit-exactly (no division by zero).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn q_disagreement_all_hold_no_contribution() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_q_disagreement(&stream);
|
||
|
||
const B: usize = 8;
|
||
const K_AUX: usize = 2;
|
||
const K_DIR: usize = 4;
|
||
const ISV_DIM: usize = 1024;
|
||
const SEED_SHORT: f32 = 0.42;
|
||
const SEED_LONG: f32 = 0.31;
|
||
const SEED_VAR: f32 = 0.07;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[Q_DISAGREEMENT_SHORT_EMA_INDEX] = SEED_SHORT;
|
||
isv[Q_DISAGREEMENT_LONG_EMA_INDEX] = SEED_LONG;
|
||
isv[Q_DISAGREEMENT_VARIANCE_EMA_INDEX] = SEED_VAR;
|
||
|
||
let aux_softmax = vec![0.5_f32; B * K_AUX];
|
||
let mut q_logits = vec![0.0_f32; B * K_DIR];
|
||
for b in 0..B {
|
||
q_logits[b * K_DIR + 1] = 5.0; // all-Hold
|
||
}
|
||
|
||
let aux_buf = unsafe { MappedF32Buffer::new(B * K_AUX) }
|
||
.expect("alloc aux buf");
|
||
aux_buf.write_from_slice(&aux_softmax);
|
||
let q_buf = unsafe { MappedF32Buffer::new(B * K_DIR) }
|
||
.expect("alloc q buf");
|
||
q_buf.write_from_slice(&q_logits);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }
|
||
.expect("alloc isv buf");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
let b_i32: i32 = B as i32;
|
||
let q_stride: i32 = K_DIR as i32;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&aux_buf.dev_ptr)
|
||
.arg(&q_buf.dev_ptr)
|
||
.arg(&isv_buf.dev_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&q_stride)
|
||
.arg(&0.3_f32)
|
||
.arg(&0.05_f32)
|
||
.arg(&0.05_f32)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (Q_DIS_BLOCK, 1, 1),
|
||
shared_mem_bytes: Q_DIS_SMEM_BYTES,
|
||
})
|
||
.expect("launch q_disagreement_update_kernel (all-hold)");
|
||
}
|
||
stream.synchronize().expect("sync after q_disagreement");
|
||
|
||
let result = isv_buf.read_all();
|
||
let short = result[Q_DISAGREEMENT_SHORT_EMA_INDEX];
|
||
let long = result[Q_DISAGREEMENT_LONG_EMA_INDEX];
|
||
let var = result[Q_DISAGREEMENT_VARIANCE_EMA_INDEX];
|
||
assert_eq!(short, SEED_SHORT, "short EMA must be preserved on empty batch");
|
||
assert_eq!(long, SEED_LONG, "long EMA must be preserved on empty batch");
|
||
assert_eq!(var, SEED_VAR, "var EMA must be preserved on empty batch");
|
||
}
|
||
|
||
/// Empty batch (B=0): no contributions; EMAs preserved bit-exactly.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn q_disagreement_empty_batch_preserves_ema() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_q_disagreement(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
const SEED_SHORT: f32 = 0.42;
|
||
const SEED_LONG: f32 = 0.31;
|
||
const SEED_VAR: f32 = 0.07;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[Q_DISAGREEMENT_SHORT_EMA_INDEX] = SEED_SHORT;
|
||
isv[Q_DISAGREEMENT_LONG_EMA_INDEX] = SEED_LONG;
|
||
isv[Q_DISAGREEMENT_VARIANCE_EMA_INDEX] = SEED_VAR;
|
||
|
||
// Allocate non-zero-sized buffers (cudarc rejects size-0 allocs)
|
||
// but pass B=0 so the kernel processes zero rows.
|
||
let aux_buf = unsafe { MappedF32Buffer::new(2) }
|
||
.expect("alloc aux buf");
|
||
aux_buf.write_from_slice(&[0.0_f32; 2]);
|
||
let q_buf = unsafe { MappedF32Buffer::new(4) }
|
||
.expect("alloc q buf");
|
||
q_buf.write_from_slice(&[0.0_f32; 4]);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }
|
||
.expect("alloc isv buf");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
let b_i32: i32 = 0;
|
||
let q_stride: i32 = 4;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&aux_buf.dev_ptr)
|
||
.arg(&q_buf.dev_ptr)
|
||
.arg(&isv_buf.dev_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&q_stride)
|
||
.arg(&0.3_f32)
|
||
.arg(&0.05_f32)
|
||
.arg(&0.05_f32)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (Q_DIS_BLOCK, 1, 1),
|
||
shared_mem_bytes: Q_DIS_SMEM_BYTES,
|
||
})
|
||
.expect("launch q_disagreement_update_kernel (empty batch)");
|
||
}
|
||
stream.synchronize().expect("sync after q_disagreement");
|
||
|
||
let result = isv_buf.read_all();
|
||
assert_eq!(result[Q_DISAGREEMENT_SHORT_EMA_INDEX], SEED_SHORT);
|
||
assert_eq!(result[Q_DISAGREEMENT_LONG_EMA_INDEX], SEED_LONG);
|
||
let var_ema = result[Q_DISAGREEMENT_VARIANCE_EMA_INDEX];
|
||
assert_eq!(
|
||
var_ema, SEED_VAR,
|
||
"Empty-batch must preserve variance EMA bit-exactly; expected {SEED_VAR}, got {var_ema}",
|
||
);
|
||
}
|
||
|
||
// ── B.6 cubin handle ────────────────────────────────────────────────────
|
||
|
||
const SP14_DIR_CONCAT_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/dir_concat_qaux_kernel.cubin"));
|
||
|
||
fn load_dir_concat(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(SP14_DIR_CONCAT_CUBIN.to_vec())
|
||
.expect("load dir_concat_qaux_kernel cubin");
|
||
module
|
||
.load_function("dir_concat_qaux_kernel")
|
||
.expect("load dir_concat_qaux_kernel function")
|
||
}
|
||
|
||
// ── Test B.6: Pre-SGEMM concat correctness ─────────────────────────────
|
||
|
||
/// 4-row batch, SH2=8. Rows 0-1 aux predicts "down" (softmax=[0.9, 0.1]),
|
||
/// rows 2-3 aux predicts "up" (softmax=[0.1, 0.9]).
|
||
///
|
||
/// Verifies:
|
||
/// - Positions 0..SH2 in each output row contain the original h_s2 row
|
||
/// values unchanged (contiguous copy, per-thread map).
|
||
/// - Position SH2 contains `softmax[b, 1] - softmax[b, 0]`: -0.8 for
|
||
/// "down" rows, +0.8 for "up" rows.
|
||
///
|
||
/// aux_softmax_diff is structurally bounded to [-1, +1] by the softmax
|
||
/// composition per `pearl_bounded_modifier_outputs_require_structural_
|
||
/// activation`. No reduction; no atomicAdd.
|
||
///
|
||
/// Coupling A (forward feature wire) — preserved through SP14 Layer C
|
||
/// Phase C.1 deletion of the α-machinery (Coupling B).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn dir_concat_qaux_correct() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_dir_concat(&stream);
|
||
|
||
const B: usize = 4;
|
||
const SH2: usize = 8;
|
||
const K_AUX: usize = 2;
|
||
|
||
// h_s2 input: sequential floats 0..B*SH2
|
||
let h_s2: Vec<f32> = (0..B * SH2).map(|i| i as f32).collect();
|
||
|
||
// aux_softmax: rows 0,1 → "down" ([0.9, 0.1]) → diff = 0.1 - 0.9 = -0.8
|
||
// rows 2,3 → "up" ([0.1, 0.9]) → diff = 0.9 - 0.1 = +0.8
|
||
let mut aux_softmax = vec![0.0_f32; B * K_AUX];
|
||
for b in 0..2 {
|
||
aux_softmax[b * K_AUX + 0] = 0.9;
|
||
aux_softmax[b * K_AUX + 1] = 0.1;
|
||
}
|
||
for b in 2..4 {
|
||
aux_softmax[b * K_AUX + 0] = 0.1;
|
||
aux_softmax[b * K_AUX + 1] = 0.9;
|
||
}
|
||
|
||
let h_buf = unsafe { MappedF32Buffer::new(B * SH2) }.expect("alloc h_s2 buf");
|
||
h_buf.write_from_slice(&h_s2);
|
||
let aux_buf = unsafe { MappedF32Buffer::new(B * K_AUX) }.expect("alloc aux buf");
|
||
aux_buf.write_from_slice(&aux_softmax);
|
||
let out_buf = unsafe { MappedF32Buffer::new(B * (SH2 + 1)) }.expect("alloc out buf");
|
||
|
||
let b_i32: i32 = B as i32;
|
||
let sh2_i32: i32 = SH2 as i32;
|
||
let total = (B * (SH2 + 1)) as u32;
|
||
let blocks = ((total + 255) / 256).max(1);
|
||
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&h_buf.dev_ptr)
|
||
.arg(&aux_buf.dev_ptr)
|
||
.arg(&out_buf.dev_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&sh2_i32)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (blocks, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("launch dir_concat_qaux_kernel");
|
||
}
|
||
stream.synchronize().expect("sync after dir_concat_qaux");
|
||
|
||
let out = out_buf.read_all();
|
||
for b in 0..B {
|
||
for s in 0..SH2 {
|
||
let want = h_s2[b * SH2 + s];
|
||
let got = out[b * (SH2 + 1) + s];
|
||
assert_eq!(got, want, "row {b} col {s}: want {want}, got {got}");
|
||
}
|
||
let diff = out[b * (SH2 + 1) + SH2];
|
||
let want_diff: f32 = if b < 2 { -0.8 } else { 0.8 };
|
||
assert!((diff - want_diff).abs() < 1e-5,
|
||
"row {b} wire col: want {want_diff}, got {diff}");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Class A P0-A (2026-05-08) — adaptive REWARD_POS/NEG_CAP producer tests.
|
||
//
|
||
// Verifies the `reward_cap_update_kernel` producer:
|
||
// 1. Cold-start Pearl-A bootstrap: ISV stays at sentinel until first valid
|
||
// observation, then REPLACES (no blend).
|
||
// 2. 2:1 asymmetry preserved at producer-time: NEG_CAP = -2 × POS_CAP.
|
||
// 3. Bounds enforced: POS in [1, 50], NEG in [-100, -2].
|
||
// 4. Welford slow EMA after bootstrap (α=0.01).
|
||
//
|
||
// All tests are #[ignore = "requires GPU"]; gated under #[cfg(feature = "cuda")].
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
mod sp14_p0a_reward_cap_gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
|
||
use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
REWARD_CAP_EMA_ALPHA, REWARD_CAP_SAFETY_FACTOR, REWARD_NEG_CAP_ADAPTIVE_INDEX,
|
||
REWARD_NEG_TO_POS_RATIO, REWARD_POS_CAP_ADAPTIVE_INDEX, REWARD_POS_CAP_MAX,
|
||
REWARD_POS_CAP_MIN, SENTINEL_REWARD_NEG_CAP, SENTINEL_REWARD_POS_CAP,
|
||
};
|
||
|
||
const REWARD_CAP_UPDATE_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/reward_cap_update_kernel.cubin"));
|
||
|
||
fn make_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(REWARD_CAP_UPDATE_CUBIN.to_vec())
|
||
.expect("load reward_cap_update_kernel cubin");
|
||
module
|
||
.load_function("reward_cap_update")
|
||
.expect("load reward_cap_update function")
|
||
}
|
||
|
||
/// 4 arrays × 256 × 4 bytes = 4096 bytes shmem.
|
||
const BLK_DIM: u32 = 256;
|
||
const SMEM_BYTES: u32 = 4 * BLK_DIM * std::mem::size_of::<f32>() as u32;
|
||
|
||
/// Helper: launch with a fixed config.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn launch_reward_cap(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
step_ret_ptr: u64,
|
||
trade_close_ptr: u64,
|
||
total_samples: i32,
|
||
isv_ptr: u64,
|
||
pos_idx: i32,
|
||
neg_idx: i32,
|
||
sentinel_pos: f32,
|
||
sentinel_neg: f32,
|
||
safety_factor: f32,
|
||
neg_to_pos_ratio: f32,
|
||
pos_min: f32,
|
||
pos_max: f32,
|
||
alpha: f32,
|
||
) {
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&step_ret_ptr)
|
||
.arg(&trade_close_ptr)
|
||
.arg(&total_samples)
|
||
.arg(&isv_ptr)
|
||
.arg(&pos_idx)
|
||
.arg(&neg_idx)
|
||
.arg(&sentinel_pos)
|
||
.arg(&sentinel_neg)
|
||
.arg(&safety_factor)
|
||
.arg(&neg_to_pos_ratio)
|
||
.arg(&pos_min)
|
||
.arg(&pos_max)
|
||
.arg(&alpha)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (BLK_DIM, 1, 1),
|
||
shared_mem_bytes: SMEM_BYTES,
|
||
})
|
||
.expect("launch reward_cap_update");
|
||
}
|
||
stream.synchronize().expect("sync after reward_cap_update");
|
||
}
|
||
|
||
/// Test 1 — Pearl-A first-observation bootstrap.
|
||
///
|
||
/// 4 winning closes (all step_ret>0) with values [1.0, 2.0, 3.0, 4.0],
|
||
/// all trade_close=1. Mean=2.5, sigma≈1.118, p99≈2.5+2.326×1.118≈5.10,
|
||
/// max=4.0. Estimator picks `max(p99, max)=5.10`, then × 1.5 = 7.65.
|
||
/// Clamped to [1, 50] → 7.65 stays.
|
||
///
|
||
/// Cold-start (current = sentinel 5.0): Pearl-A REPLACES → POS=7.65,
|
||
/// NEG=-2 × 7.65 = -15.3.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn reward_cap_pearl_a_bootstrap() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// Cold-start: sentinels (matches pre-P0-A constants).
|
||
isv[REWARD_POS_CAP_ADAPTIVE_INDEX] = SENTINEL_REWARD_POS_CAP;
|
||
isv[REWARD_NEG_CAP_ADAPTIVE_INDEX] = SENTINEL_REWARD_NEG_CAP;
|
||
|
||
let step_ret: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
|
||
let trade_close: Vec<i32> = vec![1, 1, 1, 1];
|
||
|
||
let n = step_ret.len() as i32;
|
||
let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
|
||
.expect("alloc step_ret");
|
||
ret_buf.write_from_slice(&step_ret);
|
||
// i32 buffer via MappedF32Buffer — repurpose: the kernel reads
|
||
// `int*`, so we need a u32-aligned i32 slice. We use a separate
|
||
// raw allocation for the i32 close mask.
|
||
let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
|
||
.expect("alloc trade_close");
|
||
close_buf.write_from_slice(&trade_close);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }
|
||
.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_reward_cap(
|
||
&stream, &kernel,
|
||
ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
|
||
REWARD_POS_CAP_ADAPTIVE_INDEX as i32,
|
||
REWARD_NEG_CAP_ADAPTIVE_INDEX as i32,
|
||
SENTINEL_REWARD_POS_CAP, SENTINEL_REWARD_NEG_CAP,
|
||
REWARD_CAP_SAFETY_FACTOR, REWARD_NEG_TO_POS_RATIO,
|
||
REWARD_POS_CAP_MIN, REWARD_POS_CAP_MAX,
|
||
REWARD_CAP_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let pos = result[REWARD_POS_CAP_ADAPTIVE_INDEX];
|
||
let neg = result[REWARD_NEG_CAP_ADAPTIVE_INDEX];
|
||
|
||
// Pearl-A: sentinel → REPLACE with target. Target = max(p99, max) × 1.5.
|
||
// mean=2.5, var=(1+4+9+16)/4 - 6.25 = 7.5 - 6.25 = 1.25, sigma≈1.118
|
||
// p99 ≈ 2.5 + 2.326 × 1.118 ≈ 5.10. max=4.0. Picks 5.10 × 1.5 ≈ 7.65.
|
||
// Clamped to [1, 50] → stays at ~7.65.
|
||
assert!(
|
||
(pos - 7.65).abs() < 0.05,
|
||
"Pearl-A bootstrap: expected POS≈7.65, got {pos}"
|
||
);
|
||
// 2:1 asymmetry preserved at producer-time: NEG = -2 × POS.
|
||
assert!(
|
||
(neg - (-2.0 * pos)).abs() < 1e-4,
|
||
"NEG must equal -2 × POS at producer-time (Kahneman 2:1 single source of truth); got POS={pos}, NEG={neg}"
|
||
);
|
||
}
|
||
|
||
/// Test 2 — No winning trades → ISV slots preserved bit-exactly.
|
||
///
|
||
/// All trade_close=0 (no closes). Kernel must skip the EMA update
|
||
/// (count==0 guard) and leave both slots at their seeded values.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn reward_cap_no_winning_trades_preserves_isv() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
const SEED_POS: f32 = 12.5;
|
||
const SEED_NEG: f32 = -25.0;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[REWARD_POS_CAP_ADAPTIVE_INDEX] = SEED_POS;
|
||
isv[REWARD_NEG_CAP_ADAPTIVE_INDEX] = SEED_NEG;
|
||
|
||
// Step returns present but no closes — none should contribute.
|
||
let step_ret: Vec<f32> = vec![3.0, 5.0, -2.0];
|
||
let trade_close: Vec<i32> = vec![0, 0, 0];
|
||
|
||
let n = step_ret.len() as i32;
|
||
let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
|
||
.expect("alloc step_ret");
|
||
ret_buf.write_from_slice(&step_ret);
|
||
let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
|
||
.expect("alloc trade_close");
|
||
close_buf.write_from_slice(&trade_close);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }
|
||
.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_reward_cap(
|
||
&stream, &kernel,
|
||
ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
|
||
REWARD_POS_CAP_ADAPTIVE_INDEX as i32,
|
||
REWARD_NEG_CAP_ADAPTIVE_INDEX as i32,
|
||
SENTINEL_REWARD_POS_CAP, SENTINEL_REWARD_NEG_CAP,
|
||
REWARD_CAP_SAFETY_FACTOR, REWARD_NEG_TO_POS_RATIO,
|
||
REWARD_POS_CAP_MIN, REWARD_POS_CAP_MAX,
|
||
REWARD_CAP_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
assert_eq!(
|
||
result[REWARD_POS_CAP_ADAPTIVE_INDEX], SEED_POS,
|
||
"POS slot must be preserved bit-exactly when no winning closes",
|
||
);
|
||
assert_eq!(
|
||
result[REWARD_NEG_CAP_ADAPTIVE_INDEX], SEED_NEG,
|
||
"NEG slot must be preserved bit-exactly when no winning closes",
|
||
);
|
||
}
|
||
|
||
/// Test 3 — Bounds enforcement: huge winning return clamps POS to MAX=50.
|
||
///
|
||
/// Single huge winning close at 1000.0. p99 estimate ≈ 1000 + 0 = 1000,
|
||
/// max=1000, × safety_factor=1.5 = 1500. Must clamp to POS_MAX=50.
|
||
/// NEG = -2 × 50 = -100 (also clamped at the lower bound).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn reward_cap_bounds_clamp_extreme_outlier() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[REWARD_POS_CAP_ADAPTIVE_INDEX] = SENTINEL_REWARD_POS_CAP;
|
||
isv[REWARD_NEG_CAP_ADAPTIVE_INDEX] = SENTINEL_REWARD_NEG_CAP;
|
||
|
||
// Single huge winning close — would explode the cap if unclamped.
|
||
let step_ret: Vec<f32> = vec![1000.0];
|
||
let trade_close: Vec<i32> = vec![1];
|
||
|
||
let n = step_ret.len() as i32;
|
||
let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
|
||
.expect("alloc step_ret");
|
||
ret_buf.write_from_slice(&step_ret);
|
||
let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
|
||
.expect("alloc trade_close");
|
||
close_buf.write_from_slice(&trade_close);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }
|
||
.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_reward_cap(
|
||
&stream, &kernel,
|
||
ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
|
||
REWARD_POS_CAP_ADAPTIVE_INDEX as i32,
|
||
REWARD_NEG_CAP_ADAPTIVE_INDEX as i32,
|
||
SENTINEL_REWARD_POS_CAP, SENTINEL_REWARD_NEG_CAP,
|
||
REWARD_CAP_SAFETY_FACTOR, REWARD_NEG_TO_POS_RATIO,
|
||
REWARD_POS_CAP_MIN, REWARD_POS_CAP_MAX,
|
||
REWARD_CAP_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let pos = result[REWARD_POS_CAP_ADAPTIVE_INDEX];
|
||
let neg = result[REWARD_NEG_CAP_ADAPTIVE_INDEX];
|
||
|
||
// Bounds: POS clamped to MAX=50.
|
||
assert!(
|
||
(pos - REWARD_POS_CAP_MAX).abs() < 1e-4,
|
||
"POS must clamp to MAX=50 on extreme outlier; got {pos}"
|
||
);
|
||
// NEG = -2 × 50 = -100 (lower bound by construction).
|
||
let expected_neg = -REWARD_NEG_TO_POS_RATIO * REWARD_POS_CAP_MAX;
|
||
assert!(
|
||
(neg - expected_neg).abs() < 1e-4,
|
||
"NEG must clamp to -2 × MAX = {expected_neg}; got {neg}"
|
||
);
|
||
}
|
||
|
||
/// Test 4 — Welford EMA blend after bootstrap.
|
||
///
|
||
/// Pre-seed POS=10.0 (NOT sentinel — bootstrap path NOT taken).
|
||
/// Single winning close at 4.0. mean=4, sigma=0, p99=4, max=4.
|
||
/// Target = max(p99, max) × 1.5 = 4 × 1.5 = 6. Clamped to [1, 50] → 6.
|
||
/// EMA blend: (1 - α) × 10 + α × 6 with α=0.01:
|
||
/// = 0.99 × 10 + 0.01 × 6 = 9.96. NEG = -2 × 9.96 = -19.92.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn reward_cap_welford_ema_after_bootstrap() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
const SEEDED_POS: f32 = 10.0;
|
||
const SEEDED_NEG: f32 = -20.0;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// NOT sentinel — Pearl-A skipped, EMA path taken.
|
||
isv[REWARD_POS_CAP_ADAPTIVE_INDEX] = SEEDED_POS;
|
||
isv[REWARD_NEG_CAP_ADAPTIVE_INDEX] = SEEDED_NEG;
|
||
|
||
let step_ret: Vec<f32> = vec![4.0];
|
||
let trade_close: Vec<i32> = vec![1];
|
||
|
||
let n = step_ret.len() as i32;
|
||
let ret_buf = unsafe { MappedF32Buffer::new(step_ret.len()) }
|
||
.expect("alloc step_ret");
|
||
ret_buf.write_from_slice(&step_ret);
|
||
let close_buf = unsafe { MappedI32Buffer::new(trade_close.len()) }
|
||
.expect("alloc trade_close");
|
||
close_buf.write_from_slice(&trade_close);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }
|
||
.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_reward_cap(
|
||
&stream, &kernel,
|
||
ret_buf.dev_ptr, close_buf.dev_ptr, n, isv_buf.dev_ptr,
|
||
REWARD_POS_CAP_ADAPTIVE_INDEX as i32,
|
||
REWARD_NEG_CAP_ADAPTIVE_INDEX as i32,
|
||
SENTINEL_REWARD_POS_CAP, SENTINEL_REWARD_NEG_CAP,
|
||
REWARD_CAP_SAFETY_FACTOR, REWARD_NEG_TO_POS_RATIO,
|
||
REWARD_POS_CAP_MIN, REWARD_POS_CAP_MAX,
|
||
REWARD_CAP_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let pos = result[REWARD_POS_CAP_ADAPTIVE_INDEX];
|
||
let neg = result[REWARD_NEG_CAP_ADAPTIVE_INDEX];
|
||
|
||
// EMA blend: 0.99 × 10 + 0.01 × 6 = 9.96.
|
||
let expected_pos = (1.0 - REWARD_CAP_EMA_ALPHA) * SEEDED_POS
|
||
+ REWARD_CAP_EMA_ALPHA * (4.0 * REWARD_CAP_SAFETY_FACTOR);
|
||
assert!(
|
||
(pos - expected_pos).abs() < 1e-3,
|
||
"POS Welford EMA: expected {expected_pos}, got {pos}"
|
||
);
|
||
// 2:1 ratio preserved.
|
||
assert!(
|
||
(neg - (-REWARD_NEG_TO_POS_RATIO * pos)).abs() < 1e-4,
|
||
"NEG must equal -2 × POS at producer; got POS={pos}, NEG={neg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Class A P1-Producer (2026-05-08) — adaptive Bayesian Kelly priors producer
|
||
// tests.
|
||
//
|
||
// Verifies the `kelly_bayesian_priors_update_kernel` producer:
|
||
// 1. Pearl-A first-observation bootstrap: ISV at sentinel → REPLACES with
|
||
// the realized aggregated stats (no blend).
|
||
// 2. Slow EMA (α=0.005) blend after bootstrap.
|
||
// 3. Bounds enforced: counts in [0.5, 100], sums in [0.001, 1.0].
|
||
// 4. No realized trades (zero PS_KELLY_* fields) → ISV slots preserved
|
||
// bit-exactly.
|
||
//
|
||
// All tests are #[ignore = "requires GPU"]; gated under #[cfg(feature = "cuda")].
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
mod sp14_p1_kelly_priors_gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
|
||
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
KELLY_PRIOR_COUNT_MAX, KELLY_PRIOR_COUNT_MIN, KELLY_PRIOR_EMA_ALPHA,
|
||
KELLY_PRIOR_LOSSES_INDEX, KELLY_PRIOR_SUM_LOSSES_INDEX, KELLY_PRIOR_SUM_MAX,
|
||
KELLY_PRIOR_SUM_MIN, KELLY_PRIOR_SUM_WINS_INDEX, KELLY_PRIOR_WINS_INDEX,
|
||
SENTINEL_KELLY_PRIOR_LOSSES, SENTINEL_KELLY_PRIOR_SUM_LOSSES,
|
||
SENTINEL_KELLY_PRIOR_SUM_WINS, SENTINEL_KELLY_PRIOR_WINS,
|
||
};
|
||
|
||
// PS_STRIDE constant — matches state_layout.cuh::PS_STRIDE and the
|
||
// value used by `launch_kelly_cap_update`. Grown 41→43 by Plan 3 D.4c.
|
||
const PS_STRIDE: usize = 43;
|
||
// PS_KELLY_* slot offsets within a single env's portfolio_state row.
|
||
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;
|
||
|
||
const KELLY_BAYESIAN_PRIORS_UPDATE_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/kelly_bayesian_priors_update_kernel.cubin"));
|
||
|
||
fn make_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(KELLY_BAYESIAN_PRIORS_UPDATE_CUBIN.to_vec())
|
||
.expect("load kelly_bayesian_priors_update_kernel cubin");
|
||
module
|
||
.load_function("kelly_bayesian_priors_update")
|
||
.expect("load kelly_bayesian_priors_update function")
|
||
}
|
||
|
||
/// 4 arrays × 256 × 4 bytes = 4096 bytes shmem.
|
||
const BLK_DIM: u32 = 256;
|
||
const SMEM_BYTES: u32 = 4 * BLK_DIM * std::mem::size_of::<f32>() as u32;
|
||
|
||
/// Helper: launch with a fixed config.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn launch_kelly_priors(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
portfolio_state_ptr: u64,
|
||
n_envs: i32,
|
||
ps_stride: i32,
|
||
isv_ptr: u64,
|
||
wins_idx: i32,
|
||
losses_idx: i32,
|
||
sum_wins_idx: i32,
|
||
sum_losses_idx: i32,
|
||
sentinel_wins: f32,
|
||
sentinel_losses: f32,
|
||
sentinel_sum_wins: f32,
|
||
sentinel_sum_losses: f32,
|
||
count_min: f32,
|
||
count_max: f32,
|
||
sum_min: f32,
|
||
sum_max: f32,
|
||
alpha: f32,
|
||
) {
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&portfolio_state_ptr)
|
||
.arg(&n_envs)
|
||
.arg(&ps_stride)
|
||
.arg(&isv_ptr)
|
||
.arg(&wins_idx)
|
||
.arg(&losses_idx)
|
||
.arg(&sum_wins_idx)
|
||
.arg(&sum_losses_idx)
|
||
.arg(&sentinel_wins)
|
||
.arg(&sentinel_losses)
|
||
.arg(&sentinel_sum_wins)
|
||
.arg(&sentinel_sum_losses)
|
||
.arg(&count_min)
|
||
.arg(&count_max)
|
||
.arg(&sum_min)
|
||
.arg(&sum_max)
|
||
.arg(&alpha)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (BLK_DIM, 1, 1),
|
||
shared_mem_bytes: SMEM_BYTES,
|
||
})
|
||
.expect("launch kelly_bayesian_priors_update");
|
||
}
|
||
stream.synchronize().expect("sync after kelly_bayesian_priors_update");
|
||
}
|
||
|
||
/// Build a portfolio_state buffer of `n_envs * PS_STRIDE` entries with
|
||
/// the four PS_KELLY_* fields populated per env from the parallel slices.
|
||
/// All other PS_* fields are zero.
|
||
fn build_portfolio_state(
|
||
wins_per_env: &[f32],
|
||
losses_per_env: &[f32],
|
||
sum_wins_per_env: &[f32],
|
||
sum_losses_per_env: &[f32],
|
||
) -> Vec<f32> {
|
||
let n = wins_per_env.len();
|
||
assert_eq!(losses_per_env.len(), n);
|
||
assert_eq!(sum_wins_per_env.len(), n);
|
||
assert_eq!(sum_losses_per_env.len(), n);
|
||
let mut ps = vec![0.0_f32; n * PS_STRIDE];
|
||
for e in 0..n {
|
||
let row_off = e * PS_STRIDE;
|
||
ps[row_off + PS_KELLY_WIN_COUNT] = wins_per_env[e];
|
||
ps[row_off + PS_KELLY_LOSS_COUNT] = losses_per_env[e];
|
||
ps[row_off + PS_KELLY_SUM_WINS] = sum_wins_per_env[e];
|
||
ps[row_off + PS_KELLY_SUM_LOSSES] = sum_losses_per_env[e];
|
||
}
|
||
ps
|
||
}
|
||
|
||
/// Test 1 — Pearl-A first-observation bootstrap.
|
||
///
|
||
/// 4 envs with realized stats. Total wins = 1+2+3+4 = 10, losses = 0+1+0+1 = 2,
|
||
/// sum_wins = 0.05+0.10+0.15+0.20 = 0.50, sum_losses = 0+0.03+0+0.05 = 0.08.
|
||
/// Pre-EMA clamp: counts to [0.5, 100], sums to [0.001, 1.0] —
|
||
/// 10/2/0.5/0.08 all in range, pass through unchanged. Cold-start
|
||
/// (sentinels 2.0/2.0/0.01/0.01): Pearl-A REPLACES → ISV =
|
||
/// (10, 2, 0.5, 0.08).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn kelly_priors_pearl_a_bootstrap() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// Cold-start: sentinels (matches pre-P1-Producer constants).
|
||
isv[KELLY_PRIOR_WINS_INDEX] = SENTINEL_KELLY_PRIOR_WINS;
|
||
isv[KELLY_PRIOR_LOSSES_INDEX] = SENTINEL_KELLY_PRIOR_LOSSES;
|
||
isv[KELLY_PRIOR_SUM_WINS_INDEX] = SENTINEL_KELLY_PRIOR_SUM_WINS;
|
||
isv[KELLY_PRIOR_SUM_LOSSES_INDEX] = SENTINEL_KELLY_PRIOR_SUM_LOSSES;
|
||
|
||
let wins_per_env = [1.0_f32, 2.0, 3.0, 4.0];
|
||
let losses_per_env = [0.0_f32, 1.0, 0.0, 1.0];
|
||
let sum_wins_per_env = [0.05_f32, 0.10, 0.15, 0.20];
|
||
let sum_losses_per_env = [0.0_f32, 0.03, 0.0, 0.05];
|
||
let n_envs = wins_per_env.len();
|
||
|
||
let ps = build_portfolio_state(&wins_per_env, &losses_per_env,
|
||
&sum_wins_per_env, &sum_losses_per_env);
|
||
|
||
let ps_buf = unsafe { MappedF32Buffer::new(ps.len()) }.expect("alloc ps");
|
||
ps_buf.write_from_slice(&ps);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_kelly_priors(
|
||
&stream, &kernel,
|
||
ps_buf.dev_ptr, n_envs as i32, PS_STRIDE as i32, isv_buf.dev_ptr,
|
||
KELLY_PRIOR_WINS_INDEX as i32,
|
||
KELLY_PRIOR_LOSSES_INDEX as i32,
|
||
KELLY_PRIOR_SUM_WINS_INDEX as i32,
|
||
KELLY_PRIOR_SUM_LOSSES_INDEX as i32,
|
||
SENTINEL_KELLY_PRIOR_WINS, SENTINEL_KELLY_PRIOR_LOSSES,
|
||
SENTINEL_KELLY_PRIOR_SUM_WINS, SENTINEL_KELLY_PRIOR_SUM_LOSSES,
|
||
KELLY_PRIOR_COUNT_MIN, KELLY_PRIOR_COUNT_MAX,
|
||
KELLY_PRIOR_SUM_MIN, KELLY_PRIOR_SUM_MAX,
|
||
KELLY_PRIOR_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let pw = result[KELLY_PRIOR_WINS_INDEX];
|
||
let pl = result[KELLY_PRIOR_LOSSES_INDEX];
|
||
let psw = result[KELLY_PRIOR_SUM_WINS_INDEX];
|
||
let psl = result[KELLY_PRIOR_SUM_LOSSES_INDEX];
|
||
|
||
// Pearl-A: sentinel → REPLACE with target. Targets are the
|
||
// aggregated totals clamped to dimensional-safety bounds (all
|
||
// within range here, no clamping applied).
|
||
assert!((pw - 10.0).abs() < 1e-4,
|
||
"Pearl-A bootstrap: prior_wins expected 10.0, got {pw}");
|
||
assert!((pl - 2.0).abs() < 1e-4,
|
||
"Pearl-A bootstrap: prior_losses expected 2.0, got {pl}");
|
||
assert!((psw - 0.50).abs() < 1e-4,
|
||
"Pearl-A bootstrap: prior_sum_wins expected 0.50, got {psw}");
|
||
assert!((psl - 0.08).abs() < 1e-4,
|
||
"Pearl-A bootstrap: prior_sum_losses expected 0.08, got {psl}");
|
||
}
|
||
|
||
/// Test 2 — No realized trades → ISV slots preserved bit-exactly.
|
||
///
|
||
/// All envs have zero PS_KELLY_* fields → total_wins + total_losses == 0.
|
||
/// Kernel guard skips the EMA update; both slots stay at the seeded
|
||
/// values (NOT sentinels — to verify the guard preserves arbitrary
|
||
/// pre-existing state).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn kelly_priors_no_realized_trades_preserves_isv() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
// Seed with non-sentinel values to verify they survive the guard.
|
||
const SEED_WINS: f32 = 7.0;
|
||
const SEED_LOSSES: f32 = 5.0;
|
||
const SEED_SUM_WINS: f32 = 0.25;
|
||
const SEED_SUM_LOSSES: f32 = 0.15;
|
||
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[KELLY_PRIOR_WINS_INDEX] = SEED_WINS;
|
||
isv[KELLY_PRIOR_LOSSES_INDEX] = SEED_LOSSES;
|
||
isv[KELLY_PRIOR_SUM_WINS_INDEX] = SEED_SUM_WINS;
|
||
isv[KELLY_PRIOR_SUM_LOSSES_INDEX] = SEED_SUM_LOSSES;
|
||
|
||
// 3 envs with no realized trades.
|
||
let n_envs: usize = 3;
|
||
let ps = vec![0.0_f32; n_envs * PS_STRIDE];
|
||
|
||
let ps_buf = unsafe { MappedF32Buffer::new(ps.len()) }.expect("alloc ps");
|
||
ps_buf.write_from_slice(&ps);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_kelly_priors(
|
||
&stream, &kernel,
|
||
ps_buf.dev_ptr, n_envs as i32, PS_STRIDE as i32, isv_buf.dev_ptr,
|
||
KELLY_PRIOR_WINS_INDEX as i32,
|
||
KELLY_PRIOR_LOSSES_INDEX as i32,
|
||
KELLY_PRIOR_SUM_WINS_INDEX as i32,
|
||
KELLY_PRIOR_SUM_LOSSES_INDEX as i32,
|
||
SENTINEL_KELLY_PRIOR_WINS, SENTINEL_KELLY_PRIOR_LOSSES,
|
||
SENTINEL_KELLY_PRIOR_SUM_WINS, SENTINEL_KELLY_PRIOR_SUM_LOSSES,
|
||
KELLY_PRIOR_COUNT_MIN, KELLY_PRIOR_COUNT_MAX,
|
||
KELLY_PRIOR_SUM_MIN, KELLY_PRIOR_SUM_MAX,
|
||
KELLY_PRIOR_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
assert_eq!(result[KELLY_PRIOR_WINS_INDEX], SEED_WINS,
|
||
"prior_wins must be preserved bit-exactly when no realized trades");
|
||
assert_eq!(result[KELLY_PRIOR_LOSSES_INDEX], SEED_LOSSES,
|
||
"prior_losses must be preserved bit-exactly when no realized trades");
|
||
assert_eq!(result[KELLY_PRIOR_SUM_WINS_INDEX], SEED_SUM_WINS,
|
||
"prior_sum_wins must be preserved bit-exactly when no realized trades");
|
||
assert_eq!(result[KELLY_PRIOR_SUM_LOSSES_INDEX], SEED_SUM_LOSSES,
|
||
"prior_sum_losses must be preserved bit-exactly when no realized trades");
|
||
}
|
||
|
||
/// Test 3 — Bounds enforcement: extreme aggregate clamps to upper/lower bounds.
|
||
///
|
||
/// One env with huge counts (1e6 wins, 1e6 losses) and tiny sums (1e-9).
|
||
/// Pre-EMA clamp: wins/losses → 100 (count_max), sums → 0.001 (sum_min).
|
||
/// Cold-start (sentinels): Pearl-A REPLACES with clamped targets.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn kelly_priors_bounds_clamp_extreme() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[KELLY_PRIOR_WINS_INDEX] = SENTINEL_KELLY_PRIOR_WINS;
|
||
isv[KELLY_PRIOR_LOSSES_INDEX] = SENTINEL_KELLY_PRIOR_LOSSES;
|
||
isv[KELLY_PRIOR_SUM_WINS_INDEX] = SENTINEL_KELLY_PRIOR_SUM_WINS;
|
||
isv[KELLY_PRIOR_SUM_LOSSES_INDEX] = SENTINEL_KELLY_PRIOR_SUM_LOSSES;
|
||
|
||
// Single env with extreme aggregates.
|
||
let wins_per_env = [1.0e6_f32];
|
||
let losses_per_env = [1.0e6_f32];
|
||
let sum_wins_per_env = [1.0e-9_f32];
|
||
let sum_losses_per_env = [1.0e-9_f32];
|
||
|
||
let ps = build_portfolio_state(&wins_per_env, &losses_per_env,
|
||
&sum_wins_per_env, &sum_losses_per_env);
|
||
|
||
let ps_buf = unsafe { MappedF32Buffer::new(ps.len()) }.expect("alloc ps");
|
||
ps_buf.write_from_slice(&ps);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_kelly_priors(
|
||
&stream, &kernel,
|
||
ps_buf.dev_ptr, 1, PS_STRIDE as i32, isv_buf.dev_ptr,
|
||
KELLY_PRIOR_WINS_INDEX as i32,
|
||
KELLY_PRIOR_LOSSES_INDEX as i32,
|
||
KELLY_PRIOR_SUM_WINS_INDEX as i32,
|
||
KELLY_PRIOR_SUM_LOSSES_INDEX as i32,
|
||
SENTINEL_KELLY_PRIOR_WINS, SENTINEL_KELLY_PRIOR_LOSSES,
|
||
SENTINEL_KELLY_PRIOR_SUM_WINS, SENTINEL_KELLY_PRIOR_SUM_LOSSES,
|
||
KELLY_PRIOR_COUNT_MIN, KELLY_PRIOR_COUNT_MAX,
|
||
KELLY_PRIOR_SUM_MIN, KELLY_PRIOR_SUM_MAX,
|
||
KELLY_PRIOR_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let pw = result[KELLY_PRIOR_WINS_INDEX];
|
||
let pl = result[KELLY_PRIOR_LOSSES_INDEX];
|
||
let psw = result[KELLY_PRIOR_SUM_WINS_INDEX];
|
||
let psl = result[KELLY_PRIOR_SUM_LOSSES_INDEX];
|
||
|
||
// 1e6 wins → clamped to count_max=100.
|
||
assert!((pw - KELLY_PRIOR_COUNT_MAX).abs() < 1e-4,
|
||
"prior_wins must clamp to count_max=100 on extreme; got {pw}");
|
||
assert!((pl - KELLY_PRIOR_COUNT_MAX).abs() < 1e-4,
|
||
"prior_losses must clamp to count_max=100 on extreme; got {pl}");
|
||
// 1e-9 sums → clamped to sum_min=0.001.
|
||
assert!((psw - KELLY_PRIOR_SUM_MIN).abs() < 1e-6,
|
||
"prior_sum_wins must clamp to sum_min=0.001 on tiny; got {psw}");
|
||
assert!((psl - KELLY_PRIOR_SUM_MIN).abs() < 1e-6,
|
||
"prior_sum_losses must clamp to sum_min=0.001 on tiny; got {psl}");
|
||
}
|
||
|
||
/// Test 4 — Slow EMA blend after bootstrap (α=0.005).
|
||
///
|
||
/// Pre-seed prior_wins=10.0 (NOT sentinel — bootstrap path NOT taken).
|
||
/// Single env with 5 wins. Target = 5 (in range). EMA blend:
|
||
/// (1 - 0.005) × 10 + 0.005 × 5 = 0.995 × 10 + 0.025 = 9.975.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn kelly_priors_slow_ema_after_bootstrap() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
const SEEDED_WINS: f32 = 10.0;
|
||
// Other slots stay at sentinel — only test EMA on prior_wins.
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[KELLY_PRIOR_WINS_INDEX] = SEEDED_WINS;
|
||
isv[KELLY_PRIOR_LOSSES_INDEX] = SENTINEL_KELLY_PRIOR_LOSSES;
|
||
isv[KELLY_PRIOR_SUM_WINS_INDEX] = SENTINEL_KELLY_PRIOR_SUM_WINS;
|
||
isv[KELLY_PRIOR_SUM_LOSSES_INDEX] = SENTINEL_KELLY_PRIOR_SUM_LOSSES;
|
||
|
||
// Single env with 5 wins.
|
||
let wins_per_env = [5.0_f32];
|
||
let losses_per_env = [1.0_f32];
|
||
let sum_wins_per_env = [0.10_f32];
|
||
let sum_losses_per_env = [0.02_f32];
|
||
|
||
let ps = build_portfolio_state(&wins_per_env, &losses_per_env,
|
||
&sum_wins_per_env, &sum_losses_per_env);
|
||
|
||
let ps_buf = unsafe { MappedF32Buffer::new(ps.len()) }.expect("alloc ps");
|
||
ps_buf.write_from_slice(&ps);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_kelly_priors(
|
||
&stream, &kernel,
|
||
ps_buf.dev_ptr, 1, PS_STRIDE as i32, isv_buf.dev_ptr,
|
||
KELLY_PRIOR_WINS_INDEX as i32,
|
||
KELLY_PRIOR_LOSSES_INDEX as i32,
|
||
KELLY_PRIOR_SUM_WINS_INDEX as i32,
|
||
KELLY_PRIOR_SUM_LOSSES_INDEX as i32,
|
||
SENTINEL_KELLY_PRIOR_WINS, SENTINEL_KELLY_PRIOR_LOSSES,
|
||
SENTINEL_KELLY_PRIOR_SUM_WINS, SENTINEL_KELLY_PRIOR_SUM_LOSSES,
|
||
KELLY_PRIOR_COUNT_MIN, KELLY_PRIOR_COUNT_MAX,
|
||
KELLY_PRIOR_SUM_MIN, KELLY_PRIOR_SUM_MAX,
|
||
KELLY_PRIOR_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let pw = result[KELLY_PRIOR_WINS_INDEX];
|
||
// EMA: 0.995 × 10 + 0.005 × 5 = 9.975.
|
||
let expected = (1.0_f32 - KELLY_PRIOR_EMA_ALPHA) * SEEDED_WINS
|
||
+ KELLY_PRIOR_EMA_ALPHA * 5.0_f32;
|
||
assert!(
|
||
(pw - expected).abs() < 1e-4,
|
||
"Slow EMA blend (α={KELLY_PRIOR_EMA_ALPHA}): expected {expected}, got {pw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Test B.8: Layout-fingerprint regression for direction Q-head input bump.
|
||
//
|
||
// CPU-only test (no GPU required). Ensures the fingerprint hash changed when
|
||
// `w_b0fc`'s input dim grew from `SH2` to `SH2+1`, per Invariant 8 of the
|
||
// SP14 plan: any structural reshuffle MUST bump
|
||
// `LAYOUT_FINGERPRINT_CURRENT` so old checkpoints fail-fast on load instead
|
||
// of silently mapping into the new architecture. The bump is achieved by
|
||
// renaming the seed entry `PARAM_W_B0FC` → `PARAM_W_B0FC_AUX1`. Per
|
||
// `feedback_no_legacy_aliases`, no `_DEPRECATED` shim is added.
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// Inline FNV-1a 64-bit hash for the regression test. Mirrors the
|
||
/// `const fn fnv1a_64` in `gpu_dqn_trainer.rs` so the test computes
|
||
/// the same hash the production fingerprint uses. (Mirrors the
|
||
/// equivalent helper in `sp13_layer_b_oracle_tests.rs`.)
|
||
const fn fnv1a_64(bytes: &[u8]) -> u64 {
|
||
const OFFSET: u64 = 0xcbf29ce484222325;
|
||
const PRIME: u64 = 0x00000100000001b3;
|
||
let mut h: u64 = OFFSET;
|
||
let mut i = 0;
|
||
while i < bytes.len() {
|
||
h ^= bytes[i] as u64;
|
||
h = h.wrapping_mul(PRIME);
|
||
i += 1;
|
||
}
|
||
h
|
||
}
|
||
|
||
/// Pre-B.8 seed — hashing this constant gives the pre-B.8 fingerprint
|
||
/// that the bump must differ from. The test asserts the current
|
||
/// fingerprint is distinct from this baseline; SP14 Layer C Phase C.1
|
||
/// (2026-05-08) further shifted the fingerprint by deleting α-machinery
|
||
/// slot entries and adding aux-trunk slots, so the inequality holds even
|
||
/// more strongly post-C.1.
|
||
const PRE_B8_SEED: &[u8] = b"PRE_B8_SEED_PLACEHOLDER_LEGACY_PARAM_W_B0FC";
|
||
|
||
#[test]
|
||
fn layout_fingerprint_bumps_after_sp14_wire() {
|
||
use ml::cuda_pipeline::gpu_dqn_trainer::LAYOUT_FINGERPRINT_CURRENT;
|
||
let pre_b8_fp = fnv1a_64(PRE_B8_SEED);
|
||
assert_ne!(
|
||
LAYOUT_FINGERPRINT_CURRENT, pre_b8_fp,
|
||
"B.8 fingerprint must differ from pre-B.8 placeholder value. \
|
||
current = {:#018x}, pre-B.8 placeholder = {:#018x}",
|
||
LAYOUT_FINGERPRINT_CURRENT, pre_b8_fp
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Class A audit-fix Batch 4-A (2026-05-08) — adaptive DD saturation floor
|
||
// producer tests.
|
||
//
|
||
// Verifies the `dd_saturation_floor_update_kernel` producer:
|
||
// 1. Cold-start Pearl-A bootstrap: ISV stays at sentinel until first valid
|
||
// observation, then REPLACES (no blend).
|
||
// 2. No-DD guard: when all envs have DD_MAX=0, slot is preserved bit-exactly.
|
||
// 3. Bounds enforced: floor in [0.10, 0.50].
|
||
// 4. Welford slow EMA after bootstrap (α=0.01).
|
||
//
|
||
// All tests are #[ignore = "requires GPU"]; gated under #[cfg(feature = "cuda")].
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
mod sp14_audit_4a_dd_saturation_floor_gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
|
||
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
DD_SATURATION_FLOOR_ADAPTIVE_INDEX, DD_SATURATION_FLOOR_EMA_ALPHA,
|
||
DD_SATURATION_FLOOR_MAX, DD_SATURATION_FLOOR_MIN,
|
||
DD_SATURATION_FLOOR_SAFETY_FACTOR, SENTINEL_DD_SATURATION_FLOOR,
|
||
};
|
||
|
||
const DD_SATURATION_FLOOR_UPDATE_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/dd_saturation_floor_update_kernel.cubin"));
|
||
|
||
/// Per-env DD state tile width (mirrors `dd_state_kernel` layout in
|
||
/// `dd_state_kernel.cu`: DD_CURRENT, DD_MAX, DD_RECOVERY_BARS,
|
||
/// DD_PERSISTENCE, CALMAR, DD_PCT).
|
||
const DD_STATE_STRIDE: i32 = 6;
|
||
/// DD_MAX offset within each env's tile.
|
||
const DD_MAX_OFF: i32 = 1;
|
||
|
||
fn make_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(DD_SATURATION_FLOOR_UPDATE_CUBIN.to_vec())
|
||
.expect("load dd_saturation_floor_update_kernel cubin");
|
||
module
|
||
.load_function("dd_saturation_floor_update")
|
||
.expect("load dd_saturation_floor_update function")
|
||
}
|
||
|
||
/// 4 arrays × 256 × 4 bytes = 4096 bytes shmem.
|
||
const BLK_DIM: u32 = 256;
|
||
const SMEM_BYTES: u32 = 4 * BLK_DIM * std::mem::size_of::<f32>() as u32;
|
||
|
||
/// Helper: launch with a fixed config.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn launch_dd_floor(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
dd_state_ptr: u64,
|
||
n_envs: i32,
|
||
dd_state_stride: i32,
|
||
dd_max_off: i32,
|
||
isv_ptr: u64,
|
||
floor_idx: i32,
|
||
sentinel_floor: f32,
|
||
safety_factor: f32,
|
||
floor_min: f32,
|
||
floor_max: f32,
|
||
alpha: f32,
|
||
) {
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&dd_state_ptr)
|
||
.arg(&n_envs)
|
||
.arg(&dd_state_stride)
|
||
.arg(&dd_max_off)
|
||
.arg(&isv_ptr)
|
||
.arg(&floor_idx)
|
||
.arg(&sentinel_floor)
|
||
.arg(&safety_factor)
|
||
.arg(&floor_min)
|
||
.arg(&floor_max)
|
||
.arg(&alpha)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (BLK_DIM, 1, 1),
|
||
shared_mem_bytes: SMEM_BYTES,
|
||
})
|
||
.expect("launch dd_saturation_floor_update");
|
||
}
|
||
stream.synchronize().expect("sync after dd_saturation_floor_update");
|
||
}
|
||
|
||
/// Build a per-env DD state tile [n_envs * 6] with the given DD_MAX
|
||
/// values at offset 1; other offsets zeroed.
|
||
fn build_dd_state_tile(dd_max_per_env: &[f32]) -> Vec<f32> {
|
||
let n_envs = dd_max_per_env.len();
|
||
let mut tile = vec![0.0_f32; n_envs * DD_STATE_STRIDE as usize];
|
||
for (env, dd_max) in dd_max_per_env.iter().enumerate() {
|
||
tile[env * DD_STATE_STRIDE as usize + DD_MAX_OFF as usize] = *dd_max;
|
||
}
|
||
tile
|
||
}
|
||
|
||
/// Test 1 — Pearl-A first-observation bootstrap.
|
||
///
|
||
/// 4 envs with DD_MAX=[0.10, 0.15, 0.20, 0.25]. mean=0.175, var=0.003125,
|
||
/// sigma≈0.0559. p75 ≈ 0.175 + 0.6745 × 0.0559 ≈ 0.213. Robust target
|
||
/// = max(0.213, 0.175) = 0.213. × safety_factor=1.5 = 0.3194. Clamped to
|
||
/// [0.10, 0.50] — stays at 0.3194.
|
||
///
|
||
/// Cold-start (current = sentinel 0.25): Pearl-A REPLACES → 0.3194.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn dd_floor_pearl_a_bootstrap() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// Cold-start: sentinel matches pre-fix hardcoded value.
|
||
isv[DD_SATURATION_FLOOR_ADAPTIVE_INDEX] = SENTINEL_DD_SATURATION_FLOOR;
|
||
|
||
let dd_max: [f32; 4] = [0.10, 0.15, 0.20, 0.25];
|
||
let n_envs = dd_max.len() as i32;
|
||
let tile = build_dd_state_tile(&dd_max);
|
||
|
||
let tile_buf = unsafe { MappedF32Buffer::new(tile.len()) }.expect("alloc tile");
|
||
tile_buf.write_from_slice(&tile);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_dd_floor(
|
||
&stream, &kernel,
|
||
tile_buf.dev_ptr, n_envs, DD_STATE_STRIDE, DD_MAX_OFF,
|
||
isv_buf.dev_ptr, DD_SATURATION_FLOOR_ADAPTIVE_INDEX as i32,
|
||
SENTINEL_DD_SATURATION_FLOOR, DD_SATURATION_FLOOR_SAFETY_FACTOR,
|
||
DD_SATURATION_FLOOR_MIN, DD_SATURATION_FLOOR_MAX,
|
||
DD_SATURATION_FLOOR_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let floor = result[DD_SATURATION_FLOOR_ADAPTIVE_INDEX];
|
||
|
||
// Pearl-A: sentinel → REPLACE with target. Target = max(p75, mean) × 1.5.
|
||
// mean=0.175, var=(0.01+0.0225+0.04+0.0625)/4 - 0.175² = 0.0337/4-...
|
||
// sumsq = 0.01+0.0225+0.04+0.0625 = 0.135, sumsq/n = 0.03375
|
||
// variance = 0.03375 - 0.030625 = 0.003125, sigma ≈ 0.0559
|
||
// p75 ≈ 0.175 + 0.6745 × 0.0559 ≈ 0.2127. max(p75, mean)=0.2127.
|
||
// × 1.5 = 0.319. Clamped to [0.10, 0.50] → stays at ~0.319.
|
||
assert!(
|
||
(floor - 0.319).abs() < 0.01,
|
||
"Pearl-A bootstrap: expected floor≈0.319, got {floor}"
|
||
);
|
||
}
|
||
|
||
/// Test 2 — No DD observed → ISV slot preserved bit-exactly.
|
||
///
|
||
/// All envs have DD_MAX=0 (no DD observed yet). Kernel must skip the
|
||
/// EMA update (count==0 guard) and leave the slot at its seeded value.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn dd_floor_no_dd_preserves_isv() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
const SEED: f32 = 0.37;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[DD_SATURATION_FLOOR_ADAPTIVE_INDEX] = SEED;
|
||
|
||
// All envs at DD_MAX=0.0 — no DD observed.
|
||
let dd_max: [f32; 8] = [0.0; 8];
|
||
let n_envs = dd_max.len() as i32;
|
||
let tile = build_dd_state_tile(&dd_max);
|
||
|
||
let tile_buf = unsafe { MappedF32Buffer::new(tile.len()) }.expect("alloc tile");
|
||
tile_buf.write_from_slice(&tile);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_dd_floor(
|
||
&stream, &kernel,
|
||
tile_buf.dev_ptr, n_envs, DD_STATE_STRIDE, DD_MAX_OFF,
|
||
isv_buf.dev_ptr, DD_SATURATION_FLOOR_ADAPTIVE_INDEX as i32,
|
||
SENTINEL_DD_SATURATION_FLOOR, DD_SATURATION_FLOOR_SAFETY_FACTOR,
|
||
DD_SATURATION_FLOOR_MIN, DD_SATURATION_FLOOR_MAX,
|
||
DD_SATURATION_FLOOR_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
assert_eq!(
|
||
result[DD_SATURATION_FLOOR_ADAPTIVE_INDEX], SEED,
|
||
"DD floor slot must be preserved bit-exactly when no DD observed",
|
||
);
|
||
}
|
||
|
||
/// Test 3 — Bounds enforcement: huge DD_MAX clamps floor to MAX=0.50.
|
||
///
|
||
/// 2 envs with DD_MAX=[0.80, 0.90] — both well above the saturation
|
||
/// floor MAX. Target = max(p75, mean) × 1.5 ≈ 0.85 × 1.5 = 1.275 → clamps
|
||
/// to 0.50.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn dd_floor_bounds_clamp_extreme_dd() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[DD_SATURATION_FLOOR_ADAPTIVE_INDEX] = SENTINEL_DD_SATURATION_FLOOR;
|
||
|
||
let dd_max: [f32; 2] = [0.80, 0.90];
|
||
let n_envs = dd_max.len() as i32;
|
||
let tile = build_dd_state_tile(&dd_max);
|
||
|
||
let tile_buf = unsafe { MappedF32Buffer::new(tile.len()) }.expect("alloc tile");
|
||
tile_buf.write_from_slice(&tile);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_dd_floor(
|
||
&stream, &kernel,
|
||
tile_buf.dev_ptr, n_envs, DD_STATE_STRIDE, DD_MAX_OFF,
|
||
isv_buf.dev_ptr, DD_SATURATION_FLOOR_ADAPTIVE_INDEX as i32,
|
||
SENTINEL_DD_SATURATION_FLOOR, DD_SATURATION_FLOOR_SAFETY_FACTOR,
|
||
DD_SATURATION_FLOOR_MIN, DD_SATURATION_FLOOR_MAX,
|
||
DD_SATURATION_FLOOR_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let floor = result[DD_SATURATION_FLOOR_ADAPTIVE_INDEX];
|
||
|
||
// Pearl-A: sentinel → REPLACE with clamped target. Should hit MAX=0.50.
|
||
assert!(
|
||
(floor - DD_SATURATION_FLOOR_MAX).abs() < 1e-5,
|
||
"DD floor must clamp to MAX=0.50 on extreme DD; got {floor}"
|
||
);
|
||
}
|
||
|
||
/// Test 4 — Welford slow EMA after bootstrap.
|
||
///
|
||
/// Seed slot at 0.30 (NOT sentinel — past the bootstrap). Run with
|
||
/// 4 envs at DD_MAX=[0.10, 0.10, 0.10, 0.10] (mean=0.10, sigma=0).
|
||
/// Robust target = max(0.10+0×Z_75, 0.10) × 1.5 = 0.15. Clamped to
|
||
/// [0.10, 0.50] → stays at 0.15.
|
||
/// EMA blend: blended = (1-0.01)×0.30 + 0.01×0.15 = 0.297 + 0.0015 = 0.2985.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn dd_floor_welford_ema_after_bootstrap() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
const SEED: f32 = 0.30; // NOT sentinel — Pearl-A guard fires α-blend.
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[DD_SATURATION_FLOOR_ADAPTIVE_INDEX] = SEED;
|
||
|
||
let dd_max: [f32; 4] = [0.10, 0.10, 0.10, 0.10];
|
||
let n_envs = dd_max.len() as i32;
|
||
let tile = build_dd_state_tile(&dd_max);
|
||
|
||
let tile_buf = unsafe { MappedF32Buffer::new(tile.len()) }.expect("alloc tile");
|
||
tile_buf.write_from_slice(&tile);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_dd_floor(
|
||
&stream, &kernel,
|
||
tile_buf.dev_ptr, n_envs, DD_STATE_STRIDE, DD_MAX_OFF,
|
||
isv_buf.dev_ptr, DD_SATURATION_FLOOR_ADAPTIVE_INDEX as i32,
|
||
SENTINEL_DD_SATURATION_FLOOR, DD_SATURATION_FLOOR_SAFETY_FACTOR,
|
||
DD_SATURATION_FLOOR_MIN, DD_SATURATION_FLOOR_MAX,
|
||
DD_SATURATION_FLOOR_EMA_ALPHA,
|
||
);
|
||
|
||
let result = isv_buf.read_all();
|
||
let floor = result[DD_SATURATION_FLOOR_ADAPTIVE_INDEX];
|
||
|
||
// EMA blend: (1-0.01) × 0.30 + 0.01 × 0.15 = 0.2985.
|
||
let expected = (1.0_f32 - DD_SATURATION_FLOOR_EMA_ALPHA) * SEED
|
||
+ DD_SATURATION_FLOOR_EMA_ALPHA * 0.15;
|
||
assert!(
|
||
(floor - expected).abs() < 1e-4,
|
||
"Welford EMA blend: expected ≈ {expected:.5}, got {floor:.5}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Class A audit-fix Batch 4-B (2026-05-08, Item 3) — adaptive plan_threshold
|
||
// floor inline producer (Design Y inside `plan_threshold_update_kernel`).
|
||
//
|
||
// Verifies:
|
||
// 1. Pearl-A bootstrap: sentinel 0.1 → REPLACE with `0.5 × ema`.
|
||
// 2. Slow EMA (α=0.005) blend after bootstrap.
|
||
// 3. Bilateral clamp: huge readiness clamps floor to MAX=0.50.
|
||
//
|
||
// All tests are #[ignore = "requires GPU"]; gated under #[cfg(feature = "cuda")].
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
mod sp14_audit_4b_plan_threshold_floor_gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
|
||
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
PLAN_THRESHOLD_FLOOR_ADAPTIVE_INDEX, PLAN_THRESHOLD_FLOOR_EMA_ALPHA,
|
||
PLAN_THRESHOLD_FLOOR_MAX, PLAN_THRESHOLD_FLOOR_MIN, SENTINEL_PLAN_THRESHOLD_FLOOR,
|
||
};
|
||
|
||
const PLAN_THRESHOLD_UPDATE_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/plan_threshold_update_kernel.cubin"));
|
||
|
||
/// ISV slot indices used by the kernel (mirror gpu_dqn_trainer.rs).
|
||
const READINESS_EMA_INDEX: usize = 75;
|
||
const PLAN_THRESHOLD_INDEX: usize = 49;
|
||
const SHARPE_EMA_INDEX: usize = 22;
|
||
const ALPHA_BASE: f32 = 0.05;
|
||
|
||
fn make_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(PLAN_THRESHOLD_UPDATE_CUBIN.to_vec())
|
||
.expect("load plan_threshold_update_kernel cubin");
|
||
module
|
||
.load_function("plan_threshold_update")
|
||
.expect("load plan_threshold_update function")
|
||
}
|
||
|
||
fn launch_plan_threshold(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
readiness_ptr: u64,
|
||
n_samples: i32,
|
||
isv_ptr: u64,
|
||
) {
|
||
let readiness_slot = READINESS_EMA_INDEX as i32;
|
||
let thr_slot = PLAN_THRESHOLD_INDEX as i32;
|
||
let sharpe_slot = SHARPE_EMA_INDEX as i32;
|
||
let alpha_base = ALPHA_BASE;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&readiness_ptr)
|
||
.arg(&n_samples)
|
||
.arg(&isv_ptr)
|
||
.arg(&readiness_slot)
|
||
.arg(&thr_slot)
|
||
.arg(&sharpe_slot)
|
||
.arg(&alpha_base)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("launch plan_threshold_update");
|
||
}
|
||
stream.synchronize().expect("sync after plan_threshold_update");
|
||
}
|
||
|
||
/// Test 1 — Pearl-A first-observation bootstrap.
|
||
///
|
||
/// Seed `readiness_ema` at 0.4 and `floor` at sentinel 0.1; readiness
|
||
/// samples uniform 0.5 → ema converges toward 0.5. The new
|
||
/// readiness_ema after one launch with 4 samples of 0.5 and α=0.05
|
||
/// (sharpe=0): ema = 0.95 × 0.4 + 0.05 × 0.5 = 0.405. Threshold
|
||
/// target = 0.5 × 0.405 = 0.2025. Pearl-A: floor at sentinel 0.1
|
||
/// → REPLACE with 0.2025 directly. Bilateral clamp [0.05, 0.50] →
|
||
/// stays at 0.2025.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn plan_threshold_floor_pearl_a_bootstrap() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// Seed readiness_ema at 0.4 (NOT sentinel — past readiness bootstrap).
|
||
isv[READINESS_EMA_INDEX] = 0.4;
|
||
// Seed floor at sentinel 0.1.
|
||
isv[PLAN_THRESHOLD_FLOOR_ADAPTIVE_INDEX] = SENTINEL_PLAN_THRESHOLD_FLOOR;
|
||
// Sharpe = 0 → adaptive α stays at α_base.
|
||
isv[SHARPE_EMA_INDEX] = 0.0;
|
||
|
||
let readiness: [f32; 4] = [0.5, 0.5, 0.5, 0.5];
|
||
let n_samples = readiness.len() as i32;
|
||
|
||
let r_buf = unsafe { MappedF32Buffer::new(readiness.len()) }.expect("alloc readiness");
|
||
r_buf.write_from_slice(&readiness);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_plan_threshold(&stream, &kernel, r_buf.dev_ptr, n_samples, isv_buf.dev_ptr);
|
||
|
||
let result = isv_buf.read_all();
|
||
let floor = result[PLAN_THRESHOLD_FLOOR_ADAPTIVE_INDEX];
|
||
|
||
// ema = 0.95 × 0.4 + 0.05 × 0.5 = 0.405; target = 0.5 × 0.405 = 0.2025.
|
||
let expected_ema = (1.0_f32 - ALPHA_BASE) * 0.4 + ALPHA_BASE * 0.5;
|
||
let expected_target = 0.5 * expected_ema;
|
||
assert!(
|
||
(floor - expected_target).abs() < 1e-4,
|
||
"Pearl-A bootstrap: expected floor ≈ {expected_target:.5}, got {floor:.5}"
|
||
);
|
||
}
|
||
|
||
/// Test 2 — Slow EMA after bootstrap.
|
||
///
|
||
/// Seed floor at 0.30 (NOT sentinel), readiness_ema at 0.20.
|
||
/// Readiness samples uniform 0.20 → ema stays at 0.20. Threshold
|
||
/// target = 0.5 × 0.20 = 0.10. Floor blend:
|
||
/// blended = (1 − 0.005) × 0.30 + 0.005 × 0.10 = 0.299.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn plan_threshold_floor_slow_ema_after_bootstrap() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
const SEED_FLOOR: f32 = 0.30;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// Seed readiness_ema at 0.20 (past bootstrap).
|
||
isv[READINESS_EMA_INDEX] = 0.20;
|
||
// Seed floor at 0.30 — Pearl-A guard fires α-blend (NOT sentinel).
|
||
isv[PLAN_THRESHOLD_FLOOR_ADAPTIVE_INDEX] = SEED_FLOOR;
|
||
isv[SHARPE_EMA_INDEX] = 0.0;
|
||
|
||
let readiness: [f32; 4] = [0.20, 0.20, 0.20, 0.20];
|
||
let n_samples = readiness.len() as i32;
|
||
|
||
let r_buf = unsafe { MappedF32Buffer::new(readiness.len()) }.expect("alloc readiness");
|
||
r_buf.write_from_slice(&readiness);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_plan_threshold(&stream, &kernel, r_buf.dev_ptr, n_samples, isv_buf.dev_ptr);
|
||
|
||
let result = isv_buf.read_all();
|
||
let floor = result[PLAN_THRESHOLD_FLOOR_ADAPTIVE_INDEX];
|
||
|
||
// ema = 0.95 × 0.20 + 0.05 × 0.20 = 0.20 (unchanged).
|
||
// target = 0.5 × 0.20 = 0.10.
|
||
// blended = (1 − 0.005) × 0.30 + 0.005 × 0.10 = 0.2990.
|
||
let new_ema = (1.0_f32 - ALPHA_BASE) * 0.20 + ALPHA_BASE * 0.20;
|
||
let target = 0.5 * new_ema;
|
||
let expected = (1.0_f32 - PLAN_THRESHOLD_FLOOR_EMA_ALPHA) * SEED_FLOOR
|
||
+ PLAN_THRESHOLD_FLOOR_EMA_ALPHA * target;
|
||
assert!(
|
||
(floor - expected).abs() < 1e-4,
|
||
"Slow EMA blend: expected ≈ {expected:.5}, got {floor:.5}"
|
||
);
|
||
}
|
||
|
||
/// Test 3 — Bilateral clamp on extreme readiness.
|
||
///
|
||
/// Seed readiness_ema at 1.5 (out-of-spec but defends against
|
||
/// malformed prior state). After one launch with readiness=1.5,
|
||
/// new ema ≈ 1.5; target = 0.75 → clamps to MAX=0.50. Pearl-A:
|
||
/// floor was at sentinel 0.1 → REPLACES with clamped 0.50.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn plan_threshold_floor_bounds_clamp_extreme_readiness() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[READINESS_EMA_INDEX] = 1.5; // out-of-spec to force clamp
|
||
isv[PLAN_THRESHOLD_FLOOR_ADAPTIVE_INDEX] = SENTINEL_PLAN_THRESHOLD_FLOOR;
|
||
isv[SHARPE_EMA_INDEX] = 0.0;
|
||
|
||
let readiness: [f32; 4] = [1.5, 1.5, 1.5, 1.5];
|
||
let n_samples = readiness.len() as i32;
|
||
|
||
let r_buf = unsafe { MappedF32Buffer::new(readiness.len()) }.expect("alloc readiness");
|
||
r_buf.write_from_slice(&readiness);
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_plan_threshold(&stream, &kernel, r_buf.dev_ptr, n_samples, isv_buf.dev_ptr);
|
||
|
||
let result = isv_buf.read_all();
|
||
let floor = result[PLAN_THRESHOLD_FLOOR_ADAPTIVE_INDEX];
|
||
|
||
assert!(
|
||
(floor - PLAN_THRESHOLD_FLOOR_MAX).abs() < 1e-5,
|
||
"Bounds clamp: floor must clamp to MAX=0.50 on extreme readiness; got {floor}"
|
||
);
|
||
// Defensive: also exercises the MIN bound — floor never below MIN
|
||
// even if computed value is below it.
|
||
assert!(
|
||
floor >= PLAN_THRESHOLD_FLOOR_MIN,
|
||
"Floor must respect MIN bound; got {floor}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// SP16 Phase 1 (revised, 2026-05-08) — adaptive MIN_HOLD_TEMPERATURE
|
||
// producer (`min_hold_temperature_update_kernel`).
|
||
//
|
||
// Driving signal swapped from `ISV[AUX_DIR_ACC_SHORT_EMA_INDEX=373]`
|
||
// (slot 373) to hold-rate overrun reading
|
||
// `ISV[HOLD_RATE_OBSERVED_EMA_INDEX=382]` minus
|
||
// `ISV[HOLD_RATE_TARGET_INDEX=381]`. Per train-multi-seed-pfh9n
|
||
// post-mortem: slot 373 stayed at sentinel 0.5 in Fold 1 and the
|
||
// kernel's early-return-on-sentinel guard pinned slot 460 at 50 for
|
||
// the entire fold. Hold rates are measured per-epoch from realised
|
||
// actions and survive fold reset cleanly.
|
||
//
|
||
// Verifies:
|
||
// 1. Pearl-A bootstrap: sentinel 50.0 → REPLACE with target_temp.
|
||
// 2. observed=sentinel 0.0 → ISV preserved bit-exactly (no producer
|
||
// update; consumer falls back to scalar).
|
||
// 3. Over-holding (overrun=1.0) → temp HIGH (permissive exit ramp).
|
||
// 4. At-target (no overrun) → temp LOW (strict).
|
||
// 5. Under-target → temp LOW (strict — no relaxation needed).
|
||
// 6. Mid-cadence EMA blend after bootstrap (α=0.05).
|
||
// 7. Slot 373 dependency removed — kernel responds even when slot 373
|
||
// is at sentinel 0.5 (proves the swap is real).
|
||
//
|
||
// All tests are #[ignore = "requires GPU"]; gated under #[cfg(feature = "cuda")].
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
mod sp16_phase1_min_hold_temperature_gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
|
||
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
|
||
use ml::cuda_pipeline::sp14_isv_slots::{
|
||
MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX,
|
||
MIN_HOLD_TEMPERATURE_MAX,
|
||
MIN_HOLD_TEMPERATURE_MIN, SENTINEL_HOLD_RATE_OBSERVED,
|
||
SENTINEL_MIN_HOLD_TEMPERATURE,
|
||
MHT_TARGET_MEAN_INDEX, MHT_TARGET_M2_INDEX,
|
||
MHT_DIFF_MEAN_INDEX, MHT_DIFF_M2_INDEX,
|
||
MHT_PREV_TARGET_INDEX, MHT_SAMPLE_COUNT_INDEX,
|
||
};
|
||
use ml::cuda_pipeline::sp13_isv_slots::{
|
||
AUX_DIR_ACC_SHORT_EMA_INDEX, HOLD_RATE_OBSERVED_EMA_INDEX,
|
||
HOLD_RATE_TARGET_INDEX,
|
||
};
|
||
|
||
const MIN_HOLD_TEMPERATURE_UPDATE_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/min_hold_temperature_update_kernel.cubin"));
|
||
|
||
fn make_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(MIN_HOLD_TEMPERATURE_UPDATE_CUBIN.to_vec())
|
||
.expect("load min_hold_temperature_update_kernel cubin");
|
||
module
|
||
.load_function("min_hold_temperature_update")
|
||
.expect("load min_hold_temperature_update function")
|
||
}
|
||
|
||
/// SP16 T3 (2026-05-08): kernel takes Welford accumulator slot indices
|
||
/// instead of a hardcoded α. The hardcoded α=0.05 is gone — α is
|
||
/// computed inside the kernel from the Welford state per
|
||
/// `pearl_wiener_optimal_adaptive_alpha`.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn launch_temp(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
isv_ptr: u64,
|
||
temp_idx: i32,
|
||
hold_rate_observed_idx: i32,
|
||
hold_rate_target_idx: i32,
|
||
target_mean_idx: i32,
|
||
target_m2_idx: i32,
|
||
diff_mean_idx: i32,
|
||
diff_m2_idx: i32,
|
||
prev_target_idx: i32,
|
||
sample_count_idx: i32,
|
||
sentinel_temp: f32,
|
||
sentinel_observed_hold_rate: f32,
|
||
temp_min: f32,
|
||
temp_max: f32,
|
||
) {
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&isv_ptr)
|
||
.arg(&temp_idx)
|
||
.arg(&hold_rate_observed_idx)
|
||
.arg(&hold_rate_target_idx)
|
||
.arg(&target_mean_idx)
|
||
.arg(&target_m2_idx)
|
||
.arg(&diff_mean_idx)
|
||
.arg(&diff_m2_idx)
|
||
.arg(&prev_target_idx)
|
||
.arg(&sample_count_idx)
|
||
.arg(&sentinel_temp)
|
||
.arg(&sentinel_observed_hold_rate)
|
||
.arg(&temp_min)
|
||
.arg(&temp_max)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("launch min_hold_temperature_update");
|
||
}
|
||
stream.synchronize().expect("sync after min_hold_temperature_update");
|
||
}
|
||
|
||
/// Standard launch helper: takes the canonical SP14/SP13 constants,
|
||
/// avoiding the verbose argument list at every test.
|
||
fn launch_canonical(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
isv_ptr: u64,
|
||
) {
|
||
launch_temp(
|
||
stream, kernel, isv_ptr,
|
||
MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX as i32,
|
||
HOLD_RATE_OBSERVED_EMA_INDEX as i32,
|
||
HOLD_RATE_TARGET_INDEX as i32,
|
||
MHT_TARGET_MEAN_INDEX as i32,
|
||
MHT_TARGET_M2_INDEX as i32,
|
||
MHT_DIFF_MEAN_INDEX as i32,
|
||
MHT_DIFF_M2_INDEX as i32,
|
||
MHT_PREV_TARGET_INDEX as i32,
|
||
MHT_SAMPLE_COUNT_INDEX as i32,
|
||
SENTINEL_MIN_HOLD_TEMPERATURE,
|
||
SENTINEL_HOLD_RATE_OBSERVED,
|
||
MIN_HOLD_TEMPERATURE_MIN,
|
||
MIN_HOLD_TEMPERATURE_MAX,
|
||
);
|
||
}
|
||
|
||
/// SP16 Phase 1 (revised) — Test 1: MIN_HOLD_TEMPERATURE producer
|
||
/// responds to hold-rate overrun, NOT slot 373.
|
||
///
|
||
/// Setup: observed_hold_rate=0.40, target_hold_rate=0.20 (100%
|
||
/// overrun → overrun_norm=1.0). Pearl-A bootstrap from sentinel
|
||
/// 50.0 → temp REPLACED directly with target_temp = TEMP_MAX = 50.
|
||
/// Verifies the kernel climbs toward TEMP_MAX in the canonical
|
||
/// over-holding regime.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn sp16_phase1_min_hold_temp_climbs_with_hold_overrun() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// 100% overrun: observed = 2× target.
|
||
isv[HOLD_RATE_OBSERVED_EMA_INDEX] = 0.40;
|
||
isv[HOLD_RATE_TARGET_INDEX] = 0.20;
|
||
// Temp at sentinel 50.0 → Pearl-A REPLACE with target_temp.
|
||
isv[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX] = SENTINEL_MIN_HOLD_TEMPERATURE;
|
||
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_canonical(&stream, &kernel, isv_buf.dev_ptr);
|
||
|
||
let result = isv_buf.read_all();
|
||
let temp = result[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX];
|
||
|
||
// overrun = max(0, 0.40 − 0.20) = 0.20
|
||
// overrun_norm = clamp(0.20 / max(0.20, 0.01), 0, 1) = 1.0
|
||
// target_temp = 5 + 45 × 1.0 = 50.0
|
||
// Pearl-A bootstrap: sentinel → REPLACE → blended = 50.0.
|
||
assert!(
|
||
(temp - MIN_HOLD_TEMPERATURE_MAX).abs() < 1e-4,
|
||
"Over-holding (observed=2×target) must drive temp HIGH (permissive exit ramp); \
|
||
expected {}, got {temp}",
|
||
MIN_HOLD_TEMPERATURE_MAX
|
||
);
|
||
}
|
||
|
||
/// SP16 Phase 1 (revised) — Test 2: at-target → temp LOW (strict).
|
||
///
|
||
/// Setup: observed_hold_rate=target_hold_rate=0.20 (no overrun).
|
||
/// Pearl-A bootstrap from sentinel 50.0 → REPLACE with target_temp
|
||
/// = TEMP_MIN = 5. Then run the kernel a few more times to
|
||
/// confirm the EMA stays pinned at 5.0 (no overrun → no climb).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn sp16_phase1_min_hold_temp_strict_when_at_target() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// No overrun: observed = target.
|
||
isv[HOLD_RATE_OBSERVED_EMA_INDEX] = 0.20;
|
||
isv[HOLD_RATE_TARGET_INDEX] = 0.20;
|
||
isv[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX] = SENTINEL_MIN_HOLD_TEMPERATURE;
|
||
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
// First launch: Pearl-A bootstrap → REPLACE → temp = 5.
|
||
launch_canonical(&stream, &kernel, isv_buf.dev_ptr);
|
||
// Three additional launches to confirm EMA does not drift up.
|
||
for _ in 0..3 {
|
||
launch_canonical(&stream, &kernel, isv_buf.dev_ptr);
|
||
}
|
||
|
||
let result = isv_buf.read_all();
|
||
let temp = result[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX];
|
||
|
||
// overrun = max(0, 0.20 − 0.20) = 0
|
||
// target_temp = 5 + 45 × 0 = 5.0
|
||
// After Pearl-A bootstrap, EMA blend with target=5 stays at 5.
|
||
assert!(
|
||
(temp - MIN_HOLD_TEMPERATURE_MIN).abs() < 1e-4,
|
||
"At-target (observed = target) must drive temp LOW (strict); \
|
||
expected {}, got {temp}",
|
||
MIN_HOLD_TEMPERATURE_MIN
|
||
);
|
||
}
|
||
|
||
/// SP16 Phase 1 (revised) — Test 3: under-target → temp LOW (strict).
|
||
///
|
||
/// Setup: observed_hold_rate=0.10, target_hold_rate=0.20 (under).
|
||
/// `max(0, observed − target)` clamps the overrun to 0, so target
|
||
/// temp is TEMP_MIN. Pearl-A bootstrap → temp pinned to 5.
|
||
/// Verifies the asymmetry: under-holding does not relax the
|
||
/// penalty (no exit ramp needed when the model is already
|
||
/// below target).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn sp16_phase1_min_hold_temp_strict_when_under_target() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// Under-holding: observed = 0.5× target.
|
||
isv[HOLD_RATE_OBSERVED_EMA_INDEX] = 0.10;
|
||
isv[HOLD_RATE_TARGET_INDEX] = 0.20;
|
||
isv[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX] = SENTINEL_MIN_HOLD_TEMPERATURE;
|
||
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_canonical(&stream, &kernel, isv_buf.dev_ptr);
|
||
|
||
let result = isv_buf.read_all();
|
||
let temp = result[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX];
|
||
|
||
// overrun = max(0, 0.10 − 0.20) = 0 (clamped — no relaxation).
|
||
// target_temp = 5 + 45 × 0 = 5.0
|
||
// Pearl-A REPLACE → temp = 5.
|
||
assert!(
|
||
(temp - MIN_HOLD_TEMPERATURE_MIN).abs() < 1e-4,
|
||
"Under-target (observed < target) must drive temp LOW (strict — no relaxation); \
|
||
expected {}, got {temp}",
|
||
MIN_HOLD_TEMPERATURE_MIN
|
||
);
|
||
}
|
||
|
||
/// SP16 Phase 1 (revised) — Test 4: slot 373 dependency removed.
|
||
///
|
||
/// Setup: slot 373 (AUX_DIR_ACC_SHORT_EMA) is at sentinel 0.5 —
|
||
/// the OLD kernel's early-return guard would fire here and the
|
||
/// temp slot would stay at sentinel 50.0. Setup observed_hold_rate
|
||
/// =0.40, target=0.20 (significant overrun).
|
||
/// The NEW kernel does NOT read slot 373; it must respond to the
|
||
/// overrun and climb toward TEMP_MAX. This is the canonical
|
||
/// proof that the signal swap is real.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn sp16_phase1_min_hold_temp_no_longer_reads_slot_373() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// Slot 373 at OLD-kernel sentinel 0.5. Pre-swap this would have
|
||
// tripped the early-return guard and slot 460 would stay at 50.
|
||
isv[AUX_DIR_ACC_SHORT_EMA_INDEX] = 0.5;
|
||
// Significant overrun on the NEW signal slots.
|
||
isv[HOLD_RATE_OBSERVED_EMA_INDEX] = 0.40;
|
||
isv[HOLD_RATE_TARGET_INDEX] = 0.20;
|
||
isv[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX] = SENTINEL_MIN_HOLD_TEMPERATURE;
|
||
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_canonical(&stream, &kernel, isv_buf.dev_ptr);
|
||
|
||
let result = isv_buf.read_all();
|
||
let temp = result[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX];
|
||
|
||
// The NEW kernel must respond to the hold-rate overrun even
|
||
// though slot 373 is at the OLD sentinel. Pearl-A REPLACE
|
||
// → target_temp = 50.0. The OLD kernel would have left slot
|
||
// 460 at SENTINEL_MIN_HOLD_TEMPERATURE=50.0 too — but the
|
||
// value-arrival path is different. To distinguish them
|
||
// unambiguously, set up a non-sentinel under-target case
|
||
// where OLD-kernel-sentinel-guard would preserve slot, but
|
||
// NEW kernel writes target_temp=5.
|
||
// (This sub-test is the strict version below.)
|
||
assert!(
|
||
(temp - MIN_HOLD_TEMPERATURE_MAX).abs() < 1e-4,
|
||
"Over-holding with slot 373 at OLD sentinel must still drive temp HIGH; \
|
||
expected {}, got {temp}",
|
||
MIN_HOLD_TEMPERATURE_MAX
|
||
);
|
||
|
||
// Stronger version: under-target case where slot 373 sentinel
|
||
// pre-swap would have preserved slot 460 (kernel returned
|
||
// without writing). Post-swap the kernel writes target_temp=5
|
||
// because under-target overrun_norm=0.
|
||
//
|
||
// SP16 T3 (2026-05-08): with Wiener-optimal α, cold-start
|
||
// (sample_count=0 → 1 < 3) uses α=1.0 (REPLACE). The blend
|
||
// path is exercised, but the formula `(1-α)×current + α×target`
|
||
// collapses to `target` when α=1. So slot 460 lands at
|
||
// target_temp=5 directly (NOT the OLD 28.75 result).
|
||
let mut isv2 = vec![0.0_f32; ISV_DIM];
|
||
isv2[AUX_DIR_ACC_SHORT_EMA_INDEX] = 0.5; // OLD sentinel
|
||
isv2[HOLD_RATE_OBSERVED_EMA_INDEX] = 0.10; // under
|
||
isv2[HOLD_RATE_TARGET_INDEX] = 0.20;
|
||
const SEED_TEMP: f32 = 30.0;
|
||
isv2[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX] = SEED_TEMP;
|
||
let isv_buf2 = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv2");
|
||
isv_buf2.write_from_slice(&isv2);
|
||
launch_canonical(&stream, &kernel, isv_buf2.dev_ptr);
|
||
let result2 = isv_buf2.read_all();
|
||
let temp2 = result2[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX];
|
||
|
||
// overrun = 0 → target_temp = 5.
|
||
// Cold-start path (sample_count<3) → α=1.0 → blended ≈ target_temp = 5.
|
||
// Slot moved off SEED_TEMP=30 (the OLD kernel's no-write outcome).
|
||
assert!(
|
||
(temp2 - MIN_HOLD_TEMPERATURE_MIN).abs() < 1e-4,
|
||
"Slot 373 at OLD sentinel must NOT preserve slot 460; under-target → \
|
||
cold-start REPLACE with target_temp = {}, got {temp2:.5} \
|
||
(if {temp2:.5} ≈ {SEED_TEMP} the OLD kernel's slot-373-guard is still firing — signal swap not live)",
|
||
MIN_HOLD_TEMPERATURE_MIN
|
||
);
|
||
assert!(
|
||
(temp2 - SEED_TEMP).abs() > 0.5,
|
||
"Slot 460 must have moved off seed {SEED_TEMP} (proves the swap removed the slot-373-sentinel-guard)"
|
||
);
|
||
}
|
||
|
||
/// SP16 T3 (2026-05-08) — Wiener-optimal cold-start path.
|
||
///
|
||
/// Pre-T3 this test verified `(1−0.05)×30 + 0.05×50 = 31.0`. Under
|
||
/// Wiener-optimal α the kernel computes α from Welford state. With
|
||
/// freshly-zeroed Welford accumulators (the canonical fold-reset
|
||
/// state), the first launch sees `sample_count=0 → 1 < 3` and
|
||
/// takes the cold-start path α=1.0 → blended = target_temp = 50.0.
|
||
///
|
||
/// Setup: temp at 30.0 (NOT sentinel — past Pearl-A bootstrap).
|
||
/// observed=0.40, target=0.20 → overrun_norm=1.0 → target_temp=50.
|
||
/// Welford state at fold-reset zero. Cold-start path → α=1.0 →
|
||
/// blend collapses to target_temp = MIN_HOLD_TEMPERATURE_MAX = 50.
|
||
/// After 3 launches with this constant target, `sample_count = 3`
|
||
/// reaches the Wiener threshold; with constant target_temp=50 every
|
||
/// epoch, diff_var is exactly 0 and α floors at WELFORD_ALPHA_MIN.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn sp16_phase1_min_hold_temp_mid_cadence_ema_after_bootstrap() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
const SEED_TEMP: f32 = 30.0;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
isv[HOLD_RATE_OBSERVED_EMA_INDEX] = 0.40; // overrun_norm=1.0
|
||
isv[HOLD_RATE_TARGET_INDEX] = 0.20;
|
||
isv[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX] = SEED_TEMP; // NOT sentinel
|
||
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_canonical(&stream, &kernel, isv_buf.dev_ptr);
|
||
|
||
let result = isv_buf.read_all();
|
||
let temp = result[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX];
|
||
|
||
// Cold-start path (sample_count=1 < 3) → α=1.0 → blend
|
||
// collapses to target_temp = TEMP_MAX = 50.
|
||
let expected = MIN_HOLD_TEMPERATURE_MAX;
|
||
assert!(
|
||
(temp - expected).abs() < 1e-4,
|
||
"Wiener cold-start REPLACE: expected ≈ {expected:.5}, got {temp:.5}"
|
||
);
|
||
// Welford counter must have been incremented to 1.
|
||
assert!(
|
||
(result[MHT_SAMPLE_COUNT_INDEX] - 1.0).abs() < 1e-4,
|
||
"Welford sample_count must be 1 after first launch, got {}",
|
||
result[MHT_SAMPLE_COUNT_INDEX]
|
||
);
|
||
}
|
||
|
||
/// Helper test (updated post-swap): observed-hold-rate at sentinel
|
||
/// 0.0 (no per-step Hold observations yet) → kernel preserves
|
||
/// slot 460 bit-exactly. Cold-start fold-1 protection mirroring
|
||
/// the pre-swap dir_acc=0.5 sentinel test.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn sp16_phase1_min_hold_temp_sentinel_observed_preserves_isv() {
|
||
let stream = make_stream();
|
||
let kernel = load_kernel(&stream);
|
||
|
||
const ISV_DIM: usize = 1024;
|
||
const SEED: f32 = 32.0;
|
||
let mut isv = vec![0.0_f32; ISV_DIM];
|
||
// observed at SP13 fold-reset sentinel 0.0.
|
||
isv[HOLD_RATE_OBSERVED_EMA_INDEX] = SENTINEL_HOLD_RATE_OBSERVED;
|
||
isv[HOLD_RATE_TARGET_INDEX] = 0.20;
|
||
isv[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX] = SEED;
|
||
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) }.expect("alloc isv");
|
||
isv_buf.write_from_slice(&isv);
|
||
|
||
launch_canonical(&stream, &kernel, isv_buf.dev_ptr);
|
||
|
||
let result = isv_buf.read_all();
|
||
assert_eq!(
|
||
result[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX], SEED,
|
||
"Temp slot must be preserved bit-exactly when observed_hold_rate is at sentinel"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// SP16 Phase 0 (2026-05-08) — Q-by-action HEALTH_DIAG diagnostic.
|
||
//
|
||
// Per train-multi-seed-pfh9n post-mortem: structural-Q-bias hypothesis (Adam's
|
||
// `m/sqrt(v)` favours zero-variance Hold over noisy direction Q-targets) needs
|
||
// direct per-action Q observations to verify or refute. The production path is:
|
||
//
|
||
// 1. `update_q_dir_means_cached`: dtoh of `q_out_buf [B, total_actions]`,
|
||
// then per-action averaging via `compute_q_dir_means_from_host_buf`.
|
||
// 2. `q_direction_action_means`: returns `[hold, long, short, flat]` cached
|
||
// array (HEALTH_DIAG emit order).
|
||
// 3. training_loop.rs HEALTH_DIAG emit:
|
||
// `q_by_action [hold=… long=… short=… flat=…]`.
|
||
//
|
||
// Action-index ordering verified at `state_layout.cuh:123-126`:
|
||
// DIR_SHORT=0, DIR_HOLD=1, DIR_LONG=2, DIR_FLAT=3.
|
||
//
|
||
// This test exercises the actual averaging-path helper with known per-column
|
||
// values seeded into a [B, total_actions] row-major buffer. The slot order
|
||
// returned must match the HEALTH_DIAG `[hold, long, short, flat]` contract,
|
||
// independently of whichever dir_idx a given column corresponds to.
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// SP16 Phase 0: verify the q_by_action diagnostic correctly extracts
|
||
/// per-direction-action Q-means from a `[B, total_actions]` row-major Q-out
|
||
/// buffer and emits in the contracted `[hold, long, short, flat]` slot order
|
||
/// matching the HEALTH_DIAG format. Validates the action-index ↔ slot
|
||
/// mapping against `state_layout.cuh:123-126` (DIR_SHORT=0, DIR_HOLD=1,
|
||
/// DIR_LONG=2, DIR_FLAT=3) so we can observe Q(Hold) trajectory vs
|
||
/// Q(direction) and falsify the structural-Q-bias hypothesis.
|
||
///
|
||
/// Construction:
|
||
/// * B=8, total_actions=12 (production layout: 4 dir + 3 mag + 3 ord + 3 urg).
|
||
/// * Each direction column i is filled with constant `dir_target[i]`
|
||
/// across all batch rows so the per-column mean equals the seeded
|
||
/// constant (no float averaging error). The remaining 8 mag/ord/urg
|
||
/// columns are filled with sentinel 99.0 to confirm the helper does
|
||
/// not bleed any non-direction column into the dir-action means.
|
||
/// * Targets chosen with 4 distinct values so a wrong index→slot mapping
|
||
/// is detected with 1e-3 tolerance.
|
||
///
|
||
/// CPU-only test — exercises pure-host averaging logic. The dtoh half of
|
||
/// the production updater is tested implicitly by smoke (HEALTH_DIAG
|
||
/// emits each epoch); the bug surface this test guards is the index→slot
|
||
/// mapping, which is data-independent.
|
||
#[test]
|
||
fn sp16_phase0_q_by_action_diagnostic_reads_four_action_means() {
|
||
use ml::cuda_pipeline::gpu_dqn_trainer::compute_q_dir_means_from_host_buf;
|
||
|
||
// Production direction-branch ordering, see state_layout.cuh:123-126.
|
||
// dir_idx 0 → DIR_SHORT, 1 → DIR_HOLD, 2 → DIR_LONG, 3 → DIR_FLAT.
|
||
const DIR_SHORT: usize = 0;
|
||
const DIR_HOLD: usize = 1;
|
||
const DIR_LONG: usize = 2;
|
||
const DIR_FLAT: usize = 3;
|
||
|
||
// Returned-slot ordering, see compute_q_dir_means_from_host_buf docs.
|
||
// HEALTH_DIAG `q_by_action [hold long short flat]`.
|
||
const SLOT_HOLD: usize = 0;
|
||
const SLOT_LONG: usize = 1;
|
||
const SLOT_SHORT: usize = 2;
|
||
const SLOT_FLAT: usize = 3;
|
||
|
||
const B: usize = 8;
|
||
const B0: usize = 4; // NUM_DIRECTIONS
|
||
const TOTAL_ACTIONS: usize = 12; // 4 + 3 + 3 + 3 (production layout)
|
||
|
||
// Distinct target Q-values per direction action.
|
||
// Index by canonical dir_idx (matches the column the writer uses).
|
||
let mut dir_target = [0.0_f32; 4];
|
||
dir_target[DIR_SHORT] = 0.10;
|
||
dir_target[DIR_HOLD ] = 0.50;
|
||
dir_target[DIR_LONG ] = 0.20;
|
||
dir_target[DIR_FLAT ] = 0.30;
|
||
|
||
// Build a synthetic q_out_buf [B, total_actions] row-major. Direction
|
||
// columns [0..4] are seeded with dir_target[col]; non-direction columns
|
||
// [4..12] are sentinel 99.0 so any mis-indexing leaks visibly into the
|
||
// returned means.
|
||
let mut host = vec![0.0_f32; B * TOTAL_ACTIONS];
|
||
for i in 0..B {
|
||
let row_off = i * TOTAL_ACTIONS;
|
||
for k in 0..B0 {
|
||
host[row_off + k] = dir_target[k];
|
||
}
|
||
for k in B0..TOTAL_ACTIONS {
|
||
host[row_off + k] = 99.0;
|
||
}
|
||
}
|
||
|
||
let q_by_action = compute_q_dir_means_from_host_buf(&host, B, B0, TOTAL_ACTIONS);
|
||
|
||
// Slot-order assertions: each slot must hold the canonical-dir-idx target.
|
||
assert!(
|
||
(q_by_action[SLOT_HOLD ] - dir_target[DIR_HOLD ]).abs() < 1e-3,
|
||
"slot 0 (hold) must hold mean of DIR_HOLD column. got {:.6} expected {:.6}",
|
||
q_by_action[SLOT_HOLD], dir_target[DIR_HOLD]
|
||
);
|
||
assert!(
|
||
(q_by_action[SLOT_LONG ] - dir_target[DIR_LONG ]).abs() < 1e-3,
|
||
"slot 1 (long) must hold mean of DIR_LONG column. got {:.6} expected {:.6}",
|
||
q_by_action[SLOT_LONG], dir_target[DIR_LONG]
|
||
);
|
||
assert!(
|
||
(q_by_action[SLOT_SHORT] - dir_target[DIR_SHORT]).abs() < 1e-3,
|
||
"slot 2 (short) must hold mean of DIR_SHORT column. got {:.6} expected {:.6}",
|
||
q_by_action[SLOT_SHORT], dir_target[DIR_SHORT]
|
||
);
|
||
assert!(
|
||
(q_by_action[SLOT_FLAT ] - dir_target[DIR_FLAT ]).abs() < 1e-3,
|
||
"slot 3 (flat) must hold mean of DIR_FLAT column. got {:.6} expected {:.6}",
|
||
q_by_action[SLOT_FLAT], dir_target[DIR_FLAT]
|
||
);
|
||
|
||
// Defensive: any non-direction sentinel leakage would push the dir slot
|
||
// past its dir_target. With sentinel=99.0 a one-column mis-index would
|
||
// shift the slot by (99.0 - target) / B per leaked row — at least 12.4
|
||
// for SLOT_HOLD's 0.50 target, far above the 1e-3 tolerance.
|
||
for (slot, value) in q_by_action.iter().enumerate() {
|
||
assert!(
|
||
*value < 10.0,
|
||
"slot {slot} contaminated by non-direction sentinel column \
|
||
(got {value:.6}); index→slot mapping is wrong"
|
||
);
|
||
}
|
||
|
||
// Empty-input guards: helper must return [0; 4] for degenerate cases.
|
||
let empty = compute_q_dir_means_from_host_buf(&host, 0, B0, TOTAL_ACTIONS);
|
||
assert_eq!(empty, [0.0_f32; 4], "B=0 must yield all zeros");
|
||
let empty_b0 = compute_q_dir_means_from_host_buf(&host, B, 0, TOTAL_ACTIONS);
|
||
assert_eq!(empty_b0, [0.0_f32; 4], "b0=0 must yield all zeros");
|
||
// Truncated host (under-sized) must also yield zeros — defensive guard
|
||
// that mirrors the production-path ModelError on under-sized q_out_buf.
|
||
let small = vec![0.0_f32; 4];
|
||
let truncated = compute_q_dir_means_from_host_buf(&small, B, B0, TOTAL_ACTIONS);
|
||
assert_eq!(truncated, [0.0_f32; 4], "truncated host must yield all zeros");
|
||
}
|