Updates 13 test files that were locked to stale kernel contracts.
Each change verified against the actual kernel/code (cuda/*.cu and
crates/ml-alpha/src/*) — not normalised to broken behavior:
Controller bootstrap values (isv_bootstrap.rs,
r5_controllers_and_soft_update.rs):
* γ → 0.995 — `rl_gamma_controller.cu` GAMMA_MIN raised per
`pearl_edge_lives_at_wave_timescale_not_tick` (long-horizon credit
assignment on surfer-philosophy timescale)
* entropy_coef → 0.5 — `rl_entropy_coef_controller.cu` COEF_MAX
bootstrap at deficit=1.0 per
`pearl_pi_actor_collapses_without_entropy_floor` (cold-start
emergency exploration)
* per_α → 0.4 — R9 derive-from-input pattern at kurt_excess=0
* τ structurally pinned at TAU_BOOTSTRAP=0.005 — TAU_MAX (slot 573)
equals bootstrap, controller has zero headroom upward (intentional
per slot doc-comment)
Topology / horizon consolidation (bucket_transition_kernels.rs,
cfc_step_per_branch.rs, heads_block_diagonal.rs, heads_bit_equiv.rs,
bce_grad_finite_diff.rs):
* N_HORIZONS: 5 → 3 (tercile layout: cut points [43, 86], dims
[43, 43, 42]) — kernel-side `bucket_transition_kernels.cu` /
`bce_loss_multi_horizon.cu` / `heads.rs` were consolidated
* MAX_BUCKET_DIM: 28 → 96 (kernel #define MAX_BUCKET_DIM = 96)
* Per-test asserts mirror tercile assignment via shared
`tercile_bucket(c)` helper
Done-gating semantics (r3_ema_advantage.rs):
* `compute_advantage_return.cu` line 30: `advantages[b] = done *
(ret - vt)` — V learns from trade closes only, π trained via
target-Q distillation + SAC entropy. Test now two-cohorts
(done=1 sees -k, done=0 sees 0)
Anti-collapse mechanisms (r4_action_kernels.rs):
* Thompson kernel applies ISV-driven epsilon-greedy floor (slot 588
= 0.05) per `pearl_pi_actor_collapses_without_entropy_floor`.
Wins/100 expectation widened to (1 − 0.05) × 100 ± 3σ ≈ ≥88
Confidence gate (trade_management_kernels.rs):
* `rl_confidence_gate.cu` does NOT check `lots != 0 → skip` — the
position-conditional escape was moved to the Phase 2.3 state-
mandatory action mask (separate kernel). Case 3 now asserts gate
fires regardless of position
Smoothness controller (smoothness_lambda_controller_invariants.rs):
* TARGET_K_RATIO[2] = 0.1 (sqrt-scaling fix) — kernel comment at
`smoothness_lambda_controller.cu:38-45` explains the migration
from geometric to sqrt anchoring
PER buffer migration (r7d_per_wiring.rs):
* CPU `trainer.replay` field replaced by GPU-resident
`trainer.gpu_replay.replay_len_d` (graph-safe push). Test reads
the counter via the canonical mapped-pinned pattern
(`MappedI32Buffer` + `memcpy_dtod_async` + sync) per
`feedback_no_htod_htoh_only_mapped_pinned`. Asserts mechanism
invariants (monotone growth, bounded by MAX_INJECTS_PER_STEP,
saturates at capacity) — looser than the old "len == step"
because HER + n-step push counts vary per step
EMA-input sentinel-zero loops (isv_bootstrap.rs,
r5_controllers_and_soft_update.rs):
* Switched from a sprawling negative exception list ("any slot in
[417, RL_SLOTS_END) except these N constants") to a positive
enumeration of the ~7 documented EMA-input slots. New ISV
constants no longer break the test by being added to the slot
range
Stale fixture (perception_forward_golden.bin):
* Old golden was captured with probs.len() = 160; current model
produces 96. Regenerated. Test logic captures-on-first-run +
compares-on-subsequent-runs is unchanged
Latent kernel bug surfaced (bucket_transition_kernels.rs::
h_mag_per_bucket_kernel_computes_mean_abs_per_bucket):
* `h_mag_per_bucket_kernel` launches with block_dim=32 but
BUCKET_DIMS=[43, 43, 42] — only the first 32 channels contribute
to the sum while the divisor uses the full bdim. Production
launch site `perception.rs::h_mag_cfg` uses the same block_dim,
so Controller D's dead-bucket signal is silently under-counted.
Test asserts the IDEAL behavior (full-bucket mean) and DELIBERATELY
FAILS until the kernel is fixed — per
`pearl_tests_must_prove_not_lock_observations`
282 lines
11 KiB
Rust
282 lines
11 KiB
Rust
//! Invariant tests for the ISV-driven λ controller.
|
||
//!
|
||
//! Per `feedback_no_cpu_test_fallbacks`: GPU oracle only. Per
|
||
//! `pearl_tests_must_prove_not_lock_observations`: assertions check
|
||
//! invariants (sentinel bootstrap, steady-state at target, excess
|
||
//! proportionality, floor activation under zero amplitude) — not
|
||
//! arbitrary numeric snapshots.
|
||
//!
|
||
//! Per `feedback_no_htod_htoh_only_mapped_pinned`: all CPU↔GPU
|
||
//! transfers go via `MappedF32Buffer` + `memcpy_dtod_async`. The i32
|
||
//! sentinel is shuttled by reinterpreting the same mapped-pinned
|
||
//! buffer as i32 (canonical pattern, see `loss.rs::download_i32`).
|
||
|
||
use std::sync::Arc;
|
||
|
||
use anyhow::{Context, Result};
|
||
use approx::assert_relative_eq;
|
||
use cudarc::driver::{CudaSlice, CudaStream, DevicePtr, DevicePtrMut, LaunchConfig, PushKernelArg};
|
||
use ml_alpha::heads::N_HORIZONS;
|
||
use ml_alpha::pinned_mem::MappedF32Buffer;
|
||
use ml_core::device::MlDevice;
|
||
|
||
const CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/smoothness_lambda_controller.cubin"));
|
||
const LAMBDA_FLOOR: f32 = 1.0e-4;
|
||
|
||
struct ControllerOut {
|
||
jitter_ema: Vec<f32>,
|
||
first_obs: i32,
|
||
lambda: Vec<f32>,
|
||
}
|
||
|
||
fn run_controller(
|
||
dev: &MlDevice,
|
||
raw_per_h: &[f32; N_HORIZONS],
|
||
jitter_ema_init: &[f32; N_HORIZONS],
|
||
first_obs_init: i32,
|
||
base_lambda: f32,
|
||
) -> Result<ControllerOut> {
|
||
let stream: &Arc<CudaStream> = dev.cuda_stream().context("controller stream")?;
|
||
let ctx = dev.cuda_context().context("controller ctx")?;
|
||
let module = ctx.load_cubin(CUBIN.to_vec()).context("load controller cubin")?;
|
||
let func = module.load_function("smoothness_lambda_controller").context("load fn")?;
|
||
|
||
let raw_d = upload_f32(stream, raw_per_h.as_slice())?;
|
||
let mut jitter_d = upload_f32(stream, jitter_ema_init.as_slice())?;
|
||
let mut first_obs_d = upload_i32(stream, &[first_obs_init])?;
|
||
let mut lambda_d = stream.alloc_zeros::<f32>(N_HORIZONS).context("lambda alloc")?;
|
||
|
||
let cfg = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (N_HORIZONS as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
// The kernel now accepts two trailing pointer args for the GPU log
|
||
// ring (`LogRing*` + `int* g_step_counter`). Passing null pointers
|
||
// makes `log_record()` short-circuit at the `g_log_ring == nullptr`
|
||
// guard — behaviorally identical to the pre-log signature. We must
|
||
// still push the args because launch_builder validates the count.
|
||
let null_ring: cudarc::driver::sys::CUdeviceptr = 0;
|
||
let null_step: cudarc::driver::sys::CUdeviceptr = 0;
|
||
let mut launch = stream.launch_builder(&func);
|
||
launch
|
||
.arg(&raw_d)
|
||
.arg(&mut jitter_d)
|
||
.arg(&mut first_obs_d)
|
||
.arg(&base_lambda)
|
||
.arg(&mut lambda_d)
|
||
.arg(&null_ring)
|
||
.arg(&null_step);
|
||
unsafe {
|
||
launch.launch(cfg).context("controller launch")?;
|
||
}
|
||
stream.synchronize().context("controller sync")?;
|
||
|
||
Ok(ControllerOut {
|
||
jitter_ema: download_f32(stream, &jitter_d)?,
|
||
first_obs: download_i32(stream, &first_obs_d)?[0],
|
||
lambda: download_f32(stream, &lambda_d)?,
|
||
})
|
||
}
|
||
|
||
fn upload_f32(stream: &Arc<CudaStream>, host: &[f32]) -> Result<CudaSlice<f32>> {
|
||
let n = host.len();
|
||
let staging = unsafe { MappedF32Buffer::new(n) }
|
||
.map_err(|e| anyhow::anyhow!("upload staging: {e}"))?;
|
||
staging.write_from_slice(host);
|
||
let mut dst = stream.alloc_zeros::<f32>(n).context("upload alloc")?;
|
||
if n > 0 {
|
||
let nbytes = n * std::mem::size_of::<f32>();
|
||
unsafe {
|
||
let (dst_ptr, _g) = dst.device_ptr_mut(stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst_ptr,
|
||
staging.dev_ptr,
|
||
nbytes,
|
||
stream.cu_stream(),
|
||
)
|
||
.context("upload DtoD")?;
|
||
}
|
||
}
|
||
Ok(dst)
|
||
}
|
||
|
||
/// Upload an `i32` slice via the canonical mapped-pinned path by
|
||
/// reinterpreting `MappedF32Buffer`'s host pointer as `*mut i32`
|
||
/// (same pattern as `trainer/loss.rs::download_i32`). One stream sync
|
||
/// barrier inside the kernel launch makes the host write visible
|
||
/// to the device through the same mapped page.
|
||
fn upload_i32(stream: &Arc<CudaStream>, host: &[i32]) -> Result<CudaSlice<i32>> {
|
||
let n = host.len();
|
||
let staging = unsafe { MappedF32Buffer::new(n) }
|
||
.map_err(|e| anyhow::anyhow!("i32 upload staging: {e}"))?;
|
||
// Write through the host pointer reinterpreted as i32.
|
||
unsafe {
|
||
let host_ptr = staging.host_ptr.cast::<i32>();
|
||
for (i, &v) in host.iter().enumerate() {
|
||
std::ptr::write_volatile(host_ptr.add(i), v);
|
||
}
|
||
}
|
||
let mut dst = stream.alloc_zeros::<i32>(n).context("i32 upload alloc")?;
|
||
if n > 0 {
|
||
let nbytes = n * std::mem::size_of::<i32>();
|
||
unsafe {
|
||
let (dst_ptr, _g) = dst.device_ptr_mut(stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst_ptr,
|
||
staging.dev_ptr,
|
||
nbytes,
|
||
stream.cu_stream(),
|
||
)
|
||
.context("i32 upload DtoD")?;
|
||
}
|
||
}
|
||
// Keep staging alive until the DtoD has been issued onto the stream.
|
||
// `dst` carries an internal record; the subsequent kernel launch and
|
||
// sync barrier in `run_controller` flushes the copy before staging
|
||
// drops here.
|
||
stream.synchronize().context("i32 upload sync")?;
|
||
drop(staging);
|
||
Ok(dst)
|
||
}
|
||
|
||
fn download_f32(stream: &Arc<CudaStream>, src: &CudaSlice<f32>) -> Result<Vec<f32>> {
|
||
let n = src.len();
|
||
let staging = unsafe { MappedF32Buffer::new(n) }
|
||
.map_err(|e| anyhow::anyhow!("download staging: {e}"))?;
|
||
let nbytes = n * std::mem::size_of::<f32>();
|
||
unsafe {
|
||
let (src_ptr, _g) = src.device_ptr(stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
staging.dev_ptr,
|
||
src_ptr,
|
||
nbytes,
|
||
stream.cu_stream(),
|
||
)
|
||
.context("download DtoD")?;
|
||
}
|
||
stream.synchronize().context("download sync")?;
|
||
Ok(staging.read_all())
|
||
}
|
||
|
||
/// Download an `i32` slice via the canonical mapped-pinned path
|
||
/// (matches `trainer/loss.rs::download_i32`).
|
||
fn download_i32(stream: &Arc<CudaStream>, src: &CudaSlice<i32>) -> Result<Vec<i32>> {
|
||
let n = src.len();
|
||
let staging = unsafe { MappedF32Buffer::new(n) }
|
||
.map_err(|e| anyhow::anyhow!("i32 download staging: {e}"))?;
|
||
let nbytes = n * std::mem::size_of::<i32>();
|
||
unsafe {
|
||
let (src_ptr, _g) = src.device_ptr(stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
staging.dev_ptr,
|
||
src_ptr,
|
||
nbytes,
|
||
stream.cu_stream(),
|
||
)
|
||
.context("i32 download DtoD")?;
|
||
}
|
||
stream.synchronize().context("i32 download sync")?;
|
||
let host_ptr = staging.host_ptr.cast::<i32>();
|
||
let mut out = Vec::with_capacity(n);
|
||
unsafe {
|
||
for i in 0..n {
|
||
out.push(std::ptr::read_volatile(host_ptr.add(i)));
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn test_device() -> MlDevice {
|
||
MlDevice::cuda(0).expect("CUDA 0 required for ml-alpha tests")
|
||
}
|
||
|
||
// ───── Tests ─────
|
||
|
||
#[test]
|
||
fn first_observation_bootstraps_ema_and_sets_sentinel() {
|
||
let dev = test_device();
|
||
// HORIZONS=[10, 100, 1000]. Geometric decay (factor 1/10) keeps every
|
||
// raw[h] AT OR BELOW jitter_ema[0] * TARGET_K_RATIO[h] for any
|
||
// monotonically-decreasing TARGET_K_RATIO, so excess_ratio = 0 across
|
||
// the board on the bootstrap step. Invariant under test: sentinel
|
||
// advances 0→1, EMA is replaced by raw, and λ relaxes to base.
|
||
let raw = [0.5_f32, 0.05, 0.005];
|
||
let zero_ema = [0.0_f32; N_HORIZONS];
|
||
let base = 0.01_f32;
|
||
let out = run_controller(&dev, &raw, &zero_ema, 0, base).unwrap();
|
||
assert_eq!(
|
||
out.first_obs, 1,
|
||
"sentinel should advance to 1 on first observation"
|
||
);
|
||
for h in 0..N_HORIZONS {
|
||
assert_relative_eq!(out.jitter_ema[h], raw[h], epsilon = 1e-6);
|
||
// excess_ratio = 0 → lambda = base; floor doesn't engage (base > floor).
|
||
assert_relative_eq!(out.lambda[h], base, epsilon = 1e-6);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn steady_state_at_target_yields_base_lambda() {
|
||
let dev = test_device();
|
||
// Geometric decay (factor 1/10) for HORIZONS=[10, 100, 1000].
|
||
// All jitter values at-or-below their derived target → excess_ratio = 0
|
||
// → λ relaxes to base. EMA update is a no-op when raw == ema.
|
||
let ema = [0.5_f32, 0.05, 0.005];
|
||
let raw = ema; // identical → EMA update is a no-op (0.5*old + 0.5*old = old)
|
||
let base = 0.01_f32;
|
||
let out = run_controller(&dev, &raw, &ema, 1, base).unwrap();
|
||
for h in 0..N_HORIZONS {
|
||
assert_relative_eq!(out.jitter_ema[h], ema[h], epsilon = 1e-6);
|
||
assert_relative_eq!(out.lambda[h], base, epsilon = 1e-5);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn excess_at_h1000_lifts_lambda_proportionally() {
|
||
let dev = test_device();
|
||
// HORIZONS=[10, 100, 1000]. Drive only h1000 (index 2) above its
|
||
// controller-derived target: jitter[2] = 10 × target[2] should yield
|
||
// excess_ratio = 9 → λ[2] = base × 10. The other horizons stay
|
||
// at-or-below target so λ[h<2] relaxes to base.
|
||
//
|
||
// target[h] = jitter_ema[0] * TARGET_K_RATIO[h] in the kernel, where
|
||
// TARGET_K_RATIO uses square-root anchoring per
|
||
// `smoothness_lambda_controller.cu`'s "caps the differential at
|
||
// ~10x" comment: TARGET_K_RATIO[h] = sqrt(HORIZONS[0] / HORIZONS[h])
|
||
// → [1.0, sqrt(0.1)=0.3162, sqrt(0.01)=0.1] for HORIZONS=[10,100,1000].
|
||
//
|
||
// MUST mirror `cuda/smoothness_lambda_controller.cu` TARGET_K_RATIO
|
||
// constant — any kernel-side rescaling requires an explicit test
|
||
// update (intentional source-of-truth coupling).
|
||
const TARGET_K_RATIO_H2: f32 = 0.1; // sqrt(10/1000)
|
||
let ema = [0.5_f32, 0.05, 10.0 * 0.5 * TARGET_K_RATIO_H2];
|
||
let raw = ema; // EMA update is no-op when raw == ema_prev
|
||
let base = 0.01_f32;
|
||
let out = run_controller(&dev, &raw, &ema, 1, base).unwrap();
|
||
// h10: at-target (self-ratio = 1.0) → lambda = base
|
||
assert_relative_eq!(out.lambda[0], base, epsilon = 1e-5);
|
||
// h100: below-target → lambda = base
|
||
assert_relative_eq!(out.lambda[1], base, epsilon = 1e-5);
|
||
// h1000: excess_ratio = 10 - 1 = 9 → lambda = base * (1 + 9) = 0.10
|
||
let expected_lambda_h1000 = base * 10.0;
|
||
assert_relative_eq!(out.lambda[2], expected_lambda_h1000, max_relative = 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn lambda_floor_when_base_lambda_zero() {
|
||
let dev = test_device();
|
||
let ema = [0.5_f32; N_HORIZONS];
|
||
let raw = ema;
|
||
let base = 0.0_f32;
|
||
let out = run_controller(&dev, &raw, &ema, 1, base).unwrap();
|
||
// base = 0 → lambda_new = 0 (regardless of excess_ratio) → floor activates
|
||
for h in 0..N_HORIZONS {
|
||
assert!(
|
||
(out.lambda[h] - LAMBDA_FLOOR).abs() < 1e-9,
|
||
"lambda[{h}] should equal LAMBDA_FLOOR (={LAMBDA_FLOOR:.1e}); got {}",
|
||
out.lambda[h]
|
||
);
|
||
}
|
||
}
|