Action enum extended:
a9 = HalfFlatLong (close ⌈|pos|/2⌉ of long position, no-op if not long)
a10 = HalfFlatShort (close ⌈|pos|/2⌉ of short position, no-op if not short)
`actions_to_market_targets.cu` extended with a9/a10 handlers:
HalfFlatLong (pos > 0): side=1 sell, size=max(1, (position_lots+1)/2)
HalfFlatShort (pos < 0): side=0 buy, size=max(1, (|position_lots|+1)/2)
Round-up division ensures min 1 lot closes — single-lot positions
fully close on HalfFlat (the half rounds up to 1).
N_ACTIONS=9 → 11 propagated to all 10 .cu kernels:
argmax_expected_q, bellman_target_projection, dqn_distributional_q,
log_pi_at_action, ppo_clipped_surrogate, rl_action_kernel,
rl_entropy_coef_controller, rl_pi_action_kernel, rl_q_pi_agree_b,
rl_q_pi_distill_grad
Rust-side N_ACTIONS const bumped to 11 in src/rl/common.rs.
CLI alpha_rl_train.rs action_hist + windowed_act_hist refactored
to reference `N_ACTIONS` const instead of literal 11. Caught DURING
this commit's dogfood: an intermediate state had `[0u32; 11]` but
left `(0..9).contains(&a)` unchanged — HalfFlat samples silently
dropped (a9/a10 showed 0% in diag despite P_MIN=0.02 floor
guaranteeing 2% each). Fix uses N_ACTIONS const everywhere; new
pearl `feedback_use_consts_not_literals_for_structural_dims`
codifies the meta-pattern (Rust code mirroring kernel structural
dims must reference the const, NEVER duplicate the literal —
audit-isv only scans .cu files, this class of bug is currently
unaudited in .rs).
Audits PASS:
audit-isv: all kernel #defines allowlisted (BOOK_LEVELS,
ACTION_*, structural dims)
audit-wiring: all 4 actions in manifest (TrailTighten, TrailLoosen,
HalfFlatLong, HalfFlatShort) have consumers
Local 1k-step smoke (RTX 3050 Ti, 13.5s):
* Exit 0, 0 NaN/inf, 16000/16000 samples accounted for
* Action distribution: all 11 used in 7-11% range
* HalfFL=7.99%, HalfFS=7.51% — π samples them under multinomial
* Trail=19.34% — agent continues to value trail-stop actions
Trail-stop check (rl_trail_stop_check.cu) currently still routes
force-close through a3/a4 (FlatFromLong/Short) rather than per-unit
partial-flat via a9/a10. That routing upgrade is SP20 P5b follow-up
work — it requires the per-unit close_unit_index buffer wiring per
spec §3 P5. Adding a9/a10 to the action space is the foundational
prerequisite; consumer kernel uses come with P5b.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
116 lines
5.6 KiB
Plaintext
116 lines
5.6 KiB
Plaintext
// rl_entropy_coef_controller.cu — emits entropy-bonus weight to
|
||
// ISV[RL_ENTROPY_COEF_INDEX=403].
|
||
//
|
||
// Phase D of the integrated RL trainer
|
||
// (docs/superpowers/plans/2026-05-22-integrated-rl-trainer.md).
|
||
//
|
||
// The entropy bonus in the PPO surrogate is `L_entropy = -coef × H(π_new)`.
|
||
// Per `pearl_controller_anchors_isv_driven` and
|
||
// `feedback_isv_for_adaptive_bounds`, `coef` is NOT a hardcoded constant:
|
||
// it adapts to keep the policy's action entropy ≥ 70% of ln(N_ACTIONS).
|
||
//
|
||
// Controller input: `entropy_observed_ema`, the EMA of the per-batch
|
||
// entropy reported by `ppo_clipped_surrogate_fwd`. Target entropy is
|
||
// `0.7 × ln(N_ACTIONS)` ≈ 1.538 for N_ACTIONS=9.
|
||
//
|
||
// Logic:
|
||
// * Entropy deficit > 0 (policy too sharp) → raise coef to encourage
|
||
// exploration.
|
||
// * Deficit ≈ 0 (policy maintains exploration) → shrink coef so the
|
||
// policy can sharpen on real signal as training progresses.
|
||
//
|
||
// Bootstrap discipline (per `pearl_first_observation_bootstrap`): ISV
|
||
// slot starts at 0.0 sentinel. First emit DERIVES coef from the
|
||
// current input slot via the same target formula the per-step path
|
||
// uses, instead of writing a hardcoded `COEF_BOOTSTRAP = 0.01`. At
|
||
// sentinel input (entropy_observed_ema = 0) deficit = h_target =
|
||
// 1.538, target = (1.538 / 2.197) × 0.05 = 0.035 — the cold-start
|
||
// coef is high (push exploration hard when no entropy data is in yet).
|
||
// This eliminates the dead-zone where target(h_obs ≈ 1.099) = 0.01 =
|
||
// hardcoded bootstrap froze the Wiener blend at any realistic
|
||
// mid-range entropy observation.
|
||
//
|
||
// Trade-off: cold-start coef = 0.035 (3.5× the previous canonical
|
||
// 0.01) lifts the entropy bonus's relative weight in early training.
|
||
// Acceptable cost — early exploration is desirable, and once
|
||
// entropy_observed_ema stabilises the controller drifts coef back
|
||
// toward the target driven by actual observed entropy.
|
||
//
|
||
// Subsequent emits Wiener-α blend with floor 0.4 (per
|
||
// `pearl_wiener_alpha_floor_for_nonstationary` — the policy
|
||
// entropy co-adapts with training, breaking stationarity).
|
||
//
|
||
// Bounds: coef ∈ [0.0, 0.05]. The upper bound prevents the entropy
|
||
// bonus from dominating the surrogate; the lower bound is permitted to
|
||
// hit zero (a fully-trained policy may need no exploration pressure).
|
||
|
||
#define RL_ENTROPY_COEF_INDEX 403
|
||
#define N_ACTIONS 11
|
||
#define COEF_MIN 0.0f
|
||
#define COEF_MAX 0.05f
|
||
// ISV-driven entropy-target fraction per `feedback_isv_for_adaptive_bounds`.
|
||
// Default 0.7 (70% of ln(N_ACTIONS) = "explore but not too randomly").
|
||
// Seeded by rl_isv_write at trainer init.
|
||
#define RL_ENTROPY_TARGET_FRAC_INDEX 458
|
||
#define WIENER_ALPHA_FLOOR 0.4f
|
||
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// rl_entropy_coef_controller:
|
||
// Single-thread controller — writes ONE float to
|
||
// isv[RL_ENTROPY_COEF_INDEX].
|
||
//
|
||
// Inputs:
|
||
// isv [≥ RL_ENTROPY_COEF_INDEX+1] — ISV bus
|
||
// alpha — Wiener-α (floored at WIENER_ALPHA_FLOOR)
|
||
// entropy_observed_ema — EMA of H(π) reported by surrogate fwd
|
||
//
|
||
// Outputs:
|
||
// isv[RL_ENTROPY_COEF_INDEX] — coef ∈ [COEF_MIN, COEF_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_ENTROPY_OBSERVED_EMA_INDEX=420]) feeds this controller without
|
||
// any host roundtrip per `feedback_cpu_is_read_only`.
|
||
extern "C" __global__ void rl_entropy_coef_controller(
|
||
float* __restrict__ isv,
|
||
float alpha,
|
||
int input_slot
|
||
) {
|
||
if (threadIdx.x != 0 || blockIdx.x != 0) return;
|
||
|
||
const float coef_prev = isv[RL_ENTROPY_COEF_INDEX];
|
||
|
||
// Compute target from the current input EMA. Shared between
|
||
// bootstrap and per-step paths so the dead-zone coincidence with a
|
||
// hardcoded bootstrap cannot recur.
|
||
const float entropy_observed_ema = isv[input_slot];
|
||
const float h_max = logf((float)N_ACTIONS); // ln(9) ≈ 2.197
|
||
// ISV-driven target fraction (was hardcoded 0.7).
|
||
const float target_frac = isv[RL_ENTROPY_TARGET_FRAC_INDEX];
|
||
const float h_target = target_frac * h_max;
|
||
const float deficit = fmaxf(0.0f, h_target - entropy_observed_ema);
|
||
// Scale coef proportional to the normalised deficit (0..1 fraction
|
||
// of ln N). Large deficit → push coef up to encourage exploration.
|
||
float coef_target = (deficit / h_max) * COEF_MAX;
|
||
coef_target = fmaxf(COEF_MIN, fminf(coef_target, COEF_MAX));
|
||
|
||
// Bootstrap on sentinel 0.0 per pearl_first_observation_bootstrap:
|
||
// first emit replaces directly with the computed target. At cold
|
||
// start (input EMA also sentinel-zero), deficit = h_target,
|
||
// target = 0.035 (high exploration weight). Distinct from any
|
||
// target value the formula can emit for non-sentinel input so the
|
||
// per-step Wiener blend always moves.
|
||
if (coef_prev == 0.0f) {
|
||
isv[RL_ENTROPY_COEF_INDEX] = coef_target;
|
||
return;
|
||
}
|
||
|
||
// Wiener-α blend with floor per pearl_wiener_alpha_floor_for_nonstationary.
|
||
const float a = fmaxf(alpha, WIENER_ALPHA_FLOOR);
|
||
float coef_new = (1.0f - a) * coef_prev + a * coef_target;
|
||
|
||
coef_new = fmaxf(COEF_MIN, fminf(coef_new, COEF_MAX));
|
||
isv[RL_ENTROPY_COEF_INDEX] = coef_new;
|
||
}
|