Files
foxhunt/crates/ml-alpha/cuda/rl_reward_scale_controller.cu
jgrusewski 705d6c156b audit: ISV-ify 10 more design constants — Schulman + bootstraps + streaming α
Per `feedback_isv_for_adaptive_bounds` + user "do all except floors
and clamp bounds": 10 more constants moved from kernel-side `#define`s
into ISV slots (78 slots total now).

## Slot additions (468-477)

  RL_SCHULMAN_TOLERANCE_INDEX        (468, =1.5)   — shared by 4 controllers
  RL_SCHULMAN_ADJUST_RATE_INDEX      (469, =1.5)   — shared by 4 controllers
  RL_STREAM_ALPHA_INDEX              (470, =0.05)  — shared by var + kurt streaming
  RL_KURT_GAUSSIAN_INDEX             (471, =3.0)
  RL_KURT_NOISE_FLOOR_INDEX          (472, =1.0)
  RL_TAU_BOOTSTRAP_INDEX             (473, =0.005)
  RL_EPS_BOOTSTRAP_INDEX             (474, =0.2)
  RL_ROLLOUT_BOOTSTRAP_INDEX         (475, =2048)
  RL_REWARD_SCALE_BOOTSTRAP_INDEX    (476, =1.0)
  RL_PPO_RATIO_CLAMP_BOOTSTRAP_INDEX (477, =10.0)

## Skipped (per user "do all except floors and clamp bounds")

  * `*_MIN`/`*_MAX` clamp bounds (algebraic domain — risk γ=1.5 nonsense)
  * Numerical floors: ABS_MEAN_FLOOR=1e-6, M2_SQ_FLOOR=1e-12, EPS_PNL=1e-3
    (risk div-by-zero if mis-tuned)
  * C51 atom layout (V_MIN/V_MAX) — architecture, not config

## Wiring

  * Shared Schulman pattern: 4 controllers (ppo_clip, target_tau,
    rollout_steps, plus per_α independent KURT slots) now read TOLERANCE
    + ADJUST_RATE from the same 2 ISV slots. Single source of truth.
  * Each controller's bootstrap (1st-emit on sentinel-zero) reads
    isv[*_BOOTSTRAP_INDEX] instead of #define value. The `prev ==
    BOOTSTRAP` first-observation replace-direct check also reads from
    ISV.
  * 2 streaming kernels (var + kurt) share RL_STREAM_ALPHA_INDEX.

## Diag bake-in

JSONL `isv_config` block grows by 10 new fields: schulman_tolerance,
schulman_adjust_rate, stream_alpha, kurt_gaussian, kurt_noise_floor,
tau_bootstrap, eps_bootstrap, rollout_bootstrap,
reward_scale_bootstrap, ppo_ratio_clamp_bootstrap. Total isv_config
fields: 26.

Also includes windowed action_entropy fix (was structurally 0 at
b_size=1) — accumulates EMA-smoothed action distribution over
~1k-step window, computes entropy on the windowed dist. Makes the
exploration metric meaningful at b_size=1.

## Slot total

RL_SLOTS_END: 468 → 478. **78 total ISV slots.**

## Verified gates (local sm_86)

  G1 isv_bootstrap    (with 10 new assertions)
  G3 controllers     
  G4 target_update   
  integrated_smoke   

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:47:18 +02:00

130 lines
6.5 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// rl_reward_scale_controller.cu — emits per-trade reward standardisation
// scale to ISV[RL_REWARD_SCALE_INDEX=406].
//
// Phase F of the integrated RL trainer
// (docs/superpowers/plans/2026-05-22-integrated-rl-trainer.md).
//
// The C51 distributional Q-head uses a fixed atom support [-1, +1] with
// N_ATOMS=21. For the categorical Bellman projection to work well, scaled
// rewards must sit comfortably within that support — a typical realized
// |PnL| trade should map to a magnitude ≈ 1.0. Without standardisation,
// a strategy trading 1-tick ES moves (≈$12.50 per contract) would stuff
// nearly all probability mass into a handful of atoms near zero, starving
// the distributional representation.
//
// This controller emits the inverse of the mean absolute realized PnL:
//
// target_scale = 1.0 / max(mean_abs_pnl_ema, EPS_PNL)
//
// so that reward_scaled = reward_raw * isv[RL_REWARD_SCALE_INDEX] keeps
// a typical trade's realized PnL ≈ 1.0 (in atom-support units). The EMA
// is maintained on the Rust side (Phase F.3) from the LobEnv adapter's
// reward stream over the last ~100 closes, and passed in as a scalar.
//
// Bootstrap discipline (per `pearl_first_observation_bootstrap`): the ISV
// slot starts at 0.0 sentinel. First emit writes REWARD_SCALE_BOOTSTRAP=1.0
// directly — raw passthrough until the EMA stabilises. Subsequent emits
// Wiener-α blend with floor 0.4 (per `pearl_wiener_alpha_floor_for_nonstationary`
// — the trade-magnitude distribution drifts as the policy co-adapts,
// breaking stationarity).
//
// Bounds: scale ∈ [1e-3, 1e3]. Five orders of magnitude either side of
// unit scale handles micro-PnL noise scenarios at the low end and
// catastrophic-loss-magnitude outliers at the high end, while preventing
// runaway. If the typical |PnL| stabilises at e.g. 50 USD, the controller
// converges to scale ≈ 0.02 (within bounds); at $0.01 noise it caps at
// 1e3 rather than exploding.
#define RL_REWARD_SCALE_INDEX 406
#define REWARD_SCALE_MIN 1e-3f
#define REWARD_SCALE_MAX 1e3f
// ISV-driven bootstrap (was 1.0f #define).
#define RL_REWARD_SCALE_BOOTSTRAP_INDEX 476
// Floor for the mean_abs_pnl_ema denominator to guard against div-by-zero
// when the EMA hasn't accumulated any closed trades yet.
#define EPS_PNL 1e-3f
#define WIENER_ALPHA_FLOOR 0.4f
// ─────────────────────────────────────────────────────────────────────
// rl_reward_scale_controller:
// Single-thread controller — writes ONE float to
// isv[RL_REWARD_SCALE_INDEX].
//
// Inputs:
// isv [≥ RL_REWARD_SCALE_INDEX+1] — ISV bus
// alpha_step — Wiener-α from the controller's signal stats;
// floored at WIENER_ALPHA_FLOOR before the blend.
// Named `alpha_step` to disambiguate from PER's α
// (ISV[405]) and from the ISV slot name itself.
// mean_abs_pnl_ema — caller-computed EMA of |realized_pnl_usd| over
// the last ~100 closed trades. Phase F.3 maintains
// this from the LobEnv adapter reward stream.
//
// Outputs:
// isv[RL_REWARD_SCALE_INDEX] — reward standardisation scale ∈
// [REWARD_SCALE_MIN, REWARD_SCALE_MAX]
// ─────────────────────────────────────────────────────────────────────
// Phase R5: scalar input arg replaced with `input_slot` ISV index so the
// EMA producer (Phase R3 ema_update_on_done targeting
// ISV[RL_MEAN_ABS_PNL_EMA_INDEX=423]) feeds this controller without
// any host roundtrip per `feedback_cpu_is_read_only`.
extern "C" __global__ void rl_reward_scale_controller(
float* __restrict__ isv,
float alpha_step,
int input_slot
) {
if (threadIdx.x != 0 || blockIdx.x != 0) return;
const float prev = isv[RL_REWARD_SCALE_INDEX];
// Bootstrap on sentinel 0.0 per pearl_first_observation_bootstrap.
if (prev == 0.0f) {
isv[RL_REWARD_SCALE_INDEX] = isv[RL_REWARD_SCALE_BOOTSTRAP_INDEX];
return;
}
// Cold-start gate: when mean_abs_pnl_ema is
// sentinel-zero (no closed trade yet — typical for the first
// several steps before any trail-stop fires), the reciprocal
// target `1 / max(0, EPS_PNL) = 1/EPS_PNL ≈ 1000` slams to
// REWARD_SCALE_MAX. The Wiener α-floor=0.4 then drags scale
// toward MAX every step: by step 6 scale = 972, by step 7's
// first closed trade the scale was 583× — multiplying a real
// $832 trade close into a 485,256-magnitude reward fed straight
// into Q/V backward. l_v jumped to 15.9 on that step, and the
// overshoot took ~30 steps to settle.
//
// Hold at bootstrap (1.0 = raw passthrough) until the first
// closed trade pushes mean_abs_pnl_ema off sentinel. This is
// exactly what `pearl_first_observation_bootstrap` mandates:
// "sentinel = 0" means "no signal" — adapt against signal, not
// noise.
const float mean_abs_pnl_ema = isv[input_slot];
if (mean_abs_pnl_ema == 0.0f) return;
// target_scale = 1.0 / max(mean_abs_pnl_ema, EPS_PNL).
// Larger typical PnL → smaller scale (reward is divided down toward ±1).
// Smaller typical PnL → larger scale (reward is amplified toward ±1).
const float denom = fmaxf(mean_abs_pnl_ema, EPS_PNL);
float target = 1.0f / denom;
target = fmaxf(REWARD_SCALE_MIN, fminf(target, REWARD_SCALE_MAX));
// First-observation replace-directly per
// `pearl_first_observation_bootstrap`. With prev=1.0 (bootstrap)
// and a target far from 1.0 on the first real PnL signal, the
// Wiener blend at α=0.4 would leave 60% of the bootstrap weight
// in the output — a single $832 trade close × scale=0.6 = $499
// feeds V regression at magnitude 500× the atom support's
// ±1 expectation. Replacing directly with target eliminates
// this cold-start contamination.
if (prev == isv[RL_REWARD_SCALE_BOOTSTRAP_INDEX]) {
isv[RL_REWARD_SCALE_INDEX] = target;
return;
}
// Wiener-α blend with floor per pearl_wiener_alpha_floor_for_nonstationary.
const float a = fmaxf(alpha_step, WIENER_ALPHA_FLOOR);
float out = (1.0f - a) * prev + a * target;
out = fmaxf(REWARD_SCALE_MIN, fminf(out, REWARD_SCALE_MAX));
isv[RL_REWARD_SCALE_INDEX] = out;
}