feat(sp20): Phase 1.2 sp20_emas_compute kernel

Component 5 / Kernel 1 of the SP20 design — central state-tracker
that updates 8 Wiener-α EMAs per training step:

  - 4 ISV slots:    ALPHA_EMA (511), WR_EMA (512),
                    HOLD_PCT_EMA (515), HOLD_REWARD_EMA (516)
  - 4 internal:     trade_duration_ema, aux_conf_p50_ema,
                    aux_conf_std_ema, aux_dir_acc_ema (private
                    scratch consumed by Phase 1.3 controllers)

Per-EMA i32 observation counters (mapped-pinned obs_count[8])
handle the pearl_first_observation_bootstrap sentinel transition
correctly even when 0.0 is a legitimate observation (e.g.,
first-loss WR=0). count==0 ⇒ replace, count>0 ⇒ Wiener-blend at
α = 0.4 (WIENER_ALPHA_FLOOR per
pearl_wiener_alpha_floor_for_nonstationary).

Phase 1.2 lands kernel + launcher + 5 tests (4 GPU oracle, 1
floor lock) + build entry + audit doc atomically per
feedback_no_partial_refactor. Production wire-up (Phase 1.4)
deferred — kernel is dead code until then.

Verified on RTX 3050 Ti (sm_86):
  - 4 GPU oracle tests pass: first_observation_replaces_sentinel,
    wiener_alpha_converges_to_long_run_mean,
    hold_reward_ema_gated_on_hold_bars,
    per_step_emas_fire_unconditionally
  - 4 launcher unit tests pass (constants + struct sanity)
  - 1 floor-lock test pass (WIENER_ALPHA_FLOOR == 0.4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-09 18:57:30 +02:00
parent de922c6a4a
commit 71aade18cf
6 changed files with 1113 additions and 0 deletions

View File

@@ -10907,3 +10907,207 @@ caller.
AUX_GATE_TEMP / HOLD_COST_SCALE` from the EMAs.
- Phase 1.4: training-loop wire-up calls all 3 producers in
sequence on the same stream as the aux-head forward.
## 2026-05-09 — SP20 Phase 1.2: sp20_emas_compute kernel (additive)
### Component
- New CUDA kernel `crates/ml/src/cuda_pipeline/sp20_emas_compute_kernel.cu`
- New Rust launcher `crates/ml/src/cuda_pipeline/sp20_emas_compute.rs`
(re-exported from `cuda_pipeline::sp20_emas_compute`)
- New GPU oracle test `crates/ml/tests/sp20_emas_compute_test.rs`
- Cubin manifest entry in `crates/ml/build.rs`
### Purpose
Component 5 / Kernel 1 of the SP20 fused-producer chain — the central
state-tracker that updates 8 Wiener-α EMAs per training step. Phase
1.4 will land the production launch site atomically with Kernel 2
(`sp20_controllers_compute`, Phase 1.3) per
`feedback_no_partial_refactor`.
### EMAs maintained
Per `pearl_wiener_alpha_floor_for_nonstationary` (α floor 0.4):
| EMA | Storage | Gate | Observation source |
|------------------|--------------------|---------------------------|---------------------------------------------|
| `ALPHA_EMA` | ISV[511] | `is_close == 1` | `alpha = R_event - hold_baseline` |
| `WR_EMA` | ISV[512] | `is_close == 1` | `is_win` ∈ {0.0, 1.0} |
| `TRADE_DURATION` | internal[0] | `is_close == 1` | `trade_duration` (bars) |
| `HOLD_PCT_EMA` | ISV[515] | per-step (always) | `(action_is_hold == 1) ? 1 : 0` |
| `HOLD_REWARD_EMA`| ISV[516] | `action_is_hold == 1` | `r_per_bar = -aux_conf * cost_scale` |
| `AUX_P50` | internal[1] | per-step (always) | `aux_logits_p50` (Phase 1.1 output) |
| `AUX_STD` | internal[2] | per-step (always) | `aux_logits_std` (Phase 1.1 output) |
| `AUX_DIR_ACC` | internal[3] | per-step (always) | `aux_dir_acc` ∈ {0.0, 1.0} |
The 4 internal scratch slots are deliberately NOT exposed as ISV
constants — Phase 1.3's `sp20_controllers_compute` consumes them
in-kernel to derive the public ISV controllers (`N_STEP`,
`TARGET_HOLD_PCT`, `AUX_GATE_TEMP`, `AUX_CONF_THRESHOLD`). Keeping
them private keeps the SP20 ISV slot count fixed at the 10 reserved
in `f5eed1fa7`.
### Bootstrap design — counter-based, not value-based
Per `pearl_first_observation_bootstrap`: sentinel = 0; first
observation REPLACES directly. The naive value-based sentinel-detect
pattern (`fabsf(current - 0) < EPS` ⇒ replace) used in
`sp17_a_var_ema_kernel.cu` does NOT work for some SP20 EMAs because
0.0 is a *plausible legitimate observation*:
- `WR_EMA`: first-loss ⇒ legit observation 0.0; second-loss observation
would be incorrectly treated as "still un-bootstrapped" and replace
instead of blend, never converging away from 0.
- `HOLD_PCT_EMA`: long initial non-Hold streak legitimately keeps
the EMA at 0; a first Hold action would over-correct.
- `HOLD_REWARD_EMA`: post-bootstrap, identical r_per_bar = 0.05
observations would all read `current ≈ 0.05`, not equal sentinel,
so this case happens to work — but the contract is asymmetric
across EMAs which is a contract bug waiting to happen.
This kernel uses a **per-EMA i32 observation counter** (8 entries in
a separate mapped-pinned `obs_count` buffer, aligned with
`OBS_COUNT_*` constants in the launcher) that atomically transitions
0 → 1 on the first firing observation. `count == 0` ⇒ replace,
`count > 0` ⇒ Wiener-blend at α = 0.4. This pattern is safer than
value-based sentinel detect for any EMA whose value range includes 0
as a legitimate observation; subsequent SP20 EMA producers (Kernel 2
controller outputs that aren't EMAs themselves) won't need the same
mechanism, but any future EMA-style producer added here SHOULD reuse
the same counter buffer rather than reverting to value-based detect.
### Algorithm
Single-block, single-thread kernel. Per launch, for each of the 8
EMAs:
1. If the gate is OFF for this EMA this step, skip.
2. Read `prev` from the target buffer (ISV or internal).
3. Read `count` from `obs_count[ema_idx]`.
4. If `count == 0`, `next = observation` (Pearl-A bootstrap).
5. Else `next = (1 - α) * prev + α * observation` with α = 0.4
(`WIENER_ALPHA_FLOOR_F`).
6. Write `next` back; increment `obs_count[ema_idx]`.
Single `__threadfence_system()` at end covers all writes (single
thread, sequentially consistent within itself; the fence makes them
PCIe-visible to mapped-pinned host pages).
### Wiring contract
Phase 1.2 wires kernel + launcher + tests + build entry **only**.
There is NO production caller in this commit — the kernel is dead
code awaiting Phase 1.4's atomic wire-up of:
1. Trainer-owned `MappedF32Buffer<4>` for the `internal` scratch.
2. Trainer-owned `MappedI32Buffer<8>` for the `obs_count` counters.
3. Per-step plumbing of the 9 scalar inputs (`is_close`, `is_win`,
`alpha`, `trade_duration`, `action_is_hold`, `r_per_bar`,
`aux_p50_in`, `aux_std_in`, `aux_dir_acc`) from their producers
(trade-physics for the close/win/duration; reward kernel for the
alpha; experience collector for the hold-state and r_per_bar;
`sp20_stats_compute` Phase 1.1 for the two stats; aux-head
compare for `aux_dir_acc`).
4. Stream-ordering: launch on the SAME stream as `sp20_stats_compute`
so the stream-ordering invariant guarantees `aux_p50_in` /
`aux_std_in` reads see the latest stats.
5. Fold-boundary reset: `internal` buffer + `obs_count` counters
must reset alongside ISV slots [510..520) at fold transitions.
Phase 1.4 adds the dispatch arms.
The `cuda_pipeline::sp20_emas_compute` module exports
`launch_sp20_emas_compute` + the contract constants
(`WIENER_ALPHA_FLOOR`, `EMA_INTERNAL_*`, `OBS_COUNT_*`,
`EmaInputs`) the wire-up will consume.
### Pearls + invariants honoured
- `pearl_first_observation_bootstrap` — counter-based bootstrap;
count==0 ⇒ replace, count>0 ⇒ blend.
- `pearl_wiener_alpha_floor_for_nonstationary`α = 0.4 hardcoded
per `feedback_isv_for_adaptive_bounds` (structural floor, not
adaptive bound).
- `feedback_no_atomicadd` — single-thread kernel; no concurrent
writes anywhere.
- `feedback_no_cpu_compute_strict` — every blend lives in the
kernel; the launcher only packs scalar inputs into kernel args.
- `feedback_no_htod_htoh_only_mapped_pinned` — both ISV and the
`internal` / `obs_count` buffers are mapped-pinned; kernel emits
`__threadfence_system()` for PCIe-visible coherence.
- `pearl_no_host_branches_in_captured_graph` — single-block,
single-thread kernel; safe to capture in the per-step CUDA
Graph.
- `feedback_no_partial_refactor` — kernel + launcher + tests +
build entry land in one commit; production wire-up lands
atomically in Phase 1.4 with the controller producer.
- `feedback_no_stubs` — every output written for real; no
return-zero placeholders.
### Tests
`crates/ml/tests/sp20_emas_compute_test.rs` — GPU oracle tests
(all `#[ignore = "requires GPU"]` except the floor lock):
1. `first_observation_replaces_sentinel` — fire one trade-close
with `is_win=1, alpha=0.5, trade_duration=7`; verify
`WR_EMA == 1.0`, `ALPHA_EMA == 0.5`,
`internal[TRADE_DURATION] == 7.0` (no blending). Also
verifies the per-EMA counters track only firings.
2. `wiener_alpha_converges_to_long_run_mean` — 100 alternating
`is_win = 1/0/1/0…` events; assert `|WR_EMA - 0.5| < 0.30`.
Bound is generous to absorb the alternation noise inherent to
a binary-indicator EMA at α = 0.4.
3. `hold_reward_ema_gated_on_hold_bars` — 5 non-Hold bars
(`action_is_hold=0, r_per_bar=0.05`) followed by 5 Hold bars
with same `r_per_bar`; verify `HOLD_REWARD_EMA ≈ 0.05` after
all 10 (only 5 fired, first bootstrapped, next 4 were
identical-input no-ops). Counter check: HOLD_REWARD count == 5.
4. `per_step_emas_fire_unconditionally` — 10 steps with
`is_close=0, action_is_hold=0`, `aux_p50=0.2, aux_std=0.1,
aux_dir_acc=1.0`; verify the 4 per-step EMAs reach exactly
those values (first observation bootstraps; subsequent
identical observations are no-op blends). Counter check:
per-step counters == 10, per-trade counters == 0.
5. `wiener_alpha_floor_locked_at_0_4` — non-GPU contract lock.
Plus 4 unit tests in the launcher module (Wiener floor lock,
internal/obs_count slot count locks, EmaInputs sentinel safety).
### Files modified
| File | Status | Purpose |
|------|--------|---------|
| `crates/ml/src/cuda_pipeline/sp20_emas_compute_kernel.cu` | NEW | Single-block 8-EMA producer kernel |
| `crates/ml/src/cuda_pipeline/sp20_emas_compute.rs` | NEW | Rust launcher + contract constants |
| `crates/ml/tests/sp20_emas_compute_test.rs` | NEW | 4 GPU oracle tests + 1 floor-lock test |
| `crates/ml/src/cuda_pipeline/mod.rs` | +pub mod | Module declaration |
| `crates/ml/build.rs` | +cubin entry | Compile entry for nvcc |
| `docs/dqn-wire-up-audit.md` | This entry | Audit log |
### Verification
```
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check -p ml --features cuda
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml \
--test sp20_emas_compute_test --features cuda -- --ignored --nocapture
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml \
--features cuda --lib sp20_emas_compute
```
All 4 GPU oracle tests + 4 launcher unit tests + 1 floor-lock test
pass on RTX 3050 Ti (sm_86). L40S verification deferred until Phase
1.4 lands the production caller.
### Phase 1.2 → Phase 1.4 forward references
- Phase 1.3: `sp20_controllers_compute_kernel.cu` reads the 4 ISV
EMAs (511, 512, 515, 516) + the 4 internal scratch slots
(`internal[0..4]`) and derives the 6 controller outputs
(LOSS_CAP, HOLD_COST_SCALE, TARGET_HOLD_PCT, N_STEP,
AUX_CONF_THRESHOLD, AUX_GATE_TEMP).
- Phase 1.4: trainer plumbs the per-step inputs into the
`EmaInputs` struct, allocates the `internal` /
`obs_count` buffers (with fold-reset registry entries), and
launches all 3 SP20 kernels in sequence on the same stream as
the aux-head forward (Kernel 3 → Kernel 1 → Kernel 2).