feat(rl): Phase 2.1 — scalar Bellman target + dueling Q (V baseline)

Spec: docs/superpowers/specs/2026-05-28-state-conditional-q-synthesis.md
§3 Sub-phase 2.1.

Replaces the distributional Bellman bootstrap
(fused_select_and_project_bellman) with the Dueling-DQN scalar
baseline target

  y(s_t, a_t)       = r_t + γⁿ × V_target(s_{t+1})
  advantage_scalar  = y − V(s_t)

projected via two-bin linear interpolation onto the action's atom
support. The Q-head's atom logits are now interpreted as the
advantage distribution A_dist(s, a); the CE loss between
softmax(A_dist(s_t, a_t)) and the projected target trains A to
model deviation from the scalar V baseline.

Changes:
- NEW crates/ml-alpha/cuda/bellman_scalar_target_project.cu —
  device-side scalar Bellman target + per-action atom projection.
  Grid (B, 1, 1), Block (Q_N_ATOMS=21, 1, 1). No atomicAdd, no
  cross-thread accumulation: scalar advantage produces a delta on
  exactly two atoms so each thread writes its own atom value.
- crates/ml-alpha/build.rs registers the new kernel.
- crates/ml-alpha/src/rl/dqn.rs:
  * Loads bellman_scalar_target_project from its own cubin.
  * Adds DqnHead::bellman_scalar_target_project wrapper.
  * Adds host-side composed_q_value(v_scalar, a_logits,
    atom_supports) -> Vec<f32> implementing the Dueling
    composition Q = V + A - mean_a(A) for test oracles.
- crates/ml-alpha/src/trainer/integrated.rs:
  * Allocates sampled_v_curr_d + sampled_v_target_tp1_d for
    V evaluated on PER-sampled hidden states.
  * step_synthetic_body Bellman build: replaces forward_target +
    fused_select_and_project_bellman with V forwards on sampled
    buffers + the new scalar Bellman projection (single source of
    truth — same swap applied to dqn_replay_step for k>0 path).
  * Test accessors isv_host_slice_mut_for_test,
    isv_dev_ptr_for_test, dqn_head_ref so kernel-oracle unit
    tests can drive the new kernel without a full mega-graph.
- NEW crates/ml-alpha/tests/q_projection_unit.rs — pre-impl oracle:
  projects y=0 to center atom, y=V_MAX to last, y=V_MIN to first,
  arbitrary in-range cases recover EV within Δz/2, verifies V_t
  enters as a subtraction. Plus pure host-side check of
  composed_q_value.

All Phase 2.1 unit tests pass (5 CUDA --ignored + 2 host-side).

Smoke status: BLOCKED on pre-existing NaN abort at step 4 affecting
HEAD (c52282fb4 + f11bab542 + 7e38e46e6) — l_pi=NaN, l_aux=NaN at
step 4 reproduce IDENTICALLY with and without Phase 2.1 changes.
Per feedback_stop_on_anomaly, the pre-existing NaN must be diagnosed
before the Phase 2.1 smoke gate (l_v<2.0, l_q<3.0,
action_entropy>1.5 for 500 steps) can be validated. The Phase 2.1
changes themselves are structurally sound — unit tests confirm
correct projection and the smoke loss trajectories at steps 0-3 are
within ~1% of HEAD (l_q 2.9484 vs 2.9926, l_v 6.3293 vs 6.3291).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-29 01:01:08 +02:00
parent 7e38e46e65
commit db4d9a16ff
5 changed files with 713 additions and 15 deletions

View File

@@ -41,6 +41,7 @@ const KERNELS: &[&str] = &[
"v_head_fwd_bwd", // RL Phase E.2: scalar V(s) head fwd + MSE bwd (linear layer; per-batch scratch + reduce_axis0)
"grad_h_accumulate", // RL Phase E.2: element-wise grad_h_encoder += λ × grad_h_head accumulator (one head at a time, serialised by stream)
"bellman_target_projection", // RL Phase E.2-DEFER: C51 categorical projection of Bellman target Z(s_{t+1}, a*) onto the discrete support, reads γ from ISV[400]; replaces host-side build_synthetic_bellman_target stand-in
"bellman_scalar_target_project", // Phase 2.1 (2026-05-28): Dueling-DQN scalar Bellman target y = r + γ × V_target(s_{t+1}); projects (y V(s_t)) advantage onto action's atom support; replaces fused_select_and_project_bellman in the trainer's Bellman build per spec §3 Sub-phase 2.1
"rl_lr_controller", // RL Phase E.2-DEFER: per-head learning-rate ISV emitter — bootstraps ISV[412..417] with 1e-3 (BCE/Q/π/V/aux); replaces hardcoded PHASE_E2_DEFAULT_LR
"rl_rollout_steps_controller", // RL Phase E.3b: rollout-buffer-length ISV emitter — emits ISV[RL_N_ROLLOUT_STEPS_INDEX=404] from var(advantage)/|mean A| EMA; bootstraps 2048
"rl_per_alpha_controller", // RL Phase E.3b: PER priority-exponent ISV emitter — emits ISV[RL_PER_ALPHA_INDEX=405] from TD-error kurtosis EMA; bootstraps 0.6

View File

@@ -0,0 +1,139 @@
// bellman_scalar_target_project.cu — Phase 2.1 dueling distributional Q.
//
// Spec: docs/superpowers/specs/2026-05-28-state-conditional-q-synthesis.md
// §3 Sub-phase 2.1.
//
// Replaces the classical distributional Bellman bootstrap
// (`bellman_target_projection`/`bellman_fused_select_project`, which
// projects a per-action atom distribution onto the atom support) with
// the Dueling-DQN scalar-baseline target:
//
// y(s_t, a_t) = r_t + γⁿ × V_target(s_{t+1})
// advantage_scalar = y V(s_t)
//
// The advantage scalar is projected onto the action's atom support via
// two-bin linear interpolation (delta-distribution onto the discrete
// support). The Q-head's atom logits are interpreted as the *advantage*
// distribution `A_dist(s, a)`; the categorical CE loss between
// `softmax(A_dist(s_t, a_t))` and this projected target trains A to
// model deviation from the scalar V baseline.
//
// Phase 2.1 (this implementation): the atom support is uniform — V_MIN
// and V_MAX read from `isv[RL_C51_V_MIN_INDEX]` / `isv[RL_C51_V_MAX_INDEX]`
// (slots 485/484, ratcheted by the adaptive reward-clamp controller).
// Phase 2.2 heterogenizes the support per action — at that point the
// kernel reads `isv[RL_PER_ACTION_V_MAX_BASE + action_idx]` etc.
//
// Inputs:
// rewards [B] — n-step discounted R_n per transition
// dones [B] — 0/1 done flag (reserved; current
// implementation relies on the
// caller's n_step_gammas[b] = 0 on done,
// matching the existing
// `bellman_target_projection` contract)
// n_step_gammas [B] — γⁿ per transition (0 if any done in
// the n-step window)
// v_curr [B] — V(s_t) from value_head.forward(h_t)
// v_target_tp1 [B] — V_target(s_{t+1}) from target value
// head (or current V head if no separate
// target — V baseline is slow-moving
// enough that either is acceptable per
// Sutton & Barto §11.3)
// actions [B] — taken action at time t (0..N_ACTIONS)
// isv [≥ RL_SLOTS_END] — read V_MIN/V_MAX
// B int — batch size
//
// Outputs:
// target_dist [B × Q_N_ATOMS] — projected advantage distribution
// (sums to 1 per batch row)
//
// Block layout:
// grid = (B, 1, 1)
// block = (Q_N_ATOMS, 1, 1) — one thread per output atom
//
// Per `feedback_no_atomicadd.md`: no atomicAdd. Single block per batch
// row, two-thread write (atoms l and u) bracketed by __syncthreads — no
// cross-thread race possible since the source is a SCALAR (not a
// distribution): only two atoms receive non-zero mass per row.
#define Q_N_ATOMS 21
#define N_ACTIONS 11
#define RL_C51_V_MAX_INDEX 484
#define RL_C51_V_MIN_INDEX 485
extern "C" __global__ void bellman_scalar_target_project(
const float* __restrict__ rewards, // [B]
const float* __restrict__ dones, // [B] (reserved)
const float* __restrict__ n_step_gammas, // [B]
const float* __restrict__ v_curr, // [B]
const float* __restrict__ v_target_tp1, // [B]
const int* __restrict__ actions, // [B]
const float* __restrict__ isv, // ≥ RL_C51_V_MIN_INDEX + 1
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;
// Touch `dones` and `actions` so the linker doesn't strip them — the
// explicit n_step_gammas already encodes done semantics (= 0 on
// done), but the args are part of the spec contract so callers can
// wire raw done flags + action indices for the Phase 2.2 per-action
// atom-support extension without a kernel ABI change.
// (No-op assertion: bound action_idx and ignore done semantics here.)
int a = actions[batch];
if (a < 0) a = 0;
if (a >= N_ACTIONS) a = 0;
const float done_unused = dones[batch];
(void)a;
(void)done_unused;
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);
// ── Scalar Bellman target → advantage scalar ─────────────────────
// y(s_t, a_t) = r + γⁿ × V_target(s_{t+1})
// advantage_scalar = y V(s_t)
//
// V is on the dollar-scale baseline; the advantage represents
// per-action deviation from V which is what A_dist(s, a) models in
// the Dueling-DQN decomposition.
const float r = rewards[batch];
const float gamma_eff = n_step_gammas[batch];
const float v_t = v_curr[batch];
const float v_tp1_target = v_target_tp1[batch];
const float y = r + gamma_eff * v_tp1_target;
const float adv_scalar = y - v_t;
// ── Two-bin linear interpolation onto the action's atom support ──
// Phase 2.1: uniform support — same V_MIN/V_MAX for every action.
// Phase 2.2 will read per-action V_MIN/V_MAX from
// `isv[RL_PER_ACTION_V_{MIN,MAX}_BASE + action_idx]`.
const float t_z_clamp = fmaxf(V_MIN_eff, fminf(V_MAX_eff, adv_scalar));
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;
// Each thread writes its OWN atom of the output distribution. The
// scalar advantage delta-distribution is non-zero on exactly two
// atoms (l and u) — every other atom receives 0. No cross-thread
// accumulation required (this is the key win vs. the distributional
// bootstrap kernel's serialised mass-walk).
float p_atom = 0.0f;
if (l == u) {
if (atom == l) {
p_atom = 1.0f;
}
} else {
if (atom == l) {
p_atom = 1.0f - frac;
} else if (atom == u) {
p_atom = frac;
}
}
target_dist[batch * Q_N_ATOMS + atom] = p_atom;
}

View File

@@ -57,6 +57,43 @@ use crate::trainer::raw_launch::{RawArgs, raw_launch};
/// Output dimension of the Q-head linear layer: one logit per (action, atom).
const K_OUT: usize = N_ACTIONS * Q_N_ATOMS;
/// Phase 2.1 Dueling-DQN value composition. Reads the per-batch scalar
/// `V(s)` and the Q-head's `[N_ACTIONS × Q_N_ATOMS]` advantage logits,
/// returns per-action composed Q value
///
/// `Q(s, a) = V(s) + E[A_dist(s, a)] mean_a' E[A_dist(s, a')]`
///
/// where `E[A_dist(s, a)] = Σ_z softmax(A_logits(s, a))[z] × z_value(a)`.
/// `atom_supports` is `[N_ACTIONS × Q_N_ATOMS]` per-action z values
/// (uniform in Phase 2.1; heterogeneous in Phase 2.2).
///
/// Host-side helper for tests and per-batch composed-Q diagnostics. The
/// production rollout path computes the same composition GPU-side via
/// `argmax_expected_q` + `aux_vec_add_inplace` (Q = V_broadcast + A
/// mean_a A). This helper exists so unit tests can verify the math
/// matches the kernel's output without a full mega-graph capture.
pub fn composed_q_value(v_scalar: f32, a_logits: &[f32], atom_supports: &[f32]) -> Vec<f32> {
debug_assert_eq!(a_logits.len(), N_ACTIONS * Q_N_ATOMS);
debug_assert_eq!(atom_supports.len(), N_ACTIONS * Q_N_ATOMS);
let mut e_a = vec![0.0_f32; N_ACTIONS];
for a in 0..N_ACTIONS {
let base = a * Q_N_ATOMS;
let logits = &a_logits[base..base + Q_N_ATOMS];
let zs = &atom_supports[base..base + Q_N_ATOMS];
// numerically stable softmax
let m = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let exps: Vec<f32> = logits.iter().map(|&l| (l - m).exp()).collect();
let sum: f32 = exps.iter().sum();
let mut ea = 0.0_f32;
for z in 0..Q_N_ATOMS {
ea += (exps[z] / sum) * zs[z];
}
e_a[a] = ea;
}
let mean_ea: f32 = e_a.iter().sum::<f32>() / N_ACTIONS as f32;
e_a.iter().map(|&ea| v_scalar + ea - mean_ea).collect()
}
// ── Tile constants for `dqn_q_head_grad_w` (mirror the kernel file) ──
//
// Each block computes a TM × TN output tile of grad_w by sweeping over
@@ -82,6 +119,10 @@ const BELLMAN_PROJ_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/bellman_target_projection.cubin"
));
const BELLMAN_SCALAR_CUBIN: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/bellman_scalar_target_project.cubin"
));
/// Construction config for [`DqnHead`].
#[derive(Clone, Debug)]
@@ -156,6 +197,18 @@ pub struct DqnHead {
/// `bellman_fused_select_project` — live in the same translation unit).
_bellman_module: Arc<CudaModule>,
/// Phase 2.1 (2026-05-28) — Dueling-DQN scalar Bellman target. Reads
/// scalar `V(s_t)` + `V_target(s_{t+1})` + reward + n_step_gamma,
/// projects the advantage `y V(s_t)` onto the action's atom
/// support (two-bin linear interp). Replaces the distributional
/// bootstrap path (`bellman_fused_select_project`) per spec
/// `docs/superpowers/specs/2026-05-28-state-conditional-q-synthesis.md`
/// §3 Sub-phase 2.1. Q-head logits are reinterpreted as the
/// advantage distribution `A_dist(s, a)`; the CE loss between
/// `softmax(A_dist(s_t, a_t))` and this projected target trains A.
pub bellman_scalar_target_fn: CudaFunction,
_bellman_scalar_module: Arc<CudaModule>,
/// Phase R5: element-wise target-net soft update kernel handle.
/// `target[i] = (1 - τ) · target[i] + τ · current[i]` reading τ
/// from `ISV[RL_TARGET_TAU_INDEX = 401]`. Closes defect #4 from
@@ -226,6 +279,14 @@ impl DqnHead {
.load_function("bellman_fused_select_project")
.context("load bellman_fused_select_project")?;
// Phase 2.1 (2026-05-28): Dueling-DQN scalar Bellman target.
let bellman_scalar_module = ctx
.load_cubin(BELLMAN_SCALAR_CUBIN.to_vec())
.context("load bellman_scalar_target_project cubin")?;
let bellman_scalar_target_fn = bellman_scalar_module
.load_function("bellman_scalar_target_project")
.context("load bellman_scalar_target_project")?;
// Phase R5: target soft-update kernel.
let target_soft_update_module = ctx
.load_cubin(DQN_TARGET_SOFT_UPDATE_CUBIN.to_vec())
@@ -295,6 +356,8 @@ impl DqnHead {
select_action_atoms_fn,
bellman_fused_fn,
_bellman_module: bellman_module,
bellman_scalar_target_fn,
_bellman_scalar_module: bellman_scalar_module,
target_soft_update_fn,
_target_soft_update_module: target_soft_update_module,
w_d,
@@ -782,6 +845,72 @@ impl DqnHead {
Ok(())
}
/// Phase 2.1 (2026-05-28) — Dueling-DQN scalar Bellman target.
///
/// Computes per-batch
/// `y(s_t, a_t) = r_t + γⁿ × V_target(s_{t+1})`
/// `advantage_scalar = y V(s_t)`
/// and projects the advantage scalar onto the action's atom support
/// via two-bin linear interpolation. Writes
/// `target_dist [B × Q_N_ATOMS]` consumed by [`backward_logits`].
///
/// Replaces [`fused_select_and_project_bellman`] in the trainer's
/// Bellman build per spec
/// `docs/superpowers/specs/2026-05-28-state-conditional-q-synthesis.md`
/// §3 Sub-phase 2.1. The Q-head's atom logits are now interpreted as
/// the *advantage* distribution `A_dist(s, a)`; action selection at
/// use sites must add the scalar `V(s)` baseline to recover full Q
/// (see Dueling-DQN composition `Q = V + A mean_a(A)`).
///
/// `v_target_tp1_d` is `V(s_{t+1})` from the value head; in the
/// trainer this is `v_pred_tp1_d` (the value head is slow-moving
/// enough that a separate target V is not required — Sutton &
/// Barto §11.3). The current value `v_curr_d = V(s_t)` comes from
/// `value_head.forward(sampled_h_t)`.
#[allow(clippy::too_many_arguments)]
pub fn bellman_scalar_target_project(
&self,
rewards_d: &CudaSlice<f32>,
dones_d: &CudaSlice<f32>,
n_step_gammas_d: &CudaSlice<f32>,
v_curr_d: &CudaSlice<f32>,
v_target_tp1_d: &CudaSlice<f32>,
actions_d: &CudaSlice<i32>,
isv_dev_ptr: &u64,
b_size: usize,
target_dist_d: &mut CudaSlice<f32>,
) -> Result<()> {
debug_assert_eq!(rewards_d.len(), b_size);
debug_assert_eq!(dones_d.len(), b_size);
debug_assert_eq!(n_step_gammas_d.len(), b_size);
debug_assert_eq!(v_curr_d.len(), b_size);
debug_assert_eq!(v_target_tp1_d.len(), b_size);
debug_assert_eq!(actions_d.len(), b_size);
debug_assert_eq!(target_dist_d.len(), b_size * Q_N_ATOMS);
let b_i = b_size as i32;
let mut args = RawArgs::new();
args.push_ptr(rewards_d.raw_ptr());
args.push_ptr(dones_d.raw_ptr());
args.push_ptr(n_step_gammas_d.raw_ptr());
args.push_ptr(v_curr_d.raw_ptr());
args.push_ptr(v_target_tp1_d.raw_ptr());
args.push_ptr(actions_d.raw_ptr());
args.push_ptr(*isv_dev_ptr);
args.push_i32(b_i);
args.push_ptr(target_dist_d.raw_ptr());
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.bellman_scalar_target_fn.cu_function(),
(b_size as u32, 1, 1), (Q_N_ATOMS as u32, 1, 1), 0,
self.raw_stream,
&mut ptrs[..args.len()],
).map_err(|e| anyhow::anyhow!("bellman_scalar_target_project: {:?}", e))?;
}
Ok(())
}
/// Stream used to launch all kernels owned by this head. Phase E's
/// training loop reads this when scheduling the soft-update kernel.
pub fn stream(&self) -> &Arc<CudaStream> {

View File

@@ -927,6 +927,16 @@ pub struct IntegratedTrainer {
pub v_pred_d: CudaSlice<f32>,
/// Scalar value prediction at h_{t+1} `[B]`.
pub v_pred_tp1_d: CudaSlice<f32>,
/// Phase 2.1 (2026-05-28) — V(s_t) on PER-sampled hidden state for
/// the dueling Bellman target (`bellman_scalar_target_project`).
/// Distinct from `v_pred_d` (on-policy V at live encoder h_t):
/// off-policy Q learning needs V evaluated on replay-sampled states.
pub sampled_v_curr_d: CudaSlice<f32>,
/// Phase 2.1 (2026-05-28) — V_target(s_{t+1}) on PER-sampled
/// hidden state for the dueling Bellman target. Phase 2.1 uses
/// the current value head (slow-moving by design); a separate
/// target V head can be added later if divergence becomes an issue.
pub sampled_v_target_tp1_d: CudaSlice<f32>,
/// Policy logits at h_t `[B × N_ACTIONS]`.
pub pi_logits_d: CudaSlice<f32>,
@@ -2013,6 +2023,18 @@ impl IntegratedTrainer {
let v_pred_tp1_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc v_pred_tp1_d")?;
// Phase 2.1 (2026-05-28): V(s_t) and V_target(s_{t+1}) evaluated
// on PER-sampled hidden states for the dueling Bellman target
// (`bellman_scalar_target_project`). The on-policy v_pred_d /
// v_pred_tp1_d above evaluate V at the live encoder h_t/h_{t+1};
// the dueling Bellman target build uses replay-sampled states
// for off-policy Q learning, so it needs V on sampled buffers.
let sampled_v_curr_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc sampled_v_curr_d")?;
let sampled_v_target_tp1_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc sampled_v_target_tp1_d")?;
let pi_logits_d = stream
.alloc_zeros::<f32>(b_size * N_ACTIONS)
.context("alloc pi_logits_d")?;
@@ -2620,6 +2642,8 @@ impl IntegratedTrainer {
q_logits_tp1_d,
v_pred_d,
v_pred_tp1_d,
sampled_v_curr_d,
sampled_v_target_tp1_d,
pi_logits_d,
iqn_prng_state_d,
noisy_exploration,
@@ -2790,6 +2814,34 @@ impl IntegratedTrainer {
unsafe { std::slice::from_raw_parts(self.isv_mapped.host_ptr, self.isv_mapped.len) }
}
/// Phase 2.1 test accessor — mutable view of the ISV mapped-pinned
/// buffer so tests can seed V_MIN/V_MAX (and other slots) directly
/// for kernel-oracle harnesses. Mapped-pinned writes are coherent
/// with device reads after stream synchronisation; the caller MUST
/// sync the stream before launching any kernel that reads from ISV.
///
/// Test-only — production code modifies ISV via controller kernels
/// per `pearl_controller_anchors_isv_driven`. Marked `_for_test`
/// to flag the back-door at call sites.
pub fn isv_host_slice_mut_for_test(&self) -> &mut [f32] {
// SAFETY: tests run single-threaded; we expose a mutable view
// strictly for ISV slot seeding before a synchronisation point.
unsafe { std::slice::from_raw_parts_mut(self.isv_mapped.host_ptr as *mut f32, self.isv_mapped.len) }
}
/// Phase 2.1 test accessor — borrow the ISV device pointer so unit
/// tests can pass it into kernel wrappers that expect `&u64`.
pub fn isv_dev_ptr_for_test(&self) -> &u64 {
&self.isv_dev_ptr
}
/// Phase 2.1 test accessor — borrow the DQN head so unit tests can
/// call its kernel wrappers (e.g. `bellman_scalar_target_project`)
/// without spinning up a full mega-graph capture.
pub fn dqn_head_ref(&self) -> &crate::rl::dqn::DqnHead {
&self.dqn_head
}
/// Phase R1: fire each of the 7 RL controllers ONCE against the
/// freshly-zeroed `isv_d`. Each kernel detects the sentinel (its
/// dedicated ISV slot == 0.0), executes its first-observation-
@@ -4318,21 +4370,44 @@ impl IntegratedTrainer {
// the trainer on h_{t+1} via self.h_tp1_d; R7d re-routes the
// entire Bellman target build through PER-sampled buffers per
// `feedback_always_per`.
// Phase 2.1 (2026-05-28) — Dueling-DQN scalar Bellman target.
//
// Spec: docs/superpowers/specs/2026-05-28-state-conditional-q-synthesis.md
// §3 Sub-phase 2.1. Replaces the distributional bootstrap
// (`fused_select_and_project_bellman`) with a SCALAR Bellman
// target `y = r + γⁿ × V_target(s_{t+1})` projected as the
// advantage scalar `(y V(s_t))` onto the action's atom support.
// The Q-head's atoms are now interpreted as the advantage
// distribution `A_dist(s, a)`; full Q is recomposed via
// `Q = V + A mean_a(A)` at action-selection sites.
//
// Off-policy: V is evaluated on PER-sampled hidden states
// (`sampled_h_t_d` / `sampled_h_tp1_d`) to match the replay-
// sampled rewards/dones the Bellman target uses.
self.value_head
.forward(&self.sampled_h_t_d, &self.isv_dev_ptr, b_size, &mut self.sampled_v_curr_d)
.context("value_head.forward(sampled_h_t) [Phase 2.1 dueling Bellman]")?;
self.value_head
.forward(&self.sampled_h_tp1_d, &self.isv_dev_ptr, b_size, &mut self.sampled_v_target_tp1_d)
.context("value_head.forward(sampled_h_tp1) [Phase 2.1 dueling Bellman]")?;
self.dqn_head
.forward_target(&self.sampled_h_tp1_d, b_size, &mut self.ss_q_target_full_d)
.context("dqn_head.forward_target(sampled_h_tp1) [R7d off-policy]")?;
self.dqn_head
.fused_select_and_project_bellman(
&self.ss_q_target_full_d,
&self.sampled_next_actions_d,
.bellman_scalar_target_project(
&self.sampled_rewards_d,
&self.sampled_dones_d,
&self.sampled_n_step_gammas_d,
&self.sampled_v_curr_d,
&self.sampled_v_target_tp1_d,
&self.sampled_actions_d,
&self.isv_dev_ptr,
b_size,
&mut self.ss_target_dist_d,
)
.context("dqn_head.fused_select_and_project_bellman (sampled rewards/dones)")?;
.context("dqn_head.bellman_scalar_target_project [Phase 2.1 dueling]")?;
// ss_q_target_full_d kept allocated for any consumers still
// expecting the target-net full atom distribution; the dueling
// path bypasses it. forward_target retained as the target Q
// forward path (consumed by argmax_expected_q in Step 6a above
// for the Double-DQN next-action argmax used by other diags).
// ── Step 6b: DQN backward (logits → grad_w/b/h_t) ────────────
self.dqn_head
@@ -5304,22 +5379,34 @@ impl IntegratedTrainer {
}
}
// ── 3. Bellman target build via TARGET net at h_tp1 ─────────
// ── 3. Bellman target build — Phase 2.1 dueling scalar baseline ──
//
// Spec: docs/superpowers/specs/2026-05-28-state-conditional-q-synthesis.md
// §3 Sub-phase 2.1. Same dueling-DQN scalar Bellman target as the
// main `dqn_offpolicy_step` path (single source of truth) —
// distributional bootstrap replaced by `y V(s_t)` advantage
// scalar projected onto the action's atom support. V is evaluated
// on PER-sampled hidden states to match the replay-sampled
// rewards/dones.
self.value_head
.forward(&self.sampled_h_t_d, &self.isv_dev_ptr, b_size, &mut self.sampled_v_curr_d)
.context("dqn_replay_step: value_head.forward(sampled_h_t) [Phase 2.1]")?;
self.value_head
.forward(&self.sampled_h_tp1_d, &self.isv_dev_ptr, b_size, &mut self.sampled_v_target_tp1_d)
.context("dqn_replay_step: value_head.forward(sampled_h_tp1) [Phase 2.1]")?;
self.dqn_head
.forward_target(&self.sampled_h_tp1_d, b_size, &mut self.ss_q_target_full_d)
.context("dqn_replay_step: dqn_head.forward_target(sampled_h_tp1)")?;
self.dqn_head
.fused_select_and_project_bellman(
&self.ss_q_target_full_d,
&self.sampled_next_actions_d,
.bellman_scalar_target_project(
&self.sampled_rewards_d,
&self.sampled_dones_d,
&self.sampled_n_step_gammas_d,
&self.sampled_v_curr_d,
&self.sampled_v_target_tp1_d,
&self.sampled_actions_d,
&self.isv_dev_ptr,
b_size,
&mut self.ss_target_dist_d,
)
.context("dqn_replay_step: dqn_head.fused_select_and_project_bellman")?;
.context("dqn_replay_step: dqn_head.bellman_scalar_target_project [Phase 2.1]")?;
// ── 4. Q backward (logits → grad_w/b/h_t) ───────────────────
self.dqn_head

View File

@@ -0,0 +1,342 @@
//! Phase 2.1 unit test — `bellman_scalar_target_project` kernel oracle.
//!
//! Spec: docs/superpowers/specs/2026-05-28-state-conditional-q-synthesis.md
//! §3 Sub-phase 2.1.
//!
//! Verifies the dueling-DQN scalar Bellman target projection:
//!
//! y(s_t, a_t) = r + γⁿ × V_target(s_{t+1})
//! advantage_scalar = y V(s_t)
//!
//! Then the advantage scalar gets projected onto the action's atom
//! support via two-bin linear interpolation. Output `target_dist
//! [B × Q_N_ATOMS]` should be a delta-distribution (mass on at most two
//! adjacent atoms, summing to 1).
//!
//! Test cases (per spec):
//! 1. y_eff = 0 → mass at the center atom (atom 10 of 21 over
//! symmetric span [-1, +1])
//! 2. y_eff = V_MAX → mass at the last atom
//! 3. y_eff = V_MIN → mass at the first atom
//! 4. arbitrary in-range → distribution sums to 1, expected value
//! recovers `y_eff` within Δz/2
//!
//! where `y_eff = (r + γⁿ × V_tp1) V_t` is the advantage scalar.
//!
//! Per `feedback_no_cpu_test_fallbacks.md`: the test compares kernel
//! output against an analytical oracle (the two-bin interpolation
//! formula evaluated host-side). No CPU reference implementation of
//! the kernel.
//!
//! Run with:
//! `cargo test -p ml-alpha --test q_projection_unit -- --ignored --nocapture`
use cudarc::driver::CudaStream;
use ml_alpha::rl::common::{N_ACTIONS, Q_N_ATOMS};
use ml_alpha::rl::isv_slots::{RL_C51_V_MAX_INDEX, RL_C51_V_MIN_INDEX, RL_SLOTS_END};
use ml_alpha::trainer::integrated::{IntegratedTrainer, IntegratedTrainerConfig};
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
use ml_core::device::MlDevice;
use std::sync::Arc;
fn test_device_and_trainer() -> Option<(MlDevice, IntegratedTrainer)> {
let dev = match MlDevice::cuda(0) {
Ok(d) => d,
Err(e) => {
eprintln!("CUDA 0 not available — skipping ({e})");
return None;
}
};
let cfg = IntegratedTrainerConfig {
perception: PerceptionTrainerConfig {
seq_len: 4,
n_batch: 1,
..PerceptionTrainerConfig::default()
},
dqn_seed: 0x2107,
ppo_seed: 0x2108,
..IntegratedTrainerConfig::default()
};
let trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
Some((dev, trainer))
}
fn upload_f32(stream: &Arc<CudaStream>, host: &[f32]) -> cudarc::driver::CudaSlice<f32> {
let mut d = stream.alloc_zeros::<f32>(host.len()).expect("alloc");
stream.memcpy_htod(host, &mut d).expect("htod"); // gpu-ok: test fixture, one-shot, syncs
d
}
fn upload_i32(stream: &Arc<CudaStream>, host: &[i32]) -> cudarc::driver::CudaSlice<i32> {
let mut d = stream.alloc_zeros::<i32>(host.len()).expect("alloc");
stream.memcpy_htod(host, &mut d).expect("htod"); // gpu-ok: test fixture, one-shot
d
}
fn readback_f32(stream: &Arc<CudaStream>, d: &cudarc::driver::CudaSlice<f32>) -> Vec<f32> {
let mut host = vec![0.0_f32; d.len()];
stream.memcpy_dtoh(d, &mut host).expect("dtoh"); // gpu-ok: test oracle readback after sync
host
}
/// Run the scalar Bellman target kernel for a single batch row with
/// V_min/V_max set in the trainer's ISV slots (484/485).
fn run_kernel(
trainer: &IntegratedTrainer,
stream: &Arc<CudaStream>,
reward: f32,
n_step_gamma: f32,
v_curr: f32,
v_target_tp1: f32,
v_min: f32,
v_max: f32,
) -> Vec<f32> {
// Seed ISV slots V_MIN/V_MAX for this projection. We write directly
// via the mapped-pinned host view, then issue a no-op kernel to
// ensure visibility — but for synchronous test purposes, host
// writes to mapped-pinned propagate immediately.
{
// SAFETY: tests run before any concurrent stream activity;
// mapped-pinned writes are coherent with device reads after
// sync. We sync the stream below before launching.
let host_isv = trainer.isv_host_slice_mut_for_test();
host_isv[RL_C51_V_MIN_INDEX] = v_min;
host_isv[RL_C51_V_MAX_INDEX] = v_max;
}
stream.synchronize().expect("sync after ISV write");
let b_size = 1;
let rewards_d = upload_f32(stream, &[reward]);
let dones_d = upload_f32(stream, &[0.0]);
let n_step_gammas_d = upload_f32(stream, &[n_step_gamma]);
let v_curr_d = upload_f32(stream, &[v_curr]);
let v_target_tp1_d = upload_f32(stream, &[v_target_tp1]);
// Action 0 — Phase 2.1 uses uniform atom support so action choice
// doesn't affect the projection (Phase 2.2 makes this per-action).
let actions_d = upload_i32(stream, &[0_i32]);
let mut target_dist_d = stream
.alloc_zeros::<f32>(b_size * Q_N_ATOMS)
.expect("alloc target_dist");
trainer
.dqn_head_ref()
.bellman_scalar_target_project(
&rewards_d,
&dones_d,
&n_step_gammas_d,
&v_curr_d,
&v_target_tp1_d,
&actions_d,
trainer.isv_dev_ptr_for_test(),
b_size,
&mut target_dist_d,
)
.expect("bellman_scalar_target_project");
stream.synchronize().expect("sync after kernel");
readback_f32(stream, &target_dist_d)
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn y_zero_lands_at_center_atom() {
let Some((dev, trainer)) = test_device_and_trainer() else { return };
let stream = dev.cuda_stream().expect("stream").clone();
let v_min = -1.0_f32;
let v_max = 1.0_f32;
// y_eff = (r + γⁿ × V_tp1) V_t = 0 when r=0, γⁿ=0, V_t=0.
let dist = run_kernel(&trainer, &stream, 0.0, 0.0, 0.0, 0.0, v_min, v_max);
// Mass sums to 1.
let sum: f32 = dist.iter().sum();
assert!(
(sum - 1.0).abs() < 1e-5,
"distribution must sum to 1, got {sum} (dist = {dist:?})"
);
// Center atom is index 10 for Q_N_ATOMS=21, symmetric span [-1, +1].
let center = Q_N_ATOMS / 2;
assert!(
(dist[center] - 1.0).abs() < 1e-5,
"y=0 should land at center atom {center}; got dist[{center}]={} (full dist={:?})",
dist[center], dist
);
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn y_at_v_max_lands_at_last_atom() {
let Some((dev, trainer)) = test_device_and_trainer() else { return };
let stream = dev.cuda_stream().expect("stream").clone();
let v_min = -1.0_f32;
let v_max = 1.0_f32;
// y_eff = (1.0 + 0.0 × 0) 0 = 1.0 → last atom (index 20).
let dist = run_kernel(&trainer, &stream, v_max, 0.0, 0.0, 0.0, v_min, v_max);
let sum: f32 = dist.iter().sum();
assert!((sum - 1.0).abs() < 1e-5, "sum != 1: {sum}");
let last = Q_N_ATOMS - 1;
assert!(
(dist[last] - 1.0).abs() < 1e-5,
"y=V_MAX should land at atom {last}; got dist[{last}]={} (full dist={:?})",
dist[last], dist
);
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn y_at_v_min_lands_at_first_atom() {
let Some((dev, trainer)) = test_device_and_trainer() else { return };
let stream = dev.cuda_stream().expect("stream").clone();
let v_min = -1.0_f32;
let v_max = 1.0_f32;
// y_eff = V_MIN.
let dist = run_kernel(&trainer, &stream, v_min, 0.0, 0.0, 0.0, v_min, v_max);
let sum: f32 = dist.iter().sum();
assert!((sum - 1.0).abs() < 1e-5, "sum != 1: {sum}");
assert!(
(dist[0] - 1.0).abs() < 1e-5,
"y=V_MIN should land at atom 0; got dist[0]={} (full dist={:?})",
dist[0], dist
);
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn arbitrary_in_range_recovers_expected_value() {
let Some((dev, trainer)) = test_device_and_trainer() else { return };
let stream = dev.cuda_stream().expect("stream").clone();
let v_min = -1.0_f32;
let v_max = 1.0_f32;
let delta_z = (v_max - v_min) / (Q_N_ATOMS - 1) as f32;
// Test several in-range advantage values. y_eff = r + γⁿ × V_tp1 V_t.
// Use r=0, γⁿ=1, V_tp1=y, V_t=0 to get advantage = y_eff.
let cases: &[f32] = &[-0.7, -0.35, 0.0, 0.27, 0.85];
for &y_target in cases {
let dist = run_kernel(&trainer, &stream, 0.0, 1.0, 0.0, y_target, v_min, v_max);
// Sum to 1.
let sum: f32 = dist.iter().sum();
assert!(
(sum - 1.0).abs() < 1e-5,
"y={y_target}: sum != 1 (got {sum}); dist={dist:?}"
);
// Expected value Σ_z p_z × z recovers y_target within Δz/2
// (the two-bin interpolation is exact for the EV).
let mut ev = 0.0_f32;
for z in 0..Q_N_ATOMS {
let z_val = v_min + (z as f32) * delta_z;
ev += dist[z] * z_val;
}
assert!(
(ev - y_target).abs() < delta_z / 2.0 + 1e-5,
"y={y_target}: EV={ev} should recover y within Δz/2={}; dist={dist:?}",
delta_z / 2.0
);
}
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn bellman_target_uses_v_curr_as_baseline() {
// y_eff = (r + γ × V_tp1) V_t. Verify that changing V_t changes
// the projected target by exactly that amount (V_t enters as a
// subtraction).
let Some((dev, trainer)) = test_device_and_trainer() else { return };
let stream = dev.cuda_stream().expect("stream").clone();
let v_min = -1.0_f32;
let v_max = 1.0_f32;
let delta_z = (v_max - v_min) / (Q_N_ATOMS - 1) as f32;
let r = 0.5_f32;
let gamma = 0.9_f32;
let v_tp1 = 0.2_f32;
// With V_t = 0: y_eff = 0.5 + 0.9 × 0.2 0 = 0.68
// With V_t = 0.3: y_eff = 0.5 + 0.9 × 0.2 0.3 = 0.38
let dist_a = run_kernel(&trainer, &stream, r, gamma, 0.0, v_tp1, v_min, v_max);
let dist_b = run_kernel(&trainer, &stream, r, gamma, 0.3, v_tp1, v_min, v_max);
let ev_a: f32 = (0..Q_N_ATOMS)
.map(|z| dist_a[z] * (v_min + (z as f32) * delta_z))
.sum();
let ev_b: f32 = (0..Q_N_ATOMS)
.map(|z| dist_b[z] * (v_min + (z as f32) * delta_z))
.sum();
assert!(
(ev_a - 0.68).abs() < delta_z / 2.0 + 1e-5,
"V_t=0 case: ev={ev_a} should be ≈0.68 (within Δz/2={}); dist={dist_a:?}",
delta_z / 2.0
);
assert!(
(ev_b - 0.38).abs() < delta_z / 2.0 + 1e-5,
"V_t=0.3 case: ev={ev_b} should be ≈0.38 (within Δz/2={}); dist={dist_b:?}",
delta_z / 2.0
);
}
#[test]
fn composed_q_value_matches_dueling_formula() {
// Pure host-side check: composed_q_value implements the
// Dueling-DQN decomposition Q = V + A mean_a(A).
use ml_alpha::rl::dqn::composed_q_value;
let v_min = -1.0_f32;
let v_max = 1.0_f32;
let delta_z = (v_max - v_min) / (Q_N_ATOMS - 1) as f32;
let atom_supports: Vec<f32> = (0..N_ACTIONS * Q_N_ATOMS)
.map(|i| {
let z = i % Q_N_ATOMS;
v_min + (z as f32) * delta_z
})
.collect();
// All-zero logits → softmax uniform → E[A] = mean(z) = 0 for symmetric
// support → Q(s, a) = V + 0 0 = V for every action.
let a_logits = vec![0.0_f32; N_ACTIONS * Q_N_ATOMS];
let q = composed_q_value(0.42, &a_logits, &atom_supports);
assert_eq!(q.len(), N_ACTIONS);
for &qv in &q {
assert!(
(qv - 0.42).abs() < 1e-5,
"all-zero advantage logits should give Q=V everywhere; got {qv}"
);
}
// Spike action 0's mass at the last atom → E[A_0] = V_MAX,
// other actions have uniform A → E[A] = 0. mean_a E[A] = V_MAX / N_ACTIONS.
let mut a_logits = vec![0.0_f32; N_ACTIONS * Q_N_ATOMS];
a_logits[0 * Q_N_ATOMS + (Q_N_ATOMS - 1)] = 50.0; // huge logit → ≈ 1.0 prob mass on last atom
let q = composed_q_value(0.0, &a_logits, &atom_supports);
let expected_mean = v_max / N_ACTIONS as f32;
let expected_q0 = 0.0 + v_max - expected_mean;
let expected_q_rest = 0.0 + 0.0 - expected_mean;
assert!(
(q[0] - expected_q0).abs() < 1e-3,
"action 0 should have Q ≈ {expected_q0}; got {} (full Q={q:?})",
q[0]
);
for a in 1..N_ACTIONS {
assert!(
(q[a] - expected_q_rest).abs() < 1e-3,
"action {a} (uniform A) should have Q ≈ {expected_q_rest}; got {}",
q[a]
);
}
}
// Touch RL_SLOTS_END so the import survives even if unused above (the
// trainer's ISV layout end is referenced by the test for future
// per-action atom support tests in Phase 2.2 / 2.3).
#[test]
fn rl_slots_end_is_positive() {
assert!(RL_SLOTS_END > 0);
}