Files
foxhunt/crates/ml-alpha/cuda/rl_pi_action_kernel.cu
jgrusewski d3175711b9 feat(rl): SP20 P4 — N_ACTIONS 9→11 with HalfFlat actions
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>
2026-05-24 17:17:33 +02:00

139 lines
5.7 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_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;
}