# SP19+20 WR-First Reward Implementation Plan

> **AMENDED 2026-05-09 (Path C, Phase 1.4)**: `sp20_emas_compute_kernel`
> signature refactored from 9 scalar value-args to a single device-pointer
> arg (`SP20EmaInputs* ema_inputs`). The Task 1.2 pseudocode below shows
> the kernel taking 9 scalar args (`launch_compute_emas(.., is_close,
> is_win, alpha, ...)`); read as `launch_sp20_emas_compute(stream,
> kernel, isv_dev, ema_inputs_dev, internal_dev, obs_count_dev)`. Math
> semantics bit-identical. The Path C decision lands the kernel sig
> change + a new `sp20_aggregate_inputs_kernel` (per-env arrays →
> SP20EmaInputs struct, on-device tree-reduce) + the Phase 1.4
> production wire-up + tests + audit / spec / plan amendments
> atomically per `feedback_no_partial_refactor`. The original Task 2.3
> (host-side aggregation) is **subsumed** by the GPU-side aggregation
> kernel and removed from the plan — aggregation now lives entirely
> on-device per `feedback_no_cpu_compute_strict`. See
> `docs/dqn-wire-up-audit.md` "SP20 Phase 1.4 (Path C)" entry for the
> full design and rationale.

> **AMENDED 2026-05-09**: K=3 → K=2 retarget for `sp20_stats_compute`. The
> production aux head emits `K = AUX_NEXT_BAR_K = 2` logits `{down, up}` per
> `crates/ml/src/cuda_pipeline/gpu_aux_heads.rs:61` (established by SP13 B1.1a),
> not K=3 `{short, hold, long}`. The Phase 1.1 task pseudocode below shows
> `aux_logits[B, 3]` and `aux_conf = max(...) - 1/3` (range `[0, 2/3]`); these
> should be read as `aux_logits[B, 2]` and `aux_conf = max(...) - 1/2` (range
> `[0, 1/2]`). The kernel + launcher + tests already landed K=2 atomically
> with this amendment — see the SP20 Phase 1.1 K=2 fixup commit (this
> commit's SHA in the audit log) and `docs/dqn-wire-up-audit.md`
> "K=2 fixup" subsection.

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

**Goal:** Implement event-driven WR-first reward with multi-horizon directional ground truth, adaptive asymmetric loss aversion, and aux-gated Q-targets to lift WR ≥ 55% and PF ≥ 2.0.

**Architecture:** Six components shipping atomically per `feedback_no_partial_refactor`: (1) reward kernel with 4-quadrant table, (2) Hold opp-cost dual emission, (3) n-step credit distributor, (4) aux→Q confidence gate, (5) 3 fused ISV producer kernels driving 10 new ISV slots, (6) 7 behavioral synthetic-market tests.

**Tech Stack:** Rust 1.85, cudarc 0.16+, CUDA 12.4, dev RTX 3050 Ti (sm_86) / L40S (sm_89) prod. Build: `SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check --workspace`.

**Spec:** `docs/superpowers/specs/2026-05-09-sp19-20-wr-first-design.md` (commit `9d9ca3e6e` on `feat/sp18-combined`).

**Branch:** `sp19-20-wr-first` forks from `feat/sp18-combined` HEAD `9d9ca3e6e`.

---

## File Structure

**New files (8):**
- `crates/ml/src/cuda_pipeline/sp20_stats_compute_kernel.cu` — aux confidence p50/std (Phase 1.1)
- `crates/ml/src/cuda_pipeline/sp20_emas_compute_kernel.cu` — 8 EMAs including hold_reward_ema (Phase 1.2)
- `crates/ml/src/cuda_pipeline/sp20_controllers_compute_kernel.cu` — 6 derived controllers (Phase 1.3)
- `crates/ml/src/cuda_pipeline/n_step_credit_kernel.cu` — uniform credit distribution (Phase 4.1)
- `crates/ml/src/cuda_pipeline/hold_baseline_buffer.rs` — circular buffer wrapper (Phase 3.2)
- `crates/ml/tests/sp19_20_behavioral_tests.rs` — 7 synthetic-market tests (Phase 0 + 6)
- `audit/sp20_wireup.md` — wire-up audit table (Phase 7.4)
- `docs/lookahead-bias-audit-2026-05-09.md` — close-out + memory pearl candidates (Phase 7.4)

**Modified files (8):**
- `crates/ml/src/cuda_pipeline/sp14_isv_slots.rs` — extend with 10 new slots, ISV_TOTAL_DIM 510→520, layout fingerprint update
- `crates/ml/src/cuda_pipeline/mod.rs` — declare 4 new kernel modules
- `crates/ml/build.rs` — add 4 new cubin entries
- `crates/ml/src/cuda_pipeline/experience_kernels.cu` — replace SP18 D-leg + SP12 v3 reward block with sp20_compute_event_reward (Phase 2)
- `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` — n-step distribution + replay schema (per-bar aux_conf) + SP18 B-leg trace reset (Phases 3.4, 4.2, 4.3)
- `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` — Bellman target site with aux gate (Phase 5)
- `crates/ml/src/trainers/dqn/state_reset_registry.rs` — register 10 new ISV slots (Phase Pre.3)
- `crates/ml/src/trainers/dqn/trainer/training_loop.rs` — HEALTH_DIAG emit for 10 new ISV slots (Phase 1.3)

**Test files (per kernel + behavioral suite):**
- `crates/ml/tests/sp20_stats_compute_test.rs` — Phase 1.1 unit tests
- `crates/ml/tests/sp20_emas_compute_test.rs` — Phase 1.2 unit tests
- `crates/ml/tests/sp20_controllers_compute_test.rs` — Phase 1.3 unit tests
- `crates/ml/tests/sp20_event_reward_test.rs` — Phase 2 unit tests (4-quadrant table)
- `crates/ml/tests/sp20_hold_dual_emission_test.rs` — Phase 3 unit tests
- `crates/ml/tests/sp20_n_step_credit_test.rs` — Phase 4 unit tests
- `crates/ml/tests/sp20_aux_gate_test.rs` — Phase 5 unit tests

---

## Pre-Phase: Setup

### Task Pre.1: Create branch and worktree

**Files:**
- New worktree at `.worktrees/sp19-20-wr-first/`

- [ ] **Step 1: Verify starting commit**

```bash
git -C /home/jgrusewski/Work/foxhunt/.worktrees/sp18-combined log -1 --format="%H %s"
```

Expected: `9d9ca3e6e spec(sp19+20): apply Q1(b) — Hold-reward EMA for Q-scale comparability`

- [ ] **Step 2: Create branch and worktree from HEAD**

```bash
cd /home/jgrusewski/Work/foxhunt
git worktree add .worktrees/sp19-20-wr-first -b sp19-20-wr-first feat/sp18-combined
```

Expected: `Preparing worktree (new branch 'sp19-20-wr-first')` and `HEAD is now at 9d9ca3e6e`

- [ ] **Step 3: Verify worktree builds clean**

```bash
cd .worktrees/sp19-20-wr-first
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check -p ml 2>&1 | tail -5
```

Expected: `Finished` with no errors.

- [ ] **Step 4: Verify .worktrees is gitignored**

```bash
cd /home/jgrusewski/Work/foxhunt && git check-ignore -q .worktrees && echo "OK"
```

Expected: `OK`.

### Task Pre.2: Reserve 10 new ISV slots [510..520)

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

- [ ] **Step 1: Read current ISV slot file head**

```bash
cd .worktrees/sp19-20-wr-first
grep -n "ISV_TOTAL_DIM\|SP19" crates/ml/src/cuda_pipeline/sp14_isv_slots.rs | head -10
```

Expected: shows `ISV_TOTAL_DIM = 510` and SP19 slot entries [507..510).

- [ ] **Step 2: Write failing layout-fingerprint test**

Add to `crates/ml/src/cuda_pipeline/sp14_isv_slots.rs` end of file (in `#[cfg(test)] mod tests`):

```rust
    #[test]
    fn sp20_isv_slots_reserved_510_to_520() {
        assert_eq!(ISV_TOTAL_DIM, 520);
        assert_eq!(LOSS_CAP_INDEX, 510);
        assert_eq!(ALPHA_EMA_INDEX, 511);
        assert_eq!(WR_EMA_INDEX, 512);
        assert_eq!(HOLD_COST_SCALE_INDEX, 513);
        assert_eq!(TARGET_HOLD_PCT_INDEX, 514);
        assert_eq!(HOLD_PCT_EMA_INDEX, 515);
        assert_eq!(HOLD_REWARD_EMA_INDEX, 516);
        assert_eq!(N_STEP_INDEX, 517);
        assert_eq!(AUX_CONF_THRESHOLD_INDEX, 518);
        assert_eq!(AUX_GATE_TEMP_INDEX, 519);
    }
```

- [ ] **Step 3: Run test to verify it fails**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --lib sp20_isv_slots_reserved 2>&1 | tail -10
```

Expected: FAIL with `cannot find value LOSS_CAP_INDEX`.

- [ ] **Step 4: Add 10 ISV slot constants**

Find the SP19 block (slots 507-509) in `crates/ml/src/cuda_pipeline/sp14_isv_slots.rs` and append:

```rust
// SP20 — WR-first reward + multi-horizon label utilization (2026-05-09)
//
// Slots 510-519 reserved for SP20 components per spec
// docs/superpowers/specs/2026-05-09-sp19-20-wr-first-design.md.
//
// All slots are produced by sp20_emas_compute_kernel,
// sp20_controllers_compute_kernel, or sp20_stats_compute_kernel
// per Component 5 of the spec.

/// Adaptive loss cap for Component 1 reward kernel.
/// Value: -1.0 - clamp((wr_ema - 0.50) / 0.05, 0, 1) — ranges [-1, -2].
/// Producer: sp20_controllers_compute_kernel.
pub const LOSS_CAP_INDEX: usize = 510;

/// Wiener-α EMA over alpha (= R_event - hold_baseline) at trade-close events.
/// Used by Component 1 to center R_used = alpha - alpha_ema.
/// Producer: sp20_emas_compute_kernel.
pub const ALPHA_EMA_INDEX: usize = 511;

/// Per-trade win-rate EMA. Wiener-α floor 0.4.
/// Drives LOSS_CAP_INDEX adaptive ramp.
/// Producer: sp20_emas_compute_kernel.
pub const WR_EMA_INDEX: usize = 512;

/// Adaptive Hold opp-cost magnitude (Component 2 controller output).
/// Bounded [0.01, 0.5]. Two-sided controller from hold_pct_ema vs target.
/// Producer: sp20_controllers_compute_kernel.
pub const HOLD_COST_SCALE_INDEX: usize = 513;

/// Target Hold% derived from aux confidence distribution.
/// Value: clamp(0.8 - aux_conf_p50_ema * 1.5, 0.1, 0.8).
/// Producer: sp20_controllers_compute_kernel.
pub const TARGET_HOLD_PCT_INDEX: usize = 514;

/// Wiener-α EMA of (action == Hold) indicator. Drives HOLD_COST_SCALE controller.
/// Producer: sp20_emas_compute_kernel.
pub const HOLD_PCT_EMA_INDEX: usize = 515;

/// Long-run mean of per-bar Hold reward (R_per_bar = -aux_conf * cost_scale).
/// Updated only on Hold-state bars. Used to center Hold reward for Q-target
/// scale comparability with trade reward.
/// Producer: sp20_emas_compute_kernel.
pub const HOLD_REWARD_EMA_INDEX: usize = 516;

/// Adaptive n-step credit distribution width.
/// Value: clamp(round(trade_duration_ema), 1, 30).
/// Producer: sp20_controllers_compute_kernel.
pub const N_STEP_INDEX: usize = 517;

/// Aux→Q gate threshold. Value: clamp(aux_dir_acc_ema - 0.50, 0.01, 0.20).
/// Producer: sp20_controllers_compute_kernel.
pub const AUX_CONF_THRESHOLD_INDEX: usize = 518;

/// Aux→Q gate temperature. Value: aux_conf_std_ema (with floor).
/// Producer: sp20_controllers_compute_kernel.
pub const AUX_GATE_TEMP_INDEX: usize = 519;
```

Then update `ISV_TOTAL_DIM`:

```rust
pub const ISV_TOTAL_DIM: usize = 520;  // was 510, SP20 added 10 slots [510..520)
```

- [ ] **Step 5: Update layout fingerprint seed**

Find `LAYOUT_FINGERPRINT_SEED_VERSION` constant in same file. Increment by 1 to bust caches:

```rust
pub const LAYOUT_FINGERPRINT_SEED_VERSION: u32 = <PREV_VERSION + 1>;
```

(Read the previous version with `grep "LAYOUT_FINGERPRINT_SEED_VERSION" crates/ml/src/cuda_pipeline/sp14_isv_slots.rs`.)

- [ ] **Step 6: Run test to verify it passes**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --lib sp20_isv_slots_reserved 2>&1 | tail -5
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add crates/ml/src/cuda_pipeline/sp14_isv_slots.rs
git commit -m "feat(sp20): reserve 10 ISV slots [510..520) for WR-first reward

Adds slot constants for Components 1-5 of SP20 design:
- LOSS_CAP_INDEX (Component 1): adaptive loss cap from wr_ema
- ALPHA_EMA_INDEX (Component 1): trade reward centering
- WR_EMA_INDEX (Component 1): per-trade WR
- HOLD_COST_SCALE_INDEX (Component 2): adaptive Hold cost
- TARGET_HOLD_PCT_INDEX (Component 2): aux-confidence-derived target
- HOLD_PCT_EMA_INDEX (Component 2): Hold action fraction EMA
- HOLD_REWARD_EMA_INDEX (Component 2 Q1b): Hold reward centering
- N_STEP_INDEX (Component 3): adaptive credit distribution width
- AUX_CONF_THRESHOLD_INDEX (Component 4): aux gate threshold
- AUX_GATE_TEMP_INDEX (Component 4): aux gate temperature

ISV_TOTAL_DIM bumped 510→520. Layout fingerprint seed incremented."
```

### Task Pre.3: Register 10 new ISV slots in StateResetRegistry

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

- [ ] **Step 1: Read current registry structure**

```bash
grep -n "register\|named_state\|reset_named_state" crates/ml/src/trainers/dqn/state_reset_registry.rs | head -20
```

- [ ] **Step 2: Write failing test**

Add to bottom of `crates/ml/src/trainers/dqn/state_reset_registry.rs`:

```rust
#[cfg(test)]
mod sp20_registry_tests {
    use super::*;

    #[test]
    fn sp20_isv_slots_registered_with_sentinel_zero() {
        let registry = StateResetRegistry::new();
        for slot_name in [
            "loss_cap", "alpha_ema", "wr_ema",
            "hold_cost_scale", "target_hold_pct", "hold_pct_ema", "hold_reward_ema",
            "n_step", "aux_conf_threshold", "aux_gate_temp",
        ] {
            assert!(
                registry.has_named_state(slot_name),
                "SP20 slot {slot_name} not registered"
            );
        }
    }
}
```

- [ ] **Step 3: Run test to verify it fails**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --lib sp20_isv_slots_registered 2>&1 | tail -10
```

Expected: FAIL.

- [ ] **Step 4: Register all 10 slots in StateResetRegistry::new()**

Find the existing `pub fn new() -> Self` in `state_reset_registry.rs`. After the SP19 slot registrations, add:

```rust
        // SP20 slots [510..520) — registered with sentinel = 0.0 per
        // pearl_first_observation_bootstrap. All are Wiener-α EMAs or
        // controller outputs; first observation replaces sentinel directly.
        for (name, idx) in [
            ("loss_cap", LOSS_CAP_INDEX),
            ("alpha_ema", ALPHA_EMA_INDEX),
            ("wr_ema", WR_EMA_INDEX),
            ("hold_cost_scale", HOLD_COST_SCALE_INDEX),
            ("target_hold_pct", TARGET_HOLD_PCT_INDEX),
            ("hold_pct_ema", HOLD_PCT_EMA_INDEX),
            ("hold_reward_ema", HOLD_REWARD_EMA_INDEX),
            ("n_step", N_STEP_INDEX),
            ("aux_conf_threshold", AUX_CONF_THRESHOLD_INDEX),
            ("aux_gate_temp", AUX_GATE_TEMP_INDEX),
        ] {
            registry.register(name, idx, /* sentinel */ 0.0);
        }
```

Add the corresponding `use` import at the top of the file:

```rust
use crate::cuda_pipeline::sp14_isv_slots::{
    LOSS_CAP_INDEX, ALPHA_EMA_INDEX, WR_EMA_INDEX,
    HOLD_COST_SCALE_INDEX, TARGET_HOLD_PCT_INDEX, HOLD_PCT_EMA_INDEX,
    HOLD_REWARD_EMA_INDEX, N_STEP_INDEX,
    AUX_CONF_THRESHOLD_INDEX, AUX_GATE_TEMP_INDEX,
};
```

- [ ] **Step 5: Run test to verify it passes**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --lib sp20_isv_slots_registered 2>&1 | tail -5
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add crates/ml/src/trainers/dqn/state_reset_registry.rs
git commit -m "feat(sp20): register 10 ISV slots in StateResetRegistry

Sentinel = 0.0 per pearl_first_observation_bootstrap. First observation
of each EMA replaces the sentinel directly, no blending."
```

---

## Phase 0: Behavioral Test Stubs (Failing)

Each test exists as a failing stub before implementation begins. The 7 tests gate L40S deployment per spec §4.6.

### Task 0.1: Test scaffold + LobBar harness wire-up

**Files:**
- Create: `crates/ml/tests/sp19_20_behavioral_tests.rs`

- [ ] **Step 1: Create test file scaffold**

```rust
//! SP19+20 Behavioral Test Suite — gates L40S deployment per spec §4.6.
//!
//! Reuses SP15 Phase 2A LobBar harness. Each test constructs a synthetic
//! market, runs the model for N bars, and asserts behavioral invariants.
//!
//! Run all: `SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test sp19_20_behavioral_tests`
//! Run one: `SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test sp19_20_behavioral_tests -- sp20_pure_trend`

use ml::trainers::dqn::testing::lob_bar_harness::{LobBarHarness, MarketScript};

mod sp19_20_behavioral_tests {
    use super::*;

    #[test]
    #[ignore = "Phase 0 stub — implementation in Phase 6.1"]
    fn sp20_pure_trend() {
        unimplemented!("Phase 6.1");
    }

    #[test]
    #[ignore = "Phase 0 stub — implementation in Phase 6.2"]
    fn sp20_pure_noise() {
        unimplemented!("Phase 6.2");
    }

    #[test]
    #[ignore = "Phase 0 stub — implementation in Phase 6.3"]
    fn sp20_confidence_correlation() {
        unimplemented!("Phase 6.3");
    }

    #[test]
    #[ignore = "Phase 0 stub — implementation in Phase 6.4"]
    fn sp20_asymmetry_no_game() {
        unimplemented!("Phase 6.4");
    }

    #[test]
    #[ignore = "Phase 0 stub — implementation in Phase 6.5"]
    fn sp20_n_step_distribution() {
        unimplemented!("Phase 6.5");
    }

    #[test]
    #[ignore = "Phase 0 stub — implementation in Phase 6.6"]
    fn sp20_per_regime_wr() {
        unimplemented!("Phase 6.6");
    }

    #[test]
    #[ignore = "Phase 0 stub — implementation in Phase 6.7"]
    fn sp20_regime_transition() {
        unimplemented!("Phase 6.7");
    }
}
```

- [ ] **Step 2: Verify file compiles**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo build -p ml --tests 2>&1 | tail -5
```

Expected: `Finished` with possible "ignored test" warning.

- [ ] **Step 3: Verify harness import resolves**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test sp19_20_behavioral_tests -- --list 2>&1 | tail -10
```

Expected: 7 tests listed, all marked ignored.

- [ ] **Step 4: Commit**

```bash
git add crates/ml/tests/sp19_20_behavioral_tests.rs
git commit -m "feat(sp20): scaffold 7 behavioral test stubs (Phase 0)

All 7 tests marked #[ignore] until corresponding Phase 6 task implements.
Imports SP15 Phase 2A LobBar harness (no new infra)."
```

---

## Phase 1: ISV Producer Kernels (Component 5)

Three fused kernels in dependency order. Each produces multiple ISV outputs to amortize launch overhead per `pearl_fused_per_group_statistics_oracle`.

### Task 1.1: sp20_stats_compute kernel (aux_conf_p50, aux_conf_std)

**Files:**
- Create: `crates/ml/src/cuda_pipeline/sp20_stats_compute_kernel.cu`
- Modify: `crates/ml/src/cuda_pipeline/mod.rs`
- Modify: `crates/ml/build.rs`
- Test: `crates/ml/tests/sp20_stats_compute_test.rs`

- [ ] **Step 1: Write failing oracle test**

Create `crates/ml/tests/sp20_stats_compute_test.rs`:

```rust
//! Oracle tests for sp20_stats_compute kernel.

use ml::cuda_pipeline::sp20_stats_compute::launch_sp20_stats_compute;
use cudarc::driver::{CudaDevice, DevicePtr, LaunchAsync};

#[test]
fn sp20_stats_compute_p50_uniform_dist() {
    // Aux logits over batch B=128 with 3-class output (short, hold, long).
    // Uniform softmax => max prob = 1/3, aux_conf = 0. p50 should be ≈ 0.
    let device = CudaDevice::new(0).unwrap();
    let batch = 128;
    let n_classes = 3;
    let logits = vec![0.0f32; batch * n_classes];
    let logits_d = device.htod_copy(logits).unwrap();

    let mut p50_d = device.alloc_zeros::<f32>(1).unwrap();
    let mut std_d = device.alloc_zeros::<f32>(1).unwrap();

    unsafe {
        launch_sp20_stats_compute(
            &device,
            *logits_d.device_ptr(),
            *p50_d.device_ptr_mut(),
            *std_d.device_ptr_mut(),
            batch as i32,
        ).unwrap();
    }
    device.synchronize().unwrap();

    let p50 = device.dtoh_sync_copy(&p50_d).unwrap()[0];
    let std = device.dtoh_sync_copy(&std_d).unwrap()[0];

    assert!(p50.abs() < 0.01, "p50 should be ≈ 0 for uniform, got {p50}");
    assert!(std < 0.05, "std should be ≈ 0 for uniform, got {std}");
}

#[test]
fn sp20_stats_compute_p50_concentrated_dist() {
    // Logits with class-0 dominant (logit=10, rest=0). Softmax → ~1.0 for class 0.
    // aux_conf = 1.0 - 1/3 = 0.667. p50 should be ≈ 0.667.
    let device = CudaDevice::new(0).unwrap();
    let batch = 128;
    let n_classes = 3;
    let mut logits = vec![0.0f32; batch * n_classes];
    for i in 0..batch {
        logits[i * n_classes + 0] = 10.0;
    }
    let logits_d = device.htod_copy(logits).unwrap();

    let mut p50_d = device.alloc_zeros::<f32>(1).unwrap();
    let mut std_d = device.alloc_zeros::<f32>(1).unwrap();

    unsafe {
        launch_sp20_stats_compute(
            &device,
            *logits_d.device_ptr(),
            *p50_d.device_ptr_mut(),
            *std_d.device_ptr_mut(),
            batch as i32,
        ).unwrap();
    }
    device.synchronize().unwrap();

    let p50 = device.dtoh_sync_copy(&p50_d).unwrap()[0];
    assert!((p50 - 0.667).abs() < 0.01, "p50 should be ≈ 0.667, got {p50}");
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test sp20_stats_compute_test 2>&1 | tail -10
```

Expected: FAIL with `cannot find module sp20_stats_compute`.

- [ ] **Step 3: Implement the kernel**

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

```cuda
// SP20 Stats Compute Kernel — Component 5 / Kernel 3
//
// Computes aux confidence p50 (median) and std over batch.
// aux_conf = max(softmax(aux_logits)) - 1/3
//
// Uses sp4_histogram_p99.cuh pattern for percentile (block tree-reduce,
// no atomicAdd per feedback_no_atomicadd).
//
// Inputs:
//   aux_logits[B, 3] — raw logits over {short, hold, long}
//   B = batch size
// Outputs:
//   aux_conf_p50 — scalar, median aux_conf over batch
//   aux_conf_std — scalar, std of aux_conf over batch
//
// Launch config: 1 block × 128 threads (block tree-reduce internal).
//
// Per pearl_first_observation_bootstrap, the caller writes sentinel=0
// to ISV slots before kernel runs; kernel writes computed values directly.

#include <cuda_runtime.h>
#include "sp4_histogram_p99.cuh"

extern "C" __global__ void sp20_stats_compute_kernel(
    const float* __restrict__ aux_logits,    // [B, 3]
    float* __restrict__ aux_conf_p50,        // scalar
    float* __restrict__ aux_conf_std,        // scalar
    const int B
) {
    constexpr int N_CLASSES = 3;
    constexpr int HIST_BINS = 128;
    extern __shared__ float shmem[];
    float* aux_confs = shmem;                  // [B]
    int*   hist_bins = (int*)(shmem + B);      // [HIST_BINS]

    const int tid = threadIdx.x;
    const int blockSize = blockDim.x;

    // Phase 1: compute aux_conf per batch element (max softmax - 1/3)
    for (int i = tid; i < B; i += blockSize) {
        float l0 = aux_logits[i * N_CLASSES + 0];
        float l1 = aux_logits[i * N_CLASSES + 1];
        float l2 = aux_logits[i * N_CLASSES + 2];
        float lmax = fmaxf(fmaxf(l0, l1), l2);
        float e0 = expf(l0 - lmax);
        float e1 = expf(l1 - lmax);
        float e2 = expf(l2 - lmax);
        float Z = e0 + e1 + e2;
        float p_max = fmaxf(fmaxf(e0, e1), e2) / Z;
        aux_confs[i] = p_max - (1.0f / 3.0f);   // range [0, 2/3]
    }
    __syncthreads();

    // Phase 2: compute mean and variance via two-pass reduction
    float sum = 0.0f;
    float sum_sq = 0.0f;
    for (int i = tid; i < B; i += blockSize) {
        sum += aux_confs[i];
        sum_sq += aux_confs[i] * aux_confs[i];
    }
    typedef float (*reduce_fn)(float);
    extern __shared__ float reduce_buf[];     // separate region
    // (Use cooperative groups for warp-level reduce; simplified for clarity)
    sum     = block_tree_reduce_sum(sum);
    sum_sq  = block_tree_reduce_sum(sum_sq);

    if (tid == 0) {
        float mean = sum / (float)B;
        float var  = (sum_sq / (float)B) - (mean * mean);
        *aux_conf_std = sqrtf(fmaxf(var, 1e-12f));
    }

    // Phase 3: compute p50 via sp4_histogram_p99 (with target_quantile=0.50)
    sp4_histogram_p99<HIST_BINS>(
        aux_confs, B,
        /*lo=*/0.0f, /*hi=*/0.667f,
        /*target_quantile=*/0.50f,
        /*out=*/aux_conf_p50,
        hist_bins
    );
}
```

- [ ] **Step 4: Add Rust launcher**

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

```rust
//! SP20 Stats Compute — Rust launcher for sp20_stats_compute_kernel.cu.

use anyhow::Result;
use cudarc::driver::{CudaDevice, CudaFunction, LaunchAsync, LaunchConfig};
use std::sync::Arc;

const KERNEL_NAME: &str = "sp20_stats_compute_kernel";
const PTX_PATH: &str = concat!(env!("OUT_DIR"), "/sp20_stats_compute.ptx");

pub struct Sp20StatsCompute {
    func: CudaFunction,
}

impl Sp20StatsCompute {
    pub fn new(device: Arc<CudaDevice>) -> Result<Self> {
        let ptx = std::fs::read_to_string(PTX_PATH)?;
        device.load_ptx(ptx.into(), "sp20_stats", &[KERNEL_NAME])?;
        let func = device.get_func("sp20_stats", KERNEL_NAME)
            .ok_or_else(|| anyhow::anyhow!("kernel not loaded"))?;
        Ok(Self { func })
    }

    /// # Safety
    /// Caller must ensure pointers point to valid device memory of correct
    /// sizes. `aux_logits` is [batch, 3] f32. `p50_out` and `std_out` are
    /// scalar f32 device pointers.
    pub unsafe fn launch(
        &self,
        aux_logits: u64,
        p50_out: u64,
        std_out: u64,
        batch: i32,
    ) -> Result<()> {
        let cfg = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (128, 1, 1),
            shared_mem_bytes: ((batch as usize + 128) * 4) as u32,
        };
        self.func.clone().launch(
            cfg,
            (aux_logits, p50_out, std_out, batch),
        )?;
        Ok(())
    }
}

/// Convenience function for tests; production callers should reuse a
/// constructed Sp20StatsCompute to amortize PTX load.
pub unsafe fn launch_sp20_stats_compute(
    device: &Arc<CudaDevice>,
    aux_logits: u64,
    p50_out: u64,
    std_out: u64,
    batch: i32,
) -> Result<()> {
    let kernel = Sp20StatsCompute::new(device.clone())?;
    kernel.launch(aux_logits, p50_out, std_out, batch)
}
```

- [ ] **Step 5: Add cubin entry to build.rs**

Modify `crates/ml/build.rs`. Find the existing cubin manifest array (search for `cuda_kernels` or similar list). Add:

```rust
    "sp20_stats_compute_kernel.cu",
```

after the SP19 entry.

- [ ] **Step 6: Declare module in mod.rs**

Modify `crates/ml/src/cuda_pipeline/mod.rs`. Find the SP19 module declarations. Add:

```rust
pub mod sp20_stats_compute;
```

- [ ] **Step 7: Run test to verify it passes**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test sp20_stats_compute_test 2>&1 | tail -10
```

Expected: PASS (both tests).

- [ ] **Step 8: Commit**

```bash
git add crates/ml/src/cuda_pipeline/sp20_stats_compute_kernel.cu \
        crates/ml/src/cuda_pipeline/sp20_stats_compute.rs \
        crates/ml/src/cuda_pipeline/mod.rs \
        crates/ml/build.rs \
        crates/ml/tests/sp20_stats_compute_test.rs
git commit -m "feat(sp20): sp20_stats_compute kernel — aux_conf p50 + std

Component 5 / Kernel 3. Block tree-reduce for mean/var, sp4_histogram_p99.cuh
for p50 (no atomicAdd per feedback_no_atomicadd).

Outputs: aux_conf_p50 (median over batch), aux_conf_std (std over batch).
Inputs: aux_logits [B, 3] from existing SP14-C aux head.

Oracle tests: uniform dist → p50=0, concentrated dist → p50≈0.667."
```

### Task 1.2: sp20_emas_compute kernel (8 EMAs)

**Files:**
- Create: `crates/ml/src/cuda_pipeline/sp20_emas_compute_kernel.cu`
- Create: `crates/ml/src/cuda_pipeline/sp20_emas_compute.rs`
- Modify: `crates/ml/src/cuda_pipeline/mod.rs`, `crates/ml/build.rs`
- Test: `crates/ml/tests/sp20_emas_compute_test.rs`

- [ ] **Step 1: Write failing oracle test**

Create `crates/ml/tests/sp20_emas_compute_test.rs`:

```rust
use ml::cuda_pipeline::sp20_emas_compute::Sp20EmasCompute;
use ml::cuda_pipeline::sp14_isv_slots::{
    WR_EMA_INDEX, ALPHA_EMA_INDEX, HOLD_PCT_EMA_INDEX, HOLD_REWARD_EMA_INDEX,
};
use cudarc::driver::CudaDevice;
use std::sync::Arc;

#[test]
fn sp20_emas_compute_first_observation_replaces_sentinel() {
    let device = Arc::new(CudaDevice::new(0).unwrap());
    let mut isv = device.alloc_zeros::<f32>(520).unwrap();   // sentinel = 0

    // Single trade: WR=1.0 (1 win, 0 losses)
    let inputs = Sp20EmasInputs {
        is_close: 1,
        is_win: 1,
        trade_duration: 5,
        alpha: 0.5,
        action_is_hold: 0,
        per_bar_hold_reward: 0.0,
        aux_logits_p50: 0.3,
        aux_logits_std: 0.1,
        aux_dir_acc: 0.55,
    };

    let kernel = Sp20EmasCompute::new(device.clone()).unwrap();
    unsafe {
        kernel.launch(*isv.device_ptr_mut(), &inputs).unwrap();
    }
    device.synchronize().unwrap();

    let isv_host = device.dtoh_sync_copy(&isv).unwrap();
    assert!((isv_host[WR_EMA_INDEX] - 1.0).abs() < 0.01,
        "WR_EMA after first win should be 1.0, got {}", isv_host[WR_EMA_INDEX]);
    assert!((isv_host[ALPHA_EMA_INDEX] - 0.5).abs() < 0.01,
        "ALPHA_EMA after first observation should be 0.5, got {}", isv_host[ALPHA_EMA_INDEX]);
}

#[test]
fn sp20_emas_compute_wiener_alpha_floor_active() {
    // After 100 close observations with alternating wins/losses, WR_EMA
    // should converge to ~0.5 with reasonable bandwidth.
    let device = Arc::new(CudaDevice::new(0).unwrap());
    let mut isv = device.alloc_zeros::<f32>(520).unwrap();
    let kernel = Sp20EmasCompute::new(device.clone()).unwrap();

    for i in 0..100 {
        let inputs = Sp20EmasInputs {
            is_close: 1,
            is_win: if i % 2 == 0 { 1 } else { 0 },
            trade_duration: 3,
            alpha: 0.0,
            action_is_hold: 0,
            per_bar_hold_reward: 0.0,
            aux_logits_p50: 0.05,
            aux_logits_std: 0.02,
            aux_dir_acc: 0.50,
        };
        unsafe { kernel.launch(*isv.device_ptr_mut(), &inputs).unwrap(); }
    }
    device.synchronize().unwrap();

    let isv_host = device.dtoh_sync_copy(&isv).unwrap();
    assert!((isv_host[WR_EMA_INDEX] - 0.5).abs() < 0.05,
        "WR_EMA after 100 alternating should converge to 0.5, got {}",
        isv_host[WR_EMA_INDEX]);
}
```

Plus the input struct (in test file or shared module):

```rust
#[repr(C)]
pub struct Sp20EmasInputs {
    pub is_close: i32,            // 1 if trade close event this step
    pub is_win: i32,              // 1 if close_PnL > 0 (only used when is_close)
    pub trade_duration: i32,      // bars in trade (only when is_close)
    pub alpha: f32,               // R_event - hold_baseline (only when is_close)
    pub action_is_hold: i32,      // 1 if action == Hold this step
    pub per_bar_hold_reward: f32, // -aux_conf * cost_scale (only when Hold)
    pub aux_logits_p50: f32,      // from sp20_stats_compute output
    pub aux_logits_std: f32,      // from sp20_stats_compute output
    pub aux_dir_acc: f32,         // 1 if aux predicted right at this step
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test sp20_emas_compute_test 2>&1 | tail -10
```

Expected: FAIL with module not found.

- [ ] **Step 3: Implement kernel**

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

```cuda
// SP20 EMAs Compute — Component 5 / Kernel 1
//
// Updates 8 Wiener-α EMAs into ISV slots. Per pearl_wiener_alpha_floor 0.4
// for control-loop stability under nonstationary policy.
//
// EMAs updated:
//   WR_EMA_INDEX           — per-trade (only on is_close)
//   ALPHA_EMA_INDEX        — per-trade (only on is_close)
//   TRADE_DURATION_EMA*    — per-trade (only on is_close)
//   HOLD_PCT_EMA_INDEX     — per-step (1 if Hold, 0 else)
//   HOLD_REWARD_EMA_INDEX  — per-step on Hold-state bars only
//   AUX_CONF_P50_EMA*      — per-step
//   AUX_CONF_STD_EMA*      — per-step
//   AUX_DIR_ACC_EMA*       — per-step
//
// (* = consumed by Kernel 2 controllers but slot is in [510..520) range
//  via re-use of existing slots; see sp14_isv_slots.rs for exact mapping.)
//
// First observation per pearl_first_observation_bootstrap: sentinel=0
// and on first call, EMA = current value (no blending).
//
// Single-thread kernel (one block, one thread); just 8 scalar updates.

#include <cuda_runtime.h>

extern "C" __global__ void sp20_emas_compute_kernel(
    float* __restrict__ isv,                  // ISV bus
    int is_close,
    int is_win,
    int trade_duration,
    float alpha,
    int action_is_hold,
    float per_bar_hold_reward,
    float aux_p50_in,
    float aux_std_in,
    float aux_dir_acc_in,
    int loss_cap_idx,
    int alpha_ema_idx,
    int wr_ema_idx,
    int hold_pct_ema_idx,
    int hold_reward_ema_idx,
    // Note: the remaining indices (TRADE_DURATION_EMA, AUX_CONF_P50_EMA, etc)
    // are passed as additional kernel args in the actual launcher; here
    // we show the structural pattern for brevity.
    float wiener_alpha_floor   // 0.4 per pearl_wiener_alpha_floor
) {
    if (threadIdx.x != 0 || blockIdx.x != 0) return;

    // Per-trade EMAs (fire only on close)
    if (is_close) {
        // WR_EMA
        float prev_wr = isv[wr_ema_idx];
        float new_wr = (float)is_win;
        isv[wr_ema_idx] = (prev_wr == 0.0f && /* first obs */ true)
            ? new_wr
            : prev_wr * (1.0f - wiener_alpha_floor) + new_wr * wiener_alpha_floor;

        // ALPHA_EMA
        float prev_alpha = isv[alpha_ema_idx];
        isv[alpha_ema_idx] = (prev_alpha == 0.0f) ? alpha
            : prev_alpha * (1.0f - wiener_alpha_floor) + alpha * wiener_alpha_floor;
    }

    // Per-step EMAs
    {
        float prev_hp = isv[hold_pct_ema_idx];
        float new_hp = (float)action_is_hold;
        isv[hold_pct_ema_idx] = (prev_hp == 0.0f) ? new_hp
            : prev_hp * (1.0f - wiener_alpha_floor) + new_hp * wiener_alpha_floor;
    }

    // HOLD_REWARD_EMA — only update on Hold-state bars
    if (action_is_hold) {
        float prev_hr = isv[hold_reward_ema_idx];
        isv[hold_reward_ema_idx] = (prev_hr == 0.0f) ? per_bar_hold_reward
            : prev_hr * (1.0f - wiener_alpha_floor)
              + per_bar_hold_reward * wiener_alpha_floor;
    }

    // (Remaining EMAs follow same pattern — omitted here for brevity.
    //  Full implementation matches the same first-observation-replace
    //  + Wiener-α-blend logic for trade_duration, aux_p50, aux_std,
    //  aux_dir_acc.)
}
```

- [ ] **Step 4: Add Rust launcher in `sp20_emas_compute.rs`**

```rust
//! SP20 EMAs Compute launcher.

use anyhow::Result;
use cudarc::driver::{CudaDevice, CudaFunction, LaunchAsync, LaunchConfig};
use std::sync::Arc;
use crate::cuda_pipeline::sp14_isv_slots::*;

#[repr(C)]
pub struct Sp20EmasInputs {
    pub is_close: i32,
    pub is_win: i32,
    pub trade_duration: i32,
    pub alpha: f32,
    pub action_is_hold: i32,
    pub per_bar_hold_reward: f32,
    pub aux_logits_p50: f32,
    pub aux_logits_std: f32,
    pub aux_dir_acc: f32,
}

pub struct Sp20EmasCompute {
    func: CudaFunction,
}

impl Sp20EmasCompute {
    pub fn new(device: Arc<CudaDevice>) -> Result<Self> {
        let ptx = std::fs::read_to_string(
            concat!(env!("OUT_DIR"), "/sp20_emas_compute.ptx"),
        )?;
        device.load_ptx(ptx.into(), "sp20_emas", &["sp20_emas_compute_kernel"])?;
        let func = device.get_func("sp20_emas", "sp20_emas_compute_kernel")
            .ok_or_else(|| anyhow::anyhow!("kernel not loaded"))?;
        Ok(Self { func })
    }

    /// # Safety
    /// `isv` must be a valid device pointer to the ISV bus (ISV_TOTAL_DIM f32 entries).
    pub unsafe fn launch(
        &self,
        isv: u64,
        inputs: &Sp20EmasInputs,
    ) -> Result<()> {
        let cfg = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (1, 1, 1),
            shared_mem_bytes: 0,
        };
        const WIENER_ALPHA_FLOOR: f32 = 0.4;
        self.func.clone().launch(
            cfg,
            (
                isv,
                inputs.is_close, inputs.is_win, inputs.trade_duration, inputs.alpha,
                inputs.action_is_hold, inputs.per_bar_hold_reward,
                inputs.aux_logits_p50, inputs.aux_logits_std, inputs.aux_dir_acc,
                LOSS_CAP_INDEX as i32,
                ALPHA_EMA_INDEX as i32,
                WR_EMA_INDEX as i32,
                HOLD_PCT_EMA_INDEX as i32,
                HOLD_REWARD_EMA_INDEX as i32,
                WIENER_ALPHA_FLOOR,
            ),
        )?;
        Ok(())
    }
}
```

- [ ] **Step 5: Add cubin entry to build.rs**

```rust
    "sp20_emas_compute_kernel.cu",
```

- [ ] **Step 6: Declare module in mod.rs**

```rust
pub mod sp20_emas_compute;
```

- [ ] **Step 7: Run test to verify it passes**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test sp20_emas_compute_test 2>&1 | tail -10
```

Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add crates/ml/src/cuda_pipeline/sp20_emas_compute_kernel.cu \
        crates/ml/src/cuda_pipeline/sp20_emas_compute.rs \
        crates/ml/src/cuda_pipeline/mod.rs crates/ml/build.rs \
        crates/ml/tests/sp20_emas_compute_test.rs
git commit -m "feat(sp20): sp20_emas_compute kernel — 8 Wiener-α EMAs

Component 5 / Kernel 1. First observation replaces sentinel directly per
pearl_first_observation_bootstrap, subsequent updates use Wiener-α floor 0.4
per pearl_wiener_alpha_floor_for_nonstationary.

EMAs: wr_ema, alpha_ema, trade_duration_ema (per-trade);
hold_pct_ema, hold_reward_ema, aux_conf_p50_ema, aux_conf_std_ema,
aux_dir_acc_ema (per-step)."
```

### Task 1.3: sp20_controllers_compute kernel (6 derived controllers)

**Files:** Following the same pattern as 1.1, 1.2.

**Inputs:** All EMAs from Kernel 1 + raw stats from Kernel 3.

**Outputs:** LOSS_CAP, N_STEP, AUX_CONF_THRESHOLD, AUX_GATE_TEMP, TARGET_HOLD_PCT, HOLD_COST_SCALE — written to ISV.

- [ ] **Step 1: Write failing oracle test** (controller bounds)

```rust
#[test]
fn sp20_controllers_loss_cap_ramps_with_wr() {
    // Set wr_ema = 0.50 (just above ramp start). loss_cap should be -1.0.
    // Set wr_ema = 0.55 (full ramp). loss_cap should be -2.0.
    // (Bounds test, not regression on observed value — per
    //  pearl_tests_must_prove_not_lock_observations.)
    // ...
}
```

- [ ] **Step 2-8: Implement kernel + launcher + commit, following Task 1.1 pattern.**

Kernel formulas per spec §4.5:
- `LOSS_CAP = -1.0 - clamp((wr_ema - 0.50) / 0.05, 0, 1)`
- `N_STEP = clamp(round(trade_duration_ema), 1, 30)`
- `AUX_CONF_THRESHOLD = clamp(aux_dir_acc_ema - 0.50, 0.01, 0.20)`
- `AUX_GATE_TEMP = max(aux_conf_std_ema, 0.01)`
- `TARGET_HOLD_PCT = clamp(0.8 - aux_conf_p50_ema * 1.5, 0.1, 0.8)`
- `HOLD_COST_SCALE`: two-sided controller, see spec §4.2

Commit message: `feat(sp20): sp20_controllers_compute kernel — 6 derived controllers`

### Task 1.4: Wire all 3 kernels into training_loop

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

- [ ] **Step 1: Find existing producer-launch site (look for SP14-C or SP19 producer launches as a reference pattern).**
- [ ] **Step 2: Write failing test** that verifies all 10 ISV slots have non-sentinel values after one step of mock training.
- [ ] **Step 3: Add launch ordering: stats → emas → controllers** (same per-step in this order; controllers depend on emas which depend on stats).
- [ ] **Step 4: Add HEALTH_DIAG emit** for all 10 SP20 slots (one new line: `HEALTH_DIAG[N]: sp20_isv [loss_cap=X wr_ema=X alpha_ema=X ...]`).
- [ ] **Step 5: Run test, commit.**

Commit message: `feat(sp20): wire 3 ISV producer kernels into training loop with HEALTH_DIAG emit`

---

## Phase 2: Reward Kernel (Component 1)

### Task 2.1: Add `sp20_compute_event_reward` device function

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu`
- Test: `crates/ml/tests/sp20_event_reward_test.rs`

- [ ] **Step 1: Write failing 4-quadrant truth-table test**

Create `crates/ml/tests/sp20_event_reward_test.rs` with one test per quadrant:

```rust
#[test]
fn sp20_event_reward_quadrant_right_reason_right_outcome() {
    // close_PnL > 0, dir_match=true → R_event = +win_cap (=+1.0 baseline)
    let r = compute_test_reward(/*pnl=*/0.5, /*label=*/1, /*loss_cap=*/-1.0);
    assert!((r - 1.0).abs() < 1e-6);
}

#[test]
fn sp20_event_reward_quadrant_wrong_reason_right_outcome() {
    // close_PnL > 0, dir_match=false → R_event = +0.5
    let r = compute_test_reward(/*pnl=*/0.3, /*label=*/-1, /*loss_cap=*/-1.0);
    assert!((r - 0.5).abs() < 1e-6);
}

#[test]
fn sp20_event_reward_quadrant_right_reason_wrong_outcome() {
    // close_PnL < 0, dir_match=true → R_event = -0.5
    let r = compute_test_reward(/*pnl=*/-0.2, /*label=*/-1, /*loss_cap=*/-1.0);
    assert!((r - (-0.5)).abs() < 1e-6);
}

#[test]
fn sp20_event_reward_quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_1() {
    // close_PnL < 0, dir_match=false → R_event = loss_cap (=-1.0 cold start)
    let r = compute_test_reward(/*pnl=*/-0.4, /*label=*/1, /*loss_cap=*/-1.0);
    assert!((r - (-1.0)).abs() < 1e-6);
}

#[test]
fn sp20_event_reward_quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_2() {
    // close_PnL < 0, dir_match=false → R_event = loss_cap (=-2.0 mature)
    let r = compute_test_reward(/*pnl=*/-0.4, /*label=*/1, /*loss_cap=*/-2.0);
    assert!((r - (-2.0)).abs() < 1e-6);
}

#[test]
fn sp20_event_reward_zero_pnl_returns_zero() {
    // close_PnL == 0 → R_event = 0 (no information)
    let r = compute_test_reward(/*pnl=*/0.0, /*label=*/1, /*loss_cap=*/-1.0);
    assert!(r.abs() < 1e-6);
}
```

- [ ] **Step 2: Run tests to verify failure** (function not yet defined).

- [ ] **Step 3: Add device function to `experience_kernels.cu`**

Insert near the existing reward block (search for `compute_sp18_hold_opportunity_cost` or `compute_sp12_reward_with_cost` to find the location):

```cuda
// SP20 Component 1 — Reward Kernel.
// 4-quadrant directional-correctness with adaptive asymmetric loss aversion.
//
// Per spec §4.1.
__device__ __forceinline__ float sp20_compute_event_reward(
    float close_pnl,
    int label_at_open_sign,   // -1, 0, +1 (sign of SP19 blended label at open)
    float loss_cap            // ISV[LOSS_CAP_INDEX], range [-2, -1]
) {
    constexpr float win_cap = 1.0f;
    int pnl_sign = (close_pnl > 0.0f) ? 1 : ((close_pnl < 0.0f) ? -1 : 0);
    if (pnl_sign == 0) return 0.0f;   // no information

    bool dir_match = (pnl_sign == label_at_open_sign);

    if (pnl_sign > 0 && dir_match)  return win_cap;        // +1.0
    if (pnl_sign > 0 && !dir_match) return 0.5f;           // wrong reason, right outcome
    if (pnl_sign < 0 && dir_match)  return -0.5f;          // right reason, wrong outcome
    /* pnl_sign < 0 && !dir_match */ return loss_cap;      // wrong reason, wrong outcome
}
```

- [ ] **Step 4: Add a unit-test wrapper** that the Rust test can call (use the cudarc pattern: a 1-thread kernel that calls the device function).

- [ ] **Step 5: Run tests to verify they pass.**

- [ ] **Step 6: Commit**

```bash
git commit -m "feat(sp20): sp20_compute_event_reward — 4-quadrant directional reward

Component 1 device function. Forces 'right reason for right outcome' via
SP19 multi-horizon label sign agreement check. Loss cap is adaptive via
ISV[LOSS_CAP_INDEX] (ramps from -1 to -2 with WR_EMA per spec §4.1).

Six unit tests (one per quadrant + zero-PnL edge case)."
```

### Task 2.2: Replace SP18 D-leg + SP12 v3 reward block in experience_kernels.cu

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu`

- [ ] **Step 1: Find the existing call sites** for `compute_sp18_hold_opportunity_cost` and `compute_sp12_reward_with_cost`. Document them with `grep -n` first.

```bash
grep -n "compute_sp18_hold_opportunity_cost\|compute_sp12_reward_with_cost" crates/ml/src/cuda_pipeline/experience_kernels.cu
```

- [ ] **Step 2: Write failing integration test** that asserts the trade-close path produces R_event matching `sp20_compute_event_reward` (not SP18 D-leg, not SP12 v3).

- [ ] **Step 3: Replace the call sites atomically** (per `feedback_no_partial_refactor`). Reward is computed via:

```cuda
if (is_close) {
    int label_sign = (sp19_blended_label_at_open[i] > 0.0f) ? 1
                     : ((sp19_blended_label_at_open[i] < 0.0f) ? -1 : 0);
    float r_event = sp20_compute_event_reward(
        close_pnl, label_sign, isv[LOSS_CAP_INDEX]
    );
    // hold_baseline computed in Component 2 (Phase 3); for now use 0 placeholder
    // — wired to actual buffer in Task 3.2 of Phase 3.
    float alpha = r_event - 0.0f;   // hold_baseline placeholder until Phase 3
    float r_used = alpha - isv[ALPHA_EMA_INDEX];
    // distribute via n_step in Phase 4 — for now write to per_bar_credit[i]
    per_bar_credit[i] += r_used;    // single-bar fallback until Phase 4
}
```

(Note placeholders for Phase 3, 4 wire-up — they will be filled in those phases. Per `feedback_no_stubs`, this is NOT a stub: the function is fully wired to a working but reduced behavior. Subsequent phases extend it.)

- [ ] **Step 4: DELETE** `compute_sp18_hold_opportunity_cost` and `compute_sp12_reward_with_cost` from the file. Per `feedback_no_legacy_aliases`, no deprecated wrappers — gone.

- [ ] **Step 5: Run integration test, commit.**

Commit message: `feat(sp20): replace SP18 D-leg + SP12 v3 reward block atomically`

### Task 2.3: Wire wr_ema and alpha_ema producer-call into trade-close path

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

- [ ] **Step 1: Find trade-close hook site** (`grep -n "is_close\|trade_close" crates/ml/src/cuda_pipeline/gpu_experience_collector.rs`).
- [ ] **Step 2: Write failing test** verifying ISV[WR_EMA_INDEX] and ISV[ALPHA_EMA_INDEX] update after a mock trade close.
- [ ] **Step 3: Add launch of `sp20_emas_compute` after trade close detected**, before the next forward pass.
- [ ] **Step 4: Run test, commit.**

Commit message: `feat(sp20): wire wr_ema + alpha_ema producer to trade-close path`

---

## Phase 3: Hold Opportunity-Cost + Replay Schema (Component 2)

### Task 3.1: Add hold_cost_scale_compute_kernel for Component 5 controller

**Files:**
- Create: `crates/ml/src/cuda_pipeline/hold_cost_scale_compute_kernel.cu`
- Create: `crates/ml/src/cuda_pipeline/hold_cost_scale_compute.rs`
- Modify: mod.rs, build.rs

This is the *separate* fine-grained controller noted in spec §4.2 (the two-sided controller for cost_scale). It is a sibling kernel of sp20_controllers_compute (Task 1.3) but extracted because its update logic (multiplicative ramp 1.05/0.95 with clamps) is structurally different from the other controllers' direct formulas.

- [ ] **Step 1: Write failing controller test** (assert cost_scale increases when hold_pct > target + 0.05).
- [ ] **Step 2-8: Following Task 1.1 pattern.**

Kernel formula:
```cuda
float hp = isv[HOLD_PCT_EMA_INDEX];
float tgt = isv[TARGET_HOLD_PCT_INDEX];
float cs = isv[HOLD_COST_SCALE_INDEX];
if (hp > tgt + 0.05f) {
    cs = fminf(0.5f, cs * 1.05f);
} else if (hp < tgt - 0.05f) {
    cs = fmaxf(0.01f, cs * 0.95f);
}
isv[HOLD_COST_SCALE_INDEX] = cs;
```

Commit message: `feat(sp20): hold_cost_scale_compute_kernel — Component 2 controller`

### Task 3.2: Add per-bar Hold opp-cost dual emission

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` (per-bar reward path)
- Create: `crates/ml/src/cuda_pipeline/hold_baseline_buffer.rs` (circular buffer wrapper)

- [ ] **Step 1: Write failing test** for the dual-emission shape:

```rust
#[test]
fn sp20_hold_dual_emission_centered_and_buffer() {
    // Fix aux_conf=0.3, cost_scale=0.1, hold_reward_ema=-0.02
    // Bar with action=Hold:
    //   per_bar_centered = -0.3*0.1 - (-0.02) = -0.03 + 0.02 = -0.01
    //   hold_baseline_buffer = -0.3*0.1 = -0.03 (uncentered)
    // Bar with action=Long:
    //   per_bar_centered = 0
    //   hold_baseline_buffer = -0.3*0.1 = -0.03 (always written)
    // ...
}
```

- [ ] **Step 2-3: Implement per-bar emission in experience_kernels.cu**:

```cuda
// SP20 Component 2 — Hold opportunity-cost (per-bar)
{
    float aux_conf_i = compute_aux_conf(aux_logits + i * 3);
    float cost_scale = isv[HOLD_COST_SCALE_INDEX];
    float per_bar_opp_cost = -aux_conf_i * cost_scale;

    // Path 1 — real reward (only on Hold action)
    if (action[i] == ACTION_HOLD) {
        per_bar_credit[i] += per_bar_opp_cost - isv[HOLD_REWARD_EMA_INDEX];
    }

    // Path 2 — counterfactual buffer (always)
    hold_baseline_buffer[i % HOLD_BUFFER_SIZE] = per_bar_opp_cost;
}
```

- [ ] **Step 4: Allocate `hold_baseline_buffer`** in gpu_experience_collector.rs (size=30 per spec §4.2 buffer sizing note).

- [ ] **Step 5: Wire `hold_baseline_buffer.sum_over_trade_range()`** into the alpha computation in Task 2.2 (replace the placeholder `0.0f`):

```cuda
float hold_baseline = sum_hold_baseline_buffer(
    hold_baseline_buffer, trade_open_bar, trade_close_bar
);
float alpha = r_event - hold_baseline;
```

- [ ] **Step 6: Run test, commit.**

Commit message: `feat(sp20): Hold opp-cost dual emission — centered for Q-target, uncentered for baseline`

### Task 3.3: Add aux_conf-derived target_hold_pct controller

(Already inside Task 1.3 for the other controllers; this task verifies the wire path is complete and adds a behavioral test that target_hold_pct adapts to aux confidence distribution.)

- [ ] **Step 1: Write integration test.** Force aux_conf_p50=0.05 → expect target_hold_pct ≈ 0.725. Force aux_conf_p50=0.4 → expect target ≈ 0.2.
- [ ] **Step 2: Run, commit (or note already-covered).**

### Task 3.4: Replay buffer schema — per-bar aux_conf field

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs`
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (where GpuBatchPtrs is consumed)

- [ ] **Step 1: Find existing `GpuBatchPtrs` definition** and `TradeStats`/experience tuple struct.
- [ ] **Step 2: Write failing test** that samples a batch from replay buffer and asserts `aux_conf_at_state` field is populated and matches what was stored at experience-collection time.
- [ ] **Step 3: Add `aux_conf` field** to:
  - The experience tuple struct in `gpu_experience_collector.rs`
  - `GpuBatchPtrs` struct (add `pub aux_conf_buf: u64`)
  - The collection-time write path (write aux_conf for the bar being collected)
  - The replay-sample read path (read aux_conf for the sampled bar)
- [ ] **Step 4: Run test, commit.**

Commit message: `feat(sp20): replay buffer schema — per-bar aux_conf for Component 4 gate`

---

## Phase 4: n-step Credit Distributor (Component 3)

### Task 4.1: New n_step_credit_kernel.cu

**Files:**
- Create: `crates/ml/src/cuda_pipeline/n_step_credit_kernel.cu`
- Create: `crates/ml/src/cuda_pipeline/n_step_credit.rs`
- Modify: mod.rs, build.rs

- [ ] **Step 1: Write failing test for `sp20_n_step_distribution`:**

```rust
#[test]
fn n_step_credit_uniform_distribution_3_bar_trade() {
    // R_used = 0.6, n_step = 3, trade_duration = 3 bars
    // Each of 3 bars should receive 0.6 / 3 = 0.2
    let device = Arc::new(CudaDevice::new(0).unwrap());
    let mut per_bar_credit = device.alloc_zeros::<f32>(10).unwrap();

    let kernel = NStepCredit::new(device.clone()).unwrap();
    unsafe {
        kernel.launch(
            *per_bar_credit.device_ptr_mut(),
            /*r_used=*/0.6,
            /*bar_close_idx=*/5,
            /*n_distribute=*/3,
        ).unwrap();
    }
    device.synchronize().unwrap();

    let host = device.dtoh_sync_copy(&per_bar_credit).unwrap();
    // Bars 3, 4, 5 should each have credit 0.2
    assert!((host[3] - 0.2).abs() < 1e-5);
    assert!((host[4] - 0.2).abs() < 1e-5);
    assert!((host[5] - 0.2).abs() < 1e-5);
    // Bar 2 should be 0 (outside distribution range)
    assert!(host[2].abs() < 1e-6);
}
```

- [ ] **Step 2: Run test to fail.**
- [ ] **Step 3: Implement kernel:**

```cuda
extern "C" __global__ void n_step_credit_kernel(
    float* __restrict__ per_bar_credit,
    float r_used,
    int bar_close_idx,
    int n_distribute,
    int buffer_size
) {
    int tid = threadIdx.x;
    if (tid >= n_distribute) return;
    int bar_idx = bar_close_idx - tid;
    if (bar_idx < 0 || bar_idx >= buffer_size) return;
    float credit = r_used / (float)n_distribute;
    atomicAdd(&per_bar_credit[bar_idx], credit);
    // NOTE: atomicAdd is acceptable here per code review — different
    // bars contend only across concurrent close events (rare); the
    // alternative (block tree-reduce per close) is overkill for a
    // 30-element max range.
}
```

WAIT — per `feedback_no_atomicadd` ("block tree-reduce only, never atomicAdd"), this is a violation. Revise: each kernel launch is per-trade-close, and bars within a single close don't contend (different bar_idx values, written sequentially by single thread). Use a single-thread loop instead:

```cuda
extern "C" __global__ void n_step_credit_kernel(
    float* __restrict__ per_bar_credit,
    float r_used,
    int bar_close_idx,
    int n_distribute,
    int buffer_size
) {
    if (threadIdx.x != 0 || blockIdx.x != 0) return;
    float credit = r_used / (float)n_distribute;
    for (int k = 0; k < n_distribute; k++) {
        int bar_idx = bar_close_idx - k;
        if (bar_idx >= 0 && bar_idx < buffer_size) {
            per_bar_credit[bar_idx] += credit;   // no atomic — single thread
        }
    }
}
```

- [ ] **Step 4-7: Launcher + cubin + module + run test + commit.**

Commit message: `feat(sp20): n_step_credit_kernel — uniform distribution over trade duration`

### Task 4.2: Fix SP18 B-leg self-bootstrap bug

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs:4154` (per spec)

- [ ] **Step 1: Read current code at line 4154**

```bash
sed -n '4140,4180p' crates/ml/src/cuda_pipeline/gpu_experience_collector.rs
```

- [ ] **Step 2: Write failing test** that exercises trace state across two trades and asserts state is reset between them.

- [ ] **Step 3: Add trace reset on trade close.**

Per spec §4.3 — "trace state must reset at every trade close, not carry across".

Identify the trace state field (likely `td_lambda_trace` or similar). At the trade-close detection site, add:

```rust
// SP18 B-leg fix (deferred no longer per pearl_no_deferrals_for_complementary_fixes):
// Trace state resets at every trade close. Without this, eligibility credit
// from trade N bleeds into trade N+1's bootstrap target.
if is_close[i] {
    td_lambda_trace[env_idx] = 0.0;
}
```

- [ ] **Step 4: Run test, commit.**

Commit message: `fix(sp20): SP18 B-leg trace reset at trade close (deferred no longer)`

### Task 4.3: Wire n-step distributor to trade-close site

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

- [ ] **Step 1: Find the trade-close hook** (same site as Task 2.3).
- [ ] **Step 2: Write failing test** that asserts replay buffer's per-bar credits are populated correctly after a multi-bar trade closes.
- [ ] **Step 3: Add n-step kernel launch** after `R_used` is computed at trade close:

```rust
let n_step = isv_read(N_STEP_INDEX) as i32;
let trade_duration = bar_close - bar_open;
let n_distribute = n_step.min(trade_duration);

unsafe {
    self.n_step_credit_kernel.launch(
        *per_bar_credit.device_ptr_mut(),
        r_used,
        bar_close,
        n_distribute,
        BUFFER_SIZE as i32,
    )?;
}
```

- [ ] **Step 4: Replace the single-bar-fallback placeholder** from Task 2.2 (where `per_bar_credit[i] += r_used`) with the n-step launch.

- [ ] **Step 5: Run test, commit.**

Commit message: `feat(sp20): wire n_step_credit at trade close, replace single-bar fallback`

---

## Phase 5: Aux→Q Confidence Gate (Component 4)

### Task 5.1: Modify Bellman target site with sigmoid gate

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

- [ ] **Step 1: Find Bellman target computation site** (search for `r + gamma` or `q_target`).
- [ ] **Step 2: Write failing test** for gate at threshold extremes (gate ≈ 1 when aux_conf >> threshold, gate ≈ 0 when << threshold).

```rust
#[test]
fn aux_gate_high_confidence_passes_full_target() {
    let aux_conf = 0.5;
    let threshold = 0.10;
    let temp = 0.05;
    let gate = sigmoid((aux_conf - threshold) / temp);
    assert!(gate > 0.99, "high conf should produce gate ≈ 1, got {gate}");
}

#[test]
fn aux_gate_low_confidence_pulls_toward_baseline() {
    let aux_conf = 0.02;
    let threshold = 0.10;
    let temp = 0.05;
    let gate = sigmoid((aux_conf - threshold) / temp);
    assert!(gate < 0.20, "low conf should produce gate << 1, got {gate}");
}
```

- [ ] **Step 3: Add gate computation around the existing Bellman target.**

Per spec §4.4 (replacement at line where `q_target = r + gamma * max_a' Q(s', a')` is computed):

```rust
let aux_conf = batch.aux_conf[i];                              // from replay tuple
let threshold = isv[AUX_CONF_THRESHOLD_INDEX as usize];
let temp = isv[AUX_GATE_TEMP_INDEX as usize].max(1e-3);
let gate = 1.0 / (1.0 + (-((aux_conf - threshold) / temp)).exp());

let q_target_full = r + GAMMA * q_max_next[i];
let q_target_baseline = q_mean_a[i];   // mean over actions (Component 4 baseline choice)
let q_target = gate * q_target_full + (1.0 - gate) * q_target_baseline;
```

- [ ] **Step 4: Add `q_mean_a` computation** if not already available (mean over the 12 dir×mag actions for the sampled state).

- [ ] **Step 5: Run test, commit.**

Commit message: `feat(sp20): Aux→Q confidence gate at Bellman target site`

### Task 5.2: Read aux_conf from replay tuple

(Largely covered in Phase 3.4 schema work; this task is the consumer-side wire-up.)

- [ ] **Step 1: Verify `batch.aux_conf` field is accessible** (grep the struct definition added in Task 3.4).
- [ ] **Step 2: Run gate test from Task 5.1, verify it operates on real replay data.**
- [ ] **Step 3: Commit.**

Commit message: `feat(sp20): wire aux_conf from replay tuple to Bellman gate`

### Task 5.3: Mean_a Q baseline computation

(Per spec §4.4, the baseline target when gate is low.)

- [ ] **Step 1: Find existing per-action Q computation** (likely in dueling-head section).
- [ ] **Step 2: Write failing test** that asserts `q_mean_a` equals the mean over the action dim.
- [ ] **Step 3: Add the reduction.**

```rust
// Mean_a Q(s, a) over 12 actions (4 dirs × 3 magnitudes)
let q_mean_a = q_per_action_buf[..N_ACTIONS].iter().sum::<f32>() / N_ACTIONS as f32;
```

(GPU side: replace with a small tree-reduce in the kernel that already produces per-action Q.)

- [ ] **Step 4: Run test, commit.**

Commit message: `feat(sp20): mean_a Q baseline for Aux→Q gate inactive case`

### Task 5.4: Unit tests for gate behavior at extremes

(Already covered in Task 5.1 — this task is the close-out: behavioral test sp20_confidence_correlation in Phase 6.3 is the integrated validation.)

- [ ] **Step 1: Verify all gate-related unit tests pass (`cargo test sp20_aux_gate`).**

---

## Phase 6: Behavioral Tests (Component 6)

Each test fills in one stub from Phase 0. Use the SP15 LobBar harness pattern.

### Task 6.1: sp20_pure_trend (WR > 90%, PF > 3.0)

- [ ] **Step 1: Replace stub** in `crates/ml/tests/sp19_20_behavioral_tests.rs`:

```rust
#[test]
fn sp20_pure_trend() {
    let mut harness = LobBarHarness::new(MarketScript::PureTrend {
        n_bars: 1000,
        trend_strength: 0.001,    // +0.1% per bar
        noise_std: 0.00005,       // very small noise
    });

    let result = harness.run_dqn_for_n_steps(1000);

    assert!(result.win_rate > 0.90,
        "Pure trend market: WR should be > 90%, got {:.3}", result.win_rate);
    assert!(result.profit_factor > 3.0,
        "Pure trend market: PF should be > 3.0, got {:.3}", result.profit_factor);
}
```

- [ ] **Step 2: Remove `#[ignore]` attribute.**
- [ ] **Step 3: Run test:**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test sp19_20_behavioral_tests -- sp20_pure_trend --include-ignored 2>&1 | tail -10
```

Expected: PASS.

- [ ] **Step 4: Commit.**

Commit message: `test(sp20): sp20_pure_trend behavioral test passes (WR>90%, PF>3.0)`

### Task 6.2: sp20_pure_noise (Hold% > 95%, trades < 20)

- [ ] **Step 1-4: Following Task 6.1 pattern.**

```rust
#[test]
fn sp20_pure_noise() {
    let mut harness = LobBarHarness::new(MarketScript::PureNoise {
        n_bars: 1000,
        noise_std: 0.0001,
    });
    let result = harness.run_dqn_for_n_steps(1000);
    assert!(result.hold_pct > 0.95);
    assert!(result.trade_count < 20);
}
```

Commit: `test(sp20): sp20_pure_noise behavioral test passes (Hold>95%, trades<20)`

### Task 6.3: sp20_confidence_correlation (corr(aux_conf, WR) > 0.4)

```rust
#[test]
fn sp20_confidence_correlation() {
    let mut harness = LobBarHarness::new(MarketScript::Mixed {
        trend_bars: 500,
        noise_bars: 500,
    });
    let result = harness.run_dqn_for_n_steps(1000);
    let corr = compute_correlation(&result.aux_conf_per_trade, &result.win_per_trade);
    assert!(corr > 0.4);
}
```

Commit: `test(sp20): sp20_confidence_correlation passes (corr(aux_conf, WR)>0.4)`

### Task 6.4: sp20_asymmetry_no_game (PF > 1.0 with symmetric clamp)

```rust
#[test]
fn sp20_asymmetry_no_game() {
    let mut harness = LobBarHarness::new(MarketScript::Trending);
    harness.override_loss_cap(-1.0);   // disable adaptive ramp
    let result = harness.run_dqn_for_n_steps(1000);
    assert!(result.profit_factor > 1.0);
}
```

Commit: `test(sp20): sp20_asymmetry_no_game passes (PF>1.0 with symmetric clamp)`

### Task 6.5: sp20_n_step_distribution (per-bar credit ≈ 0.2)

(Already covered as a kernel unit test in Task 4.1; this task is the integration test wrapping the full reward → distribute → replay cycle.)

```rust
#[test]
fn sp20_n_step_distribution() {
    let mut harness = LobBarHarness::new(MarketScript::ScriptedTrade {
        trade_duration: 3, close_pnl: 0.6,
    });
    let result = harness.run_dqn_for_n_steps(10);
    let trade_bars = &result.per_bar_credit_history[0..3];
    for &c in trade_bars {
        assert!((c - 0.2).abs() < 0.05);
    }
}
```

Commit: `test(sp20): sp20_n_step_distribution integration test passes`

### Task 6.6: sp20_per_regime_wr (WR_T > 65%, WR_R > 50% with high Hold%, WR_V > 50%)

```rust
#[test]
fn sp20_per_regime_wr() {
    let mut harness = LobBarHarness::new(MarketScript::TRVSegments {
        t_pct: 0.40, r_pct: 0.40, v_pct: 0.20,
    });
    let result = harness.run_dqn_for_n_steps(2000);
    assert!(result.wr_trending > 0.65);
    assert!(result.wr_ranging > 0.50);
    assert!(result.hold_pct_ranging > 0.60);
    assert!(result.wr_volatile > 0.50);
    assert!(result.trade_count_volatile < result.trade_count_trending / 2);
}
```

Commit: `test(sp20): sp20_per_regime_wr passes — adapts to T/R/V regimes`

### Task 6.7: sp20_regime_transition (trade frequency drops 50% within 10 bars)

```rust
#[test]
fn sp20_regime_transition() {
    let mut harness = LobBarHarness::new(MarketScript::TrendThenNoise {
        trend_bars: 50, noise_bars: 50,
    });
    let result = harness.run_dqn_for_n_steps(100);
    let pre_freq = result.trade_count_window(40, 50) as f32 / 10.0;
    let post_freq = result.trade_count_window(50, 60) as f32 / 10.0;
    assert!(post_freq < pre_freq * 0.5);
}
```

Commit: `test(sp20): sp20_regime_transition passes — model recognizes regime change`

### Task 6.8: All 7 behavioral tests pass — L40S deployment gate

- [ ] **Step 1: Run all 7 in sequence:**

```bash
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml --test sp19_20_behavioral_tests --include-ignored 2>&1 | tail -20
```

Expected: 7 passed, 0 failed.

- [ ] **Step 2: Commit a marker.**

Commit message: `test(sp20): ALL 7 BEHAVIORAL TESTS PASS — L40S deployment gate cleared`

---

## Phase 7: L40S Smoke + Full Validation

### Task 7.1: L40S 5-epoch × 2-fold smoke

- [ ] **Step 1: Push branch:**

```bash
git push origin sp19-20-wr-first
```

- [ ] **Step 2: Dispatch smoke per `feedback_default_to_l40s_pool`:**

```bash
./scripts/argo-train.sh dqn --branch sp19-20-wr-first --epochs 5 --folds 2 \
    --gpu-pool ci-training-l40s --tag sp20-smoke
```

- [ ] **Step 3: Monitor via TaskList Monitor pattern.**

Pass criteria (per spec §10):
- WR ≥ 50% by ep5 fold0 (smoke is short — bar lower than full validation goal)
- HEALTH_DIAG[N]: sp20_isv emit shows all 10 slots populated (non-zero)
- Hold% trajectory stable (no monotone climb to 60%+)
- All training tests pass during the run (no NaN, OOM, kernel errors)

Failure case: dispatch anomaly investigation per `feedback_kill_runs_on_anomaly_quickly`.

### Task 7.2: Anomaly investigation (conditional)

- [ ] **Step 1: If anomaly detected, kill workflow.**

```bash
argo terminate -n foxhunt train-multi-seed-<id>
```

- [ ] **Step 2: Diagnose via HEALTH_DIAG output.** Common failure modes:
  - aux_dir_acc < 0.50 → gate clamp fallback → standard Q-learning resumed (not necessarily a bug)
  - hold_pct_ema = 0 throughout → controller never fires → cost_scale stuck at sentinel (BUG: producer launch ordering wrong)
  - alpha_ema diverges to large negative → R_used dominated by EMA mean (BUG: Wiener-α floor not applied)

- [ ] **Step 3: Fix root cause, re-run smoke from Step 1 of Task 7.1.**

### Task 7.3: L40S 50-epoch × 3-seed × 3-fold full validation

(Only after smoke passes.)

- [ ] **Step 1: Dispatch:**

```bash
./scripts/argo-train.sh dqn --branch sp19-20-wr-first --epochs 50 --folds 3 \
    --multi-seed 3 --gpu-pool ci-training-l40s --tag sp20-validation
```

- [ ] **Step 2: Monitor for ~12 hours.**

Pass criteria (per spec §10 full success):
- WR ≥ 55% (mean across all folds × seeds), no fold below 52%
- Textbook PF ≥ 2.0 (mean), no fold below 1.5
- Per-regime WR_T ≥ 60%, WR_R ≥ 52%, WR_V ≥ 52%
- Hold% in [20%, 50%] across training
- All 7 behavioral tests still pass at every checkpoint

### Task 7.4: Audit doc + memory pearls + close-out commit

**Files:**
- Create: `audit/sp20_wireup.md`
- Create memory pearls if patterns surfaced

- [ ] **Step 1: Write `audit/sp20_wireup.md`** documenting all 10 ISV slots, their producer kernels, their consumers, and the StateResetRegistry registration. One row per slot, table format.

- [ ] **Step 2: Identify memory-pearl candidates from implementation.**

Plausible candidates:
- "Dual-emission discipline for centered/uncentered reward variants"
- "4-quadrant adaptive truth table for direction-correctness reward"
- "Aux gate self-stabilizing curriculum via threshold floor clamp"

For each, write `~/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_<topic>.md` with the canonical example from this implementation.

- [ ] **Step 3: Update MEMORY.md index** to reference new pearls.

- [ ] **Step 4: Final commit:**

```bash
git add audit/sp20_wireup.md
git commit -m "docs(sp20): close-out — wire-up audit + memory pearls

WR mean: X%, PF mean: X across 50e×3s×3f L40S validation.
Per-regime: T=X%, R=X%, V=X%.
All 7 behavioral tests stable at final checkpoint.

Pearls extracted: <list>. See ~/.claude/projects/.../memory/."
```

- [ ] **Step 5: Push final state.**

```bash
git push origin sp19-20-wr-first
```

Branch is ready for review/merge to main.

---

## Self-Review Notes

Per `superpowers:writing-plans` checklist, the plan author has performed:

**1. Spec coverage:**
- §4.1 (Reward kernel) → Phase 2
- §4.2 (Hold opp-cost) → Phase 3
- §4.3 (n-step distributor) → Phase 4
- §4.4 (Aux→Q gate) → Phase 5
- §4.5 (Adaptive curriculum producers) → Phase 1
- §4.6 (Behavioral tests) → Phases 0 + 6
- §5 (Data flow) → covered in Phase 1.4 (wire-up) + Phase 7 (validation)
- §7 (Testing strategy) → 5-tier gate enforced via task ordering
- §8 (Implementation footprint) → consistent with task LoC estimates
- §10 (Success criteria) → Phase 7 pass criteria

**2. Placeholder scan:** No "TBD", "TODO", "implement later", or vague "add error handling" patterns. Every code block contains compilable code or marked-pending wiring (with clear note that the next phase fills it).

**3. Type consistency:**
- `Sp20EmasInputs` struct defined in Phase 1.2, referenced consistently
- ISV slot constants (LOSS_CAP_INDEX etc.) defined in Pre.2, used everywhere
- `aux_conf` field on replay tuple introduced in Phase 3.4, consumed in Phase 5.1
- Naming consistent throughout: `sp20_*` for all new kernels and functions

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-05-09-sp19-20-wr-first.md`.

Two execution options:

**1. Subagent-Driven (recommended)** — fresh subagent per task, two-stage review (spec compliance + code quality) between tasks, fast iteration. Best for this plan because tasks are mostly independent within phases (especially behavioral tests in Phase 6).

**2. Inline Execution** — execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for human review. Best if you want to stay close to the implementation as it lands.

Which approach?

---

## Plan Accuracy Errata (appended 2026-05-09)

The following deviations from the original Phase 2 plan are documented
here for future implementers. Each entry captures the gap, the
decision made, and the rationale. The audit-doc entries
(`docs/dqn-wire-up-audit.md`) carry the implementation-level details;
this errata is the plan-level "what was actually built vs what was
specified".

### Gap 1: Task 2.0 (label-at-open infrastructure) was NEW, not in plan

The original plan jumped from Phase 1.4 (Path C aggregation) to Task
2.1 (`sp20_compute_event_reward` device function) and then Task 2.2
(replace SP12 v3 block in experience_kernels.cu). Task 2.2's call site
needed `label_at_open_per_env[i]` as input to `sp20_compute_event_
reward(..., label_at_open_sign, ...)`, but no buffer / kernel arg / write
site was specified.

**Decision**: insert a new Task 2.0 BEFORE Task 2.1 to land the buffer
+ alloc + reset registry + kernel arg + write site at `entering_trade`
in a single atomic commit. Producer-only this commit; the consumer
ships in Task 2.2 atomically with the SP12 v3 reward block deletion.

**Rationale**: per `feedback_no_partial_refactor`, the contract change
("trade-close reward needs trade-open label") needs every consumer to
migrate atomically. Task 2.2 IS the consumer migration; Task 2.0 ships
the producer infrastructure first so the consumer has a buffer to read
from. The split is defensible because Task 2.0's buffer is
independently FoldReset-tested + the kernel arg pair is NULL-tolerant
(test scaffolds without aux-head wiring continue to compile and pass).

Sign convention mapping is non-trivial: the upstream
`aux_sign_label_per_step_kernel.cu:77` emits `{0, 1, -1}` for
`{not-up, up, skip-window}`, but the SP20 spec §4.1 uses the
`{-1, 0, +1}` sign convention for `sign(SP19_blended_label)`. The Task
2.0 producer site does the mapping `1 → +1`, `0 → -1`, `-1 → 0`.

### Gap 2: Per-env alpha plumbing was NOT in Task 2.2 scope

The plan's Task 2.2 description specified replacing the SP12 v3 reward
block with `sp20_compute_event_reward`, but did NOT specify how the
resulting `alpha = R_event - hold_baseline` would feed
`sp20_aggregate_inputs_kernel`'s `EmaInputs.alpha` field (which Phase
1.4 emitted as a hardcoded `0.0f` placeholder).

**Decision**: build the `alpha_per_env[N]` plumbing IN Task 2.2 (NOT
defer to Phase 4). Phase 4's n-step distributor is about per-bar
credit decay over time; per-env alpha aggregation is orthogonal and
belongs with the producer that emits alpha. Task 2.2 atomically:
  - adds `alpha_per_env: CudaSlice<f32>` to the collector;
  - threads it as a kernel-arg output of `experience_env_step`;
  - adds it as a kernel-arg input of `sp20_aggregate_inputs_kernel`;
  - adds the 5th shmem stripe + tree-reduce + close-gated mean
    computation in the aggregation kernel;
  - updates the launcher signature in `sp20_aggregate_inputs.rs`;
  - updates the 2 placeholder pin tests
    (`sp20_aggregate_inputs_test::alpha_and_per_bar_hold_reward_are_
    phase_2_and_3_2_placeholders` →
    `null_alpha_producer_keeps_phase_1_4_placeholder_contract`;
    `sp20_phase1_4_wireup_test::alpha_and_hold_reward_emas_stay_at_
    zero_phase_2_3_2_placeholders` → `..._with_null_producers`).

**Rationale**: per `feedback_no_partial_refactor`, "when a contract
changes, every consumer migrates atomically" — the contract IS that
the aggregation kernel's `alpha` field is now load-bearing (was a
forward-reference placeholder). All 5 modifications above migrate that
contract in one commit. `hold_baseline` stays at `0.0f` until Phase
3.2's Component 2 lands the real producer, which is a SEPARATE
contract (the Hold opp-cost dual emission).

### Gap 3: Per-bar SP18 D-leg sites — KEEP for Phase 2

The plan's Task 2.2 said "Replace SP18 D-leg + SP12 v3 reward block in
experience_kernels.cu" and "DELETE `compute_sp18_hold_opportunity_cost`
and `compute_sp12_reward_with_cost` from the file" without
distinguishing the trade-close site from the per-bar sites.

**Decision**: per `feedback_no_stubs.md` ("wire it for real or delete;
no return-zero stubs, no dead params"), do NOT delete a working signal
until the replacement lands. Component 2 (Phase 3.2) owns per-bar Hold
opportunity-cost replacement. Phase 2 deletes ONLY the trade-close
site call to `compute_sp18_hold_opportunity_cost`. The function
definition in `trade_physics.cuh:655` is RETAINED — still called by 2
sites at `experience_kernels.cu:3722` and `:3833` (per-bar D-leg).

`compute_asymmetric_capped_pnl` and `compute_min_hold_penalty` device
functions in `trade_physics.cuh` are also RETAINED — both are still
called by `compute_sp18_hold_opportunity_cost` (line 680) AND by
`sp12_reward_math_test_kernel.cu` (the SP12 v3 oracle test surface).

The plan's wording "DELETE [these helpers]" was overbroad. Errata
clarifies: only the trade-close-site call is deleted; the helpers
themselves stay. The phantom `compute_sp12_reward_with_cost` mentioned
in the plan does NOT exist as a function — the SP12 v3 reward is the
inlined block at experience_kernels.cu:3216-3380 (pre-Task-2.2 line
numbers). That inlined block is what gets replaced.

### Gap 4: `sp20_compute_event_reward` placement — `sp20_reward.cuh` header

The plan specified placing the device function "in
`experience_kernels.cu`". The post-review revision said "place it near
the other SP20 helpers in that file, not in trade_physics.cuh".

**Decision**: put it in a new dedicated header `sp20_reward.cuh`
included by both `experience_kernels.cu` (Task 2.2 caller) and the
test wrapper `sp20_event_reward_test_kernel.cu` (Task 2.1 GPU oracle
tests).

**Rationale**: `feedback_no_cpu_test_fallbacks` requires GPU oracle
tests share the production function bit-for-bit. If the function lived
inside `experience_kernels.cu` only, the test wrapper couldn't reach
it without code duplication (rejected by `feedback_no_quickfixes`).
Putting it in `trade_physics.cuh` was rejected by the reviewer because
that file is for trade physics, not reward math. A dedicated
`sp20_reward.cuh` is the cleanest fit and mirrors the pattern of
`compute_asymmetric_capped_pnl` / `compute_min_hold_penalty` /
`compute_lump_sum_opp_cost` in `trade_physics.cuh` — header-only
`__device__ __forceinline__` functions shared between production and
test kernel.

### Gap 5: Task 2.3 was subsumed by Task 2.2

The plan listed Task 2.3 separately ("Wire wr_ema and alpha_ema
producer-call into trade-close path"). The aggregation kernel + EMA
kernel chain was already wired in Phase 1.4; Task 2.2's
`alpha_per_env` plumbing is what actually makes `ISV[ALPHA_EMA_INDEX]`
non-zero post-trade-close. There is no additional `sp20_emas_compute`
producer call to wire — the existing Path C chain consumes the new
`alpha` field automatically.

**Decision**: Task 2.3 is subsumed by Task 2.2's atomic commit; no
separate Task 2.3 commit is needed.

### Gap 6: `min_hold_*` kernel-arg trio cleanup deferred to Task 2.4

After deleting the SP12 v3 inlined reward block at the trade-close
site, three kernel args of `experience_env_step` (`min_hold_target`,
`min_hold_penalty_max`, `min_hold_temperature`) became dead. The plan
did NOT address the cleanup of these args or their upstream producer
chain (`min_hold_temperature_update_kernel`,
`ISV[MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX=460]`,
`read_min_hold_temperature_from_isv` helper, `config.min_hold_*`
fields).

**Decision**: per `feedback_no_hiding`, DELETE the 3 kernel args (+
their `.arg(...)` calls at the launch site) atomically with the
trade-close-site replacement in Task 2.2. DEFER the upstream producer
chain cleanup to a follow-up commit ("Task 2.4: remove orphaned SP12
v3 min_hold_* producer chain").

**Rationale**: the kernel-arg removal is mechanical and tied directly
to the kernel-body change (same site). The producer-chain removal
touches the SP14 ISV slot registry + StateResetRegistry + an ISV
layout fingerprint bump and is independently testable. Per
`feedback_no_partial_refactor` "consumer migrates with contract
change", the consumer IS what migrated in Task 2.2 — the producer can
keep emitting into ISV[460] in the meantime as a documented orphan
without breaking any contract. Task 2.4 is tracked in the audit-doc
Task 2.2 entry under "Deleted: the `min_hold_*` kernel-arg trio".

---

End of Phase 2 errata. Phase 3 / 3.2 / 4 / 5 / 6 / 7 of the plan
remain as specified; future tasks may add additional errata sections
as deviations are made during implementation.
