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>
180 lines
7.6 KiB
Plaintext
180 lines
7.6 KiB
Plaintext
// bellman_target_projection.cu — categorical (C51) projection of the
|
||
// Bellman target Z(s_{t+1}, a*) onto the discrete support [V_MIN, V_MAX]
|
||
// with Q_N_ATOMS atoms and Δ_z = (V_MAX - V_MIN) / (Q_N_ATOMS - 1) = 0.1.
|
||
//
|
||
// Standard C51 target projection (Bellemare et al. 2017):
|
||
// 1. Compute target atom values: T_z[j] = r + γ × (1 - done) × atom_value[j]
|
||
// (clamped to [V_MIN, V_MAX])
|
||
// 2. For each target atom T_z[j], distribute its probability mass
|
||
// across the two NEAREST support atoms (linear interpolation):
|
||
// l = floor((T_z[j] - V_MIN) / Δ_z) (clamp to [0, Q_N_ATOMS-1])
|
||
// u = ceil (...)
|
||
// target_dist[l] += target_probs[j] × (u - b_frac)
|
||
// target_dist[u] += target_probs[j] × (b_frac - l)
|
||
// where b_frac = (T_z - V_MIN) / Δ_z.
|
||
//
|
||
// γ is read from isv[RL_GAMMA_INDEX=400] per pearl_controller_anchors_isv_driven.
|
||
// γ is clamped to [0, 1] defensively — first-observation bootstrap (γ = 0)
|
||
// is OK (one-step bandit), the controller fills in the real anchor at the
|
||
// next emit.
|
||
//
|
||
// Inputs:
|
||
// target_logits [B × Q_N_ATOMS] — target-net's atom logits at the
|
||
// argmax-Q action of s_{t+1}
|
||
// rewards [B] — per-transition reward r_t
|
||
// dones [B] — 0/1 done flag (multiplies γ × 0 at
|
||
// terminal so V(s') is zeroed)
|
||
// isv [≥ 401] — read γ at index 400
|
||
// B — batch size
|
||
//
|
||
// Outputs:
|
||
// target_dist [B × Q_N_ATOMS] — projected target distribution
|
||
// (sums to 1 per batch row by construction)
|
||
//
|
||
// Block layout:
|
||
// grid = (B, 1, 1)
|
||
// block = (Q_N_ATOMS, 1, 1) — one thread per source/target atom
|
||
//
|
||
// Per `feedback_no_atomicadd.md`: no atomicAdd. Cross-thread accumulation
|
||
// uses shared memory + per-source-atom serial writes bracketed by
|
||
// __syncthreads — Q_N_ATOMS = 21 inner-loop syncs is negligible (single
|
||
// block per batch, small block size).
|
||
|
||
#define Q_N_ATOMS 21
|
||
#define N_ACTIONS 11
|
||
// V_MIN/V_MAX/DELTA_Z are now ISV-driven per audit 2026-05-24 followup
|
||
// so adaptive reward clamps also lift Q's distributional support.
|
||
// Slots RL_C51_V_MIN_INDEX/RL_C51_V_MAX_INDEX are ratchet (monotone-
|
||
// grow), so the C51 atom mapping never coarsens — only expands as
|
||
// wider trades are observed.
|
||
#define RL_C51_V_MAX_INDEX 484
|
||
#define RL_C51_V_MIN_INDEX 485
|
||
#define RL_GAMMA_INDEX 400
|
||
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// dqn_select_action_atoms — extract per-batch atom row at the requested
|
||
// action index from the full target-net logits tensor.
|
||
//
|
||
// in: full_logits [B × N_ACTIONS × Q_N_ATOMS]
|
||
// actions [B] (0..N_ACTIONS)
|
||
// out: action_logits[B × Q_N_ATOMS]
|
||
//
|
||
// One block per batch element, Q_N_ATOMS threads per block. Each thread
|
||
// copies one atom of the selected action's row.
|
||
//
|
||
// Out-of-range action indices are clamped to 0 — defensive only; the
|
||
// integrated trainer enforces bound-checking at the loader boundary.
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
extern "C" __global__ void dqn_select_action_atoms(
|
||
const float* __restrict__ full_logits, // [B × N_ACTIONS × Q_N_ATOMS]
|
||
const int* __restrict__ actions, // [B]
|
||
int B,
|
||
float* __restrict__ action_logits // [B × Q_N_ATOMS]
|
||
) {
|
||
const int batch = blockIdx.x;
|
||
const int atom = threadIdx.x;
|
||
if (batch >= B || atom >= Q_N_ATOMS) return;
|
||
|
||
int a = actions[batch];
|
||
if (a < 0) a = 0;
|
||
if (a >= N_ACTIONS) a = 0;
|
||
|
||
const long long src_idx =
|
||
(long long)batch * N_ACTIONS * Q_N_ATOMS
|
||
+ (long long)a * Q_N_ATOMS
|
||
+ (long long)atom;
|
||
const long long dst_idx = (long long)batch * Q_N_ATOMS + (long long)atom;
|
||
action_logits[dst_idx] = full_logits[src_idx];
|
||
}
|
||
|
||
|
||
extern "C" __global__ void bellman_target_projection(
|
||
const float* __restrict__ target_logits, // [B × Q_N_ATOMS]
|
||
const float* __restrict__ rewards, // [B]
|
||
const float* __restrict__ dones, // [B]
|
||
const float* __restrict__ isv, // ≥ 401
|
||
int B,
|
||
float* __restrict__ target_dist // [B × Q_N_ATOMS]
|
||
) {
|
||
const int batch = blockIdx.x;
|
||
const int atom = threadIdx.x;
|
||
if (batch >= B || atom >= Q_N_ATOMS) return;
|
||
|
||
__shared__ float s_softmax[Q_N_ATOMS];
|
||
__shared__ float s_max;
|
||
__shared__ float s_sumexp;
|
||
__shared__ float s_proj[Q_N_ATOMS];
|
||
|
||
const int base = batch * Q_N_ATOMS;
|
||
|
||
// ── Softmax over target logits (numerically-stable max-subtract) ─
|
||
if (atom == 0) {
|
||
float m = target_logits[base];
|
||
#pragma unroll
|
||
for (int z = 1; z < Q_N_ATOMS; ++z)
|
||
m = fmaxf(m, target_logits[base + z]);
|
||
s_max = m;
|
||
}
|
||
__syncthreads();
|
||
|
||
const float e = expf(target_logits[base + atom] - s_max);
|
||
s_softmax[atom] = e;
|
||
s_proj[atom] = 0.0f; // zero the projection accumulator
|
||
__syncthreads();
|
||
|
||
if (atom == 0) {
|
||
float sum = 0.0f;
|
||
#pragma unroll
|
||
for (int z = 0; z < Q_N_ATOMS; ++z) sum += s_softmax[z];
|
||
s_sumexp = sum;
|
||
}
|
||
__syncthreads();
|
||
|
||
const float p = s_softmax[atom] / s_sumexp;
|
||
|
||
// ── γ from ISV + done masking ────────────────────────────────────
|
||
const float gamma = fmaxf(0.0f, fminf(1.0f, isv[RL_GAMMA_INDEX]));
|
||
const float r = rewards[batch];
|
||
const float gamma_eff = gamma * (1.0f - dones[batch]);
|
||
|
||
// ── Adaptive atom span from ISV (audit follow-up). DELTA_Z is
|
||
// recomputed every kernel launch — ratchet semantics in the
|
||
// controller guarantee V_MAX > V_MIN strictly (floored at
|
||
// [-1, +1] baseline) so this is always well-defined.
|
||
const float V_MIN_eff = isv[RL_C51_V_MIN_INDEX];
|
||
const float V_MAX_eff = isv[RL_C51_V_MAX_INDEX];
|
||
const float DELTA_Z = (V_MAX_eff - V_MIN_eff) / (float)(Q_N_ATOMS - 1);
|
||
|
||
// ── Per-source-atom target value + clamp + interpolation indices ─
|
||
const float atom_value = V_MIN_eff + (float)atom * DELTA_Z;
|
||
const float t_z = r + gamma_eff * atom_value;
|
||
const float t_z_clamp = fmaxf(V_MIN_eff, fminf(V_MAX_eff, t_z));
|
||
const float b_frac = (t_z_clamp - V_MIN_eff) / DELTA_Z;
|
||
const int l = max(0, min(Q_N_ATOMS - 1, (int)floorf(b_frac)));
|
||
const int u = max(0, min(Q_N_ATOMS - 1, (int)ceilf(b_frac)));
|
||
const float frac = b_frac - (float)l;
|
||
|
||
// ── Distribute mass into the two nearest support atoms ───────────
|
||
//
|
||
// Each thread holds (l, u, frac, p) for its OWN source atom. We
|
||
// walk source-atom-index `src` from 0..Q_N_ATOMS and only the
|
||
// matching thread writes into s_proj[l] / s_proj[u], bracketed by
|
||
// __syncthreads so writes are serialised. Q_N_ATOMS = 21 inner-loop
|
||
// syncs per block is negligible and trivially correct (no
|
||
// cross-thread race, no atomicAdd).
|
||
for (int src = 0; src < Q_N_ATOMS; ++src) {
|
||
__syncthreads();
|
||
if (atom == src) {
|
||
if (l == u) {
|
||
s_proj[l] += p;
|
||
} else {
|
||
s_proj[l] += p * (1.0f - frac);
|
||
s_proj[u] += p * frac;
|
||
}
|
||
}
|
||
}
|
||
__syncthreads();
|
||
target_dist[base + atom] = s_proj[atom];
|
||
}
|