docs(plans): integrated RL trainer GPU-pure rebuild

Replaces the flawed Phase F + G arc preserved on branch
`ml-alpha-phase-f-g-flawed` (commits 99a125cdb..b3808a5ac). The prior
attempt shipped a trainer with multiple production-blocking defects:

1. ISV[400..406] uninitialised → kernels read γ=0, ε=0, entropy=0
2. rl_reward_scale_controller drifted to 1e3 on no-trade steps
3. 6 controllers exist as .cu but never launched
4. Target net never soft-updated (τ has no consumer)
5. step_with_lobsim violated feedback_cpu_is_read_only with host
   Thompson sampling + EMA tracking + advantage/return loops
6. "toy" framing leaked into production (alpha_rl_train.rs shipped
   with next_snapshots=snapshots — the F.4 next-state code path was
   a no-op until that one issue got caught mid-review)
7. No NaN abort in production CLI

The convergence-gate fixtures (dqn_toy/ppo_toy → renamed
dqn_reward_signal/ppo_reward_signal on the flawed branch) hid every
defect because MockLobEnv is state-invariant with horizon=1.

The rebuild is GPU-pure: kernel-driven action sampling, kernel-driven
EMA tracking, kernel-driven advantage/return, ISV bootstrap at trainer
construction, all 7 controllers wired with device-resident EMA inputs,
target-net soft update consumer, NaN abort, no LobEnv trait (drives
LobSimCuda via the existing decision-policy kernel pattern).

Sequenced as R1..R9 with falsifiability gates G1..G7 that exercise
the specific failure modes the convergence-gate fixtures couldn't
catch. Calendar ~8.5 dev days.

Also adds memory pearl
feedback_extending_existing_code_audits_for_existing_violations
capturing the lesson: extending pre-existing CPU/orphan-controller
violations is how this happened.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-23 09:25:03 +02:00
parent 9114374d25
commit e4c3cc60d2

View File

@@ -0,0 +1,381 @@
# Integrated RL Trainer — GPU-Pure Rebuild
> **Status:** Rebuild plan, replaces the flawed Phase F + G arc preserved
> on branch `ml-alpha-phase-f-g-flawed` (commits 99a125cdb..b3808a5ac).
> Baseline: `ml-alpha-phase-a` reset to commit `9114374d2`
> (Phase E.3b activation).
## Why this rebuild
The prior Phase F + G attempt landed 9 commits that shipped a trainer
with multiple production-blocking defects. The defects were not bugs in
the new code so much as **propagation of pre-existing violations** in
`step_with_lobsim` plus **incomplete wiring of the ISV adaptive-control
infrastructure**. The convergence-gate fixtures (`dqn_reward_signal`,
`ppo_reward_signal`, then named `dqn_toy` / `ppo_toy`) hid every defect
because their `MockLobEnv` has `done = true` every step, reward in
`[-1, +1]`, and horizon = 1. The cluster-bound smoke was the only
mechanism that would have exposed the problems — at $520 / 30120 min
each.
### Defects in the flawed branch
1. **ISV[400..406] uninitialised in production paths.** The trainer
`alloc_zeros` the ISV array; only `rl_lr_controller` was launched.
Production consumers read sentinel `0`:
- `bellman_target_projection.cu:133` reads γ = 0 → Bellman target
reduces to `r + 0·V(s_{t+1}) = r`. Q-head learns immediate-reward
only, zero credit assignment.
- `ppo_clipped_surrogate.cu:205,305` reads ε = 0 → `clip(r, 1, 1) = 1`
always. PPO trust region collapses.
- `ppo_clipped_surrogate.cu:210` reads entropy coef = 0 → no entropy
bonus, policy collapses to deterministic immediately.
2. **`rl_reward_scale_controller` drifts to `1e3` on no-trade steps.**
The launcher fired every step regardless of `mean_abs_pnl_ema`
readiness; with `ema = 0` and `prev = REWARD_SCALE_BOOTSTRAP = 1.0`,
the kernel computed `target = 1/max(0, 1e-3) = 1000`, Wiener-blended
toward it, and ramped the scale to `1e3` over the first ~10 hold
steps. Toy fixture hid this because `done = true` every step.
3. **6 controllers exist as .cu files but never launched.**
`rl_gamma`, `rl_target_tau`, `rl_ppo_clip`, `rl_entropy_coef`,
`rl_rollout_steps`, `rl_per_alpha`. The build system compiles their
cubins; no Rust launcher fires them. ISV bootstrap (defect #1)
would have masked this even if the controllers had been wired.
4. **Target-net soft update missing.** `DqnHead` owns `target_w_d /
target_b_d` but no kernel updates them. The "target network" is
actually the initial random init for the entire run — no soft Polyak
update, no hard sync. Double-DQN Bellman target uses an
untrained-static target.
5. **`feedback_cpu_is_read_only` violation in `step_with_lobsim`.**
Host code:
- 5 DtoH copies per step (Q / V / π logits at h_t, V / Q at h_{t+1})
- Host-side softmax + Thompson categorical sample + argmax loops
over `[B][N_ACTIONS][Q_N_ATOMS]`
- Host EMA updates for `mean_abs_pnl_ema`
- Host advantage / return computation
- HtoD copies for `actions`, `rewards`, `dones`
The pre-existing Phase E.3b code already had this pattern; the
flawed Phase F extended it instead of rebuilding it as GPU-resident
per `pearl_cold_path_no_exception_to_gpu_drives`.
6. **"Toy" framing in production code.** Test files `dqn_toy.rs` /
`ppo_toy.rs`, fixture constructor `MockLobEnv::toy_bandit()`, and
pervasive "toy bandit" / "for the toy bandit smoke" comments leaked
into production source. Worse: `alpha_rl_train.rs` shipped with
`next_snapshots = snapshots` — production code applying a
toy-fixture simplification — until that one issue got caught
mid-review.
7. **No NaN / divergence abort in `alpha_rl_train.rs`.** A NaN-poisoned
run would burn the full 14400-second `activeDeadlineSeconds` budget
silently.
### Pattern lesson
The convergence-gate fixtures verified the gradient mechanics
(`reward_signal_drives_argmax_to_rewarded_action`) but their
state-invariant fixture intrinsically *can't* test:
- Sparse-reward credit assignment (defect #1's γ = 0 effect)
- Reward-magnitude drift (defect #2)
- Trust-region clipping (defect #1's ε = 0 effect)
- Target-network staleness (defect #4)
**Pre-cluster falsifiability requires fixtures that exercise the same
failure modes the cluster will see.** A short bull-market loop with
state-dependent rewards + per-step holds + multi-step trades is the
minimum.
## Architectural decisions
### A1. GPU-pure action submission via the existing decision-policy path
The pre-existing production decision pipeline (`step_decision_with_latency`
in `crates/ml-backtesting/src/sim/mod.rs`) already submits actions
GPU-pure: `alpha_probs_d` (device) feeds `decision_policy_kernel`
which writes `market_targets_d` (device) which the fill kernel reads.
No host roundtrip.
**Decision:** the integrated RL trainer drives the same kernel path.
Action sampling happens IN THE DECISION KERNEL (or a new
`rl_action_kernel` that lives parallel to it), reading Q / π logits +
ISV[entropy, ε] from device + writing to `market_targets_d`. The
`LobEnv` trait + `LobSimEnvAdapter` from the flawed branch are
DELETED. The trainer interacts with `LobSimCuda` via the existing
decision-policy entry point.
**Consequence:** the trait-abstraction layer that let `MockLobEnv` mock
the env is removed. Convergence-gate tests must run against a real
`LobSimCuda` with synthetic book data (still feasible — fixtures
inject snapshots).
### A2. All EMAs live in ISV
Currently `mean_abs_pnl_ema` lives as a host `f32` field on
`IntegratedTrainer`. **Decision:** every EMA the controllers consume
lives in an ISV slot, updated by a dedicated per-step kernel that reads
the source signal from device buffers (rewards_d, dones_d, etc.).
New ISV slots needed for the EMA inputs:
- `RL_MEAN_TRADE_DURATION_EMA_INDEX` (input to `rl_gamma_controller`)
- `RL_Q_DIVERGENCE_EMA_INDEX` (input to `rl_target_tau_controller`)
- `RL_KL_PI_EMA_INDEX` (input to `rl_ppo_clip_controller`)
- `RL_ENTROPY_OBSERVED_EMA_INDEX` (input to `rl_entropy_coef_controller`)
- `RL_ADVANTAGE_VAR_RATIO_EMA_INDEX` (input to `rl_rollout_steps_controller`)
- `RL_TD_KURTOSIS_EMA_INDEX` (input to `rl_per_alpha_controller`)
- `RL_MEAN_ABS_PNL_EMA_INDEX` (input to `rl_reward_scale_controller`)
That's 7 new slots → `RL_SLOTS_END = 424`.
### A3. ISV bootstrap at trainer construction
Defect #1 and the Wiener-blend behavior of the controllers both
require slots to be at known-good values before any kernel reads them.
**Decision:** trainer `new()` does ONE memcpy_htod writing canonical
defaults to ISV[400..406]:
| Slot | Default | Source |
|------|---------|--------|
| RL_GAMMA_INDEX (400) | 0.99 | DQN convention |
| RL_TARGET_TAU_INDEX (401) | 0.005 | DQN convention |
| RL_PPO_CLIP_INDEX (402) | 0.2 | PPO original paper |
| RL_ENTROPY_COEF_INDEX (403) | 0.01 | PPO convention |
| RL_N_ROLLOUT_STEPS_INDEX (404) | 2048 | PPO convention |
| RL_PER_ALPHA_INDEX (405) | 0.6 | Schaul et al. 2016 |
| RL_REWARD_SCALE_INDEX (406) | 1.0 | identity pass-through |
Controllers then ADAPT from these defaults rather than bootstrapping
from sentinel zero. The sentinel-zero bootstrap path in each kernel
stays as a safety net but is never the production cold-start path.
### A4. Target-net soft update consumer for ISV[401]
New `dqn_target_soft_update` kernel: per-step element-wise
`target_w[i] = (1-τ)·target_w[i] + τ·w[i]` reading τ from `ISV[401]`.
Launched from `step_after_encoder_forward` after the Q-head Adam
update.
### A5. GPU-resident action sampling
New kernels:
- `thompson_sample_categorical` — input: Q logits, ISV[entropy_coef],
device PRNG state. Output: per-batch action index in `market_targets`
format directly.
- `argmax_expected_q` — input: Q logits. Output: per-batch
next-action index.
- `log_pi_at_action` — input: π logits + action_d. Output: per-batch
log π(a_t) — feeds the PPO importance-ratio path.
Device PRNG via a per-batch `curandStatePhilox4_32_10_t` allocated at
trainer init + advanced per step.
### A6. GPU-resident advantage / return / TD computation
New `compute_advantage_return` kernel: per-batch `r + γ(1-done)·V_tp1`
and `r_t - V_t`, reading γ from `ISV[400]`. Replaces the host loop in
the flawed branch's `step_with_lobsim`.
### A7. Fee model
The flawed F.3a's approach (`order_match.cu` deduct fee + new
`upload_cost_per_lot_per_side` API) was correct in principle, mirroring
the existing `apply_fill_to_pos` pattern from `resting_orders.cu`. The
rebuild keeps this change.
### A8. Loader pair API
The flawed Phase G's `MultiHorizonLoader::next_sequence_pair` was
correct (true `(s_t, s_{t+1})` consecutive sampling). The rebuild keeps
this addition.
## Falsifiability gates — pre-cluster
Each gate is a local CUDA test that exercises the failure mode it
covers. None of these existed in the flawed branch.
| Gate | Asserts | Catches defect |
|------|---------|----------------|
| G1 | ISV[400..406] held at canonical defaults after `IntegratedTrainer::new` | #1 |
| G2 | `RL_REWARD_SCALE_INDEX` stays at 1.0 for first N hold-only steps | #2 |
| G3 | Each controller's launcher visibly mutates its ISV slot after first non-sentinel input | #3 |
| G4 | `target_w` Frobenius norm drifts from `w` only after the soft-update kernel fires | #4 |
| G5 | `cudaMemcpy` count per `step_with_lobsim` is 0 host-initiated (other than ISV diagnostic readback) | #5 |
| G6 | Bull-market forced-long 50-trade loop: mean reward per trade > 0 (already in flawed F.3b's `reward_calibration.rs`) | (regression-coverage for fee + scale) |
| G7 | Run for N steps; abort + non-zero exit code if any per-head loss is non-finite | #7 |
G5 is the load-bearing gate for the GPU-pure decision (A1). Tooling:
hook `cudaMemcpyAsync` via a CUPTI counter or count Rust-side
`memcpy_htod` / `memcpy_dtoh` calls during the step.
## Required new files
| File | Purpose |
|------|---------|
| `crates/ml-alpha/cuda/thompson_sample_categorical.cu` | A5 |
| `crates/ml-alpha/cuda/argmax_expected_q.cu` | A5 |
| `crates/ml-alpha/cuda/log_pi_at_action.cu` | A5 |
| `crates/ml-alpha/cuda/compute_advantage_return.cu` | A6 |
| `crates/ml-alpha/cuda/dqn_target_soft_update.cu` | A4 |
| `crates/ml-alpha/cuda/ema_update_on_done.cu` | A2 — generic done-gated EMA (parameterised slot) |
| `crates/ml-alpha/cuda/ema_update_per_step.cu` | A2 — generic per-step EMA (parameterised slot) |
Reused / extended:
- `rl_reward_scale_controller.cu` (port from flawed branch)
- `rl_gamma_controller.cu`, `rl_target_tau_controller.cu`,
`rl_ppo_clip_controller.cu`, `rl_entropy_coef_controller.cu`,
`rl_rollout_steps_controller.cu`, `rl_per_alpha_controller.cu`
(already in baseline tree — port their Rust launchers from the
flawed branch concept, but feed device-resident EMA inputs)
- `crates/ml-backtesting/cuda/order_match.cu` (port the F.3a fee
deduction)
Deleted:
- `crates/ml-alpha/src/rl/reward.rs::LobEnv` trait
- `crates/ml-backtesting/src/sim/mod.rs::LobSimEnvAdapter`
## Phased execution
Each phase = one commit. Each phase has a green-build + green-gate
requirement before the next phase starts.
### R1 — ISV bootstrap + slot extension (1 commit)
- Add new ISV slot constants for the 7 EMA inputs (A2) → `RL_SLOTS_END = 424`.
- In `IntegratedTrainer::new`, one memcpy_htod writing canonical
defaults to ISV[400..406] (A3).
- Gate G1.
### R2 — Fee model + loader pair API (2 commits)
- R2a: port `order_match.cu` fee deduction + `upload_cost_per_lot_per_side`
host API (from F.3a). Gate: existing `lob_env_adapter_buy_close_cycle`
passes with zero fees; new with-fees variant lands.
- R2b: port `MultiHorizonLoader::next_sequence_pair` (from G fix).
### R3 — GPU-pure action sampling (3 commits)
- R3a: `thompson_sample_categorical` kernel + Rust launcher. Local CUDA
test: sample 10000 actions from a known Q distribution, assert
empirical frequency matches softmax probabilities within tolerance.
- R3b: `argmax_expected_q` kernel. Local CUDA test: feed adversarial
Q distributions, assert argmax matches host reference.
- R3c: `log_pi_at_action` kernel. Local CUDA test: feed known π
distribution, assert log-prob within 1e-5 of host softmax.
### R4 — GPU-resident EMA + advantage / return (2 commits)
- R4a: `ema_update_on_done.cu` + `ema_update_per_step.cu` generic
kernels. Local CUDA test: feed deterministic reward stream, assert
EMA tracks the analytic Wiener-α trajectory.
- R4b: `compute_advantage_return.cu`. Local CUDA test: feed known
rewards / dones / V values, assert advantage / return match host
reference.
### R5 — Wire all 7 controllers + target-net soft update (1 commit)
- Add launcher fields + helpers for all 7 controllers (gamma,
target_tau, ppo_clip, entropy_coef, rollout_steps, per_alpha,
reward_scale). Each launcher pulls its input from the corresponding
EMA slot (A2 from R4a). Gate G3.
- Add `dqn_target_soft_update` kernel + launcher (A4). Gate G4.
### R6 — Replace `LobEnv` with kernel-driven decision path (3 commits)
- R6a: delete `LobEnv` trait + `LobSimEnvAdapter`. Add direct
`LobSimCuda` integration on `IntegratedTrainer` (no trait).
- R6b: wire action submission via the decision-policy kernel pattern
(mirror `step_decision_with_latency`). Adapt `market_targets_d` to
accept the Thompson-sampled action index directly.
- R6c: rewrite the trainer's convergence-gate fixtures (formerly
`dqn_toy.rs` / `ppo_toy.rs`) to drive a real `LobSimCuda` with
synthetic book data instead of a `MockLobEnv`. Reward signal becomes
"synthetic monotonic uptick → forced long → trade close pays > 0".
- Gate G5 (no host memcpy in step_with_lobsim other than ISV
diagnostics).
### R7 — Reward composition (1 commit)
- Per-step reward = `realized_pnl_delta` (already net of fees + slippage
from R2a) scaled by `ISV[RL_REWARD_SCALE_INDEX]`. Optionally subtract
exposure penalty (deferred unless smoke shows it's needed).
- Gate G6.
### R8 — CLI binary + Argo template + dispatcher (1 commit)
- `crates/ml-alpha/examples/alpha_rl_train.rs` driving the rebuilt
trainer. Uses `next_sequence_pair` for true `(s_t, s_{t+1})` windows.
- `infra/k8s/argo/alpha-rl-template.yaml` + `scripts/argo-alpha-rl.sh`
(port from flawed branch).
- NaN abort: per-step `is_finite()` check on `l_total`, exit code 2 on
divergence. Gate G7.
### R9 — Pre-cluster smoke + first cluster smoke (out-of-band)
- Run all R1..R8 local CUDA tests on a GPU host. Confirm green.
- `git push`. `kubectl apply -f` template. `./scripts/argo-alpha-rl.sh
--n-steps 1000` for a 5-min validation smoke. If green, full 50k.
## What we keep from the flawed branch
These specific elements landed correctly and are ported directly:
- `rl_reward_scale_controller.cu` kernel (Phase F.2)
- `order_match.cu` fee deduction pattern (Phase F.3a)
- `MultiHorizonLoader::next_sequence_pair` (Phase G fix)
- The 6 unlaunched controller .cu files (Phases C, D, E) — they're
fine kernels, just need launchers
- F.4's true-h_{t+1} architectural insight (Bellman target on
h_{t+1}, V bootstrap on h_{t+1}) — the structure becomes simpler in
the GPU-pure rebuild because state lives on device
## What we discard
- `LobEnv` trait + `LobSimEnvAdapter` (replaced by kernel-driven path)
- `MockLobEnv` "toy bandit" fixture (replaced by synthetic-book
`LobSimCuda` fixture)
- Host-side action sampling, EMA tracking, advantage / return loops
- The `dqn_toy.rs` / `ppo_toy.rs` test file names (renamed to
`dqn_reward_signal.rs` / `ppo_reward_signal.rs` in the rebuild)
## Pearls honored
- `feedback_cpu_is_read_only` — A1, A5, A6 are direct compliance.
- `feedback_isv_for_adaptive_bounds` — A2, A3, R5.
- `feedback_no_partial_refactor` — each R-phase is atomic.
- `feedback_no_stubs` — every controller has a launcher AND a
consumer at landing time.
- `feedback_no_atomicadd` — new kernels use single-writer-per-block
or per-batch scratch + reduce_axis0.
- `feedback_no_htod_htoh_only_mapped_pinned` — applies to the
remaining ISV diagnostic readback.
- `pearl_first_observation_bootstrap` — combined with A3, the host
bootstrap takes precedence; kernel sentinel-zero path stays as a
safety net.
- `pearl_wiener_alpha_floor_for_nonstationary` — every EMA uses
α-floor = 0.4.
- `feedback_no_hiding` — `step_event`'s `_ts_ns` / `_trade_signed_vol`
go away with the `LobEnv` trait deletion.
- `feedback_no_feature_flags` — controllers are unconditional.
- `feedback_registry_entries_need_dispatch_arms` — controllers go in
the state-reset registry (if applicable to ml-alpha).
- `pearl_tests_must_prove_not_lock_observations` — convergence-gate
fixtures assert invariants (reward signal flows, sign correctness),
not specific learned-checkpoint values.
## Calendar estimate
- R1: 0.5 days
- R2: 1 day
- R3: 1.5 days
- R4: 1 day
- R5: 1 day
- R6: 2 days
- R7: 0.5 days
- R8: 0.5 days
- R9: 0.5 days + 1h cluster smoke
Total ~8.5 dev days. Roughly 2× the flawed Phase F + G calendar
because the rebuild does the work the flawed branch deferred or
simplified.