fix(rl): bound multiplicative controllers + add KL noise-floor gate

mjzfk diag (commit 53aeef099) showed PPO clip ε pegged at MAX=0.5
for 100% of the 50k-step run, despite kl_pi_ema median = 5.3e-9 and
max = 3.4e-4 (well below KL_TARGET=0.01). The widened clip band is
why ratio_clamp settled at (1+0.5)*10=15 instead of 12, and why π
took no meaningful updates — KL was essentially zero meaning the
policy wasn't moving.

## Root cause

The controller's adaptation was `ratio = KL_TARGET / max(kl_ema, 1e-6)`
— a multiplicative formula with no per-step bound. With kl_ema=1e-11
the kernel sees:

  ratio = 0.01 / max(1e-11, 1e-6) = 0.01 / 1e-6 = 10000
  target = eps_prev * 10000 = clamped to EPS_MAX

First-observation replace-directly then locks ε at MAX immediately,
and the Wiener α-floor=0.4 blend keeps it there forever.

The cold-start `if (kl_ema == 0.0f) return;` gate only caught EXACT
zero — first observable but tiny KL (typical: cold-start LR not yet
producing measurable policy drift) blows past the gate and saturates
the multiplier.

Same dangerous pattern existed in `rl_target_tau_controller` (uses
`q_div / DIV_TARGET` ratio with no per-step bound) — hadn't bitten
because q_divergence_norm naturally lives in the [0.01, 0.1] range,
but a quiet initialisation could hit it the same way.

## Fix: Schulman-style bounded adaptive KL

Both controllers now use a discrete-step adjustment:

  * input > target × TOLERANCE (1.5)   → ratio = ADJUST_RATE (1.5)
  * input < target / TOLERANCE          → ratio = 1/ADJUST_RATE
  * in-band                              → ratio = 1.0 (hold)

Per-step adjustment is bounded at 1.5× (50% expansion / 33%
shrinkage), so no single observation can swing the output across the
[MIN, MAX] range regardless of how outlier-tiny or outlier-huge it
is. After several consecutive out-of-band observations the output
drifts smoothly toward MIN/MAX, but the response is dampened.

## Noise-floor gate

In addition to the bounded step, both controllers now hold their
output when the input EMA is below a noise floor:

  KL_NOISE_FLOOR  = KL_TARGET  × 0.01 = 1e-4  (ppo_clip)
  DIV_NOISE_FLOOR = DIV_TARGET × 0.01 = 1e-4  (target_tau)

Two orders of magnitude below the design target = "policy isn't
actually updating" / "Q hasn't started learning" / numerical noise.
Reacting to this signal can only mis-tune the controller — we'd
rather hold a sane default than chase noise.

The TARGET-derived floors (rather than absolute constants) mean
adjusting KL_TARGET / DIV_TARGET shifts the floors proportionally —
consistent with the existing pattern.

## ISV discipline

Per `feedback_isv_for_adaptive_bounds`: KL_TARGET and DIV_TARGET
themselves are PPO/DQN design constants (like REWARD_CLAMP_WIN =
1.0 in apply_reward_scale, or LR_BOOTSTRAP = 1e-3 in
rl_lr_controller). The NOISE_FLOOR derives from them, and the
TOLERANCE / ADJUST_RATE constants are structural Schulman-recipe
parameters that don't adapt at runtime. Existing diag exposes both
controllers' outputs (isv_out.ppo_clip_eps, isv_out.target_tau) and
inputs (isv_ema_in.kl_pi, isv_ema_in.q_divergence) so the
controller behaviour is fully observable from the JSONL — no new
slots needed.

## Verified gates (local sm_86)

  G1 isv_bootstrap   
  G3 controllers      (controllers still move outputs when fed real EMAs)
  G4 target_update   
  integrated_smoke   

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-23 21:25:14 +02:00
parent 53aeef099b
commit 6a58ac9465
2 changed files with 96 additions and 31 deletions

View File

@@ -10,10 +10,25 @@
// it adapts to keep KL(π_new ‖ π_old) ≈ 0.01 (the standard PPO
// "trust region" target).
//
// Controller logic:
// * High KL → new policy diverges too quickly → shrink ε to tighten
// the trust region.
// * Low KL → policy is stable → widen ε to allow larger updates.
// 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
@@ -31,6 +46,19 @@
#define EPS_MAX 0.5f
#define EPS_BOOTSTRAP 0.2f
#define KL_TARGET 0.01f
// Below this KL magnitude the controller holds ε unchanged. Two
// orders of magnitude below the target = signal is dominated by
// numerical noise / policy isn't measurably updating, not a "low
// KL → widen" signal we should react to.
#define KL_NOISE_FLOOR (KL_TARGET * 0.01f) // = 1e-4
// In-band tolerance: KL within ±33% of target is "good enough" and
// the controller holds. Outside this band it adjusts.
#define KL_TOLERANCE 1.5f
// Per-step multiplicative adjustment when KL is out-of-band. Bounded
// at 1.5× (50% expansion / 33% shrinkage per step), preventing any
// single observation from swinging ε across the entire [MIN, MAX]
// range.
#define KL_ADJUST_RATE 1.5f
#define WIENER_ALPHA_FLOOR 0.4f
@@ -67,19 +95,34 @@ extern "C" __global__ void rl_ppo_clip_controller(
return;
}
// Cold-start gate: when KL EMA is sentinel-
// zero (no policy update has produced a measurable KL yet), the
// multiplicative ratio `KL_TARGET / max(kl, 1e-6)` collapses to
// 10,000 — the target slams to EPS_MAX (0.5) and the Wiener
// α-floor=0.4 drags ε toward MAX every step. Within ~6 cold-start
// steps ε hits 0.49 despite zero real signal, then sticks at
// 0.5 for the entire smoke. Hold at bootstrap until first
// observation.
// Cold-start gate (sentinel-zero EMA — kernel hasn't fired yet).
const float kl_ema = isv[input_slot];
if (kl_ema == 0.0f) return;
// Multiplicative adaptation toward the KL target: if measured KL is
// higher than `KL_TARGET` we want a smaller ε; if lower, grow ε.
const float ratio = KL_TARGET / fmaxf(kl_ema, 1e-6f);
// Noise-floor gate: KL below KL_NOISE_FLOOR is dominated by
// numerical noise, not real policy divergence. Hold ε unchanged.
// This catches the mjzfk failure mode where KL_PI median = 5e-9
// (essentially zero — policy isn't actually updating) but a
// multiplicative `target / max(kl, 1e-6)` design would still
// produce ratio=10000 and slam ε to MAX.
if (kl_ema < KL_NOISE_FLOOR) return;
// Bounded multiplicative adjustment (Schulman-style adaptive KL).
// At most KL_ADJUST_RATE × shift per step regardless of how far KL
// is from target — no single observation can swing ε across the
// entire [MIN, MAX] range.
float ratio;
if (kl_ema > KL_TARGET * KL_TOLERANCE) {
// KL too high → policy moving too fast → tighten ε.
ratio = 1.0f / KL_ADJUST_RATE;
} else if (kl_ema < KL_TARGET / KL_TOLERANCE) {
// KL too low (but ABOVE noise floor) → policy can take bigger
// steps → widen ε.
ratio = KL_ADJUST_RATE;
} else {
// In-band: KL within ±33 % of target → hold.
ratio = 1.0f;
}
float eps_target = eps_prev * ratio;
eps_target = fmaxf(EPS_MIN, fminf(eps_target, EPS_MAX));

View File

@@ -30,6 +30,16 @@
#define TAU_MAX 0.05f
#define TAU_BOOTSTRAP 0.005f
#define DIV_TARGET 0.01f
// Below this divergence magnitude the controller holds τ unchanged —
// the signal is dominated by numerical noise / network just-init, not
// "Q has converged and we should shrink τ". Same anti-runaway pattern
// as the PPO clip controller's KL_NOISE_FLOOR.
#define DIV_NOISE_FLOOR (DIV_TARGET * 0.01f) // = 1e-4
// In-band tolerance: divergence within ±33 % of target → hold.
#define DIV_TOLERANCE 1.5f
// Bounded per-step multiplicative adjustment so one outlier
// observation can't swing τ across the [MIN, MAX] range.
#define DIV_ADJUST_RATE 1.5f
#define WIENER_ALPHA_FLOOR 0.4f
@@ -66,22 +76,34 @@ extern "C" __global__ void rl_target_tau_controller(
return;
}
// Cold-start gate: when the input EMA is
// sentinel-zero (no signal observed yet — typical for the first
// ~N steps before any Q backward fires), the multiplicative
// ratio `q_div / DIV_TARGET` collapses to 0, which makes the
// target slam to TAU_MIN. The Wiener α-floor=0.4 then drags
// prev toward TAU_MIN every step, so by step ~6 τ has migrated
// halfway to MIN despite zero real signal. Hold at bootstrap
// until the first non-zero observation. Per
// `pearl_first_observation_bootstrap`: sentinel = 0 means "no
// data" — the controller must NOT adapt against no-data.
// Cold-start gate (sentinel-zero EMA — kernel hasn't fired yet).
const float q_divergence_norm = isv[input_slot];
if (q_divergence_norm == 0.0f) return;
// Multiplicative adaptation toward the target divergence: if the
// measured divergence is higher than `DIV_TARGET` we want a larger
// τ so the target tracks faster; if lower, shrink τ.
const float ratio = q_divergence_norm / fmaxf(DIV_TARGET, 1e-6f);
// Noise-floor gate: divergence below DIV_NOISE_FLOOR is dominated
// by numerical noise / network initialisation, not a real "Q has
// converged → shrink τ" signal. Hold τ unchanged. Mirrors the PPO
// clip controller's KL_NOISE_FLOOR design after the alpha-rl-mjzfk
// root cause.
if (q_divergence_norm < DIV_NOISE_FLOOR) return;
// Bounded multiplicative adjustment (matches PPO clip controller).
// At most DIV_ADJUST_RATE × shift per step regardless of how far
// divergence is from target.
float ratio;
if (q_divergence_norm > DIV_TARGET * DIV_TOLERANCE) {
// Divergence too high → online drifted from target faster than
// τ can track → raise τ so target accelerates.
ratio = DIV_ADJUST_RATE;
} else if (q_divergence_norm < DIV_TARGET / DIV_TOLERANCE) {
// Divergence too low → bootstrap is overly stable / Q stuck →
// lower τ to let the target lag a bit more (gives the online
// net room to actually learn before being shadowed).
ratio = 1.0f / DIV_ADJUST_RATE;
} else {
// In-band: hold.
ratio = 1.0f;
}
float tau_target = tau_prev * ratio;
tau_target = fmaxf(TAU_MIN, fminf(tau_target, TAU_MAX));