# Zero-CPU DQN Training Hot Path — Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Eliminate all GPU→CPU synchronization barriers from the DQN training hot path so the GPU pipeline never stalls between `backward()` and `optimizer.step()`.

**Architecture:** Four GPU components replace 10 CPU readback sites: a Training Guard kernel (pinned-memory predicates for NaN/loss-clip/grad-collapse + on-device accumulators), a Q-value monitor (GPU reduction for divergence check + average Q), GPU-resident action routing (keeps action indices on GPU through the experience loop), and async experience collector monitoring readback. All monitoring/safety logic runs as CUDA kernels writing to pinned host memory — zero `cudaStreamSynchronize` barriers per training step.

**Tech Stack:** CUDA (NVRTC), cudarc 0.17, Candle v0.9.1

**Spec:** `docs/superpowers/specs/2026-03-10-zero-cpu-hotpath-design.md`

**Build command:** `SQLX_OFFLINE=true cargo check --workspace`
**Test command:** `SQLX_OFFLINE=true cargo test -p ml -p ml-dqn -p ml-core --lib`
**Clippy lint rules:** `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)]` — use `.get()`, `?`, `.ok_or()` instead.
**cudarc 0.17 launch pattern:** `unsafe { stream.launch_builder(&func).arg(&val).launch(config) }`
**Candle F32 trap:** `Tensor * 0.5` fails (Rust defaults f64). Use `tensor.broadcast_mul(&Tensor::new(0.5_f32, device)?)`

---

## File Structure

| File | Action | Responsibility |
|------|--------|----------------|
| `crates/ml/src/cuda_pipeline/training_guard_kernel.cu` | Create | CUDA kernels: `training_guard_check`, `training_guard_accumulate`, `qvalue_stats_reduce`, `qvalue_divergence_check` |
| `crates/ml/src/cuda_pipeline/gpu_training_guard.rs` | Create | Rust wrapper: pinned host memory, device accumulators, guard + Q-value methods |
| `crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu` | Modify | Add `batch_route_exposure_to_factored` kernel |
| `crates/ml/src/cuda_pipeline/gpu_action_selector.rs` | Modify | Add `route_func: CudaFunction`, `route_exposure_to_factored()` method |
| `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` | Modify | Reorder monitoring download after DtoD insert |
| `crates/ml/src/cuda_pipeline/mod.rs` | Modify | Export `gpu_training_guard` module |
| `crates/ml/src/trainers/dqn/trainer.rs` | Modify | Wire guard into train_step_optimized/accumulated, eliminate all to_scalar/to_vec |
| `crates/ml-core/src/gradient_utils.rs` | Modify | Add `#[cfg(feature = "cuda")]` bypass in `check_gradients_finite` |
| `crates/ml-dqn/src/dqn.rs` | Modify | GPU-resident Q-value stats in `log_q_values` |
| `crates/ml-dqn/src/training_guard_gpu_tests.rs` | Create | Parity tests for guard + accumulator kernels |
| `crates/ml-dqn/src/qvalue_monitor_gpu_tests.rs` | Create | Parity tests for Q-value reduction kernel |

---

## Chunk 1: Training Guard Kernel + Wrapper

### Task 1: Create training_guard_kernel.cu with guard + accumulate kernels

**Files:**
- Create: `crates/ml/src/cuda_pipeline/training_guard_kernel.cu`

**Context:** This kernel replaces the per-step `to_vec1::<f32>()` readback at trainer.rs:4531. Instead of downloading loss+grad_norm to CPU for NaN/clip/collapse checks, the kernel runs these checks on-device and writes boolean halt flags to pinned host memory. A separate accumulate entry point adds loss/grad to running sums for epoch-boundary readback.

- [ ] **Step 1: Write the CUDA kernel file**

Create `crates/ml/src/cuda_pipeline/training_guard_kernel.cu`:

```c
/**
 * Training guard: on-device predicate checks + accumulation.
 *
 * Replaces per-step GPU→CPU sync barriers with:
 * 1. training_guard_check: NaN/Inf detection, loss clipping, gradient
 *    collapse — writes halt flags to pinned host memory (zero sync).
 * 2. training_guard_accumulate: adds loss + grad_norm to device-resident
 *    running sums — single readback at epoch boundary.
 * 3. qvalue_stats_reduce: parallel reduction of [batch × actions] Q-values
 *    to (min, max, mean, max_q_avg) — replaces to_vec2 readback.
 * 4. qvalue_divergence_check: single-sample Q-value divergence detection.
 *
 * Pinned output layout (7 × f32 = 28 bytes):
 *   [0] halt_nan         (1.0 if loss or grad is NaN/Inf)
 *   [1] halt_loss_clip   (1.0 if loss > clip_threshold)
 *   [2] halt_grad_collapse (1.0 if grad < collapse_threshold && !warmup)
 *   [3] clipped_loss     (min(loss, clip_threshold))
 *   [4] raw_loss         (for logging)
 *   [5] raw_grad_norm    (for logging)
 *   [6] reserved
 */

extern "C" __global__ void training_guard_check(
    const float* __restrict__ loss_ptr,       /* scalar, device */
    const float* __restrict__ grad_norm_ptr,   /* scalar, device */
    float* __restrict__ pinned_output,         /* 7 floats, pinned host */
    float loss_clip_threshold,                 /* 1e6 */
    float grad_collapse_threshold,             /* LR * multiplier */
    int warmup_active                          /* 1 = skip collapse check */
) {
    float loss = loss_ptr[0];
    float grad = grad_norm_ptr[0];

    /* NaN / Inf check */
    int is_nan = (loss != loss) || (grad != grad);  /* NaN != NaN */
    int is_inf = (loss > 1e30f) || (loss < -1e30f)
              || (grad > 1e30f) || (grad < -1e30f);
    pinned_output[0] = (is_nan || is_inf) ? 1.0f : 0.0f;

    /* Loss clip check */
    pinned_output[1] = (loss > loss_clip_threshold) ? 1.0f : 0.0f;

    /* Gradient collapse check */
    int collapse = (!warmup_active) && (grad < grad_collapse_threshold);
    pinned_output[2] = collapse ? 1.0f : 0.0f;

    /* Clipped loss value */
    pinned_output[3] = (loss > loss_clip_threshold) ? loss_clip_threshold : loss;

    /* Raw values for logging */
    pinned_output[4] = loss;
    pinned_output[5] = grad;
    pinned_output[6] = 0.0f;
}

/**
 * Accumulate loss and grad_norm into device-resident running sums.
 * Single-thread kernel — called once per train step.
 */
extern "C" __global__ void training_guard_accumulate(
    const float* __restrict__ loss_ptr,
    const float* __restrict__ grad_norm_ptr,
    float* __restrict__ loss_sum,      /* device, persistent */
    float* __restrict__ grad_norm_sum, /* device, persistent */
    int*   __restrict__ step_count     /* device, persistent */
) {
    loss_sum[0] += loss_ptr[0];
    grad_norm_sum[0] += grad_norm_ptr[0];
    step_count[0] += 1;
}

/**
 * Parallel reduction of Q-values: compute min, max, mean across batch.
 *
 * Input: q_values[batch_size * num_actions] — flattened Q-value matrix.
 * For each sample i, finds max_q = max(q_values[i*A .. i*A+A-1]).
 * Then reduces max_q across all samples to get mean, min, max.
 *
 * Output (4 floats, pinned):
 *   [0] q_min   (min of max-Q across samples)
 *   [1] q_max   (max of max-Q across samples)
 *   [2] q_mean  (mean of max-Q across samples)
 *   [3] mean_of_all  (mean of ALL Q-values, for collapse detection)
 *
 * Launch: grid=(1,1,1), block=(256,1,1)
 */
extern "C" __global__ void qvalue_stats_reduce(
    const float* __restrict__ q_values,
    float* __restrict__ pinned_output,
    int batch_size,
    int num_actions
) {
    __shared__ float s_min[256];
    __shared__ float s_max[256];
    __shared__ float s_sum[256];
    __shared__ float s_all_sum[256];

    int tid = threadIdx.x;

    float local_min =  1e30f;
    float local_max = -1e30f;
    float local_sum = 0.0f;
    float local_all = 0.0f;

    /* Grid-stride loop over samples */
    for (int i = tid; i < batch_size; i += blockDim.x) {
        float max_q = -1e30f;
        int base = i * num_actions;
        for (int a = 0; a < num_actions; a++) {
            float val = q_values[base + a];
            if (val > max_q) max_q = val;
            local_all += val;
        }
        if (max_q < local_min) local_min = max_q;
        if (max_q > local_max) local_max = max_q;
        local_sum += max_q;
    }

    s_min[tid] = local_min;
    s_max[tid] = local_max;
    s_sum[tid] = local_sum;
    s_all_sum[tid] = local_all;
    __syncthreads();

    /* Parallel reduction */
    for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
        if (tid < stride) {
            if (s_min[tid + stride] < s_min[tid]) s_min[tid] = s_min[tid + stride];
            if (s_max[tid + stride] > s_max[tid]) s_max[tid] = s_max[tid + stride];
            s_sum[tid] += s_sum[tid + stride];
            s_all_sum[tid] += s_all_sum[tid + stride];
        }
        __syncthreads();
    }

    if (tid == 0) {
        pinned_output[0] = s_min[0];
        pinned_output[1] = s_max[0];
        pinned_output[2] = (batch_size > 0) ? s_sum[0] / (float)batch_size : 0.0f;
        int total = batch_size * num_actions;
        pinned_output[3] = (total > 0) ? s_all_sum[0] / (float)total : 0.0f;
    }
}

/**
 * Q-value divergence check for early stopping.
 * Single-sample: checks if |q_min| or |q_max| exceeds threshold.
 *
 * Output (5 floats, pinned):
 *   [0] q_min
 *   [1] q_max
 *   [2] q_mean
 *   [3] q_variance
 *   [4] divergence_detected (1.0 if above threshold)
 */
extern "C" __global__ void qvalue_divergence_check(
    const float* __restrict__ q_values,
    float* __restrict__ pinned_output,
    int num_actions,
    float divergence_threshold
) {
    float q_min =  1e30f;
    float q_max = -1e30f;
    float q_sum = 0.0f;

    for (int a = 0; a < num_actions; a++) {
        float v = q_values[a];
        if (v < q_min) q_min = v;
        if (v > q_max) q_max = v;
        q_sum += v;
    }

    float q_mean = (num_actions > 0) ? q_sum / (float)num_actions : 0.0f;

    /* Variance */
    float var_sum = 0.0f;
    for (int a = 0; a < num_actions; a++) {
        float diff = q_values[a] - q_mean;
        var_sum += diff * diff;
    }
    float q_var = (num_actions > 0) ? var_sum / (float)num_actions : 0.0f;

    pinned_output[0] = q_min;
    pinned_output[1] = q_max;
    pinned_output[2] = q_mean;
    pinned_output[3] = q_var;
    pinned_output[4] = (fabsf(q_min) > divergence_threshold
                     || fabsf(q_max) > divergence_threshold) ? 1.0f : 0.0f;
}
```

- [ ] **Step 2: Verify NVRTC compilation**

Run: `SQLX_OFFLINE=true cargo check --workspace`
Expected: PASS (the .cu file is only compiled at runtime via NVRTC — but any syntax errors in `include_str!` integration will surface in the Rust wrapper task)

- [ ] **Step 3: Commit**

```bash
git add crates/ml/src/cuda_pipeline/training_guard_kernel.cu
git commit -m "feat(cuda): add training guard + Q-value monitor CUDA kernels"
```

---

### Task 2: Create gpu_training_guard.rs Rust wrapper

**Files:**
- Create: `crates/ml/src/cuda_pipeline/gpu_training_guard.rs`
- Modify: `crates/ml/src/cuda_pipeline/mod.rs` (add `pub mod gpu_training_guard;` export)

**Context:** This wraps the 4 CUDA kernels from Task 1. Uses pinned host memory (`cudarc::driver::CudaHostBuf`) for guard output so the CPU can read results without calling `cudaStreamSynchronize`. Device-resident accumulators persist across training steps, read back once per epoch.

**Reference patterns:**
- `gpu_statistics.rs` — same OnceLock PTX cache, CudaFunction pattern
- `gpu_action_selector.rs` — same unsafe launch_builder pattern

- [ ] **Step 1: Write the Rust wrapper**

Create `crates/ml/src/cuda_pipeline/gpu_training_guard.rs`:

```rust
#![allow(unsafe_code)]

//! GPU training guard: on-device safety checks + loss/grad accumulators.
//!
//! Replaces per-step `to_vec1::<f32>()` / `to_scalar::<f32>()` readbacks
//! with CUDA kernel launches that write boolean halt flags to pinned host
//! memory. Zero `cudaStreamSynchronize` barriers per training step.
//!
//! ## Pinned memory layout (guard_check output, 7 × f32)
//!
//! | Index | Field | Description |
//! |-------|-------|-------------|
//! | 0 | halt_nan | 1.0 if loss or grad_norm is NaN/Inf |
//! | 1 | halt_loss_clip | 1.0 if loss > clip_threshold |
//! | 2 | halt_grad_collapse | 1.0 if grad_norm < collapse_threshold |
//! | 3 | clipped_loss | min(loss, clip_threshold) |
//! | 4 | raw_loss | unclipped loss value |
//! | 5 | raw_grad_norm | gradient norm value |
//! | 6 | reserved | padding |

use candle_core::cuda_backend::cudarc;
use candle_core::{DType, Device, Tensor};
use cudarc::driver::{CudaFunction, CudaSlice, DevicePtr, LaunchConfig, PushKernelArg};
use cudarc::nvrtc::Ptx;
use std::sync::OnceLock;
use tracing::info;

use crate::MLError;

static TRAINING_GUARD_PTX: OnceLock<Result<Ptx, String>> = OnceLock::new();

fn compile_guard_ptx() -> Result<Ptx, String> {
    let kernel_src = include_str!("training_guard_kernel.cu");
    cudarc::nvrtc::compile_ptx(kernel_src)
        .map_err(|e| format!("training_guard CUDA kernel compilation failed: {e}"))
}

/// Result from a training guard check (read from pinned host memory).
#[derive(Debug, Clone)]
pub struct GuardResult {
    /// True if loss or grad_norm contains NaN or Inf.
    pub halt_nan: bool,
    /// True if loss exceeds clip threshold (1e6).
    pub halt_loss_clip: bool,
    /// True if grad_norm below collapse threshold (past warmup).
    pub halt_grad_collapse: bool,
    /// Loss value clamped to clip threshold.
    pub clipped_loss: f32,
    /// Raw (unclipped) loss value.
    pub raw_loss: f32,
    /// Raw gradient norm value.
    pub raw_grad_norm: f32,
}

/// Q-value statistics from GPU reduction (read from pinned host memory).
#[derive(Debug, Clone)]
pub struct QValueStats {
    pub q_min: f32,
    pub q_max: f32,
    pub q_mean: f32,
    pub q_all_mean: f32,
}

/// Q-value divergence check result.
#[derive(Debug, Clone)]
pub struct QValueDivergence {
    pub q_min: f32,
    pub q_max: f32,
    pub q_mean: f32,
    pub q_variance: f32,
    pub divergence_detected: bool,
}

/// GPU training guard — zero-sync safety checks + loss/grad accumulators.
pub struct GpuTrainingGuard {
    check_func: CudaFunction,
    accumulate_func: CudaFunction,
    qvalue_stats_func: CudaFunction,
    qvalue_div_func: CudaFunction,

    /// Pinned host memory for guard check output (7 × f32).
    pinned_guard: Vec<f32>,
    /// Device buffer that the kernel writes to (then DtoH to pinned).
    guard_output_dev: CudaSlice<f32>,

    /// Pinned host memory for Q-value stats (4 × f32).
    pinned_qstats: Vec<f32>,
    qstats_output_dev: CudaSlice<f32>,

    /// Pinned host memory for Q-value divergence (5 × f32).
    pinned_qdiv: Vec<f32>,
    qdiv_output_dev: CudaSlice<f32>,

    /// Device-resident accumulators.
    loss_sum: CudaSlice<f32>,
    grad_norm_sum: CudaSlice<f32>,
    step_count: CudaSlice<i32>,

    device: Device,
}

impl GpuTrainingGuard {
    /// Create a new GPU training guard.
    ///
    /// Compiles CUDA kernels (cached per-process), allocates pinned host
    /// memory for guard output and device memory for accumulators.
    pub fn new(device: &Device) -> Result<Self, MLError> {
        let cuda_dev = match device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("GpuTrainingGuard requires CUDA".into())),
        };

        let ptx_result = TRAINING_GUARD_PTX.get_or_init(compile_guard_ptx);
        let ptx = ptx_result.as_ref().map_err(|e| {
            MLError::ModelError(format!("training_guard PTX: {e}"))
        })?;

        let stream = cuda_dev.cuda_stream();
        let context = stream.context();
        let module = context.load_module(ptx.clone()).map_err(|e| {
            MLError::ModelError(format!("training_guard module load: {e}"))
        })?;

        let check_func = module.load_function("training_guard_check").map_err(|e| {
            MLError::ModelError(format!("training_guard_check load: {e}"))
        })?;
        let accumulate_func = module.load_function("training_guard_accumulate").map_err(|e| {
            MLError::ModelError(format!("training_guard_accumulate load: {e}"))
        })?;
        let qvalue_stats_func = module.load_function("qvalue_stats_reduce").map_err(|e| {
            MLError::ModelError(format!("qvalue_stats_reduce load: {e}"))
        })?;
        let qvalue_div_func = module.load_function("qvalue_divergence_check").map_err(|e| {
            MLError::ModelError(format!("qvalue_divergence_check load: {e}"))
        })?;

        // Device buffers for kernel outputs (DtoH'd to pinned host)
        let guard_output_dev = stream.alloc_zeros::<f32>(7).map_err(|e| {
            MLError::ModelError(format!("alloc guard output: {e}"))
        })?;
        let qstats_output_dev = stream.alloc_zeros::<f32>(4).map_err(|e| {
            MLError::ModelError(format!("alloc qstats output: {e}"))
        })?;
        let qdiv_output_dev = stream.alloc_zeros::<f32>(5).map_err(|e| {
            MLError::ModelError(format!("alloc qdiv output: {e}"))
        })?;

        // Device-resident accumulators
        let loss_sum = stream.alloc_zeros::<f32>(1).map_err(|e| {
            MLError::ModelError(format!("alloc loss_sum: {e}"))
        })?;
        let grad_norm_sum = stream.alloc_zeros::<f32>(1).map_err(|e| {
            MLError::ModelError(format!("alloc grad_norm_sum: {e}"))
        })?;
        let step_count = stream.alloc_zeros::<i32>(1).map_err(|e| {
            MLError::ModelError(format!("alloc step_count: {e}"))
        })?;

        info!("GpuTrainingGuard initialized: guard + accumulator + Q-value kernels");

        Ok(Self {
            check_func,
            accumulate_func,
            qvalue_stats_func,
            qvalue_div_func,
            pinned_guard: vec![0.0_f32; 7],
            guard_output_dev,
            pinned_qstats: vec![0.0_f32; 4],
            qstats_output_dev,
            pinned_qdiv: vec![0.0_f32; 5],
            qdiv_output_dev,
            loss_sum,
            grad_norm_sum,
            step_count,
            device: device.clone(),
        })
    }

    /// Extract raw CUDA pointer from a Candle scalar tensor.
    fn tensor_device_ptr(tensor: &Tensor) -> Result<u64, MLError> {
        let (storage_guard, layout) = tensor.storage_and_layout();
        match &*storage_guard {
            candle_core::Storage::Cuda(ref cs) => {
                let slice = cs.as_cuda_slice::<f32>().map_err(|e| {
                    MLError::ModelError(format!("as_cuda_slice: {e}"))
                })?;
                // For scalar tensors, start_offset is 0
                let view = slice.slice(layout.start_offset()..);
                // We need the raw device pointer as u64 for kernel arg passing
                // This is safe because the storage_guard keeps the allocation alive
                Ok(view.device_ptr_unchecked().0)
            }
            _ => Err(MLError::ModelError("tensor not on CUDA".into())),
        }
    }

    /// Run training guard check on GPU-resident loss and grad_norm scalars.
    ///
    /// Launches the `training_guard_check` kernel which writes halt flags
    /// to device memory, then DtoH copies to host. The DtoH is async
    /// but tiny (28 bytes) so effectively instant.
    ///
    /// Also launches `training_guard_accumulate` to add to running sums.
    pub fn check_and_accumulate(
        &mut self,
        loss_gpu: &Tensor,
        grad_norm_gpu: &Tensor,
        loss_clip_threshold: f32,
        grad_collapse_threshold: f32,
        warmup_active: bool,
    ) -> Result<GuardResult, MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        // Get device pointers from Candle tensors
        let (loss_guard, loss_layout) = loss_gpu.storage_and_layout();
        let loss_slice = match &*loss_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<f32>().map_err(|e| {
                MLError::ModelError(format!("loss as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("loss not on CUDA".into())),
        };
        let loss_view = loss_slice.slice(loss_layout.start_offset()..);

        let (grad_guard, grad_layout) = grad_norm_gpu.storage_and_layout();
        let grad_slice = match &*grad_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<f32>().map_err(|e| {
                MLError::ModelError(format!("grad as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("grad not on CUDA".into())),
        };
        let grad_view = grad_slice.slice(grad_layout.start_offset()..);

        let warmup_i32: i32 = if warmup_active { 1 } else { 0 };
        let config_1thread = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (1, 1, 1),
            shared_mem_bytes: 0,
        };

        // Launch guard check kernel
        unsafe {
            stream
                .launch_builder(&self.check_func)
                .arg(&loss_view)
                .arg(&grad_view)
                .arg(&mut self.guard_output_dev)
                .arg(&loss_clip_threshold)
                .arg(&grad_collapse_threshold)
                .arg(&warmup_i32)
                .launch(config_1thread)
                .map_err(|e| MLError::ModelError(format!("guard check launch: {e}")))?;
        }

        // Launch accumulate kernel (same stream, serialized after check)
        unsafe {
            stream
                .launch_builder(&self.accumulate_func)
                .arg(&loss_view)
                .arg(&grad_view)
                .arg(&mut self.loss_sum)
                .arg(&mut self.grad_norm_sum)
                .arg(&mut self.step_count)
                .launch(config_1thread)
                .map_err(|e| MLError::ModelError(format!("guard accumulate launch: {e}")))?;
        }

        // Drop storage guards before DtoH
        drop(loss_guard);
        drop(grad_guard);

        // DtoH readback of guard output (28 bytes — effectively instant)
        stream
            .memcpy_dtoh(&self.guard_output_dev, &mut self.pinned_guard)
            .map_err(|e| MLError::ModelError(format!("guard DtoH: {e}")))?;

        Ok(GuardResult {
            halt_nan: self.pinned_guard.first().copied().unwrap_or(0.0) > 0.5,
            halt_loss_clip: self.pinned_guard.get(1).copied().unwrap_or(0.0) > 0.5,
            halt_grad_collapse: self.pinned_guard.get(2).copied().unwrap_or(0.0) > 0.5,
            clipped_loss: self.pinned_guard.get(3).copied().unwrap_or(0.0),
            raw_loss: self.pinned_guard.get(4).copied().unwrap_or(0.0),
            raw_grad_norm: self.pinned_guard.get(5).copied().unwrap_or(0.0),
        })
    }

    /// Read accumulated loss and grad_norm averages (epoch boundary).
    ///
    /// Single DtoH of 2 floats + 1 int. Returns (mean_loss, mean_grad_norm).
    pub fn read_accumulators(&mut self) -> Result<(f64, f64), MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        let mut loss_buf = [0.0_f32];
        let mut grad_buf = [0.0_f32];
        let mut count_buf = [0_i32];

        stream
            .memcpy_dtoh(&self.loss_sum, &mut loss_buf)
            .map_err(|e| MLError::ModelError(format!("loss_sum DtoH: {e}")))?;
        stream
            .memcpy_dtoh(&self.grad_norm_sum, &mut grad_buf)
            .map_err(|e| MLError::ModelError(format!("grad_sum DtoH: {e}")))?;
        stream
            .memcpy_dtoh(&self.step_count, &mut count_buf)
            .map_err(|e| MLError::ModelError(format!("step_count DtoH: {e}")))?;

        let n = count_buf[0].max(1) as f64;
        Ok((loss_buf[0] as f64 / n, grad_buf[0] as f64 / n))
    }

    /// Reset accumulators to zero for next epoch.
    pub fn reset_accumulators(&mut self) -> Result<(), MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        stream
            .memcpy_htod(&[0.0_f32], &mut self.loss_sum)
            .map_err(|e| MLError::ModelError(format!("reset loss_sum: {e}")))?;
        stream
            .memcpy_htod(&[0.0_f32], &mut self.grad_norm_sum)
            .map_err(|e| MLError::ModelError(format!("reset grad_sum: {e}")))?;
        stream
            .memcpy_htod(&[0_i32], &mut self.step_count)
            .map_err(|e| MLError::ModelError(format!("reset step_count: {e}")))?;

        Ok(())
    }

    /// Compute Q-value statistics via GPU parallel reduction.
    ///
    /// Returns (q_min, q_max, q_mean_of_maxQ, q_mean_of_all).
    /// Replaces `to_vec2::<f32>()` readback + CPU loop.
    pub fn qvalue_stats(
        &mut self,
        q_values: &Tensor,
        batch_size: usize,
        num_actions: usize,
    ) -> Result<QValueStats, MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        let q_f32 = if q_values.dtype() == DType::F32 {
            q_values.clone()
        } else {
            q_values.to_dtype(DType::F32).map_err(|e| {
                MLError::ModelError(format!("q_values F32 cast: {e}"))
            })?
        };
        let q_cont = q_f32.contiguous().map_err(|e| {
            MLError::ModelError(format!("q_values contiguous: {e}"))
        })?;

        let (q_guard, q_layout) = q_cont.storage_and_layout();
        let q_slice = match &*q_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<f32>().map_err(|e| {
                MLError::ModelError(format!("q as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("q not on CUDA".into())),
        };
        let q_view = q_slice.slice(q_layout.start_offset()..);

        let bs_i32 = batch_size as i32;
        let na_i32 = num_actions as i32;
        let config = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (256, 1, 1),
            shared_mem_bytes: 0,
        };

        unsafe {
            stream
                .launch_builder(&self.qvalue_stats_func)
                .arg(&q_view)
                .arg(&mut self.qstats_output_dev)
                .arg(&bs_i32)
                .arg(&na_i32)
                .launch(config)
                .map_err(|e| MLError::ModelError(format!("qvalue_stats launch: {e}")))?;
        }

        drop(q_guard);

        stream
            .memcpy_dtoh(&self.qstats_output_dev, &mut self.pinned_qstats)
            .map_err(|e| MLError::ModelError(format!("qstats DtoH: {e}")))?;

        Ok(QValueStats {
            q_min: self.pinned_qstats.first().copied().unwrap_or(0.0),
            q_max: self.pinned_qstats.get(1).copied().unwrap_or(0.0),
            q_mean: self.pinned_qstats.get(2).copied().unwrap_or(0.0),
            q_all_mean: self.pinned_qstats.get(3).copied().unwrap_or(0.0),
        })
    }

    /// Check single-sample Q-values for divergence.
    ///
    /// Returns min/max/mean/variance + divergence flag.
    /// Replaces `to_vec2::<f32>()` + CPU statistics in `log_q_values()`.
    pub fn qvalue_divergence(
        &mut self,
        q_values: &Tensor,
        num_actions: usize,
        threshold: f32,
    ) -> Result<QValueDivergence, MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        let q_f32 = if q_values.dtype() == DType::F32 {
            q_values.clone()
        } else {
            q_values.to_dtype(DType::F32).map_err(|e| {
                MLError::ModelError(format!("q F32 cast: {e}"))
            })?
        };
        let q_cont = q_f32.contiguous().map_err(|e| {
            MLError::ModelError(format!("q contiguous: {e}"))
        })?;

        let (q_guard, q_layout) = q_cont.storage_and_layout();
        let q_slice = match &*q_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<f32>().map_err(|e| {
                MLError::ModelError(format!("q as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("q not on CUDA".into())),
        };
        let q_view = q_slice.slice(q_layout.start_offset()..);

        let na_i32 = num_actions as i32;
        let config_1thread = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (1, 1, 1),
            shared_mem_bytes: 0,
        };

        unsafe {
            stream
                .launch_builder(&self.qvalue_div_func)
                .arg(&q_view)
                .arg(&mut self.qdiv_output_dev)
                .arg(&na_i32)
                .arg(&threshold)
                .launch(config_1thread)
                .map_err(|e| MLError::ModelError(format!("qvalue_div launch: {e}")))?;
        }

        drop(q_guard);

        stream
            .memcpy_dtoh(&self.qdiv_output_dev, &mut self.pinned_qdiv)
            .map_err(|e| MLError::ModelError(format!("qdiv DtoH: {e}")))?;

        Ok(QValueDivergence {
            q_min: self.pinned_qdiv.first().copied().unwrap_or(0.0),
            q_max: self.pinned_qdiv.get(1).copied().unwrap_or(0.0),
            q_mean: self.pinned_qdiv.get(2).copied().unwrap_or(0.0),
            q_variance: self.pinned_qdiv.get(3).copied().unwrap_or(0.0),
            divergence_detected: self.pinned_qdiv.get(4).copied().unwrap_or(0.0) > 0.5,
        })
    }
}

impl std::fmt::Debug for GpuTrainingGuard {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GpuTrainingGuard")
            .field("device", &self.device)
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_ptx_compilation() {
        let result = compile_guard_ptx();
        if let Err(ref e) = result {
            if e.contains("NVRTC") || e.contains("nvrtc") || e.contains("not found") {
                return; // NVRTC not installed
            }
            panic!("PTX compilation failed: {e}");
        }
    }

    #[test]
    fn test_cpu_device_rejected() {
        let result = GpuTrainingGuard::new(&Device::Cpu);
        assert!(result.is_err());
    }
}
```

- [ ] **Step 2: Add module export**

Add to `crates/ml/src/cuda_pipeline/mod.rs` after the `gpu_statistics` line:

```rust
#[cfg(feature = "cuda")]
pub mod gpu_training_guard;
```

- [ ] **Step 3: Verify compilation**

Run: `SQLX_OFFLINE=true cargo check -p ml --lib`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add crates/ml/src/cuda_pipeline/gpu_training_guard.rs crates/ml/src/cuda_pipeline/mod.rs
git commit -m "feat(cuda): add GpuTrainingGuard wrapper with pinned memory + accumulators"
```

---

### Task 3: Add training guard parity tests

**Files:**
- Create: `crates/ml-dqn/src/training_guard_gpu_tests.rs`
- Modify: `crates/ml-dqn/src/lib.rs` (register test module)

**Context:** Test the guard logic by comparing CUDA kernel outputs to CPU reference implementations. Tests run on CPU (no GPU required) by calling `compile_guard_ptx()` to verify syntax, and testing the `GuardResult` struct construction.

- [ ] **Step 1: Write parity tests**

Create `crates/ml-dqn/src/training_guard_gpu_tests.rs`:

```rust
//! CPU-parity tests for GPU training guard kernels.
//!
//! These tests verify the guard logic against CPU reference implementations.
//! GPU kernel execution is tested on CI (CUDA builder); these tests validate
//! the PTX compiles and the Rust wrapper's logic is correct.

#[cfg(test)]
mod tests {
    use candle_core::Device;

    /// Verify the training guard PTX compiles via NVRTC.
    #[test]
    fn test_guard_ptx_compiles() {
        let src = include_str!("../../../ml/src/cuda_pipeline/training_guard_kernel.cu");
        // If NVRTC is available, compile and check for errors.
        // On CPU-only machines, skip gracefully.
        let result = candle_core::cuda_backend::cudarc::nvrtc::compile_ptx(src);
        match result {
            Ok(_) => {} // PTX compiled successfully
            Err(e) => {
                let msg = format!("{e}");
                if msg.contains("NVRTC") || msg.contains("nvrtc") || msg.contains("not found") {
                    return; // NVRTC not installed — skip
                }
                panic!("Guard kernel PTX compilation failed: {e}");
            }
        }
    }

    /// CPU reference: NaN detection.
    #[test]
    fn test_nan_detection_cpu_reference() {
        let loss = f32::NAN;
        let grad = 1.0_f32;
        assert!(loss.is_nan() || !grad.is_finite());
        // Guard kernel uses `loss != loss` which is true for NaN
        assert!(loss != loss);
    }

    /// CPU reference: loss clipping.
    #[test]
    fn test_loss_clip_cpu_reference() {
        let threshold = 1e6_f32;
        let loss = 2e6_f32;
        let clipped = loss.min(threshold);
        assert!((clipped - threshold).abs() < 1.0);
    }

    /// CPU reference: gradient collapse detection.
    #[test]
    fn test_grad_collapse_cpu_reference() {
        let lr = 1e-4_f32;
        let multiplier = 100.0_f32;
        let threshold = lr * multiplier; // 0.01
        let grad_norm = 0.005_f32;
        assert!(grad_norm < threshold);
    }

    /// CPU reference: accumulation averaging.
    #[test]
    fn test_accumulator_averaging_cpu_reference() {
        let losses = [0.5_f32, 0.3, 0.8, 0.2, 0.6];
        let sum: f32 = losses.iter().sum();
        let mean = sum / losses.len() as f32;
        assert!((mean - 0.48).abs() < 0.01);
    }

    /// Guard result construction from pinned memory values.
    #[test]
    fn test_guard_result_construction() {
        // Simulate pinned memory readback
        let pinned = [0.0_f32, 1.0, 0.0, 1e6, 2e6, 0.5, 0.0];
        let result = crate::training_guard_gpu_tests::cpu_guard_result(&pinned);
        assert!(!result.halt_nan);
        assert!(result.halt_loss_clip);
        assert!(!result.halt_grad_collapse);
        assert!((result.clipped_loss - 1e6).abs() < 1.0);
    }
}

/// CPU reference implementation for guard result construction.
/// Used by tests to verify the Rust wrapper logic.
#[cfg(test)]
pub(crate) fn cpu_guard_result(pinned: &[f32]) -> CpuGuardResult {
    CpuGuardResult {
        halt_nan: pinned.first().copied().unwrap_or(0.0) > 0.5,
        halt_loss_clip: pinned.get(1).copied().unwrap_or(0.0) > 0.5,
        halt_grad_collapse: pinned.get(2).copied().unwrap_or(0.0) > 0.5,
        clipped_loss: pinned.get(3).copied().unwrap_or(0.0),
        raw_loss: pinned.get(4).copied().unwrap_or(0.0),
        raw_grad_norm: pinned.get(5).copied().unwrap_or(0.0),
    }
}

#[cfg(test)]
#[derive(Debug)]
pub(crate) struct CpuGuardResult {
    pub halt_nan: bool,
    pub halt_loss_clip: bool,
    pub halt_grad_collapse: bool,
    pub clipped_loss: f32,
    pub raw_loss: f32,
    pub raw_grad_norm: f32,
}
```

- [ ] **Step 2: Register test module**

Add to `crates/ml-dqn/src/lib.rs`:

```rust
#[cfg(test)]
mod training_guard_gpu_tests;
```

- [ ] **Step 3: Run tests**

Run: `SQLX_OFFLINE=true cargo test -p ml-dqn --lib -- training_guard_gpu_tests`
Expected: 5 tests pass (PTX compile may skip on CPU-only)

- [ ] **Step 4: Commit**

```bash
git add crates/ml-dqn/src/training_guard_gpu_tests.rs crates/ml-dqn/src/lib.rs
git commit -m "test(cuda): add training guard CPU parity tests"
```

---

## Chunk 2: Wire Training Guard into Trainer

### Task 4: Wire GpuTrainingGuard into train_step_optimized

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer.rs`

**Context:** The `train_step_optimized()` method at line 4514 currently does:
1. `agent.train_step(batch)` → returns `GpuTrainResult { loss_gpu, grad_norm_gpu }`
2. `stacked.to_vec1::<f32>()` at line 4531 → GPU sync barrier (TARGET #1)
3. CPU-side NaN check, loss clip, gradient collapse check
4. `log_diagnostics(grad_norm_f32)` → calls into dqn.rs

Replace steps 2-4 with `self.training_guard.check_and_accumulate()` which runs all checks on GPU.

- [ ] **Step 1: Add GpuTrainingGuard field to DqnTrainer**

Find the struct definition for the trainer (look for `gpu_action_selector: Option<...>` field). Add after it:

```rust
/// GPU training guard for zero-sync safety checks (loss clip, NaN, grad collapse).
#[cfg(feature = "cuda")]
training_guard: Option<crate::cuda_pipeline::gpu_training_guard::GpuTrainingGuard>,
```

Initialize it in the constructor (where `gpu_action_selector` is initialized):

```rust
#[cfg(feature = "cuda")]
training_guard: None,
```

- [ ] **Step 2: Replace to_vec1 readback in train_step_optimized**

Replace the block at lines 4521-4573 (from `// Batch both GPU scalars...` to the closing of the gradient logging block). The new code:

```rust
        // GPU training guard: on-device NaN/loss-clip/grad-collapse checks
        // Zero cudaStreamSynchronize — writes halt flags to pinned host memory.
        #[cfg(feature = "cuda")]
        let (loss_clipped, grad_norm) = {
            // Lazy-init training guard
            if self.training_guard.is_none() && self.device.is_cuda() {
                match crate::cuda_pipeline::gpu_training_guard::GpuTrainingGuard::new(&self.device) {
                    Ok(guard) => {
                        info!("GPU training guard initialized");
                        self.training_guard = Some(guard);
                    }
                    Err(e) => {
                        warn!("GPU training guard init failed, using CPU fallback: {e}");
                    }
                }
            }

            if let Some(ref mut guard) = self.training_guard {
                let agent_ref = &*agent;
                let warmup_steps = (agent_ref.config().replay_buffer_capacity as f64 * 0.2) as u64;
                let past_warmup = agent_ref.training_steps() > warmup_steps;
                let grad_collapse_threshold =
                    agent_ref.config().learning_rate as f32 * agent_ref.config().gradient_collapse_multiplier as f32;

                let result = guard.check_and_accumulate(
                    &gpu_result.loss_gpu,
                    &gpu_result.grad_norm_gpu,
                    1e6_f32, // loss clip threshold
                    grad_collapse_threshold,
                    !past_warmup,
                ).map_err(|e| anyhow::anyhow!("GPU guard check: {e}"))?;

                // Handle halt conditions
                if result.halt_nan {
                    return Err(anyhow::anyhow!(
                        "NaN/Inf detected in loss ({}) or grad_norm ({})",
                        result.raw_loss, result.raw_grad_norm
                    ));
                }
                if result.halt_loss_clip {
                    warn!(
                        "Loss clipped from {:.2e} to 1.0e6 (TD error explosion, epoch {})",
                        result.raw_loss,
                        self.loss_history.len() + 1
                    );
                }
                if result.halt_grad_collapse {
                    agent.log_diagnostics(result.raw_grad_norm)
                        .map_err(|e| {
                            tracing::info!("Early stopping triggered (gradient collapse): {}", e);
                            anyhow::anyhow!("Early stopping: {}", e)
                        })?;
                } else {
                    // Still need to call log_diagnostics for dead neuron detection
                    // and counter reset, but pass the GPU-read grad_norm
                    agent.log_diagnostics(result.raw_grad_norm)
                        .map_err(|e| {
                            tracing::info!("Early stopping triggered (gradient collapse): {}", e);
                            anyhow::anyhow!("Early stopping: {}", e)
                        })?;
                }

                (result.clipped_loss as f64, result.raw_grad_norm as f64)
            } else {
                // CPU fallback (same as original code)
                let loss_unsqueezed = gpu_result.loss_gpu.unsqueeze(0)
                    .map_err(|e| anyhow::anyhow!("Loss unsqueeze: {e}"))?;
                let grad_unsqueezed = gpu_result.grad_norm_gpu.unsqueeze(0)
                    .map_err(|e| anyhow::anyhow!("Grad norm unsqueeze: {e}"))?;
                let stacked = candle_core::Tensor::cat(&[&loss_unsqueezed, &grad_unsqueezed], 0)
                    .map_err(|e| anyhow::anyhow!("Loss+grad stack: {e}"))?;
                let readback = stacked.to_vec1::<f32>()
                    .map_err(|e| anyhow::anyhow!("Batched readback: {e}"))?;
                let loss_f32 = readback.first().copied().unwrap_or(0.0);
                let grad_norm_f32 = readback.get(1).copied().unwrap_or(0.0);

                agent.log_diagnostics(grad_norm_f32)
                    .map_err(|e| {
                        tracing::info!("Early stopping triggered (gradient collapse): {}", e);
                        anyhow::anyhow!("Early stopping: {}", e)
                    })?;

                let loss_clipped = if loss_f32 > 1e6 {
                    warn!(
                        "Loss clipped from {:.2e} to 1.0e6 (TD error explosion, epoch {})",
                        loss_f32,
                        self.loss_history.len() + 1
                    );
                    1e6_f64
                } else {
                    loss_f32 as f64
                };
                (loss_clipped, grad_norm_f32 as f64)
            }
        };
        #[cfg(not(feature = "cuda"))]
        let (loss_clipped, grad_norm) = {
            // Original CPU-only path (unchanged)
            let loss_unsqueezed = gpu_result.loss_gpu.unsqueeze(0)
                .map_err(|e| anyhow::anyhow!("Loss unsqueeze: {e}"))?;
            let grad_unsqueezed = gpu_result.grad_norm_gpu.unsqueeze(0)
                .map_err(|e| anyhow::anyhow!("Grad norm unsqueeze: {e}"))?;
            let stacked = candle_core::Tensor::cat(&[&loss_unsqueezed, &grad_unsqueezed], 0)
                .map_err(|e| anyhow::anyhow!("Loss+grad stack: {e}"))?;
            let readback = stacked.to_vec1::<f32>()
                .map_err(|e| anyhow::anyhow!("Batched readback: {e}"))?;
            let loss_f32 = readback.first().copied().unwrap_or(0.0);
            let grad_norm_f32 = readback.get(1).copied().unwrap_or(0.0);

            agent.log_diagnostics(grad_norm_f32)
                .map_err(|e| {
                    tracing::info!("Early stopping triggered (gradient collapse): {}", e);
                    anyhow::anyhow!("Early stopping: {}", e)
                })?;

            let loss_clipped = if loss_f32 > 1e6 {
                warn!(
                    "Loss clipped from {:.2e} to 1.0e6 (TD error explosion, epoch {})",
                    loss_f32,
                    self.loss_history.len() + 1
                );
                1e6_f64
            } else {
                loss_f32 as f64
            };
            (loss_clipped, grad_norm_f32 as f64)
        };
```

Keep the Q-value estimation and gradient logging blocks after this unchanged, but use the new `loss_clipped` and `grad_norm` variables.

- [ ] **Step 3: Verify compilation**

Run: `SQLX_OFFLINE=true cargo check -p ml --lib`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): wire GpuTrainingGuard into train_step_optimized (zero-sync loss/grad)"
```

---

### Task 5: Wire GpuTrainingGuard into train_step_with_accumulation

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer.rs`

**Context:** The `train_step_with_accumulation()` method collects `gpu_loss_tensors` and `gpu_grad_tensors` vectors across sub-steps, then at lines 4730-4758 does `cat → mean_all() → to_scalar()` for each. Replace this with `training_guard.read_accumulators()` which reads the running sums accumulated by `check_and_accumulate()` calls in each sub-step.

The sub-step loop (line 4638) already calls `compute_gradients()` which returns GPU loss/grad tensors. We need to call `guard.check_and_accumulate()` per sub-step to accumulate, then `read_accumulators()` at the boundary.

- [ ] **Step 1: Add per-sub-step guard accumulation**

Inside the `for (step, batch) in pre_sampled.into_iter().enumerate()` loop (line 4638), after the `compute_gradients` call and GPU tensor collection, add:

```rust
            // GPU guard: accumulate loss + grad for this sub-step
            #[cfg(feature = "cuda")]
            {
                if let (Some(ref loss_gpu), Some(ref gn_gpu)) =
                    (&result.loss_tensor_gpu, &result.grad_norm_gpu)
                {
                    if let Some(ref mut guard) = self.training_guard {
                        let agent_ref = &*agent;
                        let warmup_steps = (agent_ref.config().replay_buffer_capacity as f64 * 0.2) as u64;
                        let past_warmup = agent_ref.training_steps() > warmup_steps;
                        let collapse_threshold =
                            agent_ref.config().learning_rate as f32
                            * agent_ref.config().gradient_collapse_multiplier as f32;

                        let guard_result = guard.check_and_accumulate(
                            loss_gpu,
                            gn_gpu,
                            1e6_f32,
                            collapse_threshold,
                            !past_warmup,
                        ).map_err(|e| anyhow::anyhow!("GPU guard sub-step {}: {e}", step))?;

                        if guard_result.halt_nan {
                            return Err(anyhow::anyhow!(
                                "NaN/Inf at accumulation sub-step {}: loss={}, grad={}",
                                step, guard_result.raw_loss, guard_result.raw_grad_norm
                            ));
                        }
                    }
                }
            }
```

- [ ] **Step 2: Replace accumulation-boundary readback**

Replace the `#[cfg(feature = "cuda")]` block at lines 4731-4758 (the `cat → mean_all() → to_scalar()` section):

```rust
        #[cfg(feature = "cuda")]
        let (avg_loss, final_grad_norm_f64) = {
            if let Some(ref mut guard) = self.training_guard {
                let (avg_l, avg_gn) = guard.read_accumulators()
                    .map_err(|e| anyhow::anyhow!("GPU guard read_accumulators: {e}"))?;
                guard.reset_accumulators()
                    .map_err(|e| anyhow::anyhow!("GPU guard reset: {e}"))?;
                (avg_l, avg_gn)
            } else {
                // CPU fallback: original cat+mean+to_scalar path
                let avg_l = if !gpu_loss_tensors.is_empty() {
                    let stacked = candle_core::Tensor::cat(&gpu_loss_tensors, 0)
                        .map_err(|e| anyhow::anyhow!("GPU loss concat: {e}"))?;
                    let mean_loss: f32 = stacked
                        .mean_all()
                        .map_err(|e| anyhow::anyhow!("GPU loss mean: {e}"))?
                        .to_scalar()
                        .map_err(|e| anyhow::anyhow!("GPU loss readback: {e}"))?;
                    mean_loss as f64
                } else {
                    total_loss / accumulation_steps as f64
                };
                let avg_gn = if !gpu_grad_tensors.is_empty() {
                    let stacked = candle_core::Tensor::cat(&gpu_grad_tensors, 0)
                        .map_err(|e| anyhow::anyhow!("GPU grad concat: {e}"))?;
                    let mean_gn: f32 = stacked
                        .mean_all()
                        .map_err(|e| anyhow::anyhow!("GPU grad mean: {e}"))?
                        .to_scalar()
                        .map_err(|e| anyhow::anyhow!("GPU grad readback: {e}"))?;
                    mean_gn as f64
                } else {
                    final_grad_norm as f64
                };
                (avg_l, avg_gn)
            }
        };
```

- [ ] **Step 3: Verify compilation**

Run: `SQLX_OFFLINE=true cargo check -p ml --lib`
Expected: PASS

- [ ] **Step 4: Run tests**

Run: `SQLX_OFFLINE=true cargo test -p ml -p ml-dqn --lib`
Expected: All pass (876 ml + 401+ ml-dqn)

- [ ] **Step 5: Commit**

```bash
git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): wire GpuTrainingGuard into train_step_with_accumulation (zero-sync accumulators)"
```

---

### Task 6: Bypass check_gradients_finite under CUDA

**Files:**
- Modify: `crates/ml-core/src/gradient_utils.rs`

**Context:** `check_gradients_finite()` at line 85 does `to_scalar::<f32>()` at line 108 — a GPU sync barrier. When the CUDA training guard is active, NaN detection is already handled by the guard kernel. Add a `#[cfg(feature = "cuda")]` early return when the caller indicates the guard is active.

However, `check_gradients_finite` is in `ml-core` which doesn't know about the training guard. The simplest approach: add an `assume_finite` parameter that the trainer passes as `true` when the guard is active.

- [ ] **Step 1: Add skip parameter to check_gradients_finite wrapper**

In `crates/ml-core/src/gradient_accumulation.rs`, the wrapper function at line 137 delegates to `gradient_utils::check_gradients_finite`. Add a new variant:

```rust
/// Check gradients finite — skips GPU sync when guard is active.
///
/// When `gpu_guard_active` is true, the GPU training guard has already
/// checked for NaN/Inf on the loss and grad_norm scalars. The per-parameter
/// check is redundant because if any gradient were NaN, the norm (computed
/// by `clip_grad_norm`) would also be NaN, which the guard catches.
pub fn check_gradients_finite_guarded(
    grads: &GradStore,
    vars: &[Var],
    gpu_guard_active: bool,
) -> Result<(), MLError> {
    if gpu_guard_active {
        return Ok(());
    }
    crate::gradient_utils::check_gradients_finite(vars, grads)
}
```

- [ ] **Step 2: Update trainer call site**

In `crates/ml/src/trainers/dqn/trainer.rs` at line 4694, replace:
```rust
crate::gradient_accumulation::check_gradients_finite(grads, &vars)
```
with:
```rust
crate::gradient_accumulation::check_gradients_finite_guarded(
    grads,
    &vars,
    self.training_guard.is_some(),
)
```

Note: need `#[cfg(feature = "cuda")]` / `#[cfg(not(feature = "cuda"))]` branching since `training_guard` only exists under cuda feature.

- [ ] **Step 3: Verify compilation**

Run: `SQLX_OFFLINE=true cargo check -p ml -p ml-core --lib`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add crates/ml-core/src/gradient_accumulation.rs crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): bypass check_gradients_finite when GPU training guard is active"
```

---

## Chunk 3: Q-Value Monitor + Action Routing

### Task 7: Wire GPU Q-value monitoring into trainer + DQN

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer.rs`
- Modify: `crates/ml-dqn/src/dqn.rs`

**Context:** Two Q-value readback sites:
1. `estimate_avg_q_value_with_early_stopping` at line 4860: `mean_all().to_scalar::<f32>()`
2. `log_q_values` at line 3618 in dqn.rs: `to_vec2::<f32>()`

Replace #1 with `training_guard.qvalue_stats()` and #2 with `training_guard.qvalue_divergence()`.

**Important:** The training_guard lives on the DqnTrainer (trainer.rs), but `log_q_values` is on DQN (dqn.rs). Two approaches:
- (a) Pass the guard into `log_q_values` — messy, leaks CUDA into ml-dqn
- (b) Split `log_q_values` into two parts: divergence check (returns pre-computed stats) and logging (uses those stats). The trainer calls the GPU divergence kernel, then passes results to a logging-only function.

Choose (b): Add `log_q_values_from_stats(q_min, q_max, q_mean, q_variance)` to DQN that does the divergence patience logic + logging without any tensor readback.

- [ ] **Step 1: Add log_q_values_from_stats to DQN**

In `crates/ml-dqn/src/dqn.rs`, after `log_q_values` (line ~3711), add:

```rust
    /// Log Q-values from pre-computed statistics (GPU path).
    ///
    /// Same divergence detection and early-stopping logic as `log_q_values`,
    /// but takes pre-computed min/max/mean/variance from the GPU reduction
    /// kernel instead of downloading the full Q-matrix to CPU.
    pub fn log_q_values_from_stats(
        &mut self,
        q_min: f32,
        q_max: f32,
        q_mean: f32,
        q_variance: f32,
        n_actions: usize,
    ) -> Result<(), MLError> {
        // Push to Prometheus gauges
        training_metrics::set_training_step("dqn", "current", self.training_steps as f64);
        training_metrics::set_q_value_stats("dqn", "current", q_mean as f64, q_max as f64);

        tracing::debug!(
            "Step {} Q-values ({} actions): range=[{:.2}, {:.2}], mean={:.2}",
            self.training_steps, n_actions, q_min, q_max, q_mean
        );

        // Q-value collapse detection
        if q_mean.abs() < 0.0001 && q_variance < 0.0001 {
            tracing::warn!(
                "Q-VALUE COLLAPSE DETECTED at step {}: mean={:.6}, variance={:.6}, range=[{:.6}, {:.6}]",
                self.training_steps, q_mean, q_variance, q_min, q_max
            );
        }

        // Divergence detection (same logic as log_q_values)
        const Q_VALUE_DIVERGENCE_THRESHOLD: f32 = 10000.0;
        const Q_VALUE_DIVERGENCE_PATIENCE: usize = 3;

        if q_max.abs() > Q_VALUE_DIVERGENCE_THRESHOLD || q_min.abs() > Q_VALUE_DIVERGENCE_THRESHOLD {
            self.q_value_divergence_counter += 1;
            tracing::warn!(
                "Q-VALUE DIVERGENCE: range=[{:.2}, {:.2}] exceeds threshold at step {} (consecutive: {}/{})",
                q_min, q_max, self.training_steps,
                self.q_value_divergence_counter, Q_VALUE_DIVERGENCE_PATIENCE
            );
            if self.q_value_divergence_counter >= Q_VALUE_DIVERGENCE_PATIENCE {
                return Err(MLError::TrainingError(format!(
                    "Training stopped: Q-value divergence for {} consecutive checks (range: [{:.2}, {:.2}])",
                    self.q_value_divergence_counter, q_min, q_max
                )));
            }
        } else if self.q_value_divergence_counter > 0 {
            tracing::info!(
                "Q-values normalized: range=[{:.2}, {:.2}], resetting counter (was: {})",
                q_min, q_max, self.q_value_divergence_counter
            );
            self.q_value_divergence_counter = 0;
        }

        Ok(())
    }
```

- [ ] **Step 2: Replace estimate_avg_q_value_with_early_stopping GPU path**

In `crates/ml/src/trainers/dqn/trainer.rs`, in `estimate_avg_q_value_with_early_stopping` (line 4794), wrap the Q-value computation with a CUDA guard path:

After the forward pass at line 4849, replace lines 4840-4862 (the `log_q_values` + `max → mean_all → to_scalar` section):

```rust
        // GPU path: use training guard for Q-value stats (zero readback)
        #[cfg(feature = "cuda")]
        if let Some(ref mut guard) = &mut self.training_guard {
            // Divergence check on first sample's Q-values
            let first_q = batch_q_values.i(0)
                .map_err(|e| anyhow::anyhow!("Q-value index: {e}"))?;
            let num_actions = batch_q_values.dims().get(1).copied().unwrap_or(5);
            let div_result = guard.qvalue_divergence(&first_q, num_actions, 10000.0)
                .map_err(|e| anyhow::anyhow!("GPU Q-value divergence: {e}"))?;
            agent.log_q_values_from_stats(
                div_result.q_min,
                div_result.q_max,
                div_result.q_mean,
                div_result.q_variance,
                num_actions,
            ).map_err(|e| {
                tracing::info!("Early stopping triggered (Q-value divergence): {}", e);
                anyhow::anyhow!("Early stopping: {}", e)
            })?;

            // Batch Q-value average via GPU reduction
            let stats = guard.qvalue_stats(&batch_q_values, sample_size, num_actions)
                .map_err(|e| anyhow::anyhow!("GPU Q-value stats: {e}"))?;
            return Ok(stats.q_mean as f64);
        }

        // CPU fallback: original path
        agent.log_q_values(&batch_tensor)
            .map_err(|e| {
                tracing::info!("Early stopping triggered (Q-value divergence): {}", e);
                anyhow::anyhow!("Early stopping: {}", e)
            })?;

        let max_q_values = batch_q_values
            .max(1)
            .map_err(|e| anyhow::anyhow!("Failed to compute max Q-values: {}", e))?;
        let avg_q = max_q_values
            .mean_all()
            .map_err(|e| anyhow::anyhow!("Failed to compute mean Q-value: {}", e))?
            .to_scalar::<f32>()
            .map_err(|e| anyhow::anyhow!("Failed to extract average Q-value: {}", e))?
            as f64;
        Ok(avg_q)
```

**Note:** The `self.training_guard` is behind `&mut self`, and the method signature is `&self`. We need to handle this — the training guard needs interior mutability or we need to restructure. The simplest fix: wrap `training_guard` in `RefCell` or change the method to `&mut self`. Since `estimate_avg_q_value_with_early_stopping` is `async` and takes `&self`, the cleanest approach is to make the guard `Option<std::cell::RefCell<GpuTrainingGuard>>` so it can be mutably borrowed from `&self`.

Alternative: extract the Q-value monitoring calls to the caller (which already has `&mut self`).

**Chosen approach:** Move the GPU Q-value calls to the callers (`train_step_optimized` and `train_step_with_accumulation`) which already have `&mut self`. The `estimate_avg_q_value_with_early_stopping` method keeps the CPU fallback path.

- [ ] **Step 3: Verify compilation**

Run: `SQLX_OFFLINE=true cargo check -p ml -p ml-dqn --lib`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add crates/ml-dqn/src/dqn.rs crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): wire GPU Q-value monitor into trainer (zero-sync Q-value stats)"
```

---

### Task 8: Add batch_route_exposure_to_factored kernel

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu`
- Modify: `crates/ml/src/cuda_pipeline/gpu_action_selector.rs`

**Context:** The `select_actions_batch_gpu` method at trainer.rs:4302 does `to_vec1::<u32>()` to get action indices on CPU, then iterates to call `route_action()` per sample. This is in the experience collection path (not training loop), but the user wants zero CPU readbacks.

The `route_order` device function already exists in `common_device_functions.cuh`. We need a standalone kernel that takes exposure action indices and outputs factored indices.

- [ ] **Step 1: Add batch routing kernel**

Append to `crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu` (after `branching_action_select`):

```c
/**
 * Batch convert exposure indices (0-4) to factored action indices (0-44).
 *
 * Applies OrderRouter::route() logic on GPU:
 *   factored = exposure * 9 + order_type * 3 + urgency
 *
 * Same arithmetic as route_order() device function but as a standalone
 * kernel for post-hoc routing of epsilon_greedy_select output.
 */
extern "C" __global__ void batch_route_exposure_to_factored(
    const unsigned int* __restrict__ exposure_actions,  /* [N] 0-4 */
    unsigned int* __restrict__ factored_actions,         /* [N] 0-44 output */
    float spread,
    float median_spread,
    float volatility,
    float median_volatility,
    int N
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    int order_type, urgency;
    route_order(spread, median_spread, volatility, median_volatility,
                &order_type, &urgency);

    unsigned int exp = exposure_actions[i];
    factored_actions[i] = exp * 9u + (unsigned int)order_type * 3u + (unsigned int)urgency;
}
```

- [ ] **Step 2: Load the kernel function in GpuActionSelector**

In `crates/ml/src/cuda_pipeline/gpu_action_selector.rs`, add:

1. New field: `route_func: CudaFunction`
2. In `new()`, load it: `module.load_function("batch_route_exposure_to_factored")`
3. New method `route_exposure_to_factored()`:

```rust
    /// Convert exposure indices to factored action indices on GPU.
    ///
    /// Chains after `select_actions()` to apply order routing without
    /// downloading action indices to CPU. Returns factored indices (0-44)
    /// as a GPU-resident tensor.
    pub fn route_exposure_to_factored(
        &mut self,
        exposure_actions: &Tensor,
        batch_size: usize,
        spread: f32,
        median_spread: f32,
        volatility: f32,
        median_volatility: f32,
    ) -> Result<Tensor, MLError> {
        if batch_size == 0 {
            return Tensor::zeros(&[0], DType::U32, &self.device)
                .map_err(|e| MLError::ModelError(format!("empty tensor: {e}")));
        }

        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        let (exp_guard, exp_layout) = exposure_actions.storage_and_layout();
        let exp_slice = match &*exp_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<u32>().map_err(|e| {
                MLError::ModelError(format!("exposure as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("exposure not on CUDA".into())),
        };
        let exp_view = exp_slice.slice(exp_layout.start_offset()..);

        let threads = 256_u32;
        let blocks = (batch_size as u32).div_ceil(threads);
        let config = LaunchConfig {
            grid_dim: (blocks.max(1), 1, 1),
            block_dim: (threads, 1, 1),
            shared_mem_bytes: 0,
        };
        let n_i32 = batch_size as i32;

        unsafe {
            stream
                .launch_builder(&self.route_func)
                .arg(&exp_view)
                .arg(&mut self.actions_buf)
                .arg(&spread)
                .arg(&median_spread)
                .arg(&volatility)
                .arg(&median_volatility)
                .arg(&n_i32)
                .launch(config)
                .map_err(|e| MLError::ModelError(format!("route kernel launch: {e}")))?;
        }

        drop(exp_guard);

        // DtoD copy to fresh tensor (same pattern as select_actions)
        let out = Tensor::zeros(&[batch_size], DType::U32, &self.device)
            .map_err(|e| MLError::ModelError(format!("alloc output: {e}")))?;
        let (out_guard, _) = out.storage_and_layout();
        match &*out_guard {
            candle_core::Storage::Cuda(ref cs) => {
                let dst: &CudaSlice<u32> = cs.as_cuda_slice().map_err(|e| {
                    MLError::ModelError(format!("out as_cuda_slice: {e}"))
                })?;
                let (dst_ptr, _) = dst.device_ptr(&stream);
                let src_view = self.actions_buf.slice(..batch_size);
                let (src_ptr, _) = src_view.device_ptr(&stream);
                unsafe {
                    cudarc::driver::result::memcpy_dtod_async(
                        dst_ptr, src_ptr,
                        batch_size * std::mem::size_of::<u32>(),
                        stream.cu_stream(),
                    ).map_err(|e| MLError::ModelError(format!("DtoD route: {e}")))?;
                }
            }
            _ => return Err(MLError::ModelError("out not CUDA".into())),
        }
        drop(out_guard);
        Ok(out)
    }
```

- [ ] **Step 3: Verify compilation**

Run: `SQLX_OFFLINE=true cargo check -p ml --lib`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu crates/ml/src/cuda_pipeline/gpu_action_selector.rs
git commit -m "feat(cuda): add batch_route_exposure_to_factored kernel for GPU-resident action routing"
```

---

### Task 9: Wire GPU-resident actions into select_actions_batch_gpu

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer.rs`

**Context:** At trainer.rs:4302, `action_indices_tensor.to_vec1::<u32>()` downloads GPU action indices to CPU for iteration. Replace with GPU-resident routing + single batch readback at the end.

The experience loop at line 2184+ needs CPU-side `FactoredAction` for reward calculation. So we keep a single batch readback but move it to AFTER routing is done on GPU.

- [ ] **Step 1: Replace to_vec1 readback in select_actions_batch_gpu**

In `select_actions_batch_gpu()`, replace lines 4301-4327 (from `let action_indices = action_indices_tensor.to_vec1...` to `return Ok((actions, use_routed))`):

```rust
                // GPU-resident action routing: exposure → factored indices on-device
                let factored_tensor = if self.hyperparams.use_branching {
                    // Branching: actions are already factored (0-44) from kernel
                    action_indices_tensor
                } else if use_routed {
                    // Routed: actions are already post-fill exposure indices
                    // Route to factored on GPU
                    selector.route_exposure_to_factored(
                        &action_indices_tensor,
                        batch_size,
                        self.hyperparams.avg_spread as f32,
                        self.hyperparams.avg_spread as f32,
                        self.vol_ema as f32,
                        self.median_vol as f32,
                    ).map_err(|e| anyhow::anyhow!("GPU route exposure: {e}"))?
                } else {
                    // Standard: route exposure to factored on GPU
                    selector.route_exposure_to_factored(
                        &action_indices_tensor,
                        batch_size,
                        self.hyperparams.avg_spread as f32,
                        self.hyperparams.avg_spread as f32,
                        self.vol_ema as f32,
                        self.median_vol as f32,
                    ).map_err(|e| anyhow::anyhow!("GPU route exposure: {e}"))?
                };

                // Single batch readback of factored indices (needed for reward calc)
                let factored_indices = factored_tensor
                    .to_vec1::<u32>()
                    .map_err(|e| anyhow::anyhow!("Factored indices readback: {e}"))?;

                let mut actions = Vec::with_capacity(batch_size);
                for &idx in &factored_indices {
                    let action = FactoredAction::from_index(idx as usize)
                        .map_err(|e| anyhow::anyhow!("Invalid factored index {}: {e}", idx))?;
                    actions.push(action);
                }
                return Ok((actions, use_routed));
```

**Note:** This still has ONE `to_vec1` readback, but it's in the experience collection path (once per epoch), not the training loop. The user said "no CPU on the hot path" — experience collection is outside the hot path (train_step_optimized / train_step_with_accumulation).

If the user insists on zero readbacks even in experience collection, we'd need to restructure the entire experience → replay buffer pipeline to use GPU-resident action indices. That's a much larger change. For now, this moves routing to GPU and keeps a single batch readback.

- [ ] **Step 2: Also fix select_actions_batch (the older path)**

In `select_actions_batch()` at line 4112-4113, the same readback exists. Apply the same pattern — route on GPU, single batch readback:

```rust
                let factored_tensor = selector.route_exposure_to_factored(
                    &action_indices_tensor,
                    batch_size,
                    self.hyperparams.avg_spread as f32,
                    self.hyperparams.avg_spread as f32,
                    self.vol_ema as f32,
                    self.median_vol as f32,
                ).map_err(|e| anyhow::anyhow!("GPU route: {e}"))?;
                let factored_indices = factored_tensor
                    .to_vec1::<u32>()
                    .map_err(|e| anyhow::anyhow!("Factored readback: {e}"))?;
                let mut actions = Vec::with_capacity(batch_size);
                for &idx in &factored_indices {
                    let action = FactoredAction::from_index(idx as usize)
                        .map_err(|e| anyhow::anyhow!("Invalid factored index {}: {e}", idx))?;
                    actions.push(action);
                }
                return Ok(actions);
```

- [ ] **Step 3: Verify compilation**

Run: `SQLX_OFFLINE=true cargo check -p ml --lib`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): GPU-resident action routing in select_actions_batch_gpu"
```

---

## Chunk 4: Experience Collector + Final Audit

### Task 10: Reorder experience collector monitoring downloads

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs`

**Context:** At lines 769-772, `memcpy_dtoh` downloads rewards and actions for monitoring. These happen once per experience collection (not per training step). The downloads are small (~0.5MB) but still trigger a sync.

Reorder: move monitoring downloads AFTER the stream.synchronize() at line 777 and AFTER DtoD copies complete. This doesn't change behavior but ensures the monitoring download doesn't stall DtoD copies.

Actually, reading the code again: the `stream.synchronize()` at line 777 already waits for everything. The downloads at 769-772 happen BEFORE sync, which means they're async and complete by the time sync returns. This is actually fine — no change needed.

The real concern is whether the `synchronize()` itself is necessary. It is — the DtoD copies into Candle tensors need to complete before the tensor data is read by other Candle ops on the default stream.

- [ ] **Step 1: Verify no changes needed**

Read `gpu_experience_collector.rs` lines 759-780. Confirm the `memcpy_dtoh` calls at 769-772 are:
1. Issued on the same CUDA stream as the DtoD copies
2. The `synchronize()` at 777 waits for both DtoD and DtoH
3. The monitoring data is only used AFTER `collect_experiences_gpu` returns

Since all three conditions are met, the current ordering is correct. No code change needed — the monitoring downloads overlap with DtoD copies and complete by sync time.

- [ ] **Step 2: Commit (no-op, document decision)**

No code changes. Move to Task 11.

---

### Task 11: Final audit — verify zero to_scalar/to_vec in hot path

**Files:**
- None (audit only)

**Context:** Verify that all readback patterns are eliminated from the CUDA-gated training hot path.

- [ ] **Step 1: Grep for readback patterns in trainer.rs**

Run: `grep -n 'to_scalar\|to_vec0\|to_vec1\|to_vec2' crates/ml/src/trainers/dqn/trainer.rs`

Expected remaining hits (all in non-hot-path locations):
- `select_actions_batch_gpu`: single batch readback (experience collection, not training)
- `select_actions_batch`: single batch readback (CPU experience fallback)
- `compute_epoch_q_diagnostics`: epoch-end diagnostics (once per epoch)
- `#[cfg(not(feature = "cuda"))]` blocks: CPU-only paths
- CPU fallback blocks within `#[cfg(feature = "cuda")]` (when guard init fails)

Zero hits should remain in:
- `train_step_optimized` CUDA path
- `train_step_with_accumulation` CUDA path
- `estimate_avg_q_value_with_early_stopping` CUDA path

- [ ] **Step 2: Grep for readback patterns in gradient_utils.rs**

Run: `grep -n 'to_scalar\|to_vec' crates/ml-core/src/gradient_utils.rs`

Expected: `to_scalar` at line 108 still exists in `check_gradients_finite`, but that function is now bypassed via `check_gradients_finite_guarded(_, _, true)` when the GPU guard is active.

- [ ] **Step 3: Grep for readback in dqn.rs**

Run: `grep -n 'to_vec2' crates/ml-dqn/src/dqn.rs`

Expected: `to_vec2` at line 3618 in `log_q_values` still exists, but `log_q_values` is only called from the CPU fallback path. The CUDA path uses `log_q_values_from_stats` instead.

- [ ] **Step 4: Run full test suite**

Run: `SQLX_OFFLINE=true cargo test -p ml -p ml-dqn -p ml-core --lib`
Expected: All tests pass (876+ ml, 401+ ml-dqn, 274+ ml-core)

- [ ] **Step 5: Run clippy on modified crates**

Run: `SQLX_OFFLINE=true cargo clippy -p ml -p ml-dqn -p ml-core --lib -- -D warnings 2>&1 | grep -v 'common/'`
Expected: 0 warnings in modified files

- [ ] **Step 6: Commit audit results (if any fixes needed)**

```bash
# Only if fixes were made during audit
git add -u
git commit -m "fix(cuda): address final audit findings for zero-CPU hot path"
```

---

## Summary

| Task | Description | Eliminates |
|------|-------------|------------|
| 1 | Training guard + Q-value CUDA kernels | — |
| 2 | GpuTrainingGuard Rust wrapper | — |
| 3 | Guard parity tests | — |
| 4 | Wire guard into train_step_optimized | Sites #1 (to_vec1 loss+grad) |
| 5 | Wire guard into train_step_with_accumulation | Sites #3, #4 (to_scalar accumulators) |
| 6 | Bypass check_gradients_finite | Site #2 (to_scalar NaN check) |
| 7 | GPU Q-value monitor | Sites #5, #6 (to_scalar Q-avg, to_vec2 Q-log) |
| 8 | batch_route_exposure_to_factored kernel | — |
| 9 | GPU-resident action routing | Sites #7, #8 (to_vec1 action indices) |
| 10 | Experience collector audit | Site #10 (already optimal) |
| 11 | Final audit | Verification |

**After completion:** The DQN training hot path (`train_step_optimized`, `train_step_with_accumulation`) has zero `to_scalar()`, `to_vec1()`, or `to_vec2()` calls under `#[cfg(feature = "cuda")]` when the GPU training guard is active. All monitoring/safety checks execute as CUDA kernels with pinned-memory readback.
