Files
foxhunt/crates/ml-alpha/cuda/rl_per_alpha_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

147 lines
7.6 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_per_alpha_controller.cu — emits PER (Prioritized Experience Replay)
// priority exponent to ISV[RL_PER_ALPHA_INDEX=405].
//
// Phase E.3b of the integrated RL trainer
// (docs/superpowers/plans/2026-05-22-integrated-rl-trainer.md).
//
// PER's priority exponent α controls how aggressively the replay buffer
// concentrates sampling on high-TD-error transitions. Per
// `pearl_controller_anchors_isv_driven` and
// `feedback_isv_for_adaptive_bounds`, α is NOT a hardcoded constant:
// it adapts to the tail thickness of the TD-error distribution.
//
// Controller logic:
// * High TD-error kurtosis (heavy tails) → a few transitions
// dominate the loss → raise α to sharpen the priority distribution
// and concentrate sampling on the informative tails.
// * Low kurtosis (light tails, ≈ Gaussian) → TD-errors are uniform →
// keep α near the standard PER default (0.6) — over-sharpening on a
// light-tailed distribution wastes samples on noise.
//
// Bootstrap discipline (per `pearl_first_observation_bootstrap`): the
// ISV slot starts at 0.0 sentinel. First emit DERIVES the bootstrap
// from the current `input_slot` (td_kurtosis EMA) via the same target
// formula the per-step path uses, instead of writing a hardcoded
// "canonical PER 0.6 default". The earlier hardcoded-0.6 pattern
// coincided with `target(kurt=10) = 0.6` — the canonical
// heavy-tailed-market kurtosis — creating a Wiener-blend fixed point
// where `prev = target = 0.6` froze the controller in a dead-zone
// for any input near canonical. The derive-from-input pattern
// eliminates the coincidence: bootstrap is whatever the target formula
// emits for the current input (= 0.4 at sentinel-zero input, = 0.6
// when input has already EMA'd to canonical 10, = 0.886 at kurt=20,
// etc.) so the controller's per-step Wiener blend always sees a real
// `prev` vs `target` delta on subsequent inputs.
//
// Subsequent emits Wiener-α blend with floor 0.4 (per
// `pearl_wiener_alpha_floor_for_nonstationary` — the TD-error
// distribution drifts as Q co-adapts, breaking stationarity).
//
// Bounds: α ∈ [0.3, 1.0]. Below 0.3 the priority distribution
// approaches uniform (PER degenerates into vanilla replay); at 1.0
// sampling is perfectly proportional to priority.
#define RL_PER_ALPHA_INDEX 405
#define PER_ALPHA_MIN 0.3f
#define PER_ALPHA_MAX 1.0f
// Kurtosis of a standard normal = 3.0 ("excess kurtosis 0" with the
// alternative convention). Used as the breakpoint above which we start
// raising α.
// ISV-driven kurtosis interpretation constants per
// `feedback_isv_for_adaptive_bounds`.
#define RL_KURT_GAUSSIAN_INDEX 471
#define RL_KURT_NOISE_FLOOR_INDEX 472
#define RL_KURT_LIFT_SCALE_INDEX 459
// Noise-floor gate: if the streaming kurtosis estimator emits a value
// below this magnitude, treat it as "no signal" (sentinel-zero proxy)
// and hold α at the prior value. Without this, on the first few steps
// after the streaming estimator initialises, a sub-Gaussian kurtosis
// reading (e.g. 0.5 — near-uniform per-step batch means before tails
// develop) would yield target = 0.4 = PER_ALPHA_MIN, dragging α toward
// MIN despite zero real signal. Matches the defensive noise-floor
// pattern on the other controllers (per
// pearl_multiplicative_controllers_need_bounded_step_and_noise_floor).
// (KURT_NOISE_FLOOR — was 1.0f #define — now isv[RL_KURT_NOISE_FLOOR_INDEX])
#define WIENER_ALPHA_FLOOR 0.4f
// ─────────────────────────────────────────────────────────────────────
// rl_per_alpha_controller:
// Single-thread controller — writes ONE float to
// isv[RL_PER_ALPHA_INDEX].
//
// Inputs:
// isv [≥ RL_PER_ALPHA_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 the
// output (PER's α).
// td_kurtosis_ema — caller-computed kurtosis EMA of the per-
// transition |TD-error| series. Phase F produces
// this via a 4-moment reduce over the replay
// priority column.
//
// Outputs:
// isv[RL_PER_ALPHA_INDEX] — PER priority exponent α
// [PER_ALPHA_MIN, PER_ALPHA_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_TD_KURTOSIS_EMA_INDEX=422]) feeds this controller without any
// host roundtrip per `feedback_cpu_is_read_only`.
extern "C" __global__ void rl_per_alpha_controller(
float* __restrict__ isv,
float alpha_step,
int input_slot
) {
if (threadIdx.x != 0 || blockIdx.x != 0) return;
const float prev = isv[RL_PER_ALPHA_INDEX];
// Noise-floor gate: kurtosis below KURT_NOISE_FLOOR is dominated
// by streaming-estimator startup noise (per-step batch-mean
// differences before tails accumulate). Hold α at prev to avoid
// dragging toward PER_ALPHA_MIN on cold-start.
const float td_kurtosis_ema = isv[input_slot];
if (td_kurtosis_ema > 0.0f && td_kurtosis_ema < isv[RL_KURT_NOISE_FLOOR_INDEX]) {
// Real signal present but below the noise floor — hold prev.
// (Strict sentinel zero handled by the prev==0 bootstrap path
// below, which derives target from the current EMA so the
// first-non-zero observation still seeds correctly.)
if (prev != 0.0f) return;
}
// Compute target from the current input EMA via a piecewise linear
// lift. Shared between bootstrap and per-step paths so the dead-zone
// coincidence with a hardcoded bootstrap value cannot recur.
// kurt ≤ KURT_GAUSSIAN → target = 0.4 (PER_ALPHA_MIN + 0.1)
// kurt = KURT_GAUSSIAN + KURT_LIFT_SCALE (≈10) → target = 0.6 (canonical PER)
// kurt → ∞ → target → PER_ALPHA_MAX
//
// The 0.4-0.6 baseline keeps the steady-state output near PER's
// canonical 0.6 (when input EMA stabilises at kurt=10) while
// leaving headroom to lift toward 1.0 under heavy tails.
const float kurt_excess = fmaxf(0.0f, td_kurtosis_ema - isv[RL_KURT_GAUSSIAN_INDEX]);
const float kurt_lift_scale = isv[RL_KURT_LIFT_SCALE_INDEX];
float target = 0.4f + 0.2f * (kurt_excess / kurt_lift_scale);
target = fmaxf(PER_ALPHA_MIN, fminf(target, PER_ALPHA_MAX));
// Bootstrap on sentinel 0.0 per pearl_first_observation_bootstrap:
// first emit replaces directly with the computed target. At
// construction time when input EMA is also sentinel-zero, target =
// 0.4 (min), which is distinct from EVERY non-sentinel target
// value the formula can emit (≥ 0.4). The next per-step call with
// any real input will see prev=0.4 vs target≥0.4 and Wiener-blend
// away from the sentinel.
if (prev == 0.0f) {
isv[RL_PER_ALPHA_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(PER_ALPHA_MIN, fminf(out, PER_ALPHA_MAX));
isv[RL_PER_ALPHA_INDEX] = out;
}