# Evaluation Cleanup — Remove Dead Path + Fix Train/Eval Mismatch

## Problem

1. **Dead code**: `GpuActionSelector`, `branching_action_select` (5-action hardcoded), `select_actions_batch()` have zero callers in training or evaluation. The backtest evaluator uses `evaluate_dqn_graphed()` with cuBLAS forward + `experience_action_select` (9-action). The dead path causes confusion and was the target of Task 5 (which we correctly skipped).

2. **Train/eval mismatch**: Training enforces `min_hold_bars` via action masking in `experience_action_select`. The backtest evaluator's action selection path does NOT enforce holds. The model learns under hold constraints but is evaluated without them.

3. **Degenerate objective with hold enforcement**: The trade insufficiency penalty threshold is hardcoded. With high `min_hold_bars`, fewer trades complete, and the threshold should adapt dynamically.

## Design

### 1. Remove dead code

Delete entirely:
- `crates/ml/src/cuda_pipeline/gpu_action_selector.rs`
- `crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu`

Remove from parent modules:
- `gpu_action_selector` module declaration in `crates/ml/src/cuda_pipeline/mod.rs`
- `select_actions_batch()` method in `crates/ml/src/trainers/dqn/trainer/action.rs`
- `GpuActionSelector` field in `DQNTrainer` struct (`trainer/mod.rs`)
- Any `use` imports referencing these

### 2. Add hold enforcement to backtest evaluator

The `evaluate_dqn_graphed()` path in `gpu_backtest_evaluator.rs` runs its own step loop:
1. `gather_states` → state tensor
2. cuBLAS forward → Q-values
3. `compute_expected_q` → expected Q from C51 distribution
4. `backtest_greedy_argmax` or action selection → action indices
5. `backtest_env_step` → portfolio update

The action selection at step 4 needs hold enforcement. The evaluator already has `portfolio_buf` (portfolio state per window). Two options:

**(A)** Add hold masking to the existing `backtest_greedy_argmax` kernel
**(B)** Pass `min_hold_bars` to the backtest env_step kernel and let it override (Layer 2 style)

**Choose B** — the backtest `env_step` kernel already manages portfolio state. Adding the hold override there is simpler and matches the training kernel's Layer 2 pattern. The backtest doesn't need Layer 1 (action masking) because it uses greedy argmax (no epsilon exploration to mask).

Add `min_hold_bars` parameter to `backtest_env_step` kernel. When `hold_time > 0 && hold_time < min_hold_bars`, override action to maintain current position (same logic as training kernel's Layer 2).

### 3. Dynamic trade insufficiency threshold

In `extract_objective()` in `hyperopt/adapters/dqn.rs`, the trade penalty currently uses a fixed threshold via `calculate_trade_insufficiency_penalty(total_trades)`. Make it dynamic:

```rust
// Dynamic threshold based on window size and min_hold_bars
let max_possible_trades = backtest_window_bars / min_hold_bars.max(1);
let min_trades_threshold = (max_possible_trades / 20).max(5);
let trade_penalty = calculate_trade_insufficiency_penalty_dynamic(
    backtest.total_trades,
    min_trades_threshold,
);
```

This ensures the penalty adapts: with `min_hold_bars=5` and 10K-bar windows, threshold = max(500/20, 5) = 25. With `min_hold_bars=20`, threshold = max(250/20, 5) = 12. Always reasonable.

## Files Modified

| File | Change |
|------|--------|
| `crates/ml/src/cuda_pipeline/gpu_action_selector.rs` | DELETE |
| `crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu` | DELETE |
| `crates/ml/src/cuda_pipeline/mod.rs` | Remove gpu_action_selector module |
| `crates/ml/src/trainers/dqn/trainer/action.rs` | Remove select_actions_batch() |
| `crates/ml/src/trainers/dqn/trainer/mod.rs` | Remove GpuActionSelector field + imports |
| `crates/ml/src/cuda_pipeline/backtest_env_kernel.cu` | Add min_hold_bars hold override |
| `crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs` | Pass min_hold_bars to env kernel |
| `crates/ml/src/hyperopt/adapters/dqn.rs` | Dynamic trade insufficiency threshold |
