feat(sp20): Phase 2 Task 2.1 — sp20_compute_event_reward + 8 GPU oracle tests

Lands the SP20 Component 1 reward math as a header-only `__device__
__forceinline__` function in a new `sp20_reward.cuh` header, plus the
GPU oracle test scaffold for the 4-quadrant truth table per spec §4.1.

* `sp20_reward.cuh` — `sp20_compute_event_reward(close_pnl,
  label_at_open_sign, loss_cap)` function. Returns bounded scalar
  `R_event ∈ [loss_cap, +1.0]`. 4-quadrant table:
    +1, +1 →  +1.0  (right reason, right outcome)
    +1, -1 →  +0.5  (wrong reason, right outcome)
    -1, -1 →  -0.5  (right reason, wrong outcome)
    -1, +1 →  loss_cap  (wrong reason, wrong outcome — ISV-driven)
     0, *  →   0.0  (no information — close_pnl == 0)

  Sentinel `label_at_open_sign == 0` (Task 2.0 contract) maps to
  dir_match=false ⇒ wrong-reason quadrant. Documented as the safer
  default in the header comment.

* `sp20_event_reward_test_kernel.cu` — standalone single-thread test
  wrapper, mirrors the `sp12_reward_math_test_kernel.cu` /
  `thompson_test_kernel.cu` pattern. Outputs to a mapped-pinned [1]
  f32 with `__threadfence_system()` for PCIe-visible coherence.

* `tests/sp20_event_reward_test.rs` — 8 GPU oracle tests
  (`#[ignore = "requires GPU"]`-gated):
    1. quadrant_right_reason_right_outcome
    2. quadrant_wrong_reason_right_outcome
    3. quadrant_right_reason_wrong_outcome
    4. quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_1
    5. quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_2
    6. zero_pnl_returns_zero
    7. sentinel_label_winning_trade_lands_shoulder (Task 2.0 contract)
    8. sentinel_label_losing_trade_lands_loss_cap (Task 2.0 contract)

* `experience_kernels.cu` — `#include "sp20_reward.cuh"` so Task 2.2's
  trade-close site replacement has the function in scope.

* `build.rs` — `sp20_event_reward_test_kernel.cu` cubin entry.

No production callers in this commit. Task 2.2 atomically:
  - replaces the SP12 v3 reward block at experience_kernels.cu:3216-3380
    with the SP20 4-quadrant reward;
  - adds the `is_close` consumer of `label_at_open_per_env[i]` (Task
    2.0's per-env scratch);
  - threads per-env alpha through the SP20 aggregation kernel,
    making `alpha_ema` non-zero post-trade-close (replacing the
    Phase 1.4 0.0 forward-reference placeholder).

Per `feedback_no_partial_refactor.md` the device function ships
BEFORE the production caller so Task 2.1's GPU oracle tests can
exercise the math in isolation; the partial refactor that breaks
the no-partial-refactor rule is "feature behind the function with
no caller", which this commit's tests resolve in scope.

Per `feedback_no_cpu_test_fallbacks.md` GPU oracle only — no CPU
reference impl. Per `feedback_no_htod_htoh_only_mapped_pinned.md`
all CPU↔GPU buffers are mapped-pinned (`MappedF32Buffer`).

Verification:
  SQLX_OFFLINE=true cargo check -p ml --tests --features cuda  # clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-09 23:59:49 +02:00
parent 29a2615f6a
commit eaaab152fc
6 changed files with 575 additions and 0 deletions

View File

@@ -2,6 +2,128 @@
**Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7.
## 2026-05-09 — SP20 Phase 2 Task 2.1: sp20_compute_event_reward device function + GPU oracle tests (additive)
Lands the SP20 Component 1 reward math as a header-only `__device__
__forceinline__` function in a new `sp20_reward.cuh` header, plus the
GPU oracle test scaffold for the 4-quadrant truth table per spec §4.1.
The function has no production callers in this commit — Task 2.2's
trade-close site replacement wires it in atomically with the SP12 v3
reward block deletion.
### Why a dedicated header (`sp20_reward.cuh`) instead of inlining in `experience_kernels.cu`
The plan called for adding `sp20_compute_event_reward` "in
`experience_kernels.cu`". The user-decision review of Phase 2's six
gaps revised this to "place it near the other SP20 helpers in that
file" — i.e., the spirit was "don't put it in `trade_physics.cuh` (which
is for trade physics)", not "literally inside the .cu file".
The test wrapper (`sp20_event_reward_test_kernel.cu`) needs to call
the SAME math the production caller does — per
`feedback_no_cpu_test_fallbacks` GPU oracle tests share the production
function bit-for-bit. Three options:
- **Inline in `experience_kernels.cu` only** — test kernel cannot
reach the function (it's in a `.cu` file, not a header). Breaks
`feedback_no_quickfixes` (would force code duplication in the test
kernel).
- **Inline in `trade_physics.cuh`** — bloats trade_physics with SP20
reward logic (rejected by user).
- **New `sp20_reward.cuh` header** — clean SP20 namespace, included
by both `experience_kernels.cu` (Task 2.2) and the test wrapper
(this commit). **Chosen.**
Mirrors the `compute_asymmetric_capped_pnl` / `compute_min_hold_penalty`
pattern in `trade_physics.cuh` for shared SP12 reward helpers.
### 4-quadrant truth table (spec §4.1)
| `pnl_sign` | `dir_match` | `R_event` | Constant |
|------------|--------------|-------------------|--------------------------------|
| `+1` | `true` | `+1.0` | `SP20_WIN_CAP` |
| `+1` | `false` | `+0.5` | `SP20_SHOULDER_RIGHT_OUTCOME` |
| `-1` | `true` | `-0.5` | `SP20_SHOULDER_WRONG_OUTCOME` |
| `-1` | `false` | `loss_cap` | ISV[LOSS_CAP_INDEX] (caller) |
| `0` | `*` | `0.0` | early-return |
`label_at_open_sign == 0` (sentinel: aux skip-window or
no-position-open at trade-open per Task 2.0) maps to `dir_match =
false` because the comparison `pnl_sign == 0` is never true once
`pnl_sign` is filtered to `±1`. This routes sentinel inputs to the
wrong-reason quadrant — `+0.5` shoulder on winning trades, `loss_cap`
on losing trades. Safer default than over-rewarding a no-signal close
(documented in `sp20_reward.cuh` header comment).
### Tests
`crates/ml/tests/sp20_event_reward_test.rs` — 8 GPU oracle tests
(all `#[ignore = "requires GPU"]`):
1. `quadrant_right_reason_right_outcome` — pnl=+, label=+ → +1.0
2. `quadrant_wrong_reason_right_outcome` — pnl=+, label= → +0.5
3. `quadrant_right_reason_wrong_outcome` — pnl=, label=0.5
4. `quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_1` — cold-start cap
5. `quadrant_wrong_reason_wrong_outcome_loss_cap_at_minus_2` — mature cap
6. `zero_pnl_returns_zero` — early-return path
7. `sentinel_label_winning_trade_lands_shoulder` — Task 2.0 contract
8. `sentinel_label_losing_trade_lands_loss_cap` — Task 2.0 contract
Tests 7-8 lock the Task 2.0 sentinel-label contract: when the
FoldReset-zeroed `label_at_open_per_env` slot reaches the reward
function (no `entering_trade` fired this fold OR the aux landed in
skip-window), the wrong-reason quadrant fires. This pins the Task 2.0
"safer default" semantic against future regressions that might add a
"sentinel = pass-through" early-return.
### Files modified
| File | Status | Purpose |
|------|--------|---------|
| `crates/ml/src/cuda_pipeline/sp20_reward.cuh` | NEW | Header with `sp20_compute_event_reward` |
| `crates/ml/src/cuda_pipeline/sp20_event_reward_test_kernel.cu` | NEW | Standalone GPU oracle test wrapper |
| `crates/ml/src/cuda_pipeline/experience_kernels.cu` | +1 include | Pre-includes for Task 2.2 caller |
| `crates/ml/build.rs` | +1 cubin entry | Compile entry for nvcc |
| `crates/ml/tests/sp20_event_reward_test.rs` | NEW | 8 GPU oracle tests |
| `docs/dqn-wire-up-audit.md` | This entry | Audit log |
### Verification
```
SQLX_OFFLINE=true cargo check -p ml --tests --features cuda # clean
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml \
--test sp20_event_reward_test --features cuda -- --ignored --nocapture
```
### Phase 2 Task 2.1 → Task 2.2 forward references
Task 2.2 atomically:
- replaces the SP12 v3 inlined reward block at
`experience_kernels.cu:3216-3380` (segment_complete branch only)
with the SP20 4-quadrant reward via `sp20_compute_event_reward`;
- adds the `is_close` consumer of `label_at_open_per_env[i]` (Task
2.0's per-env scratch);
- adds per-env `alpha_per_env[N]` plumbing through the SP20
aggregation kernel signature, so `alpha_ema` (ISV[ALPHA_EMA_INDEX])
becomes non-zero post-trade-close (replacing the Phase 1.4 `0.0`
forward-reference placeholder);
- updates the 2 placeholder pin tests (`sp20_aggregate_inputs_test::
alpha_and_per_bar_hold_reward_are_phase_2_and_3_2_placeholders`
and `sp20_phase1_4_wireup_test::
alpha_and_hold_reward_emas_stay_at_zero_phase_2_3_2_placeholders`)
to assert the new contract: `alpha_ema` becomes non-zero after at
least one trade close; `hold_reward_ema` stays at sentinel until
Phase 3.2.
Per-bar SP18 D-leg call sites at `experience_kernels.cu:3722` and
`:3833` are RETAINED pending Phase 3.2 Component 2 replacement (per
`feedback_no_stubs.md`: working signal stays until replacement lands).
`compute_sp18_hold_opportunity_cost`, `compute_asymmetric_capped_pnl`,
and `compute_min_hold_penalty` device functions in `trade_physics.cuh`
are RETAINED — they remain called by per-bar SP18 sites and by the
`sp12_reward_math_test_kernel.cu` test wrapper.
## 2026-05-09 — SP20 Phase 2 Task 2.0: per-env trade-open label-sign infrastructure (additive)
Lands the producer-side infrastructure for Phase 2's 4-quadrant reward