# SP21 T2.2 Phase 1.5 + Phase 2 — Enrichment real-tape wireup

**Author**: jgrusewski + assistant pair
**Date**: 2026-05-11 (continuation of 2026-05-10 SP21 work)
**Branch**: `feat/sp18-combined` (off worktree branch `sp20-aux-h-fixed`)
**Predecessor plan**: `docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md`

## State at session start

8 SP21 commits landed in the prior session:

```
6df44e4c6  docs(sp21): plan + revert sp20-aux-h-fixed experiment
1790a31b6  feat(sp21): T3.1+T3.2 ISV defrost — wr_ema + hold_pct_ema
4d4cd996d  feat(sp21): T3.3 ISV defrost — hold_reward_ema
74c7a8011  feat(sp21): T1.1a+T1.1b+T2.3 — signal-driven early-stopping
c90553953  feat(sp21): T1.2+T1.4 — enrichment real metrics + backtracking signal-driven
4ab1c132e  feat(sp21): T2.1+T2.4 — Q-value early-stop + MIN_HOLD zombies deleted
5d01190e1  feat(sp21): T2.2 Phase 1 Step A — per-trade tape kernel ABI
7d538d930  feat(sp21): T2.2 Phase 1 Step B — per-trade tape buffers + readback
```

Audit-doc detail: `docs/dqn-wire-up-audit.md` (all 2026-05-10 entries).

### What's done

- **Tier 1 closed**: val-loss-driven early-stopping with signal-driven
  `min_delta` from `ISV[VAL_SHARPE_VAR_EMA_INDEX=351]`.
- **Tier 3 closed**: wr_ema/hold_pct_ema/hold_reward_ema all signal-driven
  (binary-majority → fractional refactor); 25/25 GPU oracle tests pass.
- **Tier 2 partial**: T2.1 (Q-value early-stop deleted), T2.4 (MIN_HOLD
  zombies removed), T2.5 (PER hyperparams documented as deferred).
- **T2.2 Phase 1**: per-trade tape infrastructure — both backtest kernels
  emit per-trade records at close events (5 fields: bar_index, pnl,
  holding_bars, direction, magnitude). `GpuBacktestEvaluator` owns the
  SoA buffers + `pub fn read_per_trade_tape() -> Vec<EvalTrade>`.

### What's pending

T2.2 **Phase 1.5** (entry_q capture) + **Phase 2** (wire enrichment to
real tape) — atomic single commit.

---

## Phase 1.5 — entry_q tracking

Captures predicted Q at trade open so the per-trade tape carries the
6th field needed by E1 (q_correction = `predicted_q − pnl` bias).

### Storage

`portfolio_state[ps + 6]` → `entry_q`. Slot 6 is currently unused
(zeroed at init, see `backtest_env_kernel.cu:57`). **Verify** before
coding that `backtest_state_gather` doesn't already write to slot 6.
Using slot 6 means **no `PORTFOLIO_STATE_SIZE` bump** — gather/forward
kernels untouched.

### Capture site

In `backtest_env_step` and `backtest_env_step_batch`, AFTER the
`unified_env_step_core` call returns, detect "new trade opened":

```cuda
const int prev_pos_active = (fabsf(prev_position) > 0.001f);
const int now_pos_active  = (fabsf(position) > 0.001f);
const int is_open    = !prev_pos_active && now_pos_active;
const int is_reverse = (prev_position * position < 0.0f);

if (is_open || is_reverse) {
    /* Read q_values_per_window[w * num_actions + actions[w]] and
     * write to portfolio_state[ps + 6]. New entry_q replaces the
     * snapshot from before unified_env_step_core (which closed the
     * old trade). */
    const float entry_q_new = q_values_per_window[
        (long long)w * num_actions + actions[w]];
    portfolio_state[ps + 6] = entry_q_new;  /* via shmem_pf */
}
```

### Plumbing

New kernel arg on both backtest kernels:

```cuda
const float* __restrict__ q_values_per_window  /* [n_windows * num_actions] */
int          num_actions                       /* size of action space */
```

Pass real device pointer from launcher. The Q-values are computed by
`experience_action_select` immediately before env_step. In the chunked
batch path they're in `chunked_q_values`; in the single-step path
`q_values_buf` (or whichever buffer the action_select kernel writes).
**Locate the actual buffer name** — grep for `expected_q` or
`backtest_action_select` in `gpu_backtest_evaluator.rs`.

### Per-trade emission

Snapshot `pre_entry_q` BEFORE the call (line ~265 of
`backtest_env_kernel.cu` after the existing `pre_entry_price` snapshot):

```cuda
const float pre_entry_price = entry_price;
const int   pre_hold_bars   = (int)hold_time;
const float pre_entry_q     = portfolio_state[ps + 6];  /* via shmem_pf[local_tid * STATE_SIZE + 6] */
```

In the existing close-detection block, write the new buffer:

```cuda
per_trade_predicted_q_out[idx] = pre_entry_q;
```

### Buffer additions

`GpuBacktestEvaluator` gains a 6th SoA buffer:

```rust
per_trade_predicted_q_buf: CudaSlice<f32>,  // [n_windows × MAX_TRADES_PER_WINDOW]
```

Allocated alongside the existing 5 buffers. Constructor zero-init.
Reset at fold boundary (already handled by reset_evaluation_state if
we just zero the count; predicted_q values past `count[w]` are never
read host-side).

### EvalTrade struct extension

```rust
#[derive(Debug, Clone, Copy)]
pub struct EvalTrade {
    pub bar_index: u32,
    pub pnl: f32,
    pub predicted_q: f32,   // SP21 Phase 1.5 (2026-05-11): entry_q at trade open
    pub holding_bars: u32,
    pub direction: u8,
    pub magnitude: u8,
}
```

`read_per_trade_tape` reads the 6th SoA buffer, populates `predicted_q`
in each `EvalTrade`.

---

## Phase 2 — wire enrichment to real tape

### training_loop.rs changes

`crates/ml/src/trainers/dqn/trainer/training_loop.rs:1510-1568` —
replace the fake-trade synthesizer call with `read_per_trade_tape()`:

```rust
// BEFORE (fake synthesizer):
let eval_trades = super::enrichment::extract_eval_trades_from_metrics(
    val_sharpe_for_backtrack as f32,
    real_trade_count, real_total_pnl, real_win_rate,
    log_output.avg_q_value as f32,
    val_bars,
);

// AFTER (real per-trade tape):
let eval_trades = match self.gpu_evaluator.as_ref() {
    Some(ev) => ev.read_per_trade_tape().unwrap_or_default(),
    None     => Vec::new(),
};
```

Cold-start (no evaluator yet): empty Vec → enrichment runs with no
trades → returns defaults (current epsilon, gamma, agreement_threshold
unchanged). Honest "no signal yet" — matches the pattern already used
in T1.2 for `last_val_metrics: None`.

### enrichment.rs changes

1. **Drop `ensemble_var` field** from `EvalTrade`. The val backtest uses
   a single deterministic forward — no ensemble, no variance to capture.
   Refactor E5 (see below).

2. **Make `EvalTrade` a re-export** of
   `cuda_pipeline::gpu_backtest_evaluator::EvalTrade` (single source of
   truth):

   ```rust
   pub(crate) use crate::cuda_pipeline::gpu_backtest_evaluator::EvalTrade;
   ```

   Or duplicate-with-conversion if module visibility prevents re-export.

3. **Refactor E5 (`compute_agreement_threshold`)** to use trade-pnl
   quartile-spread Sharpe instead of `ensemble_var`. Concrete formula:

   ```rust
   /// E5 (SP21 Phase 2, 2026-05-11): agreement threshold from quartile-
   /// spread Sharpe. High spread between top-25% pnl Sharpe and bottom-
   /// 25% pnl Sharpe = high "trades when policy is right" vs "trades
   /// when wrong" separation = lower agreement threshold (trust agreement
   /// more). No ensemble_var needed — fully self-contained on per-trade
   /// pnl tape from gpu_backtest_evaluator.
   fn compute_agreement_threshold(
       trades: &[EvalTrade],
       current: f32,
   ) -> f32 {
       if trades.len() < 4 { return current; }
       let mut pnls: Vec<f32> = trades.iter().map(|t| t.pnl).collect();
       pnls.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
       let q1_end = pnls.len() / 4;
       let q4_start = 3 * pnls.len() / 4;
       let q1 = &pnls[..q1_end];
       let q4 = &pnls[q4_start..];
       let sharpe_of = |slice: &[f32]| -> f32 {
           let mean = slice.iter().sum::<f32>() / slice.len() as f32;
           let var = slice.iter().map(|x| (x - mean).powi(2)).sum::<f32>()
                   / slice.len() as f32;
           if var > 1e-12 { mean / var.sqrt() } else { 0.0 }
       };
       let q1_sharpe = sharpe_of(q1);
       let q4_sharpe = sharpe_of(q4);
       let spread = q4_sharpe - q1_sharpe;
       /* Larger spread → tighten threshold (× 0.9). Smaller spread →
        * loosen (× 1.1). Spread > 1.5σ qualifies as "tight". */
       let new = if spread > 1.5 { current * 0.9 }
                 else if spread < 0.5 { current * 1.1 }
                 else { current };
       new.clamp(0.01, 10.0)
   }
   ```

   Note: hardcoded `1.5`/`0.5` thresholds here — flag for follow-up
   signal-drive (Phase 8 of T2.2 multi-phase scope).

4. **Delete `extract_eval_trades_from_metrics`** function entirely
   (the fake-trade synthesizer). Per `feedback_no_legacy_aliases`.

5. **Delete `HindsightExperience` struct** if unused after refactor.
   Check E7 (`compute_hindsight_labels`) — keeps if E7 still emits.

### Verify all 8 enrichment functions consume real per-trade data

| Fn | Uses | After Phase 1.5+2 |
|---|---|---|
| E1 `compute_q_correction` | predicted_q − pnl | ✓ real (Phase 1.5 supplies predicted_q) |
| E2 `compute_adaptive_epsilon` | val_sharpe (aggregate) | ✓ real (already aggregate-only) |
| E3 `compute_dynamic_gamma` | holding_bars, pnl | ✓ real |
| E4 `compute_branch_lr_scale` | direction, magnitude, pnl, holding_bars | ✓ real |
| E5 `compute_agreement_threshold` | (refactored) pnl quartile spread | ✓ real |
| E6 `compute_winner_indices` | pnl, bar_index | ✓ real |
| E7 `compute_hindsight_labels` | direction, pnl, bar_index | ✓ real |
| E8 `compute_curriculum_weights` | pnl | ✓ real |

All 8 land on real data after this commit.

---

## Hard rules (carried from prior session)

- **NO NULL launcher passes.** Real buffers from the start.
- **NO `// TODO` markers** (per `feedback_no_todo_fixme`).
- **NO stub return values** / placeholder zeros for fields meant to
  carry signal. Refactor or delete the consumer if computation
  isn't honest.
- **Atomic commits** per `feedback_no_partial_refactor`: contract
  changes ship with all consumers migrated together.
- **If a kernel signature changes, both call sites migrate in the
  same commit.**
- **Pre-commit hook runs `cargo check` + GPU hot-path leak check**;
  commits must pass both. (Note: the hook complains about pre-existing
  `unwrap()` and the line-78 `return 0.0` in training_loop.rs — those
  are pre-existing warnings, not introduced.)
- **NEVER use `--no-verify`.** Per CLAUDE.md.

---

## Verification plan

Before committing:

1. `SQLX_OFFLINE=true cargo check -p ml --tests` — must pass clean.
2. `SQLX_OFFLINE=true cargo test -p ml --test sp20_aggregate_inputs_test
   --features cuda -- --ignored --nocapture` — should still be 12/12
   (aggregator unaffected by these changes).
3. `SQLX_OFFLINE=true cargo test -p ml --test sp20_phase1_4_wireup_test
   --features cuda -- --ignored --nocapture` — should still be 2/2.
4. `SQLX_OFFLINE=true cargo test -p ml --test sp20_emas_compute_test
   --features cuda -- --ignored --nocapture` — should still be 4/4.
5. `SQLX_OFFLINE=true cargo test -p ml --test sp20_controllers_compute_test
   --features cuda -- --ignored --nocapture` — should still be 7/7.

Optional (skip if blocking; document as Phase 2.5 follow-up):

6. NEW smoke test exercising `GpuBacktestEvaluator`'s per-trade tape
   end-to-end. Synthetic eval window → run kernel → read tape →
   assert non-empty `Vec<EvalTrade>` with valid `predicted_q` values.
   Test scaffolding for the eval kernel is heavy; the existing
   `sp20_aggregate_inputs` GPU oracle tests have a similar shape and
   can be adapted as a reference. Place in
   `crates/ml/tests/gpu_backtest_per_trade_tape.rs`.

---

## Files affected this session

- `crates/ml/src/cuda_pipeline/backtest_env_kernel.cu`
  - Both kernels: new `q_values_per_window` + `num_actions` args.
  - Snapshot `pre_entry_q` BEFORE `unified_env_step_core` call.
  - On close: emit `per_trade_predicted_q_out[idx] = pre_entry_q`.
  - On open / reverse: write
    `portfolio_state[ps + 6] = q_values_per_window[w * num_actions +
    actions[w]]` (via shmem path).

- `crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs`
  - New struct field `per_trade_predicted_q_buf: CudaSlice<f32>`.
  - Constructor: alloc 6th buffer.
  - Both launcher sites: pass q_values device pointer + num_actions.
  - `EvalTrade`: add `pub predicted_q: f32` field.
  - `read_per_trade_tape`: read 6th buffer + populate `predicted_q`.

- `crates/ml/src/trainers/dqn/trainer/enrichment.rs`
  - `EvalTrade` re-exports / matches gpu_backtest_evaluator's struct
    (drop `ensemble_var`).
  - `compute_agreement_threshold`: refactor to quartile-spread Sharpe.
  - Delete `extract_eval_trades_from_metrics`.
  - Delete `HindsightExperience` if unused.

- `crates/ml/src/trainers/dqn/trainer/training_loop.rs:1510-1568`
  - Replace `extract_eval_trades_from_metrics(...)` call with
    `evaluator.read_per_trade_tape()?`.
  - Drop the `extract_*`-related plumbing (`val_bars`,
    `real_trade_count` etc.) since real tape supplies these directly.

- `docs/dqn-wire-up-audit.md`
  - New section dated 2026-05-11 documenting Phase 1.5 + Phase 2
    closure. Per Invariant 7, every CUDA-pipeline change requires
    audit-doc update — pre-commit hook enforces this.

---

## Open question to resolve before coding

**E5 alternate signal — quartile-spread vs ISV ensemble disagreement?**

(a) **Quartile-spread Sharpe** (recommended): top-25% pnl Sharpe minus
bottom-25% pnl Sharpe. Fully self-contained on per-trade pnl tape.
No new infrastructure, no cross-coupling val/training data.

(b) **ISV ensemble disagreement EMA**: read from training-side ISV slot
(if exposed to val read path). Requires checking per-epoch ordering
(does training set ensemble_var EMA before val runs?). Adds val ↔
training coupling.

**Recommend (a)** unless the user has a specific reason to reach
across to training-side ensemble state. (a) is also philosophically
cleaner: enrichment runs against val data, so val should be its
self-contained signal source.

---

## After this commit lands

T2.2 multi-phase scope continues with Phases 3-7 + Phase 8:

- Phase 3: Wire E1 (q_correction) → ISV slot consumer (apply as
  additive Q-target offset at C51 loss + Bellman target).
- Phase 4: Wire E4 (per-branch LR scaling) via existing per-group
  Adam infrastructure (`pearl_per_group_adam_hyperparams`).
- Phase 5: Wire E6 (winner indices) → PER priority bumps.
- Phase 6: Wire E7 (hindsight) → replay buffer synthetic experience
  injection.
- Phase 7: Wire E8 (curriculum weights) → segment sampling weights.
- Phase 8: Signal-drive remaining hardcoded thresholds in enrichment
  (the `1.5` / `0.5` constants in the new E5 quartile-spread, the
  `2.0` / `0.5` / `-0.5` in E2 epsilon adapter, etc.) using
  appropriate ISV variance EMAs.

Each is its own atomic commit (per `feedback_no_partial_refactor` —
phase-internal contract migrations are atomic; cross-phase wiring is
sequential).

---

## After all phases land

Dispatch a smoke training run on the L40S to validate the full SP21
cascade end-to-end. Watch:

- HEALTH_DIAG `sp20_isv` line shows variance > 0 across epochs for
  wr_ema, hold_pct_ema, hold_reward_ema (Tier 3 verification).
- `🛑 Early stopping triggered` fires within `patience` epochs of
  val_Sharpe peak (Tier 1 verification).
- Enrichment receives real `Vec<EvalTrade>` instead of fake aggregates
  (T2.2 verification).
- HEALTH_DIAG controller readouts (`c51_budget`, `cql_budget`,
  `q_var`, `iqn_budget`, `lb_active`) show inter-branch variation
  (Tier 4 cascade verification).
- `evaluate_baseline` (or modern eval entry point) reproduces the
  val_Sharpe / val_PF observed in training (within 5% relative
  tolerance) on the same checkpoint + window (success criterion #6
  from the SP21 plan).
