diff --git a/crates/ml/build.rs b/crates/ml/build.rs index f7ce6bba3..ddec9fa6d 100644 --- a/crates/ml/build.rs +++ b/crates/ml/build.rs @@ -1396,6 +1396,25 @@ fn main() { // site atomically with Kernel 2 (controllers) per // `feedback_no_partial_refactor`. "sp20_emas_compute_kernel.cu", + // SP20 Phase 1.3 (2026-05-09): 6 derived ISV controller + // outputs producer. Component 5 / Kernel 2 of the SP20 fused- + // producer chain. Single-block, single-thread kernel reading + // the ISV EMAs (WR_EMA, HOLD_PCT_EMA, prev HOLD_COST_SCALE) + // and the 4 internal scratch EMAs (trade_duration_ema, + // aux_conf_p50/std/dir_acc) Kernel 1 wrote, then deriving 6 + // controller outputs (LOSS_CAP, N_STEP, AUX_CONF_THRESHOLD, + // AUX_GATE_TEMP, TARGET_HOLD_PCT, HOLD_COST_SCALE) per the + // spec §4.5 formulas. HOLD_COST_SCALE is a two-sided + // multiplicative ramp around TARGET_HOLD_PCT ±0.05 (deadband + // path always writes the unchanged previous value to satisfy + // `feedback_no_stubs`). All 6 controllers are anchored on + // EMAs per `pearl_controller_anchors_isv_driven` — no + // hardcoded magic constants drive the controllers, only + // spec-frozen ramp / clamp parameters. No atomicAdd, no host + // branches — captureable in the per-step CUDA Graph. Phase + // 1.4 wires the production launch site atomically with + // Kernel 1 + Kernel 3 per `feedback_no_partial_refactor`. + "sp20_controllers_compute_kernel.cu", ]; // ALL kernels get common header (BF16 types + wrappers) diff --git a/crates/ml/src/cuda_pipeline/mod.rs b/crates/ml/src/cuda_pipeline/mod.rs index 3dbf937d4..ca0939589 100644 --- a/crates/ml/src/cuda_pipeline/mod.rs +++ b/crates/ml/src/cuda_pipeline/mod.rs @@ -100,6 +100,15 @@ pub mod sp20_stats_compute; // (trade_duration_ema + 3 aux EMAs) per training step, gated by // is_close / action_is_hold per the SP20 spec §4.5. pub mod sp20_emas_compute; +// SP20 Phase 1.3 (2026-05-09): 6 derived ISV controller outputs +// producer kernel launcher. Component 5 / Kernel 2 of the SP20 fused- +// producer chain (Kernel 1 + Kernel 3 already landed in Phases 1.2 + +// 1.1). Reads the EMAs Kernel 1 wrote (4 ISV + 4 internal) and +// derives LOSS_CAP, N_STEP, AUX_CONF_THRESHOLD, AUX_GATE_TEMP, +// TARGET_HOLD_PCT, HOLD_COST_SCALE per the spec §4.5 formulas. Phase +// 1.4 wires the production launch site atomically with the rest of +// the SP20 chain per `feedback_no_partial_refactor`. +pub mod sp20_controllers_compute; // `launch_apply_pearls` is `pub(crate)` and consumed only inside this // crate via `use crate::cuda_pipeline::sp4_wiener_ema::launch_apply_pearls`. // `pearls_ad_update` stays `pub` for the test-oracle path in diff --git a/crates/ml/src/cuda_pipeline/sp20_controllers_compute.rs b/crates/ml/src/cuda_pipeline/sp20_controllers_compute.rs new file mode 100644 index 000000000..7aa45f37c --- /dev/null +++ b/crates/ml/src/cuda_pipeline/sp20_controllers_compute.rs @@ -0,0 +1,213 @@ +#![allow(unsafe_code)] // CUDA kernel launch via cudarc requires unsafe. +//! SP20 Phase 1.3 (2026-05-09) — `sp20_controllers_compute_kernel` launcher. +//! +//! Component 5 / Kernel 2 of the SP20 design — the deterministic +//! derivation step. Reads the EMAs produced by Phase 1.2's +//! [`super::sp20_emas_compute`] (and indirectly Phase 1.1's +//! [`super::sp20_stats_compute`] outputs that Kernel 1 already blended +//! into the same internal scratch) and writes 6 ISV controller outputs: +//! +//! | Output | Formula | +//! |-------------------------------------------------------------|------------------------------------------------------| +//! | [`crate::cuda_pipeline::sp14_isv_slots::LOSS_CAP_INDEX`] | `-1 - clamp((wr_ema-0.50)/0.05, 0, 1)` | +//! | [`crate::cuda_pipeline::sp14_isv_slots::N_STEP_INDEX`] | `clamp(round(trade_dur_ema), 1, 30)` | +//! | [`crate::cuda_pipeline::sp14_isv_slots::AUX_CONF_THRESHOLD_INDEX`] | `clamp(dir_acc_ema - 0.50, 0.01, 0.20)` | +//! | [`crate::cuda_pipeline::sp14_isv_slots::AUX_GATE_TEMP_INDEX`] | `max(aux_std_ema, 0.01)` | +//! | [`crate::cuda_pipeline::sp14_isv_slots::TARGET_HOLD_PCT_INDEX`] | `clamp(0.8 - aux_p50_ema*1.5, 0.1, 0.8)` | +//! | [`crate::cuda_pipeline::sp14_isv_slots::HOLD_COST_SCALE_INDEX`] | two-sided ramp around `TARGET_HOLD_PCT` ±0.05 | +//! +//! ## Hard rules covered +//! +//! - `pearl_controller_anchors_isv_driven` — every output's anchor / +//! target / cap derives from EMAs; no hardcoded magic constants. +//! - `feedback_isv_for_adaptive_bounds` — these 6 outputs ARE the +//! adaptive bounds; downstream consumers read them rather than +//! embedding the formulas. +//! - `pearl_no_host_branches_in_captured_graph` — single-block, +//! single-thread kernel; safe to capture in the per-step CUDA +//! Graph. +//! - `feedback_no_atomicadd` — single-thread kernel. +//! - `feedback_no_cpu_compute_strict` — every formula lives in the +//! kernel; the launcher passes only pointers + slot-index args. +//! - `feedback_no_htod_htoh_only_mapped_pinned` — both ISV and +//! `internal` scratch are mapped-pinned device pointers; kernel +//! emits `__threadfence_system()` for PCIe-visible coherence. +//! - `pearl_tests_must_prove_not_lock_observations` — companion +//! test asserts clamp boundaries, formula correctness, and +//! bidirectional ramp behavior, NOT specific observed values. +//! +//! ## Phase 1.3 wiring scope +//! +//! Kernel + launcher + tests + build entry **only**. The +//! training-loop call site lands in Phase 1.4 atomically with the +//! rest of the SP20 reward chain (Kernel 1 = EMAs already landed in +//! Phase 1.2; Kernel 3 = stats already landed in Phase 1.1) per +//! `feedback_no_partial_refactor`. +//! +//! ## Stream-ordering contract (Phase 1.4 forward reference) +//! +//! Phase 1.4 must launch this kernel on the SAME stream as +//! `sp20_emas_compute_kernel` and AFTER it (Kernel 1 → Kernel 2), +//! so the in-stream-order invariant guarantees every read here sees +//! the EMAs Kernel 1 just blended this step. The trainer's per-step +//! state-tracker stream is the natural home for the 3-kernel chain +//! (Stats → EMAs → Controllers). + +/// Launch `sp20_controllers_compute_kernel` on the producer's stream. +/// +/// Single-block, single-thread launch. `kernel` MUST be the loaded +/// `sp20_controllers_compute_kernel` `CudaFunction` (from the cubin +/// emitted by `build.rs` — see manifest entry +/// `sp20_controllers_compute_kernel.cu`). +/// +/// Buffer pointers MUST point to mapped-pinned device-visible +/// allocations: +/// - `isv_dev`: ISV bus, at least +/// [`crate::cuda_pipeline::sp14_isv_slots::SP20_ISV_SLOT_END`] f32s. +/// - `internal_dev`: 4-long f32 scratch laid out per +/// [`super::sp20_emas_compute::EMA_INTERNAL_SLOT_COUNT`] + +/// `EMA_INTERNAL_*` indices. +/// +/// # Safety +/// +/// Both buffer pointers MUST be valid mapped-pinned device pointers +/// obtained via `MappedF32Buffer::dev_ptr`. The caller MUST issue +/// this launch on the SAME stream as the upstream +/// `sp20_emas_compute` (so the stream-ordering invariant guarantees +/// every read here sees the EMAs Kernel 1 just blended this step). +/// Phase 1.4 lands the production wire site with that ordering +/// enforced. +pub unsafe fn launch_sp20_controllers_compute( + stream: &cudarc::driver::CudaStream, + kernel: &cudarc::driver::CudaFunction, + isv_dev: u64, + internal_dev: u64, +) -> Result<(), crate::MLError> { + use cudarc::driver::{LaunchConfig, PushKernelArg}; + + debug_assert!( + isv_dev != 0, + "launch_sp20_controllers_compute: isv_dev must be a valid device pointer" + ); + debug_assert!( + internal_dev != 0, + "launch_sp20_controllers_compute: internal_dev must be a valid device pointer" + ); + + // Slot indices passed in as kernel args so the kernel stays + // decoupled from SP14 slot-number drift (the contract is enforced + // by `sp14_isv_slots.rs` slot-locked tests). + let isv_idx_loss_cap: i32 = + crate::cuda_pipeline::sp14_isv_slots::LOSS_CAP_INDEX as i32; + let isv_idx_wr_ema: i32 = + crate::cuda_pipeline::sp14_isv_slots::WR_EMA_INDEX as i32; + let isv_idx_hold_cost_scale: i32 = + crate::cuda_pipeline::sp14_isv_slots::HOLD_COST_SCALE_INDEX as i32; + let isv_idx_hold_pct_ema: i32 = + crate::cuda_pipeline::sp14_isv_slots::HOLD_PCT_EMA_INDEX as i32; + let isv_idx_target_hold_pct: i32 = + crate::cuda_pipeline::sp14_isv_slots::TARGET_HOLD_PCT_INDEX as i32; + let isv_idx_n_step: i32 = + crate::cuda_pipeline::sp14_isv_slots::N_STEP_INDEX as i32; + let isv_idx_aux_conf_threshold: i32 = + crate::cuda_pipeline::sp14_isv_slots::AUX_CONF_THRESHOLD_INDEX as i32; + let isv_idx_aux_gate_temp: i32 = + crate::cuda_pipeline::sp14_isv_slots::AUX_GATE_TEMP_INDEX as i32; + + let cfg = LaunchConfig { + grid_dim: (1, 1, 1), + block_dim: (1, 1, 1), + shared_mem_bytes: 0, + }; + + stream + .launch_builder(kernel) + .arg(&isv_dev) + .arg(&internal_dev) + .arg(&isv_idx_loss_cap) + .arg(&isv_idx_wr_ema) + .arg(&isv_idx_hold_cost_scale) + .arg(&isv_idx_hold_pct_ema) + .arg(&isv_idx_target_hold_pct) + .arg(&isv_idx_n_step) + .arg(&isv_idx_aux_conf_threshold) + .arg(&isv_idx_aux_gate_temp) + .launch(cfg) + .map_err(|e| { + crate::MLError::ModelError(format!( + "sp20_controllers_compute_kernel launch: {e}" + )) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::cuda_pipeline::sp14_isv_slots::{ + AUX_CONF_THRESHOLD_INDEX, AUX_GATE_TEMP_INDEX, HOLD_COST_SCALE_INDEX, + HOLD_PCT_EMA_INDEX, LOSS_CAP_INDEX, N_STEP_INDEX, TARGET_HOLD_PCT_INDEX, + WR_EMA_INDEX, + }; + + /// All 6 output slots are inside the SP20 reserved range + /// [510..520). Locks the slot-layout contract against silent + /// changes that would put a controller output outside the range + /// reserved by SP20's spec. + #[test] + fn output_slots_within_sp20_reserved_range() { + const BASE: usize = 510; + const END: usize = 520; + for &slot in &[ + LOSS_CAP_INDEX, + HOLD_COST_SCALE_INDEX, + TARGET_HOLD_PCT_INDEX, + N_STEP_INDEX, + AUX_CONF_THRESHOLD_INDEX, + AUX_GATE_TEMP_INDEX, + ] { + assert!( + (BASE..END).contains(&slot), + "controller output slot {slot} must be inside [{BASE}..{END})" + ); + } + } + + /// All 6 output slots are unique. Locks against silent slot-index + /// collisions that would have one controller silently overwrite + /// another's output. + #[test] + fn output_slots_unique() { + let slots = [ + LOSS_CAP_INDEX, + HOLD_COST_SCALE_INDEX, + TARGET_HOLD_PCT_INDEX, + N_STEP_INDEX, + AUX_CONF_THRESHOLD_INDEX, + AUX_GATE_TEMP_INDEX, + ]; + for i in 0..slots.len() { + for j in (i + 1)..slots.len() { + assert_ne!( + slots[i], slots[j], + "output slots {} and {} collide at index {}", + i, j, slots[i] + ); + } + } + } + + /// The 2 ISV inputs (WR_EMA, HOLD_PCT_EMA) are also inside the + /// SP20 reserved range — they are written by Phase 1.2 and read + /// by this kernel in the same range. + #[test] + fn input_isv_slots_within_sp20_reserved_range() { + const BASE: usize = 510; + const END: usize = 520; + for &slot in &[WR_EMA_INDEX, HOLD_PCT_EMA_INDEX] { + assert!( + (BASE..END).contains(&slot), + "ISV input slot {slot} must be inside [{BASE}..{END})" + ); + } + } +} diff --git a/crates/ml/src/cuda_pipeline/sp20_controllers_compute_kernel.cu b/crates/ml/src/cuda_pipeline/sp20_controllers_compute_kernel.cu new file mode 100644 index 000000000..bc4d3a693 --- /dev/null +++ b/crates/ml/src/cuda_pipeline/sp20_controllers_compute_kernel.cu @@ -0,0 +1,200 @@ +/* ══════════════════════════════════════════════════════════════════════════ + * SP20 Phase 1.3 (2026-05-09) — sp20_controllers_compute kernel. + * + * Component 5 / Kernel 2 of the SP20 design — the deterministic derivation + * step. Reads the EMAs produced by Phase 1.2's `sp20_emas_compute` (Kernel + * 1) plus the per-step stats produced by Phase 1.1's `sp20_stats_compute` + * (Kernel 3) — already blended into the same internal scratch by Kernel 1 + * — and computes 6 controller outputs into ISV slots: + * + * ISV[LOSS_CAP_INDEX = 510] + * = -1.0 - clamp((wr_ema - 0.50) / 0.05, 0, 1) + * ramps from -1 (WR ≤ 50%) to -2 (WR ≥ 55%); reward kernel + * (Component 1) clamps R_event below at this value to grow the loss + * pain when WR drops. + * + * ISV[N_STEP_INDEX = 517] + * = clamp(roundf(trade_duration_ema), 1, 30) (stored as f32) + * adaptive n-step credit window — wider on long trades, tight on + * scalps. f32 storage matches the ISV bus contract; consumers cast + * to int at use site. + * + * ISV[AUX_CONF_THRESHOLD_INDEX = 518] + * = clamp(aux_dir_acc_ema - 0.50, 0.01, 0.20) + * aux→Q gate threshold — only let the aux head's signal through + * when its directional accuracy edge is above the floor. + * + * ISV[AUX_GATE_TEMP_INDEX = 519] + * = max(aux_conf_std_ema, 0.01) + * aux→Q gate temperature — softens the gate's hard threshold by the + * batch-level confidence dispersion. + * + * ISV[TARGET_HOLD_PCT_INDEX = 514] + * = clamp(0.8 - aux_conf_p50_ema * 1.5, 0.1, 0.8) + * adaptive Hold% target — high when aux is uniform (no edge ⇒ stay + * out), low when aux is sharp (edges to capture ⇒ trade). + * + * ISV[HOLD_COST_SCALE_INDEX = 513] + * two-sided multiplicative ramp: + * prev = ISV[HOLD_COST_SCALE_INDEX] + * hp = ISV[HOLD_PCT_EMA_INDEX] + * tgt = TARGET_HOLD_PCT_INDEX (just computed above) + * if hp > tgt + 0.05: new = clamp(prev * 1.05, 0.01, 0.5) + * elif hp < tgt - 0.05: new = clamp(prev * 0.95, 0.01, 0.5) + * else: new = prev + * drives the per-bar Hold cost up if model holds too much, down + * if it holds too little; deadband ±0.05 around the target. + * + * ── Pearls + invariants ──────────────────────────────────────────────── + * - `pearl_controller_anchors_isv_driven` — every output's anchor / + * target / cap derives from EMAs in the ISV / internal scratch; no + * hardcoded magic constants drive the controllers. + * - `feedback_isv_for_adaptive_bounds` — these 6 outputs ARE the + * adaptive bounds; their formulas reference EMAs as inputs. The + * structural constants (0.50, 0.05, 0.8, 1.5, 0.01, 0.20, 0.5, + * 1.05, 0.95) are spec-frozen ramp parameters, not tuned. + * - `pearl_no_host_branches_in_captured_graph` — single-block, + * single-thread kernel; safe to capture in the per-step CUDA Graph. + * - `feedback_no_atomicadd` — single-thread kernel; no concurrent + * writes anywhere. + * - `feedback_no_htod_htoh_only_mapped_pinned` — both ISV and the + * `internal` scratch are mapped-pinned device pointers; kernel + * emits `__threadfence_system()` after the writes for PCIe-visible + * coherence. + * - `feedback_no_cpu_compute_strict` — every formula lives in this + * kernel; the launcher only passes pointers + slot-index args. + * - `feedback_no_partial_refactor` — Phase 1.3 lands kernel + + * launcher + tests + build entry atomically. Phase 1.4 wires the + * production caller alongside Kernel 1 + Kernel 3. + * - `feedback_no_stubs` — every output written for real on every + * launch; HOLD_COST_SCALE in-place updates always write back + * (deadband path writes the unchanged previous value, not skipped). + * + * Ordering notes: + * - TARGET_HOLD_PCT MUST be computed BEFORE HOLD_COST_SCALE because + * the controller for HOLD_COST_SCALE reads `tgt`. We compute + * TARGET_HOLD_PCT into a local `tgt`, write it to ISV, then use + * the same local for the HOLD_COST_SCALE controller — avoids a + * redundant ISV read-back. + * - HOLD_COST_SCALE is the only output that READS its previous ISV + * value (in-place update). The other 5 are pure functions of the + * EMA inputs. + * + * Launch contract: + * grid_dim = (1, 1, 1) + * block_dim = (1, 1, 1) + * shared mem = 0 + * + * Args: + * isv — [ISV_TOTAL_DIM] f32 mapped-pinned dev ptr. Reads slots + * {WR_EMA, HOLD_PCT_EMA, HOLD_COST_SCALE}; writes slots + * {LOSS_CAP, HOLD_COST_SCALE, TARGET_HOLD_PCT, N_STEP, + * AUX_CONF_THRESHOLD, AUX_GATE_TEMP}. + * internal — [4] f32 mapped-pinned dev ptr. Reads slots + * {TRADE_DURATION (0), AUX_P50 (1), AUX_STD (2), + * AUX_DIR_ACC (3)} — written by Phase 1.2's kernel. + * isv_idx_* — six i32 ISV slot indices, passed in so the kernel + * stays decoupled from SP14 slot-number drift. + * ══════════════════════════════════════════════════════════════════════════ */ + +#include + +/* Internal scratch slot indices. MUST match the launcher's + * `EMA_INTERNAL_*` constants (which themselves match Kernel 1's + * macros). */ +#define INT_IDX_TRADE_DUR 0 +#define INT_IDX_AUX_P50 1 +#define INT_IDX_AUX_STD 2 +#define INT_IDX_AUX_DIR_ACC 3 + +extern "C" __global__ void sp20_controllers_compute_kernel( + float* __restrict__ isv, /* [ISV_TOTAL_DIM] */ + const float* __restrict__ internal,/* [4] f32 internal scratch */ + int isv_idx_loss_cap, + int isv_idx_wr_ema, + int isv_idx_hold_cost_scale, + int isv_idx_hold_pct_ema, + int isv_idx_target_hold_pct, + int isv_idx_n_step, + int isv_idx_aux_conf_threshold, + int isv_idx_aux_gate_temp) +{ + /* Single-block, single-thread contract. */ + if (blockIdx.x != 0) return; + if (threadIdx.x != 0) return; + + /* ── Read EMAs (single load each — kernel is one thread) ──────── */ + const float wr_ema = isv[isv_idx_wr_ema]; + const float hold_pct_ema = isv[isv_idx_hold_pct_ema]; + const float prev_cost_scale = isv[isv_idx_hold_cost_scale]; + const float trade_dur_ema = internal[INT_IDX_TRADE_DUR]; + const float aux_p50_ema = internal[INT_IDX_AUX_P50]; + const float aux_std_ema = internal[INT_IDX_AUX_STD]; + const float aux_dir_acc_ema = internal[INT_IDX_AUX_DIR_ACC]; + + /* ── 1. LOSS_CAP — ramp on WR_EMA ───────────────────────────────── + * = -1.0 - clamp((wr_ema - 0.50) / 0.05, 0, 1) + * Below 50% pinned at -1, above 55% pinned at -2, linear in [50, 55]. + */ + { + const float ramp = (wr_ema - 0.50f) / 0.05f; + const float ramp_clamped = fmaxf(0.0f, fminf(ramp, 1.0f)); + isv[isv_idx_loss_cap] = -1.0f - ramp_clamped; + } + + /* ── 2. N_STEP — clamp(round(trade_duration_ema), 1, 30) ───────── */ + { + const float rounded = roundf(trade_dur_ema); + const float clamped = fmaxf(1.0f, fminf(rounded, 30.0f)); + isv[isv_idx_n_step] = clamped; + } + + /* ── 3. AUX_CONF_THRESHOLD = clamp(dir_acc - 0.50, 0.01, 0.20) ─── */ + { + const float raw = aux_dir_acc_ema - 0.50f; + const float clamped = fmaxf(0.01f, fminf(raw, 0.20f)); + isv[isv_idx_aux_conf_threshold] = clamped; + } + + /* ── 4. AUX_GATE_TEMP = max(aux_conf_std_ema, 0.01) ────────────── */ + { + isv[isv_idx_aux_gate_temp] = fmaxf(aux_std_ema, 0.01f); + } + + /* ── 5. TARGET_HOLD_PCT = clamp(0.8 - p50 * 1.5, 0.1, 0.8) ────── + * Compute as local first so the HOLD_COST_SCALE controller below + * reads the freshly-computed value without a redundant ISV + * round-trip. */ + float tgt; + { + const float raw = 0.8f - aux_p50_ema * 1.5f; + tgt = fmaxf(0.1f, fminf(raw, 0.8f)); + isv[isv_idx_target_hold_pct] = tgt; + } + + /* ── 6. HOLD_COST_SCALE — two-sided ramp around tgt ±0.05 ──────── + * Reads its OWN previous value; deadband path writes prev verbatim + * so the controller always emits a fresh ISV write on every launch + * (Pearl: every output written for real per `feedback_no_stubs`). + */ + { + const float upper = tgt + 0.05f; + const float lower = tgt - 0.05f; + float new_scale; + if (hold_pct_ema > upper) { + new_scale = prev_cost_scale * 1.05f; + } else if (hold_pct_ema < lower) { + new_scale = prev_cost_scale * 0.95f; + } else { + new_scale = prev_cost_scale; + } + /* Final clamp to the spec-frozen [0.01, 0.5] range. */ + new_scale = fmaxf(0.01f, fminf(new_scale, 0.5f)); + isv[isv_idx_hold_cost_scale] = new_scale; + } + + /* PCIe-visible writes for mapped-pinned ISV. Single fence covers + * all 6 writes (single-thread kernel — sequentially consistent + * within the thread). */ + __threadfence_system(); +} diff --git a/crates/ml/tests/sp20_controllers_compute_test.rs b/crates/ml/tests/sp20_controllers_compute_test.rs new file mode 100644 index 000000000..72ee282e4 --- /dev/null +++ b/crates/ml/tests/sp20_controllers_compute_test.rs @@ -0,0 +1,432 @@ +//! SP20 Phase 1.3 (2026-05-09) — `sp20_controllers_compute_kernel` GPU oracle tests. +//! +//! Verifies the 6 derived controller producer kernel (Component 5 / Kernel 2 +//! of the SP20 design) reads the EMAs produced by Kernel 1 and Kernel 3 +//! and writes 6 ISV slots: +//! +//! - `LOSS_CAP_INDEX` (510) — ramp on `WR_EMA` +//! - `HOLD_COST_SCALE_INDEX` (513) — two-sided ramp on `HOLD_PCT_EMA` vs +//! `TARGET_HOLD_PCT` +//! - `TARGET_HOLD_PCT_INDEX` (514) — inverse-relation on `aux_conf_p50_ema` +//! - `N_STEP_INDEX` (517) — clamp(round(`trade_duration_ema`)) +//! - `AUX_CONF_THRESHOLD_INDEX` (518) — clamp(`aux_dir_acc_ema` − 0.50) +//! - `AUX_GATE_TEMP_INDEX` (519) — max(`aux_conf_std_ema`, 0.01) +//! +//! Tests assert clamp boundaries, formula correctness, and bidirectional +//! ramp behavior per `pearl_tests_must_prove_not_lock_observations` — never +//! observed-from-a-regression-run values. +//! +//! Run on a GPU host: +//! +//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \ +//! cargo test -p ml --test sp20_controllers_compute_test --features cuda \ +//! -- --ignored --nocapture + +#![allow(clippy::tests_outside_test_module)] + +#[cfg(feature = "cuda")] +#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory. +mod gpu { + use std::sync::Arc; + + use cudarc::driver::{CudaContext, CudaFunction, CudaStream}; + use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer; + use ml::cuda_pipeline::sp14_isv_slots::{ + AUX_CONF_THRESHOLD_INDEX, AUX_GATE_TEMP_INDEX, HOLD_COST_SCALE_INDEX, + HOLD_PCT_EMA_INDEX, LOSS_CAP_INDEX, N_STEP_INDEX, TARGET_HOLD_PCT_INDEX, + WR_EMA_INDEX, + }; + use ml::cuda_pipeline::sp20_controllers_compute::launch_sp20_controllers_compute; + use ml::cuda_pipeline::sp20_emas_compute::{ + EMA_INTERNAL_AUX_DIR_ACC, EMA_INTERNAL_AUX_P50, EMA_INTERNAL_AUX_STD, + EMA_INTERNAL_SLOT_COUNT, EMA_INTERNAL_TRADE_DURATION, + }; + + /// Total ISV slot count for the test allocator. Just enough to cover + /// the highest SP20 slot (519). + const ISV_TEST_LEN: usize = 520; + + const SP20_CONTROLLERS_COMPUTE_CUBIN: &[u8] = include_bytes!(concat!( + env!("OUT_DIR"), + "/sp20_controllers_compute_kernel.cubin" + )); + + fn make_test_stream() -> Arc { + let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?"); + ctx.default_stream() + } + + fn load_sp20_controllers_compute(stream: &Arc) -> CudaFunction { + let module = stream + .context() + .load_cubin(SP20_CONTROLLERS_COMPUTE_CUBIN.to_vec()) + .expect("load sp20_controllers_compute_kernel cubin"); + module + .load_function("sp20_controllers_compute_kernel") + .expect("load sp20_controllers_compute_kernel function") + } + + /// Test harness: seeds ISV[WR_EMA, HOLD_PCT_EMA, HOLD_COST_SCALE] + + /// internal[trade_dur, p50, std, dir_acc] with caller-supplied values, + /// then fires one launch and returns the post-launch ISV state. + /// + /// `prev_hold_cost_scale` — the existing HOLD_COST_SCALE value (the + /// kernel's two-sided controller reads this in-place). + fn run_one_launch( + wr_ema: f32, + hold_pct_ema: f32, + prev_hold_cost_scale: f32, + trade_duration_ema: f32, + aux_p50_ema: f32, + aux_std_ema: f32, + aux_dir_acc_ema: f32, + ) -> Vec { + let stream = make_test_stream(); + let kernel = load_sp20_controllers_compute(&stream); + + let isv = unsafe { MappedF32Buffer::new(ISV_TEST_LEN) }.expect("alloc isv"); + let mut isv_init = vec![0.0_f32; ISV_TEST_LEN]; + isv_init[WR_EMA_INDEX] = wr_ema; + isv_init[HOLD_PCT_EMA_INDEX] = hold_pct_ema; + isv_init[HOLD_COST_SCALE_INDEX] = prev_hold_cost_scale; + isv.write_from_slice(&isv_init); + + let internal = unsafe { MappedF32Buffer::new(EMA_INTERNAL_SLOT_COUNT) } + .expect("alloc internal"); + let mut internal_init = vec![0.0_f32; EMA_INTERNAL_SLOT_COUNT]; + internal_init[EMA_INTERNAL_TRADE_DURATION] = trade_duration_ema; + internal_init[EMA_INTERNAL_AUX_P50] = aux_p50_ema; + internal_init[EMA_INTERNAL_AUX_STD] = aux_std_ema; + internal_init[EMA_INTERNAL_AUX_DIR_ACC] = aux_dir_acc_ema; + internal.write_from_slice(&internal_init); + + unsafe { + launch_sp20_controllers_compute( + &stream, + &kernel, + isv.dev_ptr, + internal.dev_ptr, + ) + .expect("launch sp20_controllers_compute_kernel"); + } + stream.synchronize().expect("sync after sp20_controllers_compute"); + + isv.read_all() + } + + /// Test 1 — `LOSS_CAP_INDEX` ramp boundaries. + /// + /// Formula: `LOSS_CAP = -1.0 - clamp((wr - 0.50) / 0.05, 0, 1)`. + /// Expected: + /// - wr=0.50 → −1.0 (lower edge of ramp) + /// - wr=0.55 → −2.0 (upper edge of ramp) + /// - wr=0.40 → −1.0 (clamp lo: below 50% pinned at −1) + /// - wr=0.60 → −2.0 (clamp hi: above 55% pinned at −2) + #[test] + #[ignore = "requires GPU"] + fn loss_cap_ramp_boundaries() { + let tol = 1e-5_f32; + + // Lower edge of ramp. + let isv = run_one_launch(0.50, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5); + assert!( + (isv[LOSS_CAP_INDEX] - (-1.0)).abs() < tol, + "LOSS_CAP at wr=0.50 should be −1.0, got {}", + isv[LOSS_CAP_INDEX] + ); + + // Upper edge of ramp. + let isv = run_one_launch(0.55, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5); + assert!( + (isv[LOSS_CAP_INDEX] - (-2.0)).abs() < tol, + "LOSS_CAP at wr=0.55 should be −2.0, got {}", + isv[LOSS_CAP_INDEX] + ); + + // Clamp lo: below 50% must NOT continue past −1. + let isv = run_one_launch(0.40, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5); + assert!( + (isv[LOSS_CAP_INDEX] - (-1.0)).abs() < tol, + "LOSS_CAP at wr=0.40 (below 0.50) must clamp to −1.0, got {}", + isv[LOSS_CAP_INDEX] + ); + + // Clamp hi: above 55% must NOT continue past −2. + let isv = run_one_launch(0.60, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5); + assert!( + (isv[LOSS_CAP_INDEX] - (-2.0)).abs() < tol, + "LOSS_CAP at wr=0.60 (above 0.55) must clamp to −2.0, got {}", + isv[LOSS_CAP_INDEX] + ); + } + + /// Test 2 — `N_STEP_INDEX` bounds. + /// + /// Formula: `N_STEP = clamp(round(trade_duration_ema), 1, 30)`, + /// stored as f32. Expected: + /// - duration=0.4 → 1 (round → 0, clamp lo to 1) + /// - duration=2.5 → 2 or 3 (round, banker's or away-from-zero + /// — kernel uses `roundf` IEEE-754 + /// round-to-nearest, ties to even) + /// - duration=50 → 30 (clamp hi) + #[test] + #[ignore = "requires GPU"] + fn n_step_bounds() { + // Below 1 → clamp lo to 1. + let isv = run_one_launch(0.5, 0.5, 0.1, 0.4, 0.0, 0.05, 0.5); + assert!( + (isv[N_STEP_INDEX] - 1.0).abs() < 1e-5, + "N_STEP at duration=0.4 must clamp to 1, got {}", + isv[N_STEP_INDEX] + ); + + // 2.5 rounds to 2 or 3 (kernel uses roundf — round-to-nearest, + // ties to even ⇒ 2.5 → 2; halfway case is the only ambiguous one). + let isv = run_one_launch(0.5, 0.5, 0.1, 2.5, 0.0, 0.05, 0.5); + let n_step = isv[N_STEP_INDEX]; + assert!( + (n_step - 2.0).abs() < 1e-5 || (n_step - 3.0).abs() < 1e-5, + "N_STEP at duration=2.5 should round to 2 or 3, got {}", + n_step + ); + + // Above 30 → clamp hi to 30. + let isv = run_one_launch(0.5, 0.5, 0.1, 50.0, 0.0, 0.05, 0.5); + assert!( + (isv[N_STEP_INDEX] - 30.0).abs() < 1e-5, + "N_STEP at duration=50 must clamp to 30, got {}", + isv[N_STEP_INDEX] + ); + + // Mid-range: duration=10 → exactly 10. + let isv = run_one_launch(0.5, 0.5, 0.1, 10.0, 0.0, 0.05, 0.5); + assert!( + (isv[N_STEP_INDEX] - 10.0).abs() < 1e-5, + "N_STEP at duration=10 should be 10, got {}", + isv[N_STEP_INDEX] + ); + } + + /// Test 3 — `AUX_CONF_THRESHOLD_INDEX` bounds. + /// + /// Formula: `AUX_CONF_THRESHOLD = clamp(aux_dir_acc_ema − 0.50, 0.01, 0.20)`. + /// Expected: + /// - aux_dir_acc=0.40 → 0.01 (clamp lo: below 51% pinned at 0.01) + /// - aux_dir_acc=0.55 → 0.05 (mid-range) + /// - aux_dir_acc=0.80 → 0.20 (clamp hi: above 70% pinned at 0.20) + #[test] + #[ignore = "requires GPU"] + fn aux_conf_threshold_bounds() { + let tol = 1e-5_f32; + + // Clamp lo. + let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.40); + assert!( + (isv[AUX_CONF_THRESHOLD_INDEX] - 0.01).abs() < tol, + "AUX_CONF_THRESHOLD at aux_dir_acc=0.40 must clamp to 0.01, got {}", + isv[AUX_CONF_THRESHOLD_INDEX] + ); + + // Mid-range. + let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.55); + assert!( + (isv[AUX_CONF_THRESHOLD_INDEX] - 0.05).abs() < tol, + "AUX_CONF_THRESHOLD at aux_dir_acc=0.55 should be 0.05, got {}", + isv[AUX_CONF_THRESHOLD_INDEX] + ); + + // Clamp hi. + let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.80); + assert!( + (isv[AUX_CONF_THRESHOLD_INDEX] - 0.20).abs() < tol, + "AUX_CONF_THRESHOLD at aux_dir_acc=0.80 must clamp to 0.20, got {}", + isv[AUX_CONF_THRESHOLD_INDEX] + ); + } + + /// Test 4 — `AUX_GATE_TEMP_INDEX` floor. + /// + /// Formula: `AUX_GATE_TEMP = max(aux_conf_std_ema, 0.01)`. + /// Expected: + /// - aux_std=0.0 → 0.01 (floor) + /// - aux_std=0.05 → 0.05 (above floor, pass-through) + #[test] + #[ignore = "requires GPU"] + fn aux_gate_temp_floor() { + let tol = 1e-5_f32; + + // Below floor — pinned at floor. + let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.0, 0.5); + assert!( + (isv[AUX_GATE_TEMP_INDEX] - 0.01).abs() < tol, + "AUX_GATE_TEMP at aux_std=0 must equal floor 0.01, got {}", + isv[AUX_GATE_TEMP_INDEX] + ); + + // Above floor — pass-through. + let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5); + assert!( + (isv[AUX_GATE_TEMP_INDEX] - 0.05).abs() < tol, + "AUX_GATE_TEMP at aux_std=0.05 should be 0.05 (above floor), got {}", + isv[AUX_GATE_TEMP_INDEX] + ); + } + + /// Test 5 — `TARGET_HOLD_PCT_INDEX` inverse-relation + clamps. + /// + /// Formula: `TARGET_HOLD_PCT = clamp(0.8 − aux_conf_p50_ema * 1.5, 0.1, 0.8)`. + /// Expected: + /// - aux_p50=0.0 → 0.8 (high target when aux is uniform) + /// - aux_p50=0.5 → 0.1 (0.8 − 0.75 = 0.05, clamp lo to 0.1) + /// - aux_p50=0.2 → 0.5 (mid-range: 0.8 − 0.30 = 0.5) + #[test] + #[ignore = "requires GPU"] + fn target_hold_pct_inverse_relation() { + let tol = 1e-5_f32; + + // aux_p50=0 → 0.8 (clamp hi inactive — exact value). + let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.0, 0.05, 0.5); + assert!( + (isv[TARGET_HOLD_PCT_INDEX] - 0.8).abs() < tol, + "TARGET_HOLD_PCT at aux_p50=0 should be 0.8, got {}", + isv[TARGET_HOLD_PCT_INDEX] + ); + + // aux_p50=0.5 → 0.8 − 0.75 = 0.05 → clamp lo to 0.1. + let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.5, 0.05, 0.5); + assert!( + (isv[TARGET_HOLD_PCT_INDEX] - 0.1).abs() < tol, + "TARGET_HOLD_PCT at aux_p50=0.5 must clamp lo to 0.1, got {}", + isv[TARGET_HOLD_PCT_INDEX] + ); + + // Mid-range: aux_p50=0.2 → 0.8 − 0.30 = 0.5. + let isv = run_one_launch(0.5, 0.5, 0.1, 1.0, 0.2, 0.05, 0.5); + assert!( + (isv[TARGET_HOLD_PCT_INDEX] - 0.5).abs() < tol, + "TARGET_HOLD_PCT at aux_p50=0.2 should be 0.5, got {}", + isv[TARGET_HOLD_PCT_INDEX] + ); + } + + /// Test 6 — `HOLD_COST_SCALE_INDEX` two-sided multiplicative ramp. + /// + /// Algorithm: + /// prev = ISV[HOLD_COST_SCALE_INDEX] + /// tgt = TARGET_HOLD_PCT (computed this step) + /// if hp > tgt + 0.05 → new = clamp(prev * 1.05, 0.01, 0.5) + /// if hp < tgt − 0.05 → new = clamp(prev * 0.95, 0.01, 0.5) + /// else → new = prev + /// final clamp [0.01, 0.5] applies. + #[test] + #[ignore = "requires GPU"] + fn hold_cost_scale_two_sided_ramp() { + let tol = 1e-5_f32; + + // Setup: aux_p50=0 ⇒ tgt = 0.8. Test below/at/above. + + // hp=0.3, tgt=0.8 ⇒ hp < tgt - 0.05 ⇒ ramp DOWN by 5%. + let prev = 0.20_f32; + let isv = run_one_launch(0.5, 0.3, prev, 1.0, 0.0, 0.05, 0.5); + let expected_down = (prev * 0.95).clamp(0.01, 0.5); + assert!( + (isv[HOLD_COST_SCALE_INDEX] - expected_down).abs() < tol, + "HOLD_COST_SCALE: hp(0.3) < tgt(0.8) − 0.05 should ramp down 5%; \ + expected {expected_down}, got {}", + isv[HOLD_COST_SCALE_INDEX] + ); + + // Setup: aux_p50=0.4 ⇒ tgt = 0.8 − 0.6 = 0.2. Test above. + // hp=0.3, tgt=0.2 ⇒ hp > tgt + 0.05 = 0.25 ⇒ ramp UP by 5%. + let isv = run_one_launch(0.5, 0.3, prev, 1.0, 0.4, 0.05, 0.5); + let expected_up = (prev * 1.05).clamp(0.01, 0.5); + assert!( + (isv[HOLD_COST_SCALE_INDEX] - expected_up).abs() < tol, + "HOLD_COST_SCALE: hp(0.3) > tgt(0.2) + 0.05 should ramp up 5%; \ + expected {expected_up}, got {}", + isv[HOLD_COST_SCALE_INDEX] + ); + + // Setup: aux_p50=0.4 ⇒ tgt = 0.2. Test in deadband [0.15, 0.25]. + // hp=0.20, tgt=0.20 ⇒ |hp − tgt| <= 0.05 ⇒ unchanged. + let isv = run_one_launch(0.5, 0.20, prev, 1.0, 0.4, 0.05, 0.5); + assert!( + (isv[HOLD_COST_SCALE_INDEX] - prev).abs() < tol, + "HOLD_COST_SCALE: hp(0.20) within deadband of tgt(0.20) should stay {prev}, got {}", + isv[HOLD_COST_SCALE_INDEX] + ); + + // Clamp hi: prev=0.49, ramp-up should hit 0.5 cap (0.49 * 1.05 = 0.5145). + let isv = run_one_launch(0.5, 0.3, 0.49, 1.0, 0.4, 0.05, 0.5); + assert!( + (isv[HOLD_COST_SCALE_INDEX] - 0.5).abs() < tol, + "HOLD_COST_SCALE: ramp up from 0.49 must clamp hi to 0.5, got {}", + isv[HOLD_COST_SCALE_INDEX] + ); + + // Clamp lo: prev=0.01 (the floor itself), ramp-down would go to + // 0.0095 which clamps back up to 0.01. Verifies the lo clamp + // engages on multiplicative shrink that crosses the floor. + let isv = run_one_launch(0.5, 0.3, 0.01, 1.0, 0.0, 0.05, 0.5); + assert!( + (isv[HOLD_COST_SCALE_INDEX] - 0.01).abs() < tol, + "HOLD_COST_SCALE: ramp down from 0.01 (prev * 0.95 = 0.0095) must \ + clamp lo back to 0.01, got {}", + isv[HOLD_COST_SCALE_INDEX] + ); + } + + /// Test 7 — All 6 outputs are written in the same launch. + /// + /// A single launch with non-trivial inputs MUST update all 6 ISV + /// slots. This guards against missed writes (a regression where + /// the kernel forgot one output would be hard to spot in + /// per-controller tests above since they default-initialise the + /// slots to known values). + #[test] + #[ignore = "requires GPU"] + fn all_six_outputs_written_in_one_launch() { + // Use values that cause all 6 outputs to differ from any of + // the default ISV-init (0.0) or HOLD_COST_SCALE-init (0.1). + let isv = run_one_launch( + 0.55, // wr_ema → loss_cap = -2.0 + 0.50, // hold_pct → enters tgt-deadband for default tgt=0.8 NOT + // (0.5 < 0.75 = 0.8 − 0.05, so ramps DOWN 5%) + 0.20, // prev cost → 0.20 * 0.95 = 0.19 + 10.0, // trade_dur → n_step = 10 + 0.20, // aux_p50 → tgt = 0.5 + 0.05, // aux_std → temp = 0.05 + 0.55, // aux_dir_ac → threshold = 0.05 + ); + + // hold_pct=0.50 with tgt=0.5 falls in the deadband, so prev stays + // at 0.20 — verify the deadband path also writes (in-place write + // is still a write; this test ensures the kernel doesn't skip + // the write entirely on deadband). + // Re-run with hp clearly outside deadband for the differs-from-default + // assertion. + let isv2 = run_one_launch(0.55, 0.10, 0.20, 10.0, 0.20, 0.05, 0.55); + + assert!((isv[LOSS_CAP_INDEX] - (-2.0)).abs() < 1e-5, + "all-outputs: LOSS_CAP must be -2.0, got {}", isv[LOSS_CAP_INDEX]); + assert!((isv[N_STEP_INDEX] - 10.0).abs() < 1e-5, + "all-outputs: N_STEP must be 10, got {}", isv[N_STEP_INDEX]); + assert!((isv[AUX_CONF_THRESHOLD_INDEX] - 0.05).abs() < 1e-5, + "all-outputs: AUX_CONF_THRESHOLD must be 0.05, got {}", + isv[AUX_CONF_THRESHOLD_INDEX]); + assert!((isv[AUX_GATE_TEMP_INDEX] - 0.05).abs() < 1e-5, + "all-outputs: AUX_GATE_TEMP must be 0.05, got {}", + isv[AUX_GATE_TEMP_INDEX]); + assert!((isv[TARGET_HOLD_PCT_INDEX] - 0.5).abs() < 1e-5, + "all-outputs: TARGET_HOLD_PCT must be 0.5, got {}", + isv[TARGET_HOLD_PCT_INDEX]); + + // Second launch (hp clearly below tgt) must update HOLD_COST_SCALE + // away from prev=0.2 (downward ramp). + let expected_down = 0.20_f32 * 0.95; + assert!((isv2[HOLD_COST_SCALE_INDEX] - expected_down).abs() < 1e-5, + "all-outputs: HOLD_COST_SCALE must update on out-of-deadband hp; \ + expected {expected_down}, got {}", + isv2[HOLD_COST_SCALE_INDEX]); + } +} diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index 20d9f8f26..099de1f4e 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -11111,3 +11111,167 @@ pass on RTX 3050 Ti (sm_86). L40S verification deferred until Phase `obs_count` buffers (with fold-reset registry entries), and launches all 3 SP20 kernels in sequence on the same stream as the aux-head forward (Kernel 3 → Kernel 1 → Kernel 2). + +## 2026-05-09 — SP20 Phase 1.3: sp20_controllers_compute kernel (additive) + +### Component + +- New CUDA kernel `crates/ml/src/cuda_pipeline/sp20_controllers_compute_kernel.cu` +- New Rust launcher `crates/ml/src/cuda_pipeline/sp20_controllers_compute.rs` + (re-exported from `cuda_pipeline::sp20_controllers_compute`) +- New GPU oracle test `crates/ml/tests/sp20_controllers_compute_test.rs` +- Cubin manifest entry in `crates/ml/build.rs` + +### Purpose + +Component 5 / Kernel 2 of the SP20 fused-producer chain — the +deterministic derivation step. Reads the EMAs Phase 1.2's +`sp20_emas_compute` wrote (4 ISV + 4 internal scratch slots) and +derives 6 controller outputs into ISV slots: + +| Slot | Index | Formula | +|--------------------------------------------------|-------|------------------------------------------------------| +| `LOSS_CAP_INDEX` | 510 | `-1 - clamp((wr_ema - 0.50)/0.05, 0, 1)` ramps -1 → -2 over WR ∈ [50%, 55%] | +| `HOLD_COST_SCALE_INDEX` | 513 | two-sided ramp around `TARGET_HOLD_PCT ± 0.05`; ×1.05 / ×0.95; clamp [0.01, 0.5] | +| `TARGET_HOLD_PCT_INDEX` | 514 | `clamp(0.8 - aux_conf_p50_ema * 1.5, 0.1, 0.8)` | +| `N_STEP_INDEX` | 517 | `clamp(roundf(trade_duration_ema), 1, 30)` (f32) | +| `AUX_CONF_THRESHOLD_INDEX` | 518 | `clamp(aux_dir_acc_ema - 0.50, 0.01, 0.20)` | +| `AUX_GATE_TEMP_INDEX` | 519 | `max(aux_conf_std_ema, 0.01)` | + +### Algorithm + +Single-block, single-thread kernel. Per launch: + + 1. Read 3 ISV inputs (`WR_EMA`, `HOLD_PCT_EMA`, prev + `HOLD_COST_SCALE`) + 4 internal scratch slots (`trade_dur_ema`, + `aux_p50_ema`, `aux_std_ema`, `aux_dir_acc_ema`). + 2. Compute LOSS_CAP, N_STEP, AUX_CONF_THRESHOLD, AUX_GATE_TEMP + from inputs (each is a pure clamp/ramp formula). + 3. Compute TARGET_HOLD_PCT into a local `tgt`; write to ISV. + 4. HOLD_COST_SCALE controller reads `prev` from its own ISV slot + plus the freshly-computed `tgt` local, applies the two-sided + ramp, clamps to [0.01, 0.5], and writes back. The deadband + path writes the unchanged previous value verbatim — every + launch produces a fresh ISV write per `feedback_no_stubs`. + 5. `__threadfence_system()` covers all 6 ISV writes. + +### Ordering + +`TARGET_HOLD_PCT` MUST be computed before `HOLD_COST_SCALE` because +the HOLD_COST_SCALE controller reads `tgt`. Implemented by computing +the target into a local f32 first, writing to ISV, then re-using the +local for the HOLD_COST_SCALE controller (avoids a redundant ISV +read-back). + +`HOLD_COST_SCALE` is the only output that READS its own previous +ISV value (in-place update). The other 5 are pure functions of the +EMA inputs. + +### Wiring contract + +Phase 1.3 wires kernel + launcher + tests + build entry **only**. +The training-loop call site lands in Phase 1.4 atomically with +the rest of the SP20 reward chain (Kernel 1 = EMAs, Kernel 3 = +stats), per `feedback_no_partial_refactor`. + +Phase 1.4 must launch this kernel on the SAME stream as +`sp20_emas_compute_kernel` and AFTER it (Kernel 1 → Kernel 2), +so the in-stream-order invariant guarantees every read here sees +the EMAs Kernel 1 just blended this step. The trainer's per-step +state-tracker stream is the natural home for the 3-kernel chain +(Stats → EMAs → Controllers). + +The `cuda_pipeline::sp20_controllers_compute` module exports +`launch_sp20_controllers_compute` only — there are no contract +constants exclusive to this kernel; the slot indices come from +[`sp14_isv_slots`] and the internal scratch layout is owned by +[`sp20_emas_compute`]. + +### Pearls + invariants honoured + +- `pearl_controller_anchors_isv_driven` — every output's anchor / + target / cap derives from EMAs in the ISV / internal scratch; + no hardcoded magic constants drive the controllers, only + spec-frozen ramp / clamp parameters. +- `feedback_isv_for_adaptive_bounds` — these 6 outputs ARE the + adaptive bounds; downstream consumers read them rather than + embedding the formulas. +- `pearl_no_host_branches_in_captured_graph` — single-block, + single-thread kernel; safe to capture in the per-step CUDA + Graph. +- `feedback_no_atomicadd` — single-thread kernel; no concurrent + writes anywhere. +- `feedback_no_cpu_compute_strict` — every formula lives in the + kernel; the launcher passes only pointers + slot-index args. +- `feedback_no_htod_htoh_only_mapped_pinned` — both ISV and + `internal` scratch are mapped-pinned device pointers; kernel + emits `__threadfence_system()` for PCIe-visible coherence. +- `feedback_no_partial_refactor` — kernel + launcher + tests + + build entry land in one commit; production wire-up lands + atomically in Phase 1.4 alongside Kernel 1 + Kernel 3. +- `feedback_no_stubs` — every output written for real; HOLD_COST_SCALE + deadband path writes the unchanged `prev` verbatim, not skipped. +- `pearl_tests_must_prove_not_lock_observations` — companion tests + assert clamp boundaries, formula correctness, and bidirectional + ramp behavior, NOT specific observed values from a regression run. + +### Tests + +`crates/ml/tests/sp20_controllers_compute_test.rs` — 7 GPU oracle +tests (all `#[ignore = "requires GPU"]`): + + 1. `loss_cap_ramp_boundaries` — wr=0.50 → −1.0; wr=0.55 → −2.0; + wr=0.40 (below) → −1.0 clamped; wr=0.60 (above) → −2.0 clamped. + 2. `n_step_bounds` — duration=0.4 → 1 (clamp lo); duration=2.5 → + 2 or 3 (round); duration=10 → 10; duration=50 → 30 (clamp hi). + 3. `aux_conf_threshold_bounds` — dir_acc=0.40 → 0.01 (clamp lo); + dir_acc=0.55 → 0.05; dir_acc=0.80 → 0.20 (clamp hi). + 4. `aux_gate_temp_floor` — std=0 → 0.01 (floor); std=0.05 → 0.05. + 5. `target_hold_pct_inverse_relation` — p50=0 → 0.8; p50=0.5 → + 0.1 (clamp lo); p50=0.2 → 0.5 (mid). + 6. `hold_cost_scale_two_sided_ramp` — verifies 5% up-ramp on + hp > tgt+0.05; 5% down-ramp on hp < tgt−0.05; deadband + unchanged; clamp hi at 0.5 from prev=0.49 ramp-up; clamp lo + at 0.01 from prev=0.01 ramp-down (0.0095 → 0.01). + 7. `all_six_outputs_written_in_one_launch` — one launch updates + all 6 ISV slots; guards against a regression that forgets one + output. + +Plus 3 launcher unit tests (output slots within SP20 reserved +range; output slots unique; ISV input slots within SP20 reserved +range). + +### Files modified + +| File | Status | Purpose | +|------|--------|---------| +| `crates/ml/src/cuda_pipeline/sp20_controllers_compute_kernel.cu` | NEW | Single-block 6-controller producer kernel | +| `crates/ml/src/cuda_pipeline/sp20_controllers_compute.rs` | NEW | Rust launcher | +| `crates/ml/tests/sp20_controllers_compute_test.rs` | NEW | 7 GPU oracle tests | +| `crates/ml/src/cuda_pipeline/mod.rs` | +pub mod | Module declaration | +| `crates/ml/build.rs` | +cubin entry | Compile entry for nvcc | +| `docs/dqn-wire-up-audit.md` | This entry | Audit log | + +### Verification + +``` +SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check -p ml --features cuda +SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml \ + --test sp20_controllers_compute_test --features cuda -- --ignored --nocapture +SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml \ + --features cuda --lib sp20_controllers_compute +``` + +All 7 GPU oracle tests + 3 launcher unit tests pass on RTX 3050 Ti +(sm_86). L40S verification deferred until Phase 1.4 lands the +production caller. + +### Phase 1.3 → Phase 1.4 forward references + +- Phase 1.4: trainer launches the 3-kernel chain on the same stream + in order Stats (Kernel 3) → EMAs (Kernel 1) → Controllers (Kernel + 2), with the `internal` and `obs_count` buffers (allocated in + Phase 1.2's contract) shared between Kernel 1 and Kernel 2. +- The 6 ISV controller slots become the adaptive bounds Phase 1.4's + reward kernel + n-step credit + Hold cost reward + aux→Q gate + consumers read; no consumer has its own copy of these formulas.