Files
foxhunt/crates/ml-alpha/cuda/rl_rollout_steps_controller.cu
jgrusewski 95dcc4e312 fix(rl): ISV-driven ADV_VAR_RATIO_TARGET for rl_rollout_steps_controller
cvf86 controller_branch diag (commit 708c121f2) revealed:
  rollout_steps: 99.99% WIDEN, 0% HOLD, 0% SHRINK

The bounded-step + noise-floor fix was correctly applied, but the
controller WIDENED 99.99% of steps because the hardcoded
ADV_VAR_RATIO_TARGET = 0.1 (`#define` in the kernel) was a b_size>1
design choice. The streaming-EMA regime at b_size=1 has var/|mean|
naturally living in [1, 10] (median 3.9), so input is ALWAYS >> 0.1
and the controller correctly says "noisy advantages → widen". Result:
n_rollout pegs at MAX=8192 within ~5 steps and stays for 50k steps,
PPO update frequency drops 4×, KL stays in numerical noise (median
1.7e-8), Q can't learn (l_q stuck at ~2.7 vs uniform 3.04).

## Fix: ISV-driven target

Per `feedback_isv_for_adaptive_bounds`: ADV_VAR_RATIO_TARGET now
lives in ISV slot 449 (`RL_ADV_VAR_RATIO_TARGET_INDEX`), seeded at
trainer init to 5.0 (matches streaming-regime median 3.9). The
controller reads `isv[RL_ADV_VAR_RATIO_TARGET_INDEX]` each step
instead of a `#define`.

Expected behavior at TARGET=5.0:
  * Median input 3.9 lands in-band [3.33, 7.5] → HOLD
  * n_rollout stays near BOOTSTRAP=2048 instead of MAX
  * 4× more PPO updates per step → policy actually moves
  * KL leaves noise floor → ε controller activates
  * Q has gradient signal → can learn

Noise floor is now derived multiplicatively from the ISV target
(`target × ADV_VAR_RATIO_NOISE_FLOOR_FRAC = 0.01`) so adjusting
the target proportionally adjusts the floor — no separate slot
needed.

## Wiring

`rl_streaming_clamp_init.cu` extended to seed all three ISV-resident
design constants (adv_var clamp ceiling, td_kurt clamp ceiling, AND
adv_var regression target). Single kernel call at trainer init —
still no HtoD per `feedback_no_htod_htoh_only_mapped_pinned`.

## Diag bake-in

`controller_branch.rollout_steps_target` now reads from
`isv[RL_ADV_VAR_RATIO_TARGET_INDEX]` instead of the prior hardcoded
`0.1f32` literal. The diag shows the current ISV-resident target
so post-hoc branch analysis uses the actual value the controller
saw, and lets us track whether a future adaptive controller (one
that maintains target from observed-input percentile EMA) is
moving the target correctly.

## Slot allocation

RL_SLOTS_END: 449 → 450 (one new design-constant slot).

## Test updates

G1 (isv_bootstrap) + G3 (r5_controllers) skip slot 449 in the
sentinel-zero loop and assert the seeded value (5.0) separately.
G3's `advantage_var_ratio` input bumped from 5.0 → 20.0 so the
WIDEN branch still fires (input > new target × 1.5 = 7.5) and the
test still validates that the controller moves off bootstrap.

## Verified gates (local sm_86)

  G1 isv_bootstrap   
  G3 controllers      (with updated input)
  G4 target_update   
  integrated_smoke   

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

161 lines
7.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_rollout_steps_controller.cu — emits PPO rollout buffer flush size
// to ISV[RL_N_ROLLOUT_STEPS_INDEX=404].
//
// Phase E.3b of the integrated RL trainer
// (docs/superpowers/plans/2026-05-22-integrated-rl-trainer.md).
//
// The rollout length sets how many on-policy transitions PPO accumulates
// before flushing into an update batch. Per
// `pearl_controller_anchors_isv_driven` and
// `feedback_isv_for_adaptive_bounds`, this is NOT a hardcoded constant:
// it adapts to the noise level of the advantage estimator.
//
// Controller logic (Schulman-style bounded step):
// * input > ADV_VAR_RATIO_TARGET × TOLERANCE → noisy → widen rollout
// by ADJUST_RATE.
// * input < ADV_VAR_RATIO_TARGET / TOLERANCE → stable → shrink rollout
// by ADJUST_RATE.
// * in-band → hold.
//
// CRITICAL: the prior design used `scale = clamp(input/target, 0.5, 2.0)`
// which saturated to ±2× on the SIGN of (input target), not the
// magnitude. With typical streaming input 110 and target=0.1, every
// step doubled until the controller hit ROLLOUT_MAX in ≤4 steps
// (canonical: alpha-rl-kc2h9 fold0 had n_rollout pegged at MAX 100% of
// 50000 steps despite no actual signal change). Schulman-style bounded
// discrete adjustment converges smoothly to MAX/MIN when signal is
// consistently out-of-band, but doesn't slam there from one
// observation.
//
// Noise-floor gate holds the controller when input is below
// ADV_VAR_RATIO_NOISE_FLOOR = ADV_VAR_RATIO_TARGET × 0.01 — at that
// magnitude the streaming kernel's signal is dominated by numerical
// noise / cold-start, not a real "advantages are clean" indication
// worth shrinking the rollout for.
//
// Bootstrap discipline (per `pearl_first_observation_bootstrap`): the
// ISV slot starts at 0.0 sentinel. First emit writes
// `ROLLOUT_BOOTSTRAP = 2048` directly (the canonical PPO default).
// Subsequent emits Wiener-α blend with floor 0.4 (per
// `pearl_wiener_alpha_floor_for_nonstationary` — the advantage
// distribution drifts as π_new co-adapts, breaking stationarity).
//
// Bounds: rollout ∈ [256, 8192]. Below 256 PPO has too little data per
// update and the surrogate is dominated by sampling noise; above 8192
// updates lag the data so far behind that the importance ratios blow
// past the clip band.
#define RL_N_ROLLOUT_STEPS_INDEX 404
#define ROLLOUT_MIN 256.0f
#define ROLLOUT_MAX 8192.0f
#define ROLLOUT_BOOTSTRAP 2048.0f
// ISV slot holding the adaptive regression target. Per
// `feedback_isv_for_adaptive_bounds`: target lives in ISV (seeded at
// trainer init by rl_streaming_clamp_init, visible in diag,
// modifiable at runtime) rather than as a kernel-side `#define`.
// Default 5.0 — matches the b_size=1 streaming regime where var/|mean|
// naturally lives in [1, 10]. Prior `#define = 0.1` was wrong for the
// streaming regime, causing 99.99% WIDEN events in alpha-rl-cvf86.
#define RL_ADV_VAR_RATIO_TARGET_INDEX 449
// Noise-floor multiplier: input below TARGET × NOISE_FLOOR_FRAC is
// numerical noise — hold. Derived multiplicatively from the
// ISV-resident target so adjusting the target proportionally
// adjusts the floor.
#define ADV_VAR_RATIO_NOISE_FLOOR_FRAC 0.01f
// In-band tolerance: input within ±33 % of target → hold.
#define ADV_VAR_RATIO_TOLERANCE 1.5f
// Per-step bounded multiplicative adjustment. 1.5× == 50% growth /
// 33% shrinkage per fire.
#define ADV_VAR_RATIO_ADJUST_RATE 1.5f
#define WIENER_ALPHA_FLOOR 0.4f
// ─────────────────────────────────────────────────────────────────────
// rl_rollout_steps_controller:
// Single-thread controller — writes ONE float to
// isv[RL_N_ROLLOUT_STEPS_INDEX].
//
// Inputs:
// isv [≥ RL_N_ROLLOUT_STEPS_INDEX+1] — ISV bus
// alpha — Wiener-α from controller stats;
// floored at WIENER_ALPHA_FLOOR before
// the blend.
// advantage_var_over_abs_mean — caller-computed
// `var(A) / max(|mean A|, ε)` EMA;
// Phase F produces this via a small
// reduce kernel over the rollout
// buffer's advantage column.
//
// Outputs:
// isv[RL_N_ROLLOUT_STEPS_INDEX] — rollout length ∈ [ROLLOUT_MIN, ROLLOUT_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_ADVANTAGE_VAR_RATIO_EMA_INDEX=421]) feeds this controller
// without any host roundtrip per `feedback_cpu_is_read_only`.
extern "C" __global__ void rl_rollout_steps_controller(
float* __restrict__ isv,
float alpha,
int input_slot
) {
if (threadIdx.x != 0 || blockIdx.x != 0) return;
const float prev = isv[RL_N_ROLLOUT_STEPS_INDEX];
// Bootstrap on sentinel 0.0 per pearl_first_observation_bootstrap.
if (prev == 0.0f) {
isv[RL_N_ROLLOUT_STEPS_INDEX] = ROLLOUT_BOOTSTRAP;
return;
}
// Cold-start gate: sentinel-zero EMA (streaming kernel hasn't
// fired yet).
const float advantage_var_over_abs_mean = isv[input_slot];
if (advantage_var_over_abs_mean == 0.0f) return;
// ISV-driven regression target — read from
// RL_ADV_VAR_RATIO_TARGET_INDEX (seeded at trainer init).
const float adv_var_target = isv[RL_ADV_VAR_RATIO_TARGET_INDEX];
// Noise-floor gate: input below target × NOISE_FLOOR_FRAC is
// dominated by numerical noise — hold rollout unchanged.
// Mirrors the ppo_clip / target_tau controllers' design after the
// alpha-rl-mjzfk multiplicative-blow-up incident.
const float adv_var_noise_floor =
adv_var_target * ADV_VAR_RATIO_NOISE_FLOOR_FRAC;
if (advantage_var_over_abs_mean < adv_var_noise_floor) return;
// Bounded multiplicative adjustment (Schulman-style adaptive).
// At most ADV_VAR_RATIO_ADJUST_RATE × shift per step regardless of
// how far input is from target. After several consecutive
// out-of-band observations the output drifts smoothly toward
// MIN/MAX, but a single observation can't slam it there.
float scale;
if (advantage_var_over_abs_mean
> adv_var_target * ADV_VAR_RATIO_TOLERANCE) {
// Noisy → widen.
scale = ADV_VAR_RATIO_ADJUST_RATE;
} else if (advantage_var_over_abs_mean
< adv_var_target / ADV_VAR_RATIO_TOLERANCE) {
// Clean → shrink.
scale = 1.0f / ADV_VAR_RATIO_ADJUST_RATE;
} else {
// In-band: hold.
scale = 1.0f;
}
float target = prev * scale;
target = fmaxf(ROLLOUT_MIN, fminf(target, ROLLOUT_MAX));
// First-observation replace-directly per
// `pearl_first_observation_bootstrap`.
if (prev == ROLLOUT_BOOTSTRAP) {
isv[RL_N_ROLLOUT_STEPS_INDEX] = target;
return;
}
// Wiener-α blend with floor per pearl_wiener_alpha_floor_for_nonstationary.
const float a = fmaxf(alpha, WIENER_ALPHA_FLOOR);
float out = (1.0f - a) * prev + a * target;
out = fmaxf(ROLLOUT_MIN, fminf(out, ROLLOUT_MAX));
isv[RL_N_ROLLOUT_STEPS_INDEX] = out;
}