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>
134 lines
6.1 KiB
Plaintext
134 lines
6.1 KiB
Plaintext
// rl_ppo_clip_controller.cu — emits ε to ISV[RL_PPO_CLIP_INDEX=402].
|
||
//
|
||
// Phase D of the integrated RL trainer
|
||
// (docs/superpowers/plans/2026-05-22-integrated-rl-trainer.md).
|
||
//
|
||
// ε is the PPO clipped-surrogate band half-width — see
|
||
// `ppo_clipped_surrogate.cu` for the consumer. Per
|
||
// `pearl_controller_anchors_isv_driven` and
|
||
// `feedback_isv_for_adaptive_bounds`, ε is NOT a hardcoded constant:
|
||
// it adapts to keep KL(π_new ‖ π_old) ≈ 0.01 (the standard PPO
|
||
// "trust region" target).
|
||
//
|
||
// Controller logic (Schulman-style adaptive KL, bounded step):
|
||
// * KL > KL_TARGET × KL_TOLERANCE → policy diverging too quickly →
|
||
// shrink ε by KL_ADJUST_RATE (tighten the trust region)
|
||
// * KL < KL_TARGET / KL_TOLERANCE → policy stable → widen ε by
|
||
// KL_ADJUST_RATE (allow larger updates)
|
||
// * else (in-band): hold ε unchanged
|
||
//
|
||
// CRITICAL: KL ≪ KL_TARGET is meaningless if the policy isn't actually
|
||
// updating at all (LR too small, etc). Without a noise-floor gate the
|
||
// prior multiplicative-ratio design `KL_TARGET / max(kl, 1e-6)` would
|
||
// produce ratio=10000 from KL=1e-11, slam target to EPS_MAX, and stick
|
||
// there forever even after KL grew to small-but-real values. Canonical
|
||
// incident: alpha-rl-mjzfk fold0 (commit 53aeef099) had ε pegged at MAX
|
||
// for 100 % of the run while KL_PI median was 5e-9.
|
||
//
|
||
// FIX: the bounded multiplicative step (1.5× max change per step)
|
||
// prevents one observation from swinging ε across the entire range,
|
||
// and the KL_NOISE_FLOOR gate holds the controller when KL signal is
|
||
// below "meaningful policy update" magnitude.
|
||
//
|
||
// Bootstrap discipline (per `pearl_first_observation_bootstrap`): the
|
||
// ISV slot starts at 0.0 sentinel. First emit writes ε = 0.2 directly
|
||
// (the canonical PPO default). Subsequent emits Wiener-α blend with
|
||
// floor 0.4 (per `pearl_wiener_alpha_floor_for_nonstationary` — the KL
|
||
// target drifts as π_old co-adapts with π_new, breaking stationarity).
|
||
//
|
||
// Bounds: ε ∈ [0.05, 0.5]. Below 0.05 the policy is effectively frozen;
|
||
// above 0.5 the surrogate is so loose that the clip doesn't bite and
|
||
// PPO degenerates into vanilla policy gradient (which destabilises
|
||
// when ratios run away).
|
||
|
||
#define RL_PPO_CLIP_INDEX 402
|
||
#define EPS_MIN 0.05f
|
||
#define EPS_MAX 0.5f
|
||
// ISV-driven bootstrap + KL target per `feedback_isv_for_adaptive_bounds`.
|
||
#define RL_EPS_BOOTSTRAP_INDEX 474
|
||
#define RL_KL_TARGET_INDEX 454
|
||
// Schulman pattern parameters — global slots shared by 4 controllers.
|
||
#define RL_SCHULMAN_TOLERANCE_INDEX 468
|
||
#define RL_SCHULMAN_ADJUST_RATE_INDEX 469
|
||
#define KL_NOISE_FLOOR_FRAC 0.01f
|
||
#define WIENER_ALPHA_FLOOR 0.4f
|
||
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// rl_ppo_clip_controller:
|
||
// Single-thread controller — writes ONE float to isv[RL_PPO_CLIP_INDEX].
|
||
//
|
||
// Inputs:
|
||
// isv [≥ RL_PPO_CLIP_INDEX+1] — ISV bus
|
||
// alpha — Wiener-α from the controller's signal stats; floored at
|
||
// WIENER_ALPHA_FLOOR before the blend.
|
||
// kl_ema — caller-computed EMA of KL(π_new ‖ π_old). Phase E
|
||
// produces this via a small reduce-norm kernel over the
|
||
// per-batch log-prob delta from the surrogate fwd kernel.
|
||
//
|
||
// Outputs:
|
||
// isv[RL_PPO_CLIP_INDEX] — ε ∈ [EPS_MIN, EPS_MAX]
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Phase R5: scalar input arg replaced with `input_slot` ISV index so the
|
||
// EMA producer (Phase R3 ema_update_per_step targeting
|
||
// ISV[RL_KL_PI_EMA_INDEX=419]) feeds this controller without any host
|
||
// roundtrip per `feedback_cpu_is_read_only`.
|
||
extern "C" __global__ void rl_ppo_clip_controller(
|
||
float* __restrict__ isv,
|
||
float alpha,
|
||
int input_slot
|
||
) {
|
||
if (threadIdx.x != 0 || blockIdx.x != 0) return;
|
||
|
||
const float eps_prev = isv[RL_PPO_CLIP_INDEX];
|
||
// Bootstrap on sentinel 0.0 per pearl_first_observation_bootstrap.
|
||
if (eps_prev == 0.0f) {
|
||
isv[RL_PPO_CLIP_INDEX] = isv[RL_EPS_BOOTSTRAP_INDEX];
|
||
return;
|
||
}
|
||
|
||
// Cold-start gate (sentinel-zero EMA — kernel hasn't fired yet).
|
||
const float kl_ema = isv[input_slot];
|
||
if (kl_ema == 0.0f) return;
|
||
|
||
// ISV-driven KL target (was hardcoded #define).
|
||
const float kl_target = isv[RL_KL_TARGET_INDEX];
|
||
const float kl_noise_floor = kl_target * KL_NOISE_FLOOR_FRAC;
|
||
|
||
// Noise-floor gate: KL below kl_noise_floor is dominated by
|
||
// numerical noise, not real policy divergence. Hold ε unchanged.
|
||
if (kl_ema < kl_noise_floor) return;
|
||
|
||
// Bounded multiplicative adjustment (Schulman-style adaptive KL).
|
||
// Schulman params from ISV — shared with target_tau, rollout_steps.
|
||
const float tolerance = isv[RL_SCHULMAN_TOLERANCE_INDEX];
|
||
const float adjust_rate = isv[RL_SCHULMAN_ADJUST_RATE_INDEX];
|
||
float ratio;
|
||
if (kl_ema > kl_target * tolerance) {
|
||
ratio = 1.0f / adjust_rate;
|
||
} else if (kl_ema < kl_target / tolerance) {
|
||
ratio = adjust_rate;
|
||
} else {
|
||
ratio = 1.0f;
|
||
}
|
||
float eps_target = eps_prev * ratio;
|
||
eps_target = fmaxf(EPS_MIN, fminf(eps_target, EPS_MAX));
|
||
|
||
// First-observation replace-directly per
|
||
// `pearl_first_observation_bootstrap`. See rl_target_tau_controller
|
||
// for the rationale — the Wiener blend's bootstrap contamination
|
||
// would otherwise leave 60% of the canonical default in the
|
||
// output on the first real KL observation.
|
||
if (eps_prev == isv[RL_EPS_BOOTSTRAP_INDEX]) {
|
||
isv[RL_PPO_CLIP_INDEX] = eps_target;
|
||
return;
|
||
}
|
||
|
||
// Wiener-α blend with floor per pearl_wiener_alpha_floor_for_nonstationary.
|
||
const float a = fmaxf(alpha, WIENER_ALPHA_FLOOR);
|
||
float eps_new = (1.0f - a) * eps_prev + a * eps_target;
|
||
|
||
eps_new = fmaxf(EPS_MIN, fminf(eps_new, EPS_MAX));
|
||
isv[RL_PPO_CLIP_INDEX] = eps_new;
|
||
}
|