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>
139 lines
5.7 KiB
Plaintext
139 lines
5.7 KiB
Plaintext
// rl_pi_action_kernel.cu — GPU-resident multinomial sampler over the
|
||
// PPO policy head's softmax(pi_logits). Replaces rl_action_kernel
|
||
// (which sampled from Q-Thompson) as the canonical actor-side
|
||
// decision per `pearl_q_thompson_actor_makes_pi_dead_weight`.
|
||
//
|
||
// The prior architecture had Q being BOTH the actor (via Thompson)
|
||
// AND the critic (via Bellman target). π trained as a third head
|
||
// via PPO surrogate against Q's actions but never drove any
|
||
// decision — `q_pi_agree_ema` decayed to 0 across every smoke
|
||
// because π converged to Q's Thompson SAMPLING distribution mode,
|
||
// not Q's argmax mode. The actor-critic design was inverted.
|
||
//
|
||
// New architecture (this kernel): π drives action selection via
|
||
// multinomial sampling from softmax(pi_logits). Q remains the
|
||
// critic (Bellman target via `argmax_expected_q` on h_{t+1}). The
|
||
// PPO importance-ratio surrogate now has its canonical semantics —
|
||
// π_new / π_old at the action that π_old sampled.
|
||
//
|
||
// PRNG: same xorshift32-per-batch pattern as rl_action_kernel.
|
||
// Single thread per block (N_ACTIONS=9 is small enough that the
|
||
// per-thread parallelism of the Thompson kernel is unjustified;
|
||
// multinomial CDF walk is sequential anyway).
|
||
//
|
||
// Anti-collapse: each action probability is floored at P_MIN before
|
||
// the CDF walk, then renormalised. This is a STRUCTURAL guarantee
|
||
// per `pearl_blend_formulas_must_have_permanent_floor` — H(π_sample)
|
||
// is bounded below regardless of what the logits say. P_MIN=0.02 →
|
||
// max-collapse entropy ≈ 0.77 nats (vs uniform ln(9)=2.197), well
|
||
// above the δ-function attractor observed in alpha-rl-9k9x6 where
|
||
// the policy collapsed to action 1 by step 2500 with action_entropy
|
||
// EMA at 0.001 and never recovered (controller pegged at COEF_MAX
|
||
// without effect because PPO updates couldn't escape the fixed point).
|
||
//
|
||
// Discrepancy with PPO importance ratio: log_pi_at_action records
|
||
// log(softmax(logits)[a]), not log(p_floored[a]). At P_MIN=0.02 the
|
||
// divergence is negligible when π is healthy (max abs Δlog_prob is
|
||
// ~0.01 for π(a)=0.99), and during recovery it nudges PPO toward
|
||
// conservative updates on rare-action samples — desirable.
|
||
//
|
||
// P_MIN is exempt from `feedback_isv_for_adaptive_bounds` per the
|
||
// user-stated "floors and clamp bounds" exemption; lives here as a
|
||
// design constant alongside N_ACTIONS.
|
||
//
|
||
// Per `feedback_no_atomicadd`: single-thread kernel, no atomics.
|
||
// Per `feedback_cpu_is_read_only`: pure device-side sampling.
|
||
|
||
#include <stdint.h>
|
||
|
||
#define N_ACTIONS 11
|
||
#define P_MIN 0.02f
|
||
|
||
__device__ static uint32_t xorshift32(uint32_t* state) {
|
||
uint32_t x = *state;
|
||
x ^= x << 13;
|
||
x ^= x >> 17;
|
||
x ^= x << 5;
|
||
*state = x;
|
||
return x;
|
||
}
|
||
|
||
// One block per batch (grid_dim.x = b_size). Single thread per
|
||
// block (tid==0). Computes numerically-stable softmax over the
|
||
// batch's N_ACTIONS pi_logits, then multinomial-samples via CDF
|
||
// walk using the per-batch xorshift32 PRNG state.
|
||
//
|
||
// Inputs:
|
||
// pi_logits [b_size × N_ACTIONS] row-major raw logits
|
||
// prng_state [b_size] per-batch xorshift32 state (mutated)
|
||
// Outputs:
|
||
// actions [b_size] multinomial-sampled action index per batch
|
||
extern "C" __global__ void rl_pi_action_kernel(
|
||
const float* __restrict__ pi_logits,
|
||
uint32_t* __restrict__ prng_state,
|
||
int* __restrict__ actions,
|
||
int b_size
|
||
) {
|
||
const int b = blockIdx.x;
|
||
if (b >= b_size) return;
|
||
if (threadIdx.x != 0) return;
|
||
|
||
// ── Numerically-stable softmax over pi_logits[b, :]. ──────────
|
||
const int base = b * N_ACTIONS;
|
||
float logits[N_ACTIONS];
|
||
float max_logit = pi_logits[base];
|
||
#pragma unroll
|
||
for (int a = 0; a < N_ACTIONS; ++a) {
|
||
logits[a] = pi_logits[base + a];
|
||
if (logits[a] > max_logit) max_logit = logits[a];
|
||
}
|
||
float sum_exp = 0.0f;
|
||
float probs[N_ACTIONS];
|
||
#pragma unroll
|
||
for (int a = 0; a < N_ACTIONS; ++a) {
|
||
probs[a] = expf(logits[a] - max_logit);
|
||
sum_exp += probs[a];
|
||
}
|
||
// Guard against pathological zero-sum (shouldn't happen with
|
||
// expf of bounded values, but defensive).
|
||
const float inv_sum = (sum_exp > 1e-9f) ? (1.0f / sum_exp) : (1.0f / (float)N_ACTIONS);
|
||
#pragma unroll
|
||
for (int a = 0; a < N_ACTIONS; ++a) probs[a] *= inv_sum;
|
||
|
||
// ── Anti-collapse floor: max(P_MIN, p[a]) then renormalise. ───
|
||
// Structural guarantee that no action probability falls below
|
||
// P_MIN. See header comment for rationale + alpha-rl-9k9x6
|
||
// failure mode.
|
||
float renorm_sum = 0.0f;
|
||
#pragma unroll
|
||
for (int a = 0; a < N_ACTIONS; ++a) {
|
||
probs[a] = fmaxf(P_MIN, probs[a]);
|
||
renorm_sum += probs[a];
|
||
}
|
||
const float inv_renorm = 1.0f / renorm_sum;
|
||
#pragma unroll
|
||
for (int a = 0; a < N_ACTIONS; ++a) probs[a] *= inv_renorm;
|
||
|
||
// ── Draw uniform u ∈ [0, 1) from per-batch PRNG. ──────────────
|
||
uint32_t state = prng_state[b];
|
||
// A few warmup advances dispel low-quality seeding correlations.
|
||
#pragma unroll
|
||
for (int w = 0; w < 4; ++w) xorshift32(&state);
|
||
const uint32_t r = xorshift32(&state);
|
||
// Convert to [0, 1): divide by 2^32. Use 24-bit mantissa precision
|
||
// (good enough for multinomial — Q_N_ATOMS=21 actions max).
|
||
const float u = (float)(r >> 8) * (1.0f / 16777216.0f);
|
||
prng_state[b] = state;
|
||
|
||
// ── CDF walk: pick the action whose cumulative prob crosses u. ──
|
||
int chosen = N_ACTIONS - 1; // defensive default if numerical drift loses the last bit
|
||
float cdf = 0.0f;
|
||
#pragma unroll
|
||
for (int a = 0; a < N_ACTIONS; ++a) {
|
||
cdf += probs[a];
|
||
if (u < cdf) { chosen = a; break; }
|
||
}
|
||
|
||
actions[b] = chosen;
|
||
}
|