Files
foxhunt/crates/ml-alpha/src/trainer/integrated.rs
jgrusewski 95dcc4e312 fix(rl): ISV-driven ADV_VAR_RATIO_TARGET for rl_rollout_steps_controller
cvf86 controller_branch diag (commit 708c121f2) revealed:
  rollout_steps: 99.99% WIDEN, 0% HOLD, 0% SHRINK

The bounded-step + noise-floor fix was correctly applied, but the
controller WIDENED 99.99% of steps because the hardcoded
ADV_VAR_RATIO_TARGET = 0.1 (`#define` in the kernel) was a b_size>1
design choice. The streaming-EMA regime at b_size=1 has var/|mean|
naturally living in [1, 10] (median 3.9), so input is ALWAYS >> 0.1
and the controller correctly says "noisy advantages → widen". Result:
n_rollout pegs at MAX=8192 within ~5 steps and stays for 50k steps,
PPO update frequency drops 4×, KL stays in numerical noise (median
1.7e-8), Q can't learn (l_q stuck at ~2.7 vs uniform 3.04).

## Fix: ISV-driven target

Per `feedback_isv_for_adaptive_bounds`: ADV_VAR_RATIO_TARGET now
lives in ISV slot 449 (`RL_ADV_VAR_RATIO_TARGET_INDEX`), seeded at
trainer init to 5.0 (matches streaming-regime median 3.9). The
controller reads `isv[RL_ADV_VAR_RATIO_TARGET_INDEX]` each step
instead of a `#define`.

Expected behavior at TARGET=5.0:
  * Median input 3.9 lands in-band [3.33, 7.5] → HOLD
  * n_rollout stays near BOOTSTRAP=2048 instead of MAX
  * 4× more PPO updates per step → policy actually moves
  * KL leaves noise floor → ε controller activates
  * Q has gradient signal → can learn

Noise floor is now derived multiplicatively from the ISV target
(`target × ADV_VAR_RATIO_NOISE_FLOOR_FRAC = 0.01`) so adjusting
the target proportionally adjusts the floor — no separate slot
needed.

## Wiring

`rl_streaming_clamp_init.cu` extended to seed all three ISV-resident
design constants (adv_var clamp ceiling, td_kurt clamp ceiling, AND
adv_var regression target). Single kernel call at trainer init —
still no HtoD per `feedback_no_htod_htoh_only_mapped_pinned`.

## Diag bake-in

`controller_branch.rollout_steps_target` now reads from
`isv[RL_ADV_VAR_RATIO_TARGET_INDEX]` instead of the prior hardcoded
`0.1f32` literal. The diag shows the current ISV-resident target
so post-hoc branch analysis uses the actual value the controller
saw, and lets us track whether a future adaptive controller (one
that maintains target from observed-input percentile EMA) is
moving the target correctly.

## Slot allocation

RL_SLOTS_END: 449 → 450 (one new design-constant slot).

## Test updates

G1 (isv_bootstrap) + G3 (r5_controllers) skip slot 449 in the
sentinel-zero loop and assert the seeded value (5.0) separately.
G3's `advantage_var_ratio` input bumped from 5.0 → 20.0 so the
WIDEN branch still fires (input > new target × 1.5 = 7.5) and the
test still validates that the controller moves off bootstrap.

## Verified gates (local sm_86)

  G1 isv_bootstrap   
  G3 controllers      (with updated input)
  G4 target_update   
  integrated_smoke   

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:18:51 +02:00

3537 lines
158 KiB
Rust
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.
//! Integrated RL trainer: BCE + DQN/C51 Q + PPO π + V + aux on a shared
//! Mamba2+CfC encoder.
//!
//! Phase E.2 + E.2-DEFER (this commit closes 4 deferred items):
//! 1. `PerceptionTrainer::backward_encoder_with_grad_h_t` —
//! the combined Q+π+V grad_h_t is folded into the encoder backward
//! path via the new entry point, so the encoder is no longer
//! BCE+aux-only.
//! 2. Bellman target projection kernel —
//! replaces the host-side `build_synthetic_bellman_target` stand-in
//! with `bellman_target_projection.cu`, reading γ from ISV[400] and
//! bootstrapping atom probabilities from the target-network output.
//! 3. Per-head LR ISV controller —
//! `rl_lr_controller.cu` emits per-head learning rates to
//! ISV[412..417]; the host reads them via the ISV mirror and
//! mutates each `AdamW.lr` field before the step. The
//! `PHASE_E2_DEFAULT_LR` constant is DELETED.
//! 4. PPO V-loss canonicalisation —
//! `ppo_clipped_surrogate_fwd` no longer touches `returns_/v_pred/
//! loss_v`; the V signal flows ONLY through the dedicated
//! `v_head_fwd_bwd` kernels per
//! `feedback_single_source_of_truth_no_duplicates`.
//!
//! Architecture (synthetic-data path):
//!
//! 1. `perception.forward_encoder(snapshots)` produces `h_t [B × HIDDEN_DIM]`
//! via the captured-graph encoder forward path.
//! 2. `dqn_head.forward(h_t, ...)` → `q_logits [B × N_ACTIONS × Q_N_ATOMS]`
//! `policy_head.forward_logits(h_t, ...)` → `pi_logits [B × N_ACTIONS]`
//! `value_head.forward(h_t, ...)` → `v_pred [B]`
//! 3. `policy_head.surrogate_forward(...)` consumes pi_logits + synthetic
//! log π_old + advantages + ISV(ε,coef) → π loss + entropy loss + per-
//! batch log π / entropy diagnostics. (V-loss path moved out — see #4)
//! 4. Bellman target build (kernel path, item 2):
//! a. `dqn_head.forward_target(...)` → `q_target_logits [B × N_ACTIONS × Q_N_ATOMS]`
//! b. `dqn_head.select_action_atoms(...)` → `[B × Q_N_ATOMS]` slice
//! c. `dqn_head.project_bellman_target(...)` → `target_dist [B × Q_N_ATOMS]`,
//! reading γ from ISV[RL_GAMMA_INDEX=400]
//! 5. `dqn_head.backward_logits(...)` → `q_loss [1]` + `grad_logits`.
//! 6. `dqn_head.backward_to_w_b_h(...)` → per-batch grad_w / grad_b
//! scratch + grad_h_t (OVERWRITE).
//! 7. `policy_head.surrogate_backward_logits(...)` → `pi_grad_logits`.
//! 8. `policy_head.backward_to_w_b_h(...)` → per-batch grad_w / grad_b
//! + grad_h_t (OVERWRITE).
//! 9. `value_head.backward(...)` → per-batch loss + grad_w / grad_b
//! + grad_h_t (OVERWRITE).
//! 10. `reduce_axis0` collapses per-batch grad_w / grad_b → final
//! `grad_w [...]` / `grad_b [...]` for each head.
//! 11. LR ISV controller (item 3): emit ISV[412..417], DtoH-mirror, and
//! mutate per-head AdamW.lr.
//! 12. Per-head `AdamW.step()` runs on the reduced gradients. Each head
//! owns 2 Adam instances (one for `w_d`, one for `b_d`).
//! 13. `grad_h_accumulate_scaled` folds Q/π/V grad_h_t (loss-balance λ
//! weighted) into the encoder grad slot `grad_h_t_combined_d`.
//! 14. `perception.backward_encoder_with_grad_h_t(grad_h_t_combined_d)`
//! runs the CfC + Mamba2 bwd + encoder Adam step (item 1).
//! 15. Per-batch losses (V head + DQN loss scalar) are reduced/read back
//! via mapped-pinned and returned in `IntegratedStepStats`.
//!
//! Constraints honoured:
//! * `feedback_no_stubs.md` — every kernel runs real math, no
//! placeholder host losses.
//! * `feedback_no_atomicadd.md` — new kernels use per-batch scratch
//! + `reduce_axis0` reduction (DQN/PPO bwd loss scalars still atomic
//! — Phase C/D deferral, kept loud-flagged in their kernel headers).
//! * `feedback_no_htod_htoh_only_mapped_pinned.md` — all CPU↔GPU paths
//! via `MappedF32Buffer` + DtoD pattern from `pinned_mem.rs`.
//! * `feedback_no_nvrtc.md` — pre-compiled cubins via build.rs.
//! * `pearl_controller_anchors_isv_driven` — λs + per-head LRs read
//! from ISV; ε / coef read inside the surrogate kernel from
//! `ISV[402,403]`; γ read inside the Bellman projection from
//! `ISV[RL_GAMMA_INDEX=400]`.
//! * `feedback_single_source_of_truth_no_duplicates` — V-loss path now
//! lives ONLY in the v_head kernels; PPO surrogate dropped its
//! redundant loss_v accumulator.
use std::sync::Arc;
use anyhow::{Context, Result};
use cudarc::driver::{
CudaFunction, CudaModule, CudaSlice, CudaStream, DevicePtr, DevicePtrMut, LaunchConfig,
PushKernelArg,
};
use ml_core::device::MlDevice;
use crate::cfc::snap_features::Mbp10RawInput;
use crate::heads::HIDDEN_DIM;
use crate::pinned_mem::{MappedF32Buffer, MappedI32Buffer};
use rand::{Rng, SeedableRng};
use crate::rl::common::{N_ACTIONS, Q_N_ATOMS, Q_V_MAX, Q_V_MIN};
use crate::rl::dqn::{DqnHead, DqnHeadConfig};
use crate::rl::isv_slots::{
RL_LR_BCE_INDEX, RL_LR_AUX_INDEX, RL_LR_PI_INDEX, RL_LR_Q_INDEX,
RL_LR_V_INDEX, RL_SLOTS_END,
};
use crate::rl::loss_balance::{read_loss_lambdas_from_isv, LossLambdas};
use crate::rl::ppo::{PolicyHead, PpoHeadsConfig, ValueHead};
use crate::rl::reward::RlLobBackend;
use crate::trainer::optim::AdamW;
use crate::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig};
const GRAD_H_ACCUMULATE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/grad_h_accumulate.cubin"));
const REDUCE_AXIS0_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/reduce_axis0.cubin"));
const RL_LR_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_lr_controller.cubin"));
// Phase R1: cubin includes for the 7 RL adaptive controllers
// (γ / τ / ε / entropy_coef / n_rollout_steps / per_α / reward_scale).
// All 7 share the `(isv*, alpha, scalar_input)` signature and the
// `pearl_first_observation_bootstrap` sentinel-zero path. Loaded in
// `IntegratedTrainer::new` + bootstrap-launched once at construction so
// every consumer kernel sees the canonical default in its ISV slot
// before any in-loop access.
const RL_GAMMA_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_gamma_controller.cubin"));
const RL_TARGET_TAU_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_target_tau_controller.cubin"));
const RL_PPO_CLIP_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_ppo_clip_controller.cubin"));
const RL_ENTROPY_COEF_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_entropy_coef_controller.cubin"));
const RL_ROLLOUT_STEPS_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_rollout_steps_controller.cubin"));
const RL_PER_ALPHA_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_per_alpha_controller.cubin"));
const RL_REWARD_SCALE_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_reward_scale_controller.cubin"));
// Phase R3: GPU-resident EMA + advantage/return kernels. Generic over
// the ISV slot they update (one kernel serves multiple controller-input
// EMAs). Replace the host-side EMA + advantage loops the flawed Phase F
// shipped in step_with_lobsim (which violated `feedback_cpu_is_read_only`).
const EMA_UPDATE_ON_DONE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/ema_update_on_done.cubin"));
const EMA_UPDATE_PER_STEP_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/ema_update_per_step.cubin"));
const COMPUTE_ADVANTAGE_RETURN_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/compute_advantage_return.cubin"));
// Phase R4: GPU-resident action sampling kernels. Replace the host
// Thompson + host argmax + host log-softmax loops the flawed Phase F
// shipped in step_with_lobsim (which violated `feedback_cpu_is_read_only`).
const RL_ACTION_KERNEL_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_action_kernel.cubin"));
const ARGMAX_EXPECTED_Q_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/argmax_expected_q.cubin"));
const LOG_PI_AT_ACTION_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/log_pi_at_action.cubin"));
// Phase R6: GPU-pure env interaction kernels. extract_realized_pnl_delta
// reads pos.realized_pnl from the device Pos array and writes per-batch
// (reward delta, done flag); apply_reward_scale multiplies rewards by
// ISV[406]; actions_to_market_targets translates the 9-action grid to
// LobSim's market_targets[B*2] format on device.
const EXTRACT_REALIZED_PNL_DELTA_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/extract_realized_pnl_delta.cubin"));
const APPLY_REWARD_SCALE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/apply_reward_scale.cubin"));
const ACTIONS_TO_MARKET_TARGETS_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/actions_to_market_targets.cubin"));
// Phase R7a: element-wise abs-copy kernel for the |reward| signal
// feeding ema_update_on_done's MEAN_ABS_PNL_EMA slot.
const ABS_COPY_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/abs_copy.cubin"));
// Reduction kernels that derive scalar inputs for the controller-input
// EMAs (variance ratio + kurtosis). entropy_observed reuses
// ema_update_per_step's built-in mean reduce directly on entropy_d.
const RL_VAR_OVER_ABS_MEAN_STREAMING_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_var_over_abs_mean_streaming.cubin"));
const RL_KURTOSIS_STREAMING_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_kurtosis_streaming.cubin"));
// Derivation kernels for the EMA inputs that need a new signal
// computation (not just a reduction over an existing buffer):
// per-batch KL approximation, Q weight-divergence L2 norm, and the
// trade-duration step counter.
const RL_KL_APPROX_B_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_kl_approx_b.cubin"));
const RL_L2_DIFF_NORM_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_l2_diff_norm.cubin"));
const RL_STEP_COUNTER_UPDATE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_step_counter_update.cubin"));
const RL_L2_NORM_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_l2_norm.cubin"));
// R9 — PPO ratio clamp controller (emits ISV[440]) + per-step
// log-ratio max diagnostic kernel (writes ISV[441]).
const RL_PPO_RATIO_CLAMP_CONTROLLER_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_ppo_ratio_clamp_controller.cubin"));
const PPO_LOG_RATIO_ABS_MAX_B_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/ppo_log_ratio_abs_max_b.cubin"));
const RL_STREAMING_CLAMP_INIT_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_streaming_clamp_init.cubin"));
/// Per-head LR controller Wiener-α target. The controller kernel floors
/// this at 0.4 per `pearl_wiener_alpha_floor_for_nonstationary` so the
/// effective blend rate stays ≥ 0.4 regardless of this seed value. Kept
/// host-side so the trainer can tune the diagnostic-input weighting in
/// Phase E.3+ without touching the kernel.
const RL_LR_CONTROLLER_ALPHA: f32 = 0.4;
/// Configuration for [`IntegratedTrainer`]. Wraps a `PerceptionTrainerConfig`
/// (the encoder + BCE + aux side) plus RL-specific overrides for the new
/// heads.
#[derive(Clone, Debug)]
pub struct IntegratedTrainerConfig {
/// Inner perception trainer config. Encoder dims (hidden_dim, n_batch,
/// seq_len) are read from this struct.
pub perception: PerceptionTrainerConfig,
/// Seed for DQN head Xavier init.
pub dqn_seed: u64,
/// Seed for PPO heads (policy + value share derived seeds).
pub ppo_seed: u64,
/// PER buffer capacity (Phase R7d). Per the canonical replay.rs
/// doc, naive O(N) sampling is acceptable up to ≈ 4096; production
/// scale-up to 100k transitions needs a GPU sum-tree (R-future).
pub per_capacity: usize,
/// PER seed for deterministic sampling per
/// `pearl_scoped_init_seed_for_reproducibility`. Fed to
/// `ReplayBuffer::new(capacity, seed)`.
pub per_seed: u64,
}
impl Default for IntegratedTrainerConfig {
/// Per `pearl_first_observation_bootstrap` + R1 controller defaults:
/// every adaptive knob sources from ISV; the only knobs here are
/// structural (encoder dims via PerceptionTrainerConfig) or
/// reproducibility (seeds).
fn default() -> Self {
Self {
perception: PerceptionTrainerConfig::default(),
dqn_seed: 0xDA_DA_DA_DA,
ppo_seed: 0xBE_BE_BE_BE,
per_capacity: 4096,
per_seed: 0x9E37_79B9_7F4A_7C15,
}
}
}
/// Stats returned from `step_synthetic`. Useful for tests + diagnostics.
#[derive(Clone, Debug)]
pub struct IntegratedStepStats {
pub l_bce: f32,
pub l_q: f32,
pub l_pi: f32,
pub l_v: f32,
pub l_aux: f32,
pub l_total: f32,
pub lambdas: LossLambdas,
}
pub struct IntegratedTrainer {
#[allow(dead_code)]
cfg: IntegratedTrainerConfig,
/// Inner trainer owns the encoder weights + BCE/aux heads + optimizer.
pub perception: PerceptionTrainer,
/// DQN distributional Q-head (Phase C).
pub dqn_head: DqnHead,
/// PPO policy head (Phase D).
pub policy_head: PolicyHead,
/// PPO value head (Phase D).
pub value_head: ValueHead,
/// Per-head Adam optimisers — Phase E.2. One pair (w + b) per head.
/// All share the existing project-wide `adamw_step` cubin via the
/// `AdamW` wrapper; each instance owns its own m / v / step counter.
pub dqn_w_adam: AdamW,
pub dqn_b_adam: AdamW,
pub policy_w_adam: AdamW,
pub policy_b_adam: AdamW,
pub value_w_adam: AdamW,
pub value_b_adam: AdamW,
/// Device ISV buffer (RL-allocated, length `RL_SLOTS_END = 424` as
/// of Phase R1). Allocated zero, then immediately seeded by the 7
/// RL controllers' bootstrap launches in `new()` so every consumer
/// kernel sees the canonical default at its slot before any read.
/// Per `pearl_first_observation_bootstrap` + `feedback_isv_for_adaptive_bounds`.
pub isv_d: CudaSlice<f32>,
/// Host mirror of ISV for loss-balance λ reads. Refreshed before each
/// step via stream.memcpy_dtoh(isv_d, host).
pub isv_host: Vec<f32>,
stream: Arc<CudaStream>,
// ── Kernel handles for grad combine + cross-batch reduce ──────────
_grad_h_module: Arc<CudaModule>,
grad_h_accumulate_fn: CudaFunction,
_reduce_axis0_module: Arc<CudaModule>,
reduce_axis0_fn: CudaFunction,
// ── Per-head LR ISV controller (Phase E.2-DEFER item 3) ───────────
/// Owns the `rl_lr_controller` cubin lifetime.
_rl_lr_controller_module: Arc<CudaModule>,
/// Kernel handle for `rl_lr_controller` — emits per-head LRs to
/// `ISV[412..417]` via the Wiener-α blend defined in
/// `cuda/rl_lr_controller.cu`. Bootstraps on first observation
/// (sentinel zero → `LR_BOOTSTRAP = 1e-3`).
rl_lr_controller_fn: CudaFunction,
// ── 7 RL adaptive controllers (Phase R1) ──────────────────────────
// Each controller emits ONE float into its dedicated ISV slot
// (γ→400, τ→401, ε→402, entropy_coef→403, n_rollout_steps→404,
// per_α→405, reward_scale→406). Bootstrap-launched once at the end
// of `new()` so every consumer kernel reads a canonical value on
// first access — eliminates the Phase F defect where sparse-reward
// production paths read sentinel-zero γ/ε/entropy and trained
// against degenerate Bellman / surrogate targets. Per-step launches
// (with real EMA inputs sourced from ISV[417..424]) land in Phase R5.
_rl_gamma_controller_module: Arc<CudaModule>,
rl_gamma_controller_fn: CudaFunction,
_rl_target_tau_controller_module: Arc<CudaModule>,
rl_target_tau_controller_fn: CudaFunction,
_rl_ppo_clip_controller_module: Arc<CudaModule>,
rl_ppo_clip_controller_fn: CudaFunction,
_rl_entropy_coef_controller_module: Arc<CudaModule>,
rl_entropy_coef_controller_fn: CudaFunction,
_rl_rollout_steps_controller_module: Arc<CudaModule>,
rl_rollout_steps_controller_fn: CudaFunction,
_rl_per_alpha_controller_module: Arc<CudaModule>,
rl_per_alpha_controller_fn: CudaFunction,
_rl_reward_scale_controller_module: Arc<CudaModule>,
rl_reward_scale_controller_fn: CudaFunction,
// ── Phase R3: GPU-resident EMA + advantage/return kernels ─────────
_ema_update_on_done_module: Arc<CudaModule>,
ema_update_on_done_fn: CudaFunction,
_ema_update_per_step_module: Arc<CudaModule>,
ema_update_per_step_fn: CudaFunction,
_compute_advantage_return_module: Arc<CudaModule>,
compute_advantage_return_fn: CudaFunction,
// ── Phase R4: GPU action sampling ─────────────────────────────────
_rl_action_kernel_module: Arc<CudaModule>,
rl_action_kernel_fn: CudaFunction,
_argmax_expected_q_module: Arc<CudaModule>,
argmax_expected_q_fn: CudaFunction,
_log_pi_at_action_module: Arc<CudaModule>,
log_pi_at_action_fn: CudaFunction,
/// Per-batch xorshift32 PRNG state for `rl_action_kernel`. Allocated
/// + host-seeded once at trainer init from `cfg.dqn_seed` via
/// `rand_chacha::ChaCha8Rng` per `pearl_scoped_init_seed_for_reproducibility`.
/// The kernel advances state in place each call. Length = `n_batch`.
pub prng_state_d: CudaSlice<u32>,
/// Atom support vector `[Q_V_MIN, Q_V_MIN + step, …, Q_V_MAX]` of
/// length `Q_N_ATOMS`. Allocated + host-uploaded once at init;
/// read by `rl_action_kernel` (Thompson sample lookup) and
/// `argmax_expected_q` (expected-value accumulator). Per
/// `feedback_isv_for_adaptive_bounds`: structural constant, not
/// adaptive (atom span is fixed [Q_V_MIN, Q_V_MAX] = [-1, +1] per
/// `crate::rl::common`). Per-branch adaptive atom span (per
/// `pearl_per_branch_c51_atom_span`) is a Phase R-future extension.
pub atom_supports_d: CudaSlice<f32>,
// ── Phase R6: GPU-pure env-step kernels + per-step buffers ────────
_extract_realized_pnl_delta_module: Arc<CudaModule>,
extract_realized_pnl_delta_fn: CudaFunction,
_apply_reward_scale_module: Arc<CudaModule>,
apply_reward_scale_fn: CudaFunction,
_actions_to_market_targets_module: Arc<CudaModule>,
actions_to_market_targets_fn: CudaFunction,
// ── Phase R7a: abs-copy + persistent per-step device buffers ──────
_abs_copy_module: Arc<CudaModule>,
abs_copy_fn: CudaFunction,
// ── Per-batch reducers for controller-input EMAs ──
_rl_var_over_abs_mean_streaming_module: Arc<CudaModule>,
rl_var_over_abs_mean_streaming_fn: CudaFunction,
_rl_kurtosis_streaming_module: Arc<CudaModule>,
rl_kurtosis_streaming_fn: CudaFunction,
/// 1-float scratch for the reduce kernels' scalar output. Reused
/// across per-step launches (reductions serialised by stream order).
/// Feeds `ema_update_per_step` with `b_size=1` for the controller
/// EMA slots whose input requires a transform (variance ratio,
/// kurtosis, KL approximation, weight L2 divergence).
pub ema_input_scratch_d: CudaSlice<f32>,
// ── Derivation kernels + per-batch state for the remaining EMAs ──
_rl_kl_approx_b_module: Arc<CudaModule>,
rl_kl_approx_b_fn: CudaFunction,
_rl_l2_diff_norm_module: Arc<CudaModule>,
rl_l2_diff_norm_fn: CudaFunction,
_rl_step_counter_update_module: Arc<CudaModule>,
rl_step_counter_update_fn: CudaFunction,
_rl_l2_norm_module: Arc<CudaModule>,
rl_l2_norm_fn: CudaFunction,
// R9 PPO ratio clamp + log-ratio diag.
_rl_ppo_ratio_clamp_controller_module: Arc<CudaModule>,
rl_ppo_ratio_clamp_controller_fn: CudaFunction,
_ppo_log_ratio_abs_max_b_module: Arc<CudaModule>,
ppo_log_ratio_abs_max_b_fn: CudaFunction,
_rl_streaming_clamp_init_module: Arc<CudaModule>,
rl_streaming_clamp_init_fn: CudaFunction,
/// Per-batch step counter for `trade_duration_ema` — increments
/// every step, resets on done. Zero-init at construction.
pub steps_since_done_d: CudaSlice<i32>,
/// Per-batch duration emit buffer — populated by
/// `rl_step_counter_update` with the counter value AT done
/// (zero otherwise). Consumed by `ema_update_on_done` gated on
/// dones_d so only real trade closes contribute to the EMA.
pub trade_duration_emit_d: CudaSlice<f32>,
/// Trainer-owned snapshot of `lobsim.pos.realized_pnl` taken at end
/// of previous step. `extract_realized_pnl_delta` reads
/// `lobsim.pos_d` post-fill, computes
/// `reward = pos.realized_pnl prev_realized_pnl_d`, and updates
/// `prev_realized_pnl_d` for the next step. Separate from
/// `LobSimCuda::prev_realized_pnl_d` (which serves the production
/// decision pipeline, not the RL trainer).
pub prev_realized_pnl_d: CudaSlice<f32>,
/// Trainer-owned snapshot of `lobsim.pos.position_lots` for the
/// done-flag detection: `done = (prev != 0 && current == 0)`.
pub prev_position_lots_d: CudaSlice<i32>,
/// Per-step rewards buffer (output of `extract_realized_pnl_delta`,
/// in-place modified by `apply_reward_scale`).
pub rewards_d: CudaSlice<f32>,
/// Per-step dones buffer (output of `extract_realized_pnl_delta`).
pub dones_d: CudaSlice<f32>,
/// Per-step |reward| scratch (output of `abs_copy(rewards_d)`).
/// Feeds `ema_update_on_done` for the `MEAN_ABS_PNL_EMA` slot —
/// keeping signed `rewards_d` intact for the
/// `compute_advantage_return` + `apply_reward_scale` pipeline.
pub reward_abs_d: CudaSlice<f32>,
/// Per-step actions (Thompson sample). R7a keeps the host Thompson
/// loop and uploads here; R7b lifts to R4's `rl_action_kernel`.
pub actions_d: CudaSlice<i32>,
/// Per-step next-state argmax actions for the Bellman target.
/// R7a keeps the host argmax loop and uploads here; R7b lifts to
/// R4's `argmax_expected_q` on h_{t+1}.
pub next_actions_d: CudaSlice<i32>,
/// Per-step log π_old at the sampled action. R7a keeps the host
/// log-softmax loop and uploads here; R7b lifts to R4's
/// `log_pi_at_action`.
pub log_pi_old_d: CudaSlice<f32>,
/// Per-step advantages A_t. Output of R3's
/// `compute_advantage_return`.
pub advantages_d: CudaSlice<f32>,
/// Per-step returns R_t (V regression target). Output of R3's
/// `compute_advantage_return`.
pub returns_d: CudaSlice<f32>,
/// Phase R7c (data correctness): trainer-owned `h_{t+1}` buffer
/// (`[B × HIDDEN_DIM]`). Populated by a SECOND
/// `perception.forward_encoder(next_snapshots)` call in
/// `step_with_lobsim`, with a stream-ordered DtoD copy out of
/// `perception.h_t_d` BEFORE the third `forward_encoder(snapshots)`
/// call overwrites it. Closes the long-standing
/// `// FUTURE: pass next_h_t separately` approximation noted in
/// step_synthetic's Bellman target build (which had been using
/// `h_t` as a stand-in for `s_{t+1}`'s encoder representation since
/// Phase E.2). Three consumers downstream:
/// 1. `value_head.forward(&self.h_tp1_d) → v_pred_tp1_d`, which
/// compute_advantage_return reads as the true `V(s_{t+1})`
/// (replaces R7b's `v_tp1_d_ref = &v_pred_d` alias).
/// 2. `dqn_head.forward(&self.h_tp1_d) → q_logits_tp1_d`, fed to
/// `argmax_expected_q` for `next_actions_d` — the Double-DQN
/// argmax must be on `h_{t+1}`, not `h_t` (R7b's q_logits_d
/// was on h_t, so the argmax was wrong since R4).
/// 3. `dqn_head.forward_target(&self.h_tp1_d)` inside
/// step_synthetic's Bellman target build (replaces the
/// h_t-as-proxy comment at the call site).
pub h_tp1_d: CudaSlice<f32>,
// ── Phase R7d: PER + off-policy Q replay ──────────────────────────
/// Prioritised Experience Replay buffer (off-policy DQN training).
/// Per `feedback_always_per` PER is always-on. Wired in R7d after
/// the existing dead struct sat in `src/rl/replay.rs` since Phase C.
pub replay: crate::rl::replay::ReplayBuffer,
/// Per-step gather buffer for PER-sampled `h_t`. Populated each step
/// by a per-batch DtoD loop over `replay.transitions[sampled_idx].h_t`.
/// Length `B × HIDDEN_DIM`.
pub sampled_h_t_d: CudaSlice<f32>,
/// Per-step gather buffer for PER-sampled `h_{t+1}`. Same pattern as
/// `sampled_h_t_d`. Length `B × HIDDEN_DIM`.
pub sampled_h_tp1_d: CudaSlice<f32>,
/// Per-step gather buffer for PER-sampled actions (HtoD per step
/// from the host metadata replay stores alongside each transition).
/// Length `B`.
pub sampled_actions_d: CudaSlice<i32>,
/// Per-step gather buffer for PER-sampled rewards. Length `B`.
pub sampled_rewards_d: CudaSlice<f32>,
/// Per-step gather buffer for PER-sampled done flags. Length `B`.
pub sampled_dones_d: CudaSlice<f32>,
/// Per-step gather buffer for PER-sampled next-state argmax actions
/// (Double-DQN online-Q argmax on `sampled_h_tp1`). Computed
/// per-step inside `dqn_offpolicy_step` via `argmax_expected_q` —
/// not stored in the replay buffer (online net weights drift, so
/// the argmax is recomputed each step). Length `B`.
pub sampled_next_actions_d: CudaSlice<i32>,
/// Per-step CE loss output of `dqn_distributional_q_bwd` (R7d
/// kernel signature addition). Length `B`. DtoH'd at the end of
/// `dqn_offpolicy_step` into `td_per_sample_host`; fed to
/// `replay.update_priorities(per_indices, td_per_sample_host)`.
pub td_per_sample_d: CudaSlice<f32>,
/// Combined encoder grad slot — folded from Q + π + V `grad_h_t`
/// contributions via `grad_h_accumulate_scaled` (item 1). Consumed by
/// `PerceptionTrainer::backward_encoder_with_grad_h_t`. Size
/// `B × HIDDEN_DIM`; zeroed at the start of each
/// `step_synthetic` call.
grad_h_t_combined_d: CudaSlice<f32>,
/// Last-read PPO π loss host scalar — internal scratch threading the
/// surrogate kernel's atomic-write loss readback through the
/// borrow-checker dance in `step_synthetic`. NOT a public API.
last_pi_loss: f32,
/// Last step's Q-head loss (Q CE, host scalar) — fed to the LR
/// controller at the start of NEXT step_synthetic for plateau
/// detection. Zero at construction; populated at the end of
/// step_synthetic after the Q backward's scalar readback.
last_q_loss: f32,
/// Last step's V-head loss (MSE, host scalar) — same pattern as
/// `last_q_loss`. Zero at construction.
last_v_loss: f32,
/// Host-side step counter for Thompson-sampling RNG salt (Phase E.3b).
/// Increments once per `step_with_lobsim` call so consecutive calls
/// draw different actions from the same Q-distribution. Per
/// `pearl_scoped_init_seed_for_reproducibility`: the RNG seed is
/// `cfg.dqn_seed.wrapping_add(step_counter)`, deterministic given
/// the same config + step sequence.
step_counter: u64,
}
impl IntegratedTrainer {
pub fn new(dev: &MlDevice, cfg: IntegratedTrainerConfig) -> Result<Self> {
let stream: Arc<CudaStream> = dev.cuda_stream().context("integrated stream")?.clone();
let perception =
PerceptionTrainer::new(dev, &cfg.perception).context("PerceptionTrainer::new")?;
// Encoder hidden dim — single source of truth via heads::HIDDEN_DIM
// (the same const the perception trainer uses).
let hidden_dim = HIDDEN_DIM;
let dqn_head = DqnHead::new(
dev,
DqnHeadConfig {
hidden_dim,
seed: cfg.dqn_seed,
},
)
.context("DqnHead::new")?;
let policy_head = PolicyHead::new(
dev,
PpoHeadsConfig {
hidden_dim,
seed: cfg.ppo_seed,
},
)
.context("PolicyHead::new")?;
let value_head = ValueHead::new(
dev,
PpoHeadsConfig {
hidden_dim,
seed: cfg.ppo_seed.wrapping_add(0xA1),
},
)
.context("ValueHead::new")?;
// Per-head Adam optimisers — one per (head, w | b) pair. Each owns
// independent m/v buffers and a device-resident step counter (per
// pearl_no_host_branches_in_captured_graph: counter advancement
// happens inside a captured kernel, not in host code).
//
// LR is sourced from ISV[RL_LR_*_INDEX] per step (item 3); the
// construction-time value passed to AdamW::new is a sentinel-zero-
// bootstrap PLACEHOLDER overwritten before the first AdamW.step.
// Per `pearl_first_observation_bootstrap` the controller seeds
// 1e-3 on the first ISV emit, then the trainer mirrors that value
// into each AdamW.lr field before the Adam step launches.
let lr_placeholder = 0.0_f32;
let dqn_w_adam =
AdamW::new(dev, dqn_head.w_d.len(), lr_placeholder).context("dqn_w_adam")?;
let dqn_b_adam =
AdamW::new(dev, dqn_head.b_d.len(), lr_placeholder).context("dqn_b_adam")?;
let policy_w_adam =
AdamW::new(dev, policy_head.w_d.len(), lr_placeholder).context("policy_w_adam")?;
let policy_b_adam =
AdamW::new(dev, policy_head.b_d.len(), lr_placeholder).context("policy_b_adam")?;
let value_w_adam =
AdamW::new(dev, value_head.w_d.len(), lr_placeholder).context("value_w_adam")?;
let value_b_adam =
AdamW::new(dev, value_head.b_d.len(), lr_placeholder).context("value_b_adam")?;
// ISV buffer — zero-init, sentinel-bootstrap on first controller emit.
let isv_d = stream
.alloc_zeros::<f32>(RL_SLOTS_END)
.context("alloc isv_d")?;
let isv_host = vec![0.0_f32; RL_SLOTS_END];
// R9 clamp-ceiling seeding happens via the
// rl_streaming_clamp_init kernel launched alongside the
// controller bootstraps below — per
// `feedback_no_htod_htoh_only_mapped_pinned` we never
// host-seed device-resident state.
// Combined-encoder-grad slot for item 1 (perception backward).
// Sized [B × HIDDEN_DIM]; the perception config holds n_batch.
let grad_h_t_combined_d = stream
.alloc_zeros::<f32>(cfg.perception.n_batch * hidden_dim)
.context("alloc grad_h_t_combined_d")?;
// Load helper kernels.
let ctx = dev.cuda_context().context("integrated ctx")?;
let grad_h_module = ctx
.load_cubin(GRAD_H_ACCUMULATE_CUBIN.to_vec())
.context("load grad_h_accumulate cubin")?;
let grad_h_accumulate_fn = grad_h_module
.load_function("grad_h_accumulate_scaled")
.context("load grad_h_accumulate_scaled")?;
let reduce_axis0_module = ctx
.load_cubin(REDUCE_AXIS0_CUBIN.to_vec())
.context("load reduce_axis0 cubin")?;
let reduce_axis0_fn = reduce_axis0_module
.load_function("reduce_axis0")
.context("load reduce_axis0")?;
let rl_lr_controller_module = ctx
.load_cubin(RL_LR_CONTROLLER_CUBIN.to_vec())
.context("load rl_lr_controller cubin")?;
let rl_lr_controller_fn = rl_lr_controller_module
.load_function("rl_lr_controller")
.context("load rl_lr_controller")?;
// Phase R1: load the 7 RL adaptive controllers.
let rl_gamma_controller_module = ctx
.load_cubin(RL_GAMMA_CONTROLLER_CUBIN.to_vec())
.context("load rl_gamma_controller cubin")?;
let rl_gamma_controller_fn = rl_gamma_controller_module
.load_function("rl_gamma_controller")
.context("load rl_gamma_controller")?;
let rl_target_tau_controller_module = ctx
.load_cubin(RL_TARGET_TAU_CONTROLLER_CUBIN.to_vec())
.context("load rl_target_tau_controller cubin")?;
let rl_target_tau_controller_fn = rl_target_tau_controller_module
.load_function("rl_target_tau_controller")
.context("load rl_target_tau_controller")?;
let rl_ppo_clip_controller_module = ctx
.load_cubin(RL_PPO_CLIP_CONTROLLER_CUBIN.to_vec())
.context("load rl_ppo_clip_controller cubin")?;
let rl_ppo_clip_controller_fn = rl_ppo_clip_controller_module
.load_function("rl_ppo_clip_controller")
.context("load rl_ppo_clip_controller")?;
let rl_entropy_coef_controller_module = ctx
.load_cubin(RL_ENTROPY_COEF_CONTROLLER_CUBIN.to_vec())
.context("load rl_entropy_coef_controller cubin")?;
let rl_entropy_coef_controller_fn = rl_entropy_coef_controller_module
.load_function("rl_entropy_coef_controller")
.context("load rl_entropy_coef_controller")?;
let rl_rollout_steps_controller_module = ctx
.load_cubin(RL_ROLLOUT_STEPS_CONTROLLER_CUBIN.to_vec())
.context("load rl_rollout_steps_controller cubin")?;
let rl_rollout_steps_controller_fn = rl_rollout_steps_controller_module
.load_function("rl_rollout_steps_controller")
.context("load rl_rollout_steps_controller")?;
let rl_per_alpha_controller_module = ctx
.load_cubin(RL_PER_ALPHA_CONTROLLER_CUBIN.to_vec())
.context("load rl_per_alpha_controller cubin")?;
let rl_per_alpha_controller_fn = rl_per_alpha_controller_module
.load_function("rl_per_alpha_controller")
.context("load rl_per_alpha_controller")?;
let rl_reward_scale_controller_module = ctx
.load_cubin(RL_REWARD_SCALE_CONTROLLER_CUBIN.to_vec())
.context("load rl_reward_scale_controller cubin")?;
let rl_reward_scale_controller_fn = rl_reward_scale_controller_module
.load_function("rl_reward_scale_controller")
.context("load rl_reward_scale_controller")?;
// Phase R3 kernels.
let ema_update_on_done_module = ctx
.load_cubin(EMA_UPDATE_ON_DONE_CUBIN.to_vec())
.context("load ema_update_on_done cubin")?;
let ema_update_on_done_fn = ema_update_on_done_module
.load_function("ema_update_on_done")
.context("load ema_update_on_done")?;
let ema_update_per_step_module = ctx
.load_cubin(EMA_UPDATE_PER_STEP_CUBIN.to_vec())
.context("load ema_update_per_step cubin")?;
let ema_update_per_step_fn = ema_update_per_step_module
.load_function("ema_update_per_step")
.context("load ema_update_per_step")?;
let compute_advantage_return_module = ctx
.load_cubin(COMPUTE_ADVANTAGE_RETURN_CUBIN.to_vec())
.context("load compute_advantage_return cubin")?;
let compute_advantage_return_fn = compute_advantage_return_module
.load_function("compute_advantage_return")
.context("load compute_advantage_return")?;
// Phase R4 kernels.
let rl_action_kernel_module = ctx
.load_cubin(RL_ACTION_KERNEL_CUBIN.to_vec())
.context("load rl_action_kernel cubin")?;
let rl_action_kernel_fn = rl_action_kernel_module
.load_function("rl_action_kernel")
.context("load rl_action_kernel")?;
let argmax_expected_q_module = ctx
.load_cubin(ARGMAX_EXPECTED_Q_CUBIN.to_vec())
.context("load argmax_expected_q cubin")?;
let argmax_expected_q_fn = argmax_expected_q_module
.load_function("argmax_expected_q")
.context("load argmax_expected_q")?;
let log_pi_at_action_module = ctx
.load_cubin(LOG_PI_AT_ACTION_CUBIN.to_vec())
.context("load log_pi_at_action cubin")?;
let log_pi_at_action_fn = log_pi_at_action_module
.load_function("log_pi_at_action")
.context("load log_pi_at_action")?;
// Phase R6 kernels.
let extract_realized_pnl_delta_module = ctx
.load_cubin(EXTRACT_REALIZED_PNL_DELTA_CUBIN.to_vec())
.context("load extract_realized_pnl_delta cubin")?;
let extract_realized_pnl_delta_fn = extract_realized_pnl_delta_module
.load_function("extract_realized_pnl_delta")
.context("load extract_realized_pnl_delta")?;
let apply_reward_scale_module = ctx
.load_cubin(APPLY_REWARD_SCALE_CUBIN.to_vec())
.context("load apply_reward_scale cubin")?;
let apply_reward_scale_fn = apply_reward_scale_module
.load_function("apply_reward_scale")
.context("load apply_reward_scale")?;
let actions_to_market_targets_module = ctx
.load_cubin(ACTIONS_TO_MARKET_TARGETS_CUBIN.to_vec())
.context("load actions_to_market_targets cubin")?;
let actions_to_market_targets_fn = actions_to_market_targets_module
.load_function("actions_to_market_targets")
.context("load actions_to_market_targets")?;
// Phase R7a kernel.
let abs_copy_module = ctx
.load_cubin(ABS_COPY_CUBIN.to_vec())
.context("load abs_copy cubin")?;
let abs_copy_fn = abs_copy_module
.load_function("abs_copy")
.context("load abs_copy")?;
// Streaming kernels for the variance-ratio + kurtosis EMAs.
// Per R9 fix: these now fold ACROSS STEPS (Welford-EMA on
// per-batch means in ISV state slots) instead of across batch
// — works correctly at b_size=1 where per-batch variance is
// identically zero. The kernels write the smoothed estimate
// directly to the controller-input ISV slot; no separate
// ema_update_per_step is needed downstream.
let rl_var_over_abs_mean_streaming_module = ctx
.load_cubin(RL_VAR_OVER_ABS_MEAN_STREAMING_CUBIN.to_vec())
.context("load rl_var_over_abs_mean_streaming cubin")?;
let rl_var_over_abs_mean_streaming_fn = rl_var_over_abs_mean_streaming_module
.load_function("rl_var_over_abs_mean_streaming")
.context("load rl_var_over_abs_mean_streaming")?;
let rl_kurtosis_streaming_module = ctx
.load_cubin(RL_KURTOSIS_STREAMING_CUBIN.to_vec())
.context("load rl_kurtosis_streaming cubin")?;
let rl_kurtosis_streaming_fn = rl_kurtosis_streaming_module
.load_function("rl_kurtosis_streaming")
.context("load rl_kurtosis_streaming")?;
let ema_input_scratch_d = stream
.alloc_zeros::<f32>(1)
.context("alloc ema_input_scratch_d")?;
// Derivation kernels + per-batch state for KL approx, weight
// L2 divergence, and the trade-duration counter EMAs.
let rl_kl_approx_b_module = ctx
.load_cubin(RL_KL_APPROX_B_CUBIN.to_vec())
.context("load rl_kl_approx_b cubin")?;
let rl_kl_approx_b_fn = rl_kl_approx_b_module
.load_function("rl_kl_approx_b")
.context("load rl_kl_approx_b")?;
let rl_l2_diff_norm_module = ctx
.load_cubin(RL_L2_DIFF_NORM_CUBIN.to_vec())
.context("load rl_l2_diff_norm cubin")?;
let rl_l2_diff_norm_fn = rl_l2_diff_norm_module
.load_function("rl_l2_diff_norm")
.context("load rl_l2_diff_norm")?;
let rl_step_counter_update_module = ctx
.load_cubin(RL_STEP_COUNTER_UPDATE_CUBIN.to_vec())
.context("load rl_step_counter_update cubin")?;
let rl_step_counter_update_fn = rl_step_counter_update_module
.load_function("rl_step_counter_update")
.context("load rl_step_counter_update")?;
let rl_l2_norm_module = ctx
.load_cubin(RL_L2_NORM_CUBIN.to_vec())
.context("load rl_l2_norm cubin")?;
let rl_l2_norm_fn = rl_l2_norm_module
.load_function("rl_l2_norm")
.context("load rl_l2_norm")?;
// R9 — PPO ratio clamp controller + per-step log-ratio diag.
let rl_ppo_ratio_clamp_controller_module = ctx
.load_cubin(RL_PPO_RATIO_CLAMP_CONTROLLER_CUBIN.to_vec())
.context("load rl_ppo_ratio_clamp_controller cubin")?;
let rl_ppo_ratio_clamp_controller_fn = rl_ppo_ratio_clamp_controller_module
.load_function("rl_ppo_ratio_clamp_controller")
.context("load rl_ppo_ratio_clamp_controller")?;
let ppo_log_ratio_abs_max_b_module = ctx
.load_cubin(PPO_LOG_RATIO_ABS_MAX_B_CUBIN.to_vec())
.context("load ppo_log_ratio_abs_max_b cubin")?;
let ppo_log_ratio_abs_max_b_fn = ppo_log_ratio_abs_max_b_module
.load_function("ppo_log_ratio_abs_max_b")
.context("load ppo_log_ratio_abs_max_b")?;
let rl_streaming_clamp_init_module = ctx
.load_cubin(RL_STREAMING_CLAMP_INIT_CUBIN.to_vec())
.context("load rl_streaming_clamp_init cubin")?;
let rl_streaming_clamp_init_fn = rl_streaming_clamp_init_module
.load_function("rl_streaming_clamp_init")
.context("load rl_streaming_clamp_init")?;
// Per-batch PRNG state for the Thompson sampler. Seeded
// deterministically from cfg.dqn_seed via ChaCha8 host RNG so
// (cfg.dqn_seed, b_size) → identical Thompson draws across
// re-runs of the same trainer init per
// `pearl_scoped_init_seed_for_reproducibility`. Zero is the
// only forbidden xorshift32 state (would freeze) so the
// `.max(1)` guard ensures every batch's seed is non-zero.
let b_size = cfg.perception.n_batch;
let mut prng_host_rng =
<rand_chacha::ChaCha8Rng as SeedableRng>::seed_from_u64(
cfg.dqn_seed.wrapping_add(0xC0_C0_C0_C0),
);
let prng_seeds: Vec<u32> = (0..b_size)
.map(|_| prng_host_rng.gen::<u32>().max(1))
.collect();
let prng_state_d = stream
.alloc_zeros::<u32>(b_size)
.context("alloc prng_state_d")?;
// Mapped-pinned upload via inline DtoD per
// `feedback_no_htod_htoh_only_mapped_pinned`. One-shot at
// construction. Cast through i32 for the typed mapped-pinned
// buffer (u32 and i32 share width); the kernel reads as u32 —
// wraparound is well-defined.
{
let mut staging = unsafe { MappedI32Buffer::new(b_size) }
.map_err(|e| anyhow::anyhow!("prng_state staging: {e}"))?;
let dst = staging.host_slice_mut();
for (i, &v) in prng_seeds.iter().enumerate() {
dst[i] = v as i32;
}
unsafe {
let s = stream.cu_stream();
let (dev_dst, _g) = prng_state_d.device_ptr(&stream);
cudarc::driver::result::memcpy_dtod_async(
dev_dst,
staging.dev_ptr,
b_size * std::mem::size_of::<u32>(),
s,
)
.context("prng_state DtoD upload")?;
}
stream.synchronize().context("prng_state sync")?;
}
// Atom supports — structural constant (atom span [-1, +1]).
// Single allocation + upload at init; read every step by the
// Thompson + argmax kernels.
let atom_step = (Q_V_MAX - Q_V_MIN) / ((Q_N_ATOMS - 1) as f32);
let atom_supports_host: Vec<f32> = (0..Q_N_ATOMS)
.map(|i| Q_V_MIN + (i as f32) * atom_step)
.collect();
let mut atom_supports_d = stream
.alloc_zeros::<f32>(Q_N_ATOMS)
.context("alloc atom_supports_d")?;
// Mapped-pinned upload per
// `feedback_no_htod_htoh_only_mapped_pinned`. One-shot.
write_slice_f32_d(&stream, &atom_supports_host, &mut atom_supports_d)
.context("atom_supports upload")?;
// Phase R6 per-step buffers. prev_* are sentinel-zero at init
// so the first call's delta is `current - 0 = current`. For a
// freshly-constructed LobSimCuda, `pos.realized_pnl == 0` and
// `pos.position_lots == 0` too, so the first delta is 0 and
// the first done is false (prev_lots == 0 fails the `prev != 0`
// condition). Subsequent steps see real prev_* values.
let prev_realized_pnl_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc prev_realized_pnl_d")?;
let prev_position_lots_d = stream
.alloc_zeros::<i32>(b_size)
.context("alloc prev_position_lots_d")?;
let rewards_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc rewards_d")?;
let dones_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc dones_d")?;
// Per-batch trade-duration counter (mutated by
// `rl_step_counter_update`: incremented each step, reset on
// done) and its done-gated emit buffer (consumed by
// `ema_update_on_done` to feed `mean_trade_duration_ema`).
let steps_since_done_d = stream
.alloc_zeros::<i32>(b_size)
.context("alloc steps_since_done_d")?;
let trade_duration_emit_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc trade_duration_emit_d")?;
// Phase R7a per-step buffers (persistent so step_with_lobsim
// doesn't churn allocations every call).
let reward_abs_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc reward_abs_d")?;
let actions_d = stream
.alloc_zeros::<i32>(b_size)
.context("alloc actions_d")?;
let next_actions_d = stream
.alloc_zeros::<i32>(b_size)
.context("alloc next_actions_d")?;
let log_pi_old_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc log_pi_old_d")?;
let advantages_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc advantages_d")?;
let returns_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc returns_d")?;
// Phase R7c (data correctness): trainer-owned h_{t+1} buffer.
// Zero at init — first step_with_lobsim call writes it via the
// forward_encoder(next_snapshots) + DtoD copy out of
// perception.h_t_d BEFORE any consumer reads it.
let h_tp1_d = stream
.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
.context("alloc h_tp1_d")?;
// Phase R7d: PER buffer + sampled-batch device scratch.
// The replay buffer holds per-transition device payloads
// (h_t / next_h_t as small `CudaSlice<f32>(HIDDEN_DIM)` slices
// owned by each `Transition`). Sample → gather populates the
// trainer-owned sampled_* buffers via per-batch DtoD copies
// out of the buffer's transitions.
let replay = crate::rl::replay::ReplayBuffer::new(
cfg.per_capacity,
cfg.per_seed,
);
let sampled_h_t_d = stream
.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
.context("alloc sampled_h_t_d")?;
let sampled_h_tp1_d = stream
.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
.context("alloc sampled_h_tp1_d")?;
let sampled_actions_d = stream
.alloc_zeros::<i32>(b_size)
.context("alloc sampled_actions_d")?;
let sampled_rewards_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc sampled_rewards_d")?;
let sampled_dones_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc sampled_dones_d")?;
let sampled_next_actions_d = stream
.alloc_zeros::<i32>(b_size)
.context("alloc sampled_next_actions_d")?;
let td_per_sample_d = stream
.alloc_zeros::<f32>(b_size)
.context("alloc td_per_sample_d")?;
Ok(Self {
cfg,
perception,
dqn_head,
policy_head,
value_head,
dqn_w_adam,
dqn_b_adam,
policy_w_adam,
policy_b_adam,
value_w_adam,
value_b_adam,
isv_d,
isv_host,
stream,
_grad_h_module: grad_h_module,
grad_h_accumulate_fn,
_reduce_axis0_module: reduce_axis0_module,
reduce_axis0_fn,
_rl_lr_controller_module: rl_lr_controller_module,
rl_lr_controller_fn,
_rl_gamma_controller_module: rl_gamma_controller_module,
rl_gamma_controller_fn,
_rl_target_tau_controller_module: rl_target_tau_controller_module,
rl_target_tau_controller_fn,
_rl_ppo_clip_controller_module: rl_ppo_clip_controller_module,
rl_ppo_clip_controller_fn,
_rl_entropy_coef_controller_module: rl_entropy_coef_controller_module,
rl_entropy_coef_controller_fn,
_rl_rollout_steps_controller_module: rl_rollout_steps_controller_module,
rl_rollout_steps_controller_fn,
_rl_per_alpha_controller_module: rl_per_alpha_controller_module,
rl_per_alpha_controller_fn,
_rl_reward_scale_controller_module: rl_reward_scale_controller_module,
rl_reward_scale_controller_fn,
_ema_update_on_done_module: ema_update_on_done_module,
ema_update_on_done_fn,
_ema_update_per_step_module: ema_update_per_step_module,
ema_update_per_step_fn,
_compute_advantage_return_module: compute_advantage_return_module,
compute_advantage_return_fn,
_rl_action_kernel_module: rl_action_kernel_module,
rl_action_kernel_fn,
_argmax_expected_q_module: argmax_expected_q_module,
argmax_expected_q_fn,
_log_pi_at_action_module: log_pi_at_action_module,
log_pi_at_action_fn,
prng_state_d,
atom_supports_d,
_extract_realized_pnl_delta_module: extract_realized_pnl_delta_module,
extract_realized_pnl_delta_fn,
_apply_reward_scale_module: apply_reward_scale_module,
apply_reward_scale_fn,
_actions_to_market_targets_module: actions_to_market_targets_module,
actions_to_market_targets_fn,
_abs_copy_module: abs_copy_module,
abs_copy_fn,
_rl_var_over_abs_mean_streaming_module: rl_var_over_abs_mean_streaming_module,
rl_var_over_abs_mean_streaming_fn,
_rl_kurtosis_streaming_module: rl_kurtosis_streaming_module,
rl_kurtosis_streaming_fn,
ema_input_scratch_d,
_rl_kl_approx_b_module: rl_kl_approx_b_module,
rl_kl_approx_b_fn,
_rl_l2_diff_norm_module: rl_l2_diff_norm_module,
rl_l2_diff_norm_fn,
_rl_step_counter_update_module: rl_step_counter_update_module,
rl_step_counter_update_fn,
_rl_l2_norm_module: rl_l2_norm_module,
rl_l2_norm_fn,
_rl_ppo_ratio_clamp_controller_module: rl_ppo_ratio_clamp_controller_module,
rl_ppo_ratio_clamp_controller_fn,
_ppo_log_ratio_abs_max_b_module: ppo_log_ratio_abs_max_b_module,
ppo_log_ratio_abs_max_b_fn,
_rl_streaming_clamp_init_module: rl_streaming_clamp_init_module,
rl_streaming_clamp_init_fn,
steps_since_done_d,
trade_duration_emit_d,
prev_realized_pnl_d,
prev_position_lots_d,
rewards_d,
dones_d,
reward_abs_d,
actions_d,
next_actions_d,
log_pi_old_d,
advantages_d,
returns_d,
h_tp1_d,
replay,
sampled_h_t_d,
sampled_h_tp1_d,
sampled_actions_d,
sampled_rewards_d,
sampled_dones_d,
sampled_next_actions_d,
td_per_sample_d,
grad_h_t_combined_d,
last_pi_loss: 0.0,
last_q_loss: 0.0,
last_v_loss: 0.0,
step_counter: 0,
}
.with_controllers_bootstrapped()?)
}
/// 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-
/// bootstrap path per `pearl_first_observation_bootstrap`, and
/// writes its canonical default into the slot:
///
/// ISV[400] γ = target(trade_duration_ema) — 0.90 at
/// sentinel input (after the bootstrap audit; was
/// hardcoded 0.99 which coincided with
/// target(d≈69) creating a Wiener-blend
/// dead-zone at canonical hold time)
/// ISV[401] τ = 0.005 (TAU_BOOTSTRAP)
/// ISV[402] ε = 0.2 (EPS_BOOTSTRAP)
/// ISV[403] entropy_coef = target(entropy_observed_ema) — 0.035
/// at sentinel input (after the bootstrap audit;
/// was hardcoded 0.01 which coincided
/// with target(h≈1.099) creating a
/// Wiener-blend dead-zone at canonical
/// mid-range entropy)
/// ISV[404] n_rollout_steps= 2048 (ROLLOUT_BOOTSTRAP)
/// ISV[405] per_α = target(td_kurtosis_ema) — 0.4 at
/// sentinel input (after the bootstrap audit; was
/// hardcoded 0.6 which coincided with
/// target(kurt=10) creating a Wiener-
/// blend dead-zone at canonical market
/// kurtosis)
/// ISV[406] reward_scale = 1.0 (REWARD_SCALE_BOOTSTRAP)
///
/// The scalar `input` argument is IGNORED on the bootstrap path
/// (each kernel `return`s immediately after the write). Phase R5
/// reuses [`Self::launch_isv_controller_3arg`] with real EMA
/// inputs sourced from ISV[417..424] — at that point the Wiener-α
/// blend kicks in and the slots adapt from the bootstrap defaults.
///
/// Replaces the flawed Phase F approach of host-side `memcpy_htod`
/// of canonical constants, which violated
/// `feedback_no_htod_htoh_only_mapped_pinned` (tests not exempt)
/// and short-circuited `pearl_first_observation_bootstrap`.
///
/// Phase R5 update: each controller now reads its EMA input from
/// the corresponding ISV[417..423] slot via `input_slot`. At
/// bootstrap time those slots are also at `alloc_zeros` sentinel
/// zero, but the kernel returns before the input read (the
/// controller's own output slot is at sentinel zero, triggering
/// the `pearl_first_observation_bootstrap` return path). The same
/// helper, `launch_isv_controller_3arg`, serves bootstrap and
/// per-step launches.
fn with_controllers_bootstrapped(self) -> Result<Self> {
let alpha = RL_LR_CONTROLLER_ALPHA;
self.launch_isv_controller_3arg(
&self.rl_gamma_controller_fn,
alpha,
crate::rl::isv_slots::RL_MEAN_TRADE_DURATION_EMA_INDEX as i32,
)
.context("R1 bootstrap rl_gamma_controller")?;
self.launch_isv_controller_3arg(
&self.rl_target_tau_controller_fn,
alpha,
crate::rl::isv_slots::RL_Q_DIVERGENCE_EMA_INDEX as i32,
)
.context("R1 bootstrap rl_target_tau_controller")?;
self.launch_isv_controller_3arg(
&self.rl_ppo_clip_controller_fn,
alpha,
crate::rl::isv_slots::RL_KL_PI_EMA_INDEX as i32,
)
.context("R1 bootstrap rl_ppo_clip_controller")?;
self.launch_isv_controller_3arg(
&self.rl_entropy_coef_controller_fn,
alpha,
crate::rl::isv_slots::RL_ENTROPY_OBSERVED_EMA_INDEX as i32,
)
.context("R1 bootstrap rl_entropy_coef_controller")?;
self.launch_isv_controller_3arg(
&self.rl_rollout_steps_controller_fn,
alpha,
crate::rl::isv_slots::RL_ADVANTAGE_VAR_RATIO_EMA_INDEX as i32,
)
.context("R1 bootstrap rl_rollout_steps_controller")?;
self.launch_isv_controller_3arg(
&self.rl_per_alpha_controller_fn,
alpha,
crate::rl::isv_slots::RL_TD_KURTOSIS_EMA_INDEX as i32,
)
.context("R1 bootstrap rl_per_alpha_controller")?;
self.launch_isv_controller_3arg(
&self.rl_reward_scale_controller_fn,
alpha,
crate::rl::isv_slots::RL_MEAN_ABS_PNL_EMA_INDEX as i32,
)
.context("R1 bootstrap rl_reward_scale_controller")?;
// R9 — PPO ratio clamp ceiling. At bootstrap RL_PPO_CLIP_INDEX
// is still at sentinel zero; the kernel writes
// PPO_RATIO_CLAMP_BOOTSTRAP=10.0 and returns before reading.
self.launch_isv_controller_3arg(
&self.rl_ppo_ratio_clamp_controller_fn,
alpha,
crate::rl::isv_slots::RL_PPO_CLIP_INDEX as i32,
)
.context("R9 bootstrap rl_ppo_ratio_clamp_controller")?;
// R9 — seed streaming-kernel output clamp ceilings device-side
// (no HtoD per feedback_no_htod_htoh_only_mapped_pinned).
// Single-thread kernel writes design constants to ISV[447]
// and ISV[448] which the streaming variance / kurtosis kernels
// read each step to clamp their outputs.
{
let adv_var_slot: i32 =
crate::rl::isv_slots::RL_ADV_VAR_RATIO_CLAMP_INDEX as i32;
let adv_var_val: f32 = 100.0;
let td_kurt_slot: i32 =
crate::rl::isv_slots::RL_TD_KURTOSIS_CLAMP_INDEX as i32;
let td_kurt_val: f32 = 30.0;
// ISV-driven regression target for rl_rollout_steps_controller.
// 5.0 matches the b_size=1 streaming regime where
// var/|mean| naturally lives in [1, 10]. The prior
// hardcoded #define 0.1 was a b_size>1 design choice and
// caused 99.99% WIDEN events in alpha-rl-cvf86.
let adv_var_target_slot: i32 =
crate::rl::isv_slots::RL_ADV_VAR_RATIO_TARGET_INDEX as i32;
let adv_var_target_val: f32 = 5.0;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = self
.stream
.launch_builder(&self.rl_streaming_clamp_init_fn);
launch
.arg(&self.isv_d)
.arg(&adv_var_slot)
.arg(&adv_var_val)
.arg(&td_kurt_slot)
.arg(&td_kurt_val)
.arg(&adv_var_target_slot)
.arg(&adv_var_target_val);
unsafe {
launch
.launch(cfg)
.context("R9 launch rl_streaming_clamp_init")?;
}
}
self.stream
.synchronize()
.context("R1 controller bootstrap sync")?;
Ok(self)
}
/// Phase R1 / R5: single-thread launch of any of the 7 RL adaptive
/// controllers. All 7 share the `(isv*, alpha, input_slot)`
/// signature after R5; the kernel reads the EMA input from
/// `isv[input_slot]` directly, eliminating the 7 DtoH-per-step host
/// roundtrips a scalar-arg signature would have required (per
/// `feedback_cpu_is_read_only`).
///
/// At bootstrap (R1), the controller's output slot is at sentinel
/// zero; the kernel writes its `*_BOOTSTRAP` value and returns
/// before the input read, so `input_slot` can point at the (also
/// sentinel-zero) EMA-input slot safely. At per-step launches
/// (R5+), the EMA producer (Phase R3 `ema_update_*` kernels) has
/// populated `isv[input_slot]` with a real observation and the
/// Wiener-α-with-floor blend per
/// `pearl_wiener_alpha_floor_for_nonstationary` runs against it.
fn launch_isv_controller_3arg(
&self,
func: &CudaFunction,
alpha: f32,
input_slot: i32,
) -> Result<()> {
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = self.stream.launch_builder(func);
launch.arg(&self.isv_d).arg(&alpha).arg(&input_slot);
unsafe {
launch
.launch(cfg)
.context("isv controller 3-arg launch")?;
}
Ok(())
}
/// Phase R5: fire all 7 RL adaptive controllers in sequence,
/// each reading its EMA input from the corresponding ISV slot
/// populated by the Phase R3 `ema_update_*` kernels.
///
/// Slot pairing (output ← input):
/// ISV[400] γ ← ISV[417] MEAN_TRADE_DURATION_EMA
/// ISV[401] τ ← ISV[418] Q_DIVERGENCE_EMA
/// ISV[402] ε ← ISV[419] KL_PI_EMA
/// ISV[403] entropy_coef ← ISV[420] ENTROPY_OBSERVED_EMA
/// ISV[404] n_rollout_steps← ISV[421] ADVANTAGE_VAR_RATIO_EMA
/// ISV[405] per_α ← ISV[422] TD_KURTOSIS_EMA
/// ISV[406] reward_scale ← ISV[423] MEAN_ABS_PNL_EMA
///
/// R6's `step_with_lobsim` will call this AFTER the EMA producers
/// have updated the input slots. Idempotent on a single trainer
/// step but should fire exactly once.
pub fn launch_rl_controllers_per_step(&self) -> Result<()> {
let alpha = RL_LR_CONTROLLER_ALPHA;
self.launch_isv_controller_3arg(
&self.rl_gamma_controller_fn,
alpha,
crate::rl::isv_slots::RL_MEAN_TRADE_DURATION_EMA_INDEX as i32,
)
.context("R5 launch rl_gamma_controller")?;
self.launch_isv_controller_3arg(
&self.rl_target_tau_controller_fn,
alpha,
crate::rl::isv_slots::RL_Q_DIVERGENCE_EMA_INDEX as i32,
)
.context("R5 launch rl_target_tau_controller")?;
self.launch_isv_controller_3arg(
&self.rl_ppo_clip_controller_fn,
alpha,
crate::rl::isv_slots::RL_KL_PI_EMA_INDEX as i32,
)
.context("R5 launch rl_ppo_clip_controller")?;
self.launch_isv_controller_3arg(
&self.rl_entropy_coef_controller_fn,
alpha,
crate::rl::isv_slots::RL_ENTROPY_OBSERVED_EMA_INDEX as i32,
)
.context("R5 launch rl_entropy_coef_controller")?;
self.launch_isv_controller_3arg(
&self.rl_rollout_steps_controller_fn,
alpha,
crate::rl::isv_slots::RL_ADVANTAGE_VAR_RATIO_EMA_INDEX as i32,
)
.context("R5 launch rl_rollout_steps_controller")?;
self.launch_isv_controller_3arg(
&self.rl_per_alpha_controller_fn,
alpha,
crate::rl::isv_slots::RL_TD_KURTOSIS_EMA_INDEX as i32,
)
.context("R5 launch rl_per_alpha_controller")?;
self.launch_isv_controller_3arg(
&self.rl_reward_scale_controller_fn,
alpha,
crate::rl::isv_slots::RL_MEAN_ABS_PNL_EMA_INDEX as i32,
)
.context("R5 launch rl_reward_scale_controller")?;
// R9 — PPO ratio clamp ceiling, anchored on ε at RL_PPO_CLIP_INDEX
// (which the rl_ppo_clip_controller just refreshed two calls up).
self.launch_isv_controller_3arg(
&self.rl_ppo_ratio_clamp_controller_fn,
alpha,
crate::rl::isv_slots::RL_PPO_CLIP_INDEX as i32,
)
.context("R9 launch rl_ppo_ratio_clamp_controller")?;
Ok(())
}
/// Phase R3: launch `ema_update_on_done` — done-gated EMA producer
/// that writes the per-batch closed-trade mean into `isv[slot_index]`.
/// `alpha` is the Wiener-α (caller must pre-floor at 0.4 per
/// `pearl_wiener_alpha_floor_for_nonstationary`). `obs_d` and
/// `dones_d` are `[b_size]` device buffers; only entries where
/// `dones[b] >= 0.5` contribute to the EMA observation. If no
/// batch closed a trade this step, the kernel is a no-op (ISV slot
/// retains its prior value).
///
/// Shared-memory tree reduce; single block, `b_size` threads.
/// Caller's responsibility: `b_size <= 1024` (one block max).
pub fn launch_ema_update_on_done(
&self,
slot_index: usize,
alpha: f32,
obs_d: &CudaSlice<f32>,
dones_d: &CudaSlice<f32>,
b_size: usize,
) -> Result<()> {
debug_assert_eq!(obs_d.len(), b_size);
debug_assert_eq!(dones_d.len(), b_size);
debug_assert!(b_size <= 1024, "ema_update_on_done: b_size > 1024 needs multi-block reduce");
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (b_size as u32, 1, 1),
shared_mem_bytes: (2 * b_size * std::mem::size_of::<f32>()) as u32,
};
let slot_i = slot_index as i32;
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.ema_update_on_done_fn);
launch
.arg(&self.isv_d)
.arg(&slot_i)
.arg(&alpha)
.arg(obs_d)
.arg(dones_d)
.arg(&b_size_i);
unsafe {
launch.launch(cfg).context("ema_update_on_done launch")?;
}
Ok(())
}
/// Phase R3: launch `ema_update_per_step` — per-step EMA producer
/// that writes the per-batch mean into `isv[slot_index]` on every
/// call (no done-gate). Used for continuous EMAs (KL, entropy,
/// advantage variance). Same shared-mem tree-reduce as
/// `ema_update_on_done`.
pub fn launch_ema_update_per_step(
&self,
slot_index: usize,
alpha: f32,
obs_d: &CudaSlice<f32>,
b_size: usize,
) -> Result<()> {
debug_assert_eq!(obs_d.len(), b_size);
debug_assert!(b_size <= 1024, "ema_update_per_step: b_size > 1024 needs multi-block reduce");
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (b_size as u32, 1, 1),
shared_mem_bytes: (b_size * std::mem::size_of::<f32>()) as u32,
};
let slot_i = slot_index as i32;
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.ema_update_per_step_fn);
launch
.arg(&self.isv_d)
.arg(&slot_i)
.arg(&alpha)
.arg(obs_d)
.arg(&b_size_i);
unsafe {
launch.launch(cfg).context("ema_update_per_step launch")?;
}
Ok(())
}
/// Phase R4: launch `rl_action_kernel` — Thompson sampler over
/// the C51 Q distribution. Writes per-batch action indices to
/// `actions_d`. Advances per-batch xorshift32 PRNG state in place.
/// One block per batch, `N_ACTIONS` threads. `q_logits_d` is
/// `[b_size, N_ACTIONS, Q_N_ATOMS]` row-major.
pub fn launch_rl_action_kernel(
&mut self,
q_logits_d: &CudaSlice<f32>,
actions_d: &mut CudaSlice<i32>,
b_size: usize,
) -> Result<()> {
debug_assert_eq!(q_logits_d.len(), b_size * N_ACTIONS * Q_N_ATOMS);
debug_assert_eq!(actions_d.len(), b_size);
let cfg = LaunchConfig {
grid_dim: (b_size as u32, 1, 1),
block_dim: (N_ACTIONS as u32, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.rl_action_kernel_fn);
launch
.arg(q_logits_d)
.arg(&self.atom_supports_d)
.arg(&mut self.prng_state_d)
.arg(actions_d)
.arg(&b_size_i);
unsafe {
launch.launch(cfg).context("rl_action_kernel launch")?;
}
Ok(())
}
/// Phase R4: launch `argmax_expected_q` — Bellman-target argmax
/// over expected Q per action. Deterministic (no PRNG). Pairs with
/// `launch_rl_action_kernel` per
/// `pearl_thompson_for_distributional_action_selection` (Thompson
/// for rollout, argmax for Bellman target).
pub fn launch_argmax_expected_q(
&self,
q_logits_d: &CudaSlice<f32>,
next_actions_d: &mut CudaSlice<i32>,
b_size: usize,
) -> Result<()> {
debug_assert_eq!(q_logits_d.len(), b_size * N_ACTIONS * Q_N_ATOMS);
debug_assert_eq!(next_actions_d.len(), b_size);
let cfg = LaunchConfig {
grid_dim: (b_size as u32, 1, 1),
block_dim: (N_ACTIONS as u32, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.argmax_expected_q_fn);
launch
.arg(q_logits_d)
.arg(&self.atom_supports_d)
.arg(next_actions_d)
.arg(&b_size_i);
unsafe {
launch.launch(cfg).context("argmax_expected_q launch")?;
}
Ok(())
}
/// Phase R4: launch `log_pi_at_action` — per-batch
/// `log π(actions[b] | s_b)` via log-softmax + lookup. Feeds the
/// PPO importance ratio. One thread per batch entry.
pub fn launch_log_pi_at_action(
&self,
pi_logits_d: &CudaSlice<f32>,
actions_d: &CudaSlice<i32>,
log_pi_out_d: &mut CudaSlice<f32>,
b_size: usize,
) -> Result<()> {
debug_assert_eq!(pi_logits_d.len(), b_size * N_ACTIONS);
debug_assert_eq!(actions_d.len(), b_size);
debug_assert_eq!(log_pi_out_d.len(), b_size);
let grid_x = ((b_size as u32) + 31) / 32;
let cfg = LaunchConfig {
grid_dim: (grid_x.max(1), 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.log_pi_at_action_fn);
launch
.arg(pi_logits_d)
.arg(actions_d)
.arg(log_pi_out_d)
.arg(&b_size_i);
unsafe {
launch.launch(cfg).context("log_pi_at_action launch")?;
}
Ok(())
}
/// Phase R7a: launch `abs_copy` — element-wise `dst[b] = fabsf(src[b])`.
/// Feeds the `|reward|` signal into `ema_update_on_done` for the
/// `MEAN_ABS_PNL_EMA` slot without mutating the signed `rewards_d`.
pub fn launch_abs_copy(
&self,
src_d: &CudaSlice<f32>,
dst_d: &mut CudaSlice<f32>,
b_size: usize,
) -> Result<()> {
debug_assert_eq!(src_d.len(), b_size);
debug_assert_eq!(dst_d.len(), b_size);
let grid_x = ((b_size as u32) + 31) / 32;
let cfg = LaunchConfig {
grid_dim: (grid_x.max(1), 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.abs_copy_fn);
launch.arg(src_d).arg(dst_d).arg(&b_size_i);
unsafe {
launch.launch(cfg).context("abs_copy launch")?;
}
Ok(())
}
/// Phase R6: launch `apply_reward_scale` — element-wise
/// `scaled = rewards[b] * isv[RL_REWARD_SCALE_INDEX = 406]`,
/// then asymmetric clamp to `[-REWARD_CLAMP_LOSS, +REWARD_CLAMP_WIN]`
/// = `[-3, +1]` (per
/// `pearl_audit_unboundedness_for_implicit_asymmetry`).
/// Also writes per-step pre-clamp `max(|scaled|)` to
/// `ISV[RL_MAX_ABS_SCALED_REWARD_PRE_CLAMP_INDEX = 439]` for diag.
///
/// Single-block layout (no atomics per `pearl_no_atomicadd`).
/// Block dim = min(b_size, 256); shared memory = block_dim · 4 B
/// for the tree reduction.
pub fn launch_apply_reward_scale(
&self,
rewards_d: &mut CudaSlice<f32>,
b_size: usize,
) -> Result<()> {
debug_assert_eq!(rewards_d.len(), b_size);
let block_x: u32 = (b_size as u32).min(256).max(1);
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (block_x, 1, 1),
shared_mem_bytes: block_x * (std::mem::size_of::<f32>() as u32),
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.apply_reward_scale_fn);
launch.arg(&self.isv_d).arg(rewards_d).arg(&b_size_i);
unsafe {
launch.launch(cfg).context("apply_reward_scale launch")?;
}
Ok(())
}
/// Phase R3: launch `compute_advantage_return` — element-wise
/// `returns[b] = r + γ(1-done)·V(s_{t+1})`,
/// `advantages[b] = returns[b] V(s_t)`. Reads γ from `ISV[400]`
/// (R1 bootstraps to 0.99; R5 adapts via trade-duration EMA).
///
/// Element-wise; trivially parallel. Block layout: 32-thread blocks
/// covering `b_size`.
#[allow(clippy::too_many_arguments)]
pub fn launch_compute_advantage_return(
&self,
rewards_d: &CudaSlice<f32>,
dones_d: &CudaSlice<f32>,
v_t_d: &CudaSlice<f32>,
v_tp1_d: &CudaSlice<f32>,
returns_d: &mut CudaSlice<f32>,
advantages_d: &mut CudaSlice<f32>,
b_size: usize,
) -> Result<()> {
debug_assert_eq!(rewards_d.len(), b_size);
debug_assert_eq!(dones_d.len(), b_size);
debug_assert_eq!(v_t_d.len(), b_size);
debug_assert_eq!(v_tp1_d.len(), b_size);
debug_assert_eq!(returns_d.len(), b_size);
debug_assert_eq!(advantages_d.len(), b_size);
let b_size_i = b_size as i32;
let grid_x = ((b_size as u32) + 31) / 32;
let cfg = LaunchConfig {
grid_dim: (grid_x.max(1), 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = self.stream.launch_builder(&self.compute_advantage_return_fn);
launch
.arg(&self.isv_d)
.arg(rewards_d)
.arg(dones_d)
.arg(v_t_d)
.arg(v_tp1_d)
.arg(returns_d)
.arg(advantages_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("compute_advantage_return launch")?;
}
Ok(())
}
/// Phase E.2 + E.2-DEFER: synthetic-batch end-to-end step. Runs real
/// GPU forward + backward + per-head Adam on Q, π, V heads on a
/// SHARED encoder; combined per-head `grad_h_t` flows back into the
/// encoder via `PerceptionTrainer::backward_encoder_with_grad_h_t`
/// so the encoder is no longer BCE+aux-only.
///
/// All learning rates are sourced from `ISV[RL_LR_*_INDEX]` per
/// `pearl_controller_anchors_isv_driven`; the `lr` parameter is
/// removed (greenfield — no caller override). Bellman target
/// distribution is built on-device by `bellman_target_projection`
/// reading γ from `ISV[RL_GAMMA_INDEX=400]`.
///
/// Returns the per-head kernel-emitted losses and the combined-loss
/// total weighted by the ISV-driven λs.
/// Phase R7a refactor: step_synthetic reads the per-step inputs from
/// trainer-owned device buffers (`self.actions_d`, `self.rewards_d`,
/// `self.dones_d`, `self.next_actions_d`, `self.advantages_d`,
/// `self.returns_d`, `self.log_pi_old_d`) instead of taking host
/// slices. Caller's responsibility (step_with_lobsim) is to
/// populate those buffers via GPU kernels before calling. This
/// eliminates the 7 host-slice uploads (+4 DtoH copies in
/// step_with_lobsim's old handoff) that violated
/// `feedback_cpu_is_read_only` in the flawed Phase F+G branch.
pub fn step_synthetic(
&mut self,
snapshots: &[Mbp10RawInput],
) -> Result<IntegratedStepStats> {
let b_size = self.cfg.perception.n_batch;
if b_size == 0 {
anyhow::bail!("step_synthetic: empty batch (b_size = 0)");
}
// Validate per-batch input buffer lengths.
debug_assert_eq!(self.actions_d.len(), b_size);
debug_assert_eq!(self.rewards_d.len(), b_size);
debug_assert_eq!(self.dones_d.len(), b_size);
debug_assert_eq!(self.next_actions_d.len(), b_size);
debug_assert_eq!(self.advantages_d.len(), b_size);
debug_assert_eq!(self.returns_d.len(), b_size);
debug_assert_eq!(self.log_pi_old_d.len(), b_size);
// ── Step 1: encoder forward ──────────────────────────────────
let _ = self
.perception
.forward_encoder(snapshots)
.context("forward_encoder")?;
// ── Step 2: per-head LR controller emit + ISV host mirror ────
// The controller emits the bootstrap target on first call
// (sentinel-zero → 1e-3); subsequent calls Wiener-α blend.
// Use last step's losses as plateau-decay observations. At
// construction these are 0.0 (sentinel) and the controller's
// cold-start gate (early-return on observed_loss == 0) holds
// LR at LR_BOOTSTRAP for the first step.
self.launch_rl_lr_controller(
self.last_q_loss,
self.last_pi_loss,
self.last_v_loss,
)
.context("rl_lr_controller launch")?;
// Mapped-pinned ISV mirror refresh per
// `feedback_no_htod_htoh_only_mapped_pinned` — was a raw
// `stream.memcpy_dtoh` (forbidden by
// `feedback_no_htod_htoh_only_mapped_pinned`).
let isv_host_fresh =
read_slice_d(&self.stream, &self.isv_d, self.isv_host.len())
.context("step_synthetic: read isv")?;
self.isv_host.copy_from_slice(&isv_host_fresh);
let lambdas = read_loss_lambdas_from_isv(&self.isv_host);
// Mutate each per-head Adam's lr field from the ISV mirror. The
// controller has just emitted into ISV[412..417], so this read
// reflects this step's learning rate. Reading ALL five before
// touching ANY AdamW keeps the field-by-field mutations
// borrow-checker safe and avoids per-head DtoH roundtrips.
let lr_bce = self.isv_host[RL_LR_BCE_INDEX];
let lr_q = self.isv_host[RL_LR_Q_INDEX];
let lr_pi = self.isv_host[RL_LR_PI_INDEX];
let lr_v = self.isv_host[RL_LR_V_INDEX];
let lr_aux = self.isv_host[RL_LR_AUX_INDEX];
// The aux head LR is consumed by the PerceptionTrainer's aux
// optimisers; the perception trainer reads it through its own
// mutator (`set_lr_aux`). The integrated trainer only needs to
// propagate the value — its own Adam instances are Q / π / V.
// BCE LR currently rides the existing `lr_cfc` on the perception
// side (the BCE head's grad path is interlocked with the CfC
// backward through the K-loop). Phase E.3+ separates the BCE
// optimiser; until then we keep the BCE slot in ISV updated for
// forward-looking diagnostics + the controller's first-obs
// bootstrap, and we propagate `lr_aux` via the perception
// trainer's existing mutator hook.
let _ = lr_bce;
self.perception.set_lr_aux(lr_aux);
self.dqn_w_adam.lr = lr_q;
self.dqn_b_adam.lr = lr_q;
self.policy_w_adam.lr = lr_pi;
self.policy_b_adam.lr = lr_pi;
self.value_w_adam.lr = lr_v;
self.value_b_adam.lr = lr_v;
// Borrow encoder hidden state for forward kernels.
let h_t_borrow: &CudaSlice<f32> = self.perception.h_t_view();
debug_assert_eq!(h_t_borrow.len(), b_size * HIDDEN_DIM);
// ── Step 3: allocate per-step scratch ────────────────────────
let k_dqn = N_ACTIONS * Q_N_ATOMS;
let mut q_logits_d = self.stream.alloc_zeros::<f32>(b_size * k_dqn)?;
let mut pi_logits_d = self.stream.alloc_zeros::<f32>(b_size * N_ACTIONS)?;
let mut v_pred_d = self.stream.alloc_zeros::<f32>(b_size)?;
// Loss accumulators (atomicAdd into single floats from kernels).
let q_loss_d = self.stream.alloc_zeros::<f32>(1)?;
let pi_loss_d = self.stream.alloc_zeros::<f32>(1)?;
let pi_loss_entropy_d = self.stream.alloc_zeros::<f32>(1)?;
// Per-batch V loss scratch (reduced below).
let mut v_loss_per_batch_d = self.stream.alloc_zeros::<f32>(b_size)?;
let mut v_loss_sum_d = self.stream.alloc_zeros::<f32>(1)?;
// Per-batch diagnostic scratch (consumed by the surrogate kernel).
let mut pi_log_prob_d = self.stream.alloc_zeros::<f32>(b_size)?;
let mut entropy_d = self.stream.alloc_zeros::<f32>(b_size)?;
// Per-batch grad scratch (reduced across batches via reduce_axis0).
let mut q_grad_logits_d = self.stream.alloc_zeros::<f32>(b_size * k_dqn)?;
let mut q_grad_w_per_batch_d = self
.stream
.alloc_zeros::<f32>(b_size * k_dqn * HIDDEN_DIM)?;
let mut q_grad_b_per_batch_d = self.stream.alloc_zeros::<f32>(b_size * k_dqn)?;
let mut q_grad_h_t_d = self.stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)?;
let mut q_grad_w_d = self.stream.alloc_zeros::<f32>(k_dqn * HIDDEN_DIM)?;
let mut q_grad_b_d = self.stream.alloc_zeros::<f32>(k_dqn)?;
let mut pi_grad_logits_d = self.stream.alloc_zeros::<f32>(b_size * N_ACTIONS)?;
let mut pi_grad_w_per_batch_d =
self.stream.alloc_zeros::<f32>(b_size * N_ACTIONS * HIDDEN_DIM)?;
let mut pi_grad_b_per_batch_d = self.stream.alloc_zeros::<f32>(b_size * N_ACTIONS)?;
let mut pi_grad_h_t_d = self.stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)?;
let mut pi_grad_w_d = self.stream.alloc_zeros::<f32>(N_ACTIONS * HIDDEN_DIM)?;
let mut pi_grad_b_d = self.stream.alloc_zeros::<f32>(N_ACTIONS)?;
let mut v_grad_w_per_batch_d = self.stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)?;
let mut v_grad_b_per_batch_d = self.stream.alloc_zeros::<f32>(b_size)?;
let mut v_grad_h_t_d = self.stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)?;
let mut v_grad_w_d = self.stream.alloc_zeros::<f32>(HIDDEN_DIM)?;
let mut v_grad_b_d = self.stream.alloc_zeros::<f32>(1)?;
// Bellman projection scratch (item 2).
let mut q_target_full_d = self.stream.alloc_zeros::<f32>(b_size * k_dqn)?;
let mut q_target_action_d = self.stream.alloc_zeros::<f32>(b_size * Q_N_ATOMS)?;
let mut target_dist_d = self.stream.alloc_zeros::<f32>(b_size * Q_N_ATOMS)?;
// ── Step 4 (Phase R7a): inputs are trainer-owned device buffers,
// populated by step_with_lobsim's GPU pipeline before this call.
// No host uploads here.
// ── Step 5: forward kernels (Q, π, V) ────────────────────────
// R7d: Q forward consumes PER-SAMPLED h_t (off-policy DQN);
// π and V forwards stay on current-step h_t (on-policy PPO + V).
// The shared encoder gets gradient signal ONLY from π + V (and
// BCE/aux via perception's separate step_batched path); Q's
// encoder-direction gradient is stop-grad'd (discarded in
// Step 10 by NOT accumulating q_grad_h_t into the encoder
// grad slot).
self.dqn_head
.forward(&self.sampled_h_t_d, b_size, &mut q_logits_d)
.context("dqn_head.forward(sampled_h_t) [R7d off-policy]")?;
self.policy_head
.forward_logits(h_t_borrow, b_size, &mut pi_logits_d)
.context("policy_head.forward_logits")?;
self.value_head
.forward(h_t_borrow, b_size, &mut v_pred_d)
.context("value_head.forward")?;
// R7d: Online Q at sampled_h_tp1 for the Double-DQN argmax that
// feeds the Bellman target. The argmax is recomputed every step
// because online net weights drift faster than transitions
// recycle through the replay buffer; storing it at push time
// would feed stale-action data into the projection.
let mut q_logits_tp1_sampled_d = self.stream.alloc_zeros::<f32>(b_size * k_dqn)?;
self.dqn_head
.forward(&self.sampled_h_tp1_d, b_size, &mut q_logits_tp1_sampled_d)
.context("dqn_head.forward(sampled_h_tp1) [R7d Double-DQN argmax src]")?;
{
let cfg_argmax = LaunchConfig {
grid_dim: (b_size as u32, 1, 1),
block_dim: (N_ACTIONS as u32, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.argmax_expected_q_fn);
launch
.arg(&q_logits_tp1_sampled_d)
.arg(&self.atom_supports_d)
.arg(&mut self.sampled_next_actions_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg_argmax)
.context("argmax_expected_q launch (sampled_h_tp1 → sampled_next_actions)")?;
}
}
// PPO surrogate forward — policy loss + entropy bonus only.
// V loss flows through the V-head kernels (item 4).
{
let mut pi_loss_d_mut = pi_loss_d;
let mut pi_loss_entropy_d_mut = pi_loss_entropy_d;
self.policy_head
.surrogate_forward(
&pi_logits_d,
&self.log_pi_old_d,
&self.actions_d,
&self.advantages_d,
&self.isv_d,
b_size,
&mut pi_log_prob_d,
&mut entropy_d,
&mut pi_loss_d_mut,
&mut pi_loss_entropy_d_mut,
)
.context("policy_head.surrogate_forward")?;
let l_pi_host = read_scalar_d(&self.stream, &pi_loss_d_mut)?;
let _l_pi_ent_host = read_scalar_d(&self.stream, &pi_loss_entropy_d_mut)?;
self.last_pi_loss = l_pi_host;
}
// ── Step 6a: Bellman target build (item 2) ───────────────────
// R7d off-policy: target Q on PER-SAMPLED h_{t+1}, action
// selector = sampled_next_actions (Double-DQN argmax on
// online_Q(sampled_h_tp1) computed just above), rewards/dones
// from the SAMPLED transitions' metadata. The projection
// itself reads γ from ISV[400] on-device.
//
// R7c data correctness lift (this commit's predecessor) put
// 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`.
self.dqn_head
.forward_target(&self.sampled_h_tp1_d, b_size, &mut q_target_full_d)
.context("dqn_head.forward_target(sampled_h_tp1) [R7d off-policy]")?;
self.dqn_head
.select_action_atoms(
&q_target_full_d,
&self.sampled_next_actions_d,
b_size,
&mut q_target_action_d,
)
.context("dqn_head.select_action_atoms (sampled_next_actions)")?;
self.dqn_head
.project_bellman_target(
&q_target_action_d,
&self.sampled_rewards_d,
&self.sampled_dones_d,
&self.isv_d,
b_size,
&mut target_dist_d,
)
.context("dqn_head.project_bellman_target (sampled rewards/dones)")?;
// ── Step 6b: DQN backward (logits → grad_w/b/h_t) ────────────
let mut q_loss_d_mut = q_loss_d;
self.dqn_head
.backward_logits(
&q_logits_d,
&target_dist_d,
&self.sampled_actions_d,
b_size,
&mut q_loss_d_mut,
&mut self.td_per_sample_d,
&mut q_grad_logits_d,
)
.context("dqn_head.backward_logits (sampled_actions) [R7d off-policy]")?;
let l_q_host = read_scalar_d(&self.stream, &q_loss_d_mut)?;
// Mirror for next step's LR controller plateau detection.
self.last_q_loss = l_q_host / (b_size as f32);
self.dqn_head
.backward_to_w_b_h(
&self.sampled_h_t_d,
&q_grad_logits_d,
b_size,
&mut q_grad_w_per_batch_d,
&mut q_grad_b_per_batch_d,
&mut q_grad_h_t_d,
)
.context("dqn_head.backward_to_w_b_h(sampled_h_t) [R7d off-policy]")?;
// R7d stop-grad: q_grad_h_t_d is the gradient wrt SAMPLED h_t
// (a past-step encoder output). Accumulating it into the
// shared encoder via grad_h_t_combined_d would poison the
// encoder with stale-state gradient signal. Standard pattern
// for off-policy + shared encoder (SAC / R2D2 / IMPALA do the
// same). The buffer is allocated above for backward_to_w_b_h
// to write into; we just don't FOLD it into the encoder grad
// combine below. Encoder learns from π + V (on-policy) +
// BCE/aux (supervised via step_batched) only.
let _ = &q_grad_h_t_d;
self.launch_reduce_axis0(&q_grad_w_per_batch_d, b_size, k_dqn * HIDDEN_DIM, &mut q_grad_w_d)?;
self.launch_reduce_axis0(&q_grad_b_per_batch_d, b_size, k_dqn, &mut q_grad_b_d)?;
// ── Step 7: PPO backward (logits → grad_w/b/h_t) ─────────────
self.policy_head
.surrogate_backward_logits(
&pi_logits_d,
&self.log_pi_old_d,
&self.actions_d,
&self.advantages_d,
&self.isv_d,
b_size,
&mut pi_grad_logits_d,
)
.context("policy_head.surrogate_backward_logits")?;
self.policy_head
.backward_to_w_b_h(
h_t_borrow,
&pi_grad_logits_d,
b_size,
&mut pi_grad_w_per_batch_d,
&mut pi_grad_b_per_batch_d,
&mut pi_grad_h_t_d,
)
.context("policy_head.backward_to_w_b_h")?;
self.launch_reduce_axis0(
&pi_grad_w_per_batch_d,
b_size,
N_ACTIONS * HIDDEN_DIM,
&mut pi_grad_w_d,
)?;
self.launch_reduce_axis0(&pi_grad_b_per_batch_d, b_size, N_ACTIONS, &mut pi_grad_b_d)?;
// ── Step 8: V backward ───────────────────────────────────────
self.value_head
.backward(
h_t_borrow,
&v_pred_d,
&self.returns_d,
b_size,
&mut v_loss_per_batch_d,
&mut v_grad_w_per_batch_d,
&mut v_grad_b_per_batch_d,
&mut v_grad_h_t_d,
)
.context("value_head.backward")?;
self.launch_reduce_axis0(&v_grad_w_per_batch_d, b_size, HIDDEN_DIM, &mut v_grad_w_d)?;
self.launch_reduce_axis0(&v_grad_b_per_batch_d, b_size, 1, &mut v_grad_b_d)?;
self.launch_reduce_axis0(&v_loss_per_batch_d, b_size, 1, &mut v_loss_sum_d)?;
let l_v_sum_host = read_scalar_d(&self.stream, &v_loss_sum_d)?;
let l_v_host = l_v_sum_host / (b_size as f32);
// Mirror for next step's LR controller plateau detection.
self.last_v_loss = l_v_host;
// ── Step 9: Adam updates on each head's w and b ──────────────
self.dqn_w_adam
.step(&mut self.dqn_head.w_d, &q_grad_w_d)
.context("dqn_w_adam.step")?;
self.dqn_b_adam
.step(&mut self.dqn_head.b_d, &q_grad_b_d)
.context("dqn_b_adam.step")?;
self.policy_w_adam
.step(&mut self.policy_head.w_d, &pi_grad_w_d)
.context("policy_w_adam.step")?;
self.policy_b_adam
.step(&mut self.policy_head.b_d, &pi_grad_b_d)
.context("policy_b_adam.step")?;
self.value_w_adam
.step(&mut self.value_head.w_d, &v_grad_w_d)
.context("value_w_adam.step")?;
self.value_b_adam
.step(&mut self.value_head.b_d, &v_grad_b_d)
.context("value_b_adam.step")?;
// ── Step 10: encoder grad combine (Phase E.2-DEFER item 1) ───
// Zero the combined slot, fold π+V grad_h_t with λ-weights.
//
// R7d stop-grad: Q's grad_h_t is OMITTED here per the off-policy
// shared-encoder pattern (Q is trained on PER-sampled past h_t
// values, whose gradient should NOT flow into the encoder
// — accumulating stale-state grad would poison the encoder).
// Encoder learns from π + V (on-policy current-step) + BCE +
// aux (supervised via perception.step_batched). Standard
// off-policy AC pattern (SAC / R2D2 / IMPALA do the same).
//
// The combined grad slot feeds `backward_encoder_with_grad_h_t`
// below, which runs the shared encoder backward chain that
// `dispatch_train_step` also uses (single source of truth per
// feedback_single_source_of_truth_no_duplicates).
self.stream
.memset_zeros(&mut self.grad_h_t_combined_d)
.map_err(|e| anyhow::anyhow!("zero grad_h_t_combined_d: {e}"))?;
accumulate_grad_h(
&self.stream,
&self.grad_h_accumulate_fn,
&pi_grad_h_t_d,
lambdas.pi,
&mut self.grad_h_t_combined_d,
)?;
accumulate_grad_h(
&self.stream,
&self.grad_h_accumulate_fn,
&v_grad_h_t_d,
lambdas.v,
&mut self.grad_h_t_combined_d,
)?;
// ── Step 11: encoder backward (Phase E.3a) ───────────────────
// Seed the perception trainer's per-K hidden-state grad buffer
// from `grad_h_t_combined_d` at slot K-1 (the only slot the
// RL heads consumed `h_t` from) and run the shared encoder
// backward kernel chain. This updates ALL encoder params:
// CfC ×4, LN ×2, VSN, attn_q, Mamba2 L1+L2 — closes the last
// deferred item from Phase E.2.
self.perception
.backward_encoder_with_grad_h_t(
&self.grad_h_t_combined_d,
self.cfg.perception.n_batch,
self.cfg.perception.seq_len,
)
.context("perception.backward_encoder_with_grad_h_t")?;
// ── Step 11b: feed step_synthetic's EMA inputs (entropy,
// td_kurtosis, KL) into their controller ISV slots. Deferred
// to AFTER the encoder backward so the `&self.perception`
// borrow (held by h_t_borrow earlier in this function) is
// released before the `&mut self` launches here.
//
// 1. `entropy_observed_ema` (ISV[420]) ← per-batch entropy
// from PPO surrogate forward (entropy_d still live here).
// ema_update_per_step does the mean reduce internally,
// so entropy_d goes in directly.
// 2. `td_kurtosis_ema` (ISV[422]) ← kurtosis of per-sample
// CE loss (td_per_sample_d). rl_kurtosis_b reduces to
// a scalar, then ema_update_per_step with b_size=1.
//
// Both updates take effect on the NEXT step's controller
// fire (controllers fire in step_with_lobsim BEFORE
// step_synthetic). One-step lag is acceptable — controllers
// adapt over many steps.
self.launch_ema_update_per_step(
crate::rl::isv_slots::RL_ENTROPY_OBSERVED_EMA_INDEX,
RL_LR_CONTROLLER_ALPHA,
&entropy_d,
b_size,
)
.context("ema_update_per_step(entropy_observed)")?;
// Streaming kurtosis kernel writes directly to
// RL_TD_KURTOSIS_EMA_INDEX; no separate ema_update_per_step
// needed (the streaming kernel IS the EMA — folds across STEPS).
self.launch_kurtosis(&self.td_per_sample_d.clone(), b_size)
.context("launch_kurtosis(td_per_sample_d)")?;
// kl_pi EMA — Schulman-style approximation
// `mean(log π_old(a) log π_new(a))` over the sampled action.
// log_pi_old_d was recorded at action-sample time (per-batch
// log π under the previous policy at the action taken);
// pi_log_prob_d (= log π_new at the same action) is the PPO
// surrogate forward's per-batch output. Mean-reduce to scalar
// → ema_update_per_step with b_size=1.
{
let block_dim = (b_size.next_power_of_two() as u32).max(1);
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (block_dim, 1, 1),
shared_mem_bytes: (block_dim as usize * std::mem::size_of::<f32>()) as u32,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.rl_kl_approx_b_fn);
launch
.arg(&self.log_pi_old_d)
.arg(&pi_log_prob_d)
.arg(&mut self.ema_input_scratch_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("rl_kl_approx_b launch")?;
}
}
// R9 diag — per-batch max |log π_new log π_old| → ISV[441].
// Same input buffers as rl_kl_approx_b but max-reduce |diff|
// instead of mean signed-diff. Surfaces the largest single
// policy excursion in this step's batch for the diag JSONL.
// Cross-check: log(isv[RL_PPO_RATIO_CLAMP_MAX_INDEX]) is the
// ceiling — when this slot exceeds that, the ratio clamp fired
// this step.
{
let block_dim = (b_size.next_power_of_two() as u32).max(1);
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (block_dim, 1, 1),
shared_mem_bytes: (block_dim as usize * std::mem::size_of::<f32>()) as u32,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.ppo_log_ratio_abs_max_b_fn);
launch
.arg(&self.log_pi_old_d)
.arg(&pi_log_prob_d)
.arg(&mut self.isv_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("ppo_log_ratio_abs_max_b launch")?;
}
}
self.launch_ema_update_per_step(
crate::rl::isv_slots::RL_KL_PI_EMA_INDEX,
RL_LR_CONTROLLER_ALPHA,
&self.ema_input_scratch_d.clone(),
1,
)
.context("ema_update_per_step(kl_pi)")?;
// Per-head gradient-norm EMAs (Q, π, V) — feed the
// signal-modulated LR controller (rl_lr_controller's target
// formula: `lr_prev × TARGET_GRAD_NORM / observed_grad_norm`).
// grad_w_*_d buffers are still in scope here (allocated at
// top of step_synthetic, consumed by the Adam steps in Step 9
// but not freed until function return); the l2_norm launches
// here read them after the Adam step has already used them.
let k_dqn_hidden = (N_ACTIONS * Q_N_ATOMS * HIDDEN_DIM) as usize;
self.launch_l2_norm(&q_grad_w_d.clone(), k_dqn_hidden)
.context("launch_l2_norm(q_grad_w_d)")?;
self.launch_ema_update_per_step(
crate::rl::isv_slots::RL_Q_GRAD_NORM_EMA_INDEX,
RL_LR_CONTROLLER_ALPHA,
&self.ema_input_scratch_d.clone(),
1,
)
.context("ema_update_per_step(q_grad_norm)")?;
let n_actions_hidden = (N_ACTIONS * HIDDEN_DIM) as usize;
self.launch_l2_norm(&pi_grad_w_d.clone(), n_actions_hidden)
.context("launch_l2_norm(pi_grad_w_d)")?;
self.launch_ema_update_per_step(
crate::rl::isv_slots::RL_PI_GRAD_NORM_EMA_INDEX,
RL_LR_CONTROLLER_ALPHA,
&self.ema_input_scratch_d.clone(),
1,
)
.context("ema_update_per_step(pi_grad_norm)")?;
self.launch_l2_norm(&v_grad_w_d.clone(), HIDDEN_DIM)
.context("launch_l2_norm(v_grad_w_d)")?;
self.launch_ema_update_per_step(
crate::rl::isv_slots::RL_V_GRAD_NORM_EMA_INDEX,
RL_LR_CONTROLLER_ALPHA,
&self.ema_input_scratch_d.clone(),
1,
)
.context("ema_update_per_step(v_grad_norm)")?;
// ── Step 12: compose stats ───────────────────────────────────
// BCE / aux losses are NOT read this phase — perception is driven
// separately by callers via its existing step_batched path. The
// encoder backward here learns ONLY from the Q+π+V grad_h_t
// contributions, which is by design (BCE+aux own their own loss
// sources via step_batched).
let l_bce = 0.0_f32;
let l_aux = 0.0_f32;
let l_pi = self.last_pi_loss;
// Normalise the Q CE by batch (matches l_pi / l_v conventions).
let l_q = l_q_host / (b_size as f32);
// Combined: λ-weighted average across 5 heads (per
// pearl_loss_balance_controller, sum over 5 heads / 5).
let l_total = (lambdas.bce * l_bce
+ lambdas.q * l_q
+ lambdas.pi * l_pi
+ lambdas.v * l_v_host
+ lambdas.aux * l_aux)
/ 5.0;
Ok(IntegratedStepStats {
l_bce,
l_q,
l_pi,
l_v: l_v_host,
l_aux,
l_total,
lambdas,
})
}
/// Phase R6: run one integrated training step driven by a real
/// `LobSimCuda` (via the narrow `RlLobBackend` trait, dep-cycle
/// break only). GPU-pure for the env interaction: Thompson-
/// sampled actions land in `lobsim.market_targets_d` via the
/// `actions_to_market_targets` kernel; the fill runs via
/// `lobsim.step_fill_from_market_targets`; rewards + done flags
/// extract from `lobsim.pos_d` via the
/// `extract_realized_pnl_delta` kernel. No per-batch host loop.
///
/// Replaces the synthetic args of [`step_synthetic`] with values
/// derived from a real action sampling + reward signal:
///
/// 1. `forward_encoder(snapshots)` → `h_t [B × HIDDEN_DIM]`.
/// 2. Forward Q-head + V-head; read `q_logits`, `v_pred` to host.
/// 3. Thompson-sample one action per batch from the per-action
/// atom distribution (per
/// `pearl_thompson_for_distributional_action_selection`).
/// 4. For each batch:
/// a. `lobsim.apply_snapshot(snapshot)`,
/// b. `lobsim.submit_action(batch_idx, action, ts_ns)`,
/// c. `lobsim.step_event(batch_idx, ts_ns, trade_signed_vol)`
/// → `(reward, done)`.
/// 5. Build the synthetic-style arg vectors from the real action /
/// reward / done values:
/// * `actions[b] = sampled action`,
/// * `rewards[b] = lobsim reward`,
/// * `dones[b] = 1.0 if lobsim done else 0.0`,
/// * `next_actions[b] = argmax_a Q(s_t, a)` (host-side
/// expected-value argmax — Phase F may
/// move this to a kernel),
/// * `advantages[b] = Q(s_t, a_t) - V(s_t)` (one-step
/// advantage from the same-state V),
/// * `returns[b] = reward + γ × (1 - done) × V(s_t)`
/// (one-step bootstrap target; γ is
/// read from the host-mirrored
/// `ISV[RL_GAMMA_INDEX]`),
/// * `log_pi_old[b] = log π_sampled(a_t | s_t)` recorded at
/// sample time (note: `step_synthetic`
/// re-runs the policy forward on the
/// same `h_t`, so log π_new == log
/// π_old on the first PPO pass — the
/// importance ratio is 1 and the
/// clipped surrogate's gradient flows
/// from the entropy + advantage path,
/// not the ratio. Phase F adds a
/// proper rollout buffer so multiple
/// PPO epochs see a non-trivial ratio).
/// 6. Delegate to [`Self::step_synthetic`] for the actual backward
/// + per-head Adam update + encoder backward — keeping the
/// training kernel chain a single source of truth per
/// `feedback_single_source_of_truth_no_duplicates`. The
/// encoder forward runs TWICE per step (once here for action
/// sampling, once inside `step_synthetic`); the encoder is
/// deterministic given the same input, so this is a pure
/// compute redundancy that Phase F can fuse.
///
/// Returns the same stats struct as `step_synthetic`. Tests
/// (`dqn_toy`, `ppo_toy`, `integrated_trainer_smoke`) drive this in
/// a loop with `MockLobEnv` to gate that the trainer converges on
/// the toy bandit optimal action.
pub fn step_with_lobsim(
&mut self,
snapshots: &[Mbp10RawInput],
next_snapshots: &[Mbp10RawInput],
lobsim: &mut dyn RlLobBackend,
) -> Result<IntegratedStepStats> {
let b_size = self.cfg.perception.n_batch;
if b_size == 0 {
anyhow::bail!("step_with_lobsim: empty batch (n_batch = 0)");
}
if snapshots.is_empty() {
anyhow::bail!("step_with_lobsim: snapshots empty (need ≥ 1 for apply_snapshot)");
}
if next_snapshots.len() != snapshots.len() {
anyhow::bail!(
"step_with_lobsim: next_snapshots.len() ({}) must match snapshots.len() ({}) — \
R8's CLI binary builds them via MultiHorizonLoader::next_sequence_pair (R2) which \
guarantees this invariant for adjacent (s_t, s_{{t+1}}) windows",
next_snapshots.len(),
snapshots.len()
);
}
// ── Step 1a (R7c): encoder forward on NEXT snapshots to land
// h_{t+1} at slot K-1. Done FIRST so the subsequent
// forward_encoder(snapshots) call leaves perception's internal
// forward state (h_new_per_k_d / CfC h_state_d) populated for
// the CURRENT-step encoder backward in step_synthetic — the
// most recent forward_encoder call wins. We DtoD-copy
// perception.h_t_d into the trainer-owned self.h_tp1_d
// immediately so the second forward_encoder doesn't clobber it.
let _ = self
.perception
.forward_encoder(next_snapshots)
.context("step_with_lobsim: forward_encoder(next_snapshots)")?;
{
let nbytes = b_size * HIDDEN_DIM * std::mem::size_of::<f32>();
unsafe {
let s = self.stream.cu_stream();
let (src, _gs) = self.perception.h_t_view().device_ptr(&self.stream);
let (dst, _gd) = self.h_tp1_d.device_ptr_mut(&self.stream);
cudarc::driver::result::memcpy_dtod_async(dst, src, nbytes, s)
.context("step_with_lobsim: DtoD perception.h_t_d → self.h_tp1_d")?;
}
}
// ── Step 1b: encoder forward on CURRENT snapshots — leaves
// perception's internal forward state primed for the encoder
// backward in step_synthetic, AND lands h_t at slot K-1 for
// the action-sampling forwards below.
let _ = self
.perception
.forward_encoder(snapshots)
.context("step_with_lobsim: forward_encoder(snapshots)")?;
// ── Step 2: Q + V forwards on h_t for action sampling. ────────
let h_t_borrow: &CudaSlice<f32> = self.perception.h_t_view();
debug_assert_eq!(h_t_borrow.len(), b_size * HIDDEN_DIM);
debug_assert_eq!(self.h_tp1_d.len(), b_size * HIDDEN_DIM);
let k_dqn = N_ACTIONS * Q_N_ATOMS;
let mut q_logits_d = self.stream.alloc_zeros::<f32>(b_size * k_dqn)?;
let mut q_logits_tp1_d = self.stream.alloc_zeros::<f32>(b_size * k_dqn)?;
let mut v_pred_d = self.stream.alloc_zeros::<f32>(b_size)?;
let mut v_pred_tp1_d = self.stream.alloc_zeros::<f32>(b_size)?;
self.dqn_head
.forward(h_t_borrow, b_size, &mut q_logits_d)
.context("step_with_lobsim: dqn_head.forward(h_t)")?;
self.dqn_head
.forward(&self.h_tp1_d, b_size, &mut q_logits_tp1_d)
.context("step_with_lobsim: dqn_head.forward(h_tp1) for Double-DQN argmax")?;
self.value_head
.forward(h_t_borrow, b_size, &mut v_pred_d)
.context("step_with_lobsim: value_head.forward(h_t)")?;
self.value_head
.forward(&self.h_tp1_d, b_size, &mut v_pred_tp1_d)
.context("step_with_lobsim: value_head.forward(h_tp1) for true V(s_{t+1})")?;
// ── Step 2b: Forward π logits for log_pi_old. ─────────────────
let mut pi_logits_d = self.stream.alloc_zeros::<f32>(b_size * N_ACTIONS)?;
self.policy_head
.forward_logits(h_t_borrow, b_size, &mut pi_logits_d)
.context("step_with_lobsim: policy_head.forward_logits")?;
// ── Step 3 (Phase R7b): GPU-pure action sampling. ─────────────
// R4's rl_action_kernel performs Thompson sampling on device
// (per-batch xorshift32 PRNG, per-action softmax + CDF walk,
// argmax over sampled per-action returns). argmax_expected_q
// computes the Bellman-target argmax (deterministic, expected
// value rather than sampled) per
// `pearl_thompson_for_distributional_action_selection`.
// log_pi_at_action computes log π_old via log-softmax + lookup.
//
// The host Thompson + host argmax + host log-softmax loops the
// flawed Phase F+G shipped are GONE. The xorshift32 PRNG state
// is per-batch device-resident; the host-side step_counter
// increment that drove the old ChaCha8 host RNG is also gone.
self.step_counter = self.step_counter.wrapping_add(1);
{
let cfg = LaunchConfig {
grid_dim: (b_size as u32, 1, 1),
block_dim: (N_ACTIONS as u32, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.rl_action_kernel_fn);
launch
.arg(&q_logits_d)
.arg(&self.atom_supports_d)
.arg(&mut self.prng_state_d)
.arg(&mut self.actions_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("rl_action_kernel launch (Thompson)")?;
}
}
// argmax over expected Q for next_actions — Double-DQN: argmax
// of ONLINE Q at h_{t+1} (per
// `pearl_thompson_for_distributional_action_selection`'s
// distinction between Thompson selector (on h_t for rollout)
// and argmax Bellman selector (on h_{t+1} for target)).
//
// R7c fix: was reading q_logits_d (online Q at h_t) which
// produced an off-by-one-time-index Bellman argmax since R4.
// Now reads q_logits_tp1_d (online Q at h_{t+1}) which is the
// canonical Double-DQN target-action selector.
{
let cfg = LaunchConfig {
grid_dim: (b_size as u32, 1, 1),
block_dim: (N_ACTIONS as u32, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.argmax_expected_q_fn);
launch
.arg(&q_logits_tp1_d)
.arg(&self.atom_supports_d)
.arg(&mut self.next_actions_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("argmax_expected_q launch (Bellman target on h_tp1)")?;
}
}
// log π_old at the sampled action — feeds the PPO importance
// ratio. Reads actions_d (just written by rl_action_kernel).
{
let grid_x = ((b_size as u32) + 31) / 32;
let cfg = LaunchConfig {
grid_dim: (grid_x.max(1), 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.log_pi_at_action_fn);
launch
.arg(&pi_logits_d)
.arg(&self.actions_d)
.arg(&mut self.log_pi_old_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("log_pi_at_action launch")?;
}
}
// ── Step 5 (Phase R6): GPU-pure env step. ─────────────────────
// No per-batch host loop — actions land in lobsim's
// market_targets_d via the `actions_to_market_targets` kernel
// (which reads current position_lots from lobsim.pos_d to
// resolve conditional Flat-from-Long/Short sizes), the fill
// runs via `step_fill_from_market_targets`, and rewards +
// done flags extract from the post-fill Pos array via
// `extract_realized_pnl_delta` (reading pos.realized_pnl
// delta against the trainer's prev_realized_pnl_d snapshot
// taken at end of the previous step).
//
// Phase E.3b broadcasts the LAST snapshot in the window to
// the book; Phase F may stream every snapshot through.
let last_snap = snapshots
.last()
.expect("snapshots non-empty: guarded above");
lobsim
.apply_snapshot(last_snap)
.context("step_with_lobsim: lobsim.apply_snapshot")?;
// GPU translate (action index → market_targets {side, size}).
// Reads current position_lots from lobsim.pos_d for Flat-from-*
// actions; reads actions from self.actions_d (just written by
// rl_action_kernel above).
let pos_bytes_i = lobsim.pos_bytes() as i32;
let b_size_i = b_size as i32;
{
let (pos_d_ref, market_targets_d) = lobsim.pos_and_market_targets_mut();
let cfg = LaunchConfig {
grid_dim: (((b_size as u32) + 31) / 32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch =
self.stream.launch_builder(&self.actions_to_market_targets_fn);
launch
.arg(&self.actions_d)
.arg(pos_d_ref)
.arg(market_targets_d)
.arg(&b_size_i)
.arg(&pos_bytes_i);
unsafe {
launch
.launch(cfg)
.context("actions_to_market_targets launch")?;
}
}
// Run the fill + pnl_track on whatever's now in
// market_targets_d.
lobsim
.step_fill_from_market_targets(last_snap.ts_ns)
.context("step_with_lobsim: lobsim.step_fill_from_market_targets")?;
// Extract per-batch (reward, done) deltas vs the trainer's
// prev_realized_pnl_d snapshot. Updates prev_* in place for
// the next step's delta.
{
let pos_d_ref: &CudaSlice<u8> = lobsim.pos_d();
let cfg = LaunchConfig {
grid_dim: (((b_size as u32) + 31) / 32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = self
.stream
.launch_builder(&self.extract_realized_pnl_delta_fn);
launch
.arg(pos_d_ref)
.arg(&mut self.prev_realized_pnl_d)
.arg(&mut self.prev_position_lots_d)
.arg(&mut self.rewards_d)
.arg(&mut self.dones_d)
.arg(&b_size_i)
.arg(&pos_bytes_i);
unsafe {
launch
.launch(cfg)
.context("extract_realized_pnl_delta launch")?;
}
}
// Trade-duration counter update + done-gated EMA emit. The
// counter increments every step; on done it emits the count
// (= trade duration in events) into `trade_duration_emit_d`
// and resets. `ema_update_on_done` then folds the per-batch
// duration values into ISV[417] for the rl_gamma controller.
{
let cfg = LaunchConfig {
grid_dim: (((b_size as u32) + 31) / 32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch =
self.stream.launch_builder(&self.rl_step_counter_update_fn);
launch
.arg(&self.dones_d)
.arg(&mut self.steps_since_done_d)
.arg(&mut self.trade_duration_emit_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("rl_step_counter_update launch")?;
}
}
{
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (b_size as u32, 1, 1),
shared_mem_bytes: (2 * b_size * std::mem::size_of::<f32>()) as u32,
};
let slot_i = crate::rl::isv_slots::RL_MEAN_TRADE_DURATION_EMA_INDEX as i32;
let alpha = RL_LR_CONTROLLER_ALPHA;
let mut launch = self.stream.launch_builder(&self.ema_update_on_done_fn);
launch
.arg(&self.isv_d)
.arg(&slot_i)
.arg(&alpha)
.arg(&self.trade_duration_emit_d)
.arg(&self.dones_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("ema_update_on_done(mean_trade_duration) launch")?;
}
}
// ── Step 6 (Phase R7c): GPU-pure post-fill pipeline. ──────────
// All actions / next_actions / log_pi_old are already in
// trainer-owned device buffers (R4 kernels wrote them in Step 3).
// No host uploads here. V(s_t) and V(s_{t+1}) BOTH live on
// device — v_pred_d from value_head.forward(h_t),
// v_pred_tp1_d from value_head.forward(h_tp1) (R7c data
// correctness lift: was an alias of v_pred_d in R7a/R7b).
// |reward| → reward_abs_d (input for mean_abs_pnl EMA).
// Inline launch (vs `launch_abs_copy`) because the per-method
// helper would take `&self` + `&mut CudaSlice` separately,
// conflicting with the disjoint-field borrow split here.
{
let cfg = LaunchConfig {
grid_dim: (((b_size as u32) + 31) / 32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = self.stream.launch_builder(&self.abs_copy_fn);
launch
.arg(&self.rewards_d)
.arg(&mut self.reward_abs_d)
.arg(&b_size_i);
unsafe {
launch.launch(cfg).context("abs_copy launch")?;
}
}
// Update ISV[423] MEAN_ABS_PNL_EMA from |reward| over ALL
// non-zero reward events (NOT just trade closes).
//
// Originally gated on dones_d so only closed-trade realized
// PnL contributed to the EMA. Cluster smoke `alpha-rl-9cbpj`
// diag revealed that 170 of 266 non-zero reward events occur
// on non-done steps (mid-trade PnL deltas from trail-stop
// adjustments / mark-to-market / partial fills). These
// mid-trade swings can be 2-3× larger than realized close
// PnL — invisible to a done-gated EMA, they end up scaled
// by `reward_scale = 1/mean_abs_pnl_close` which is calibrated
// for the smaller magnitude. Result: V regression target
// spikes ×50-100 on volatile mid-trade steps (l_v=3,456
// observed at step 590 with raw $2,390 PnL × scale=0.0011).
//
// The kernel's "done" parameter is really a generic gate
// tested as `d >= 0.5`. Passing reward_abs_d as the gate
// gives "gate on |reward| ≥ 0.5" (= "non-zero reward" for
// any practical magnitude in dollars) — exactly the
// semantics we want. Zero-reward steps stay excluded so the
// EMA isn't biased toward zero on idle steps.
{
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (b_size as u32, 1, 1),
shared_mem_bytes: (2 * b_size * std::mem::size_of::<f32>()) as u32,
};
let slot_i = crate::rl::isv_slots::RL_MEAN_ABS_PNL_EMA_INDEX as i32;
let alpha = RL_LR_CONTROLLER_ALPHA;
let mut launch = self.stream.launch_builder(&self.ema_update_on_done_fn);
launch
.arg(&self.isv_d)
.arg(&slot_i)
.arg(&alpha)
.arg(&self.reward_abs_d)
.arg(&self.reward_abs_d) // gate on |reward|>0, not just on dones
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("ema_update_on_done(MEAN_ABS_PNL) launch")?;
}
}
// Fire all 7 RL controllers — each reads its ISV EMA-input
// slot and Wiener-blends its output. Critical: this happens
// BEFORE apply_reward_scale so the scale ISV[406] reflects
// this step's mean_abs_pnl EMA update.
self.launch_rl_controllers_per_step()
.context("launch_rl_controllers_per_step")?;
// Apply the reward scale on device (in place on rewards_d) +
// asymmetric clamp [-3, +1] + per-step pre-clamp max diag.
// Reads ISV[406] which the controller just updated; writes
// ISV[439] for the diag emitter.
//
// Single-block layout: tree reduction needs every thread in
// the same block to participate in __syncthreads, so we cap
// block_x at min(b_size, 256). For b_size > 256 the kernel's
// grid-stride loop walks the rest.
{
let block_x: u32 = (b_size as u32).min(256).max(1);
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (block_x, 1, 1),
shared_mem_bytes: block_x * (std::mem::size_of::<f32>() as u32),
};
let mut launch = self.stream.launch_builder(&self.apply_reward_scale_fn);
launch
.arg(&self.isv_d)
.arg(&mut self.rewards_d)
.arg(&b_size_i);
unsafe {
launch.launch(cfg).context("apply_reward_scale launch")?;
}
}
// GPU compute_advantage_return: returns_d, advantages_d
// populated for step_synthetic to consume.
{
let cfg = LaunchConfig {
grid_dim: (((b_size as u32) + 31) / 32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = self
.stream
.launch_builder(&self.compute_advantage_return_fn);
launch
.arg(&self.isv_d)
.arg(&self.rewards_d)
.arg(&self.dones_d)
.arg(&v_pred_d)
.arg(&v_pred_tp1_d)
.arg(&mut self.returns_d)
.arg(&mut self.advantages_d)
.arg(&b_size_i);
unsafe {
launch
.launch(cfg)
.context("compute_advantage_return launch")?;
}
}
// γ is read on-device by compute_advantage_return from
// ISV[400] — no host gamma scalar needed in this hot path
// post-R7b (the bootstrap-fallback host read of γ that the
// flawed branch did is gone with the host advantage loop).
// Feed `advantage_var_ratio_ema` (ISV[421] → rl_rollout_steps
// controller) from this step's advantages_d. Streaming kernel
// writes directly to ISV[421] (folds across STEPS — works at
// b_size=1 where per-batch variance is undefined). No
// ema_update_per_step needed downstream.
self.launch_var_over_abs_mean(&self.advantages_d.clone(), b_size)
.context("launch_var_over_abs_mean(advantages_d)")?;
// ── Step 7a (R7d): PER push + sample + gather. ────────────────
// Push CURRENT step's b_size transitions (one per batch) to
// ReplayBuffer; sample b_size indices (priority^α from
// ISV[405]) and gather into sampled_*_d buffers that
// step_synthetic's Q forward + Bellman + backward consume.
//
// ORDER MATTERS: push BEFORE sample so the first
// step_with_lobsim call has ≥ b_size transitions to sample from
// (sample_indices on an empty buffer returns Vec::new(), which
// would force a Q-skip warmup path; pushing first eliminates
// that path and keeps Q always-on per `feedback_always_per`).
//
// Need an ISV host mirror refresh first so sample_and_gather
// reads ISV[405] for per_α. The refresh inside step_synthetic
// is later (Step 2 of step_synthetic) — too late for this
// pre-step_synthetic call. The cost is one DtoH of the full
// ISV slice (424 floats), bounded.
// Mapped-pinned staging for ISV mirror refresh per
// `feedback_no_htod_htoh_only_mapped_pinned`. The full ISV
// slice is 424 floats; one mapped-pinned alloc + DtoD per
// step is bounded.
let isv_host_fresh =
read_slice_d(&self.stream, &self.isv_d, self.isv_host.len())
.context("step_with_lobsim: read isv (pre-PER)")?;
self.isv_host.copy_from_slice(&isv_host_fresh);
self.push_to_replay(b_size)
.context("step_with_lobsim: push_to_replay")?;
let per_indices = self
.sample_and_gather(b_size)
.context("step_with_lobsim: sample_and_gather")?;
// ── Step 7b: delegate to step_synthetic. ──────────────────────
// step_synthetic now reads:
// * SAMPLED buffers (sampled_h_t_d / sampled_h_tp1_d /
// sampled_actions_d / sampled_rewards_d / sampled_dones_d /
// sampled_next_actions_d) for the off-policy Q forward +
// Bellman target build + Q backward (PER's "always on" per
// `feedback_always_per`).
// * CURRENT-step buffers (h_t via perception.h_t_view(),
// actions_d, log_pi_old_d, advantages_d, returns_d) for
// the on-policy PPO surrogate + V regression.
//
// The encoder backward consumes ONLY π + V grad_h_t
// contributions (R7d stop-grad on Q's encoder grad — see
// step_synthetic Step 10 commentary).
let stats = self.step_synthetic(snapshots)?;
// ── Step 7c (R7d): PER priority update. ───────────────────────
// step_synthetic's Q backward populated self.td_per_sample_d
// (per-sample CE loss = |TD-error| analogue for distributional
// Q). DtoH and feed back to the replay buffer so the next
// step's sample picks the high-TD transitions per the
// canonical Schaul 2015 PER recipe.
if !per_indices.is_empty() {
let td_per_sample_host =
read_slice_d(&self.stream, &self.td_per_sample_d, b_size)
.context("step_with_lobsim: read td_per_sample_d")?;
self.replay
.update_priorities(&per_indices, &td_per_sample_host);
}
// Target-net soft update (Phase R5 + R6) — runs once per step
// after the Q-head Adam update inside step_synthetic. Reads τ
// from ISV[401].
let isv_d_clone = self.isv_d.clone();
self.dqn_head
.soft_update_target(&isv_d_clone)
.context("dqn_head.soft_update_target")?;
// q_divergence EMA — `‖W_online W_target‖₂` after the soft
// update. Single-block grid-stride reduce over the full
// weight tensor (24,192 floats), result feeds the
// rl_target_tau controller via ISV[418].
{
let n_total = self.dqn_head.w_d.len() as i32;
let block_dim: u32 = 256;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (block_dim, 1, 1),
shared_mem_bytes: (block_dim as usize * std::mem::size_of::<f32>()) as u32,
};
let mut launch = self.stream.launch_builder(&self.rl_l2_diff_norm_fn);
launch
.arg(&self.dqn_head.w_d)
.arg(&self.dqn_head.w_target_d)
.arg(&mut self.ema_input_scratch_d)
.arg(&n_total);
unsafe {
launch
.launch(cfg)
.context("rl_l2_diff_norm(W, W_target) launch")?;
}
}
self.launch_ema_update_per_step(
crate::rl::isv_slots::RL_Q_DIVERGENCE_EMA_INDEX,
RL_LR_CONTROLLER_ALPHA,
&self.ema_input_scratch_d.clone(),
1,
)
.context("ema_update_per_step(q_divergence)")?;
Ok(stats)
}
/// Phase R7d: push the current step's b_size transitions to the
/// PER replay buffer. Each transition stores per-batch slices of
/// `perception.h_t_view()` and `self.h_tp1_d` as newly-allocated
/// `CudaSlice<f32>(HIDDEN_DIM)` device buffers (owned by the
/// Transition; freed when the buffer's random-replacement evicts
/// the transition or when the trainer drops). Per-batch metadata
/// (action, reward, done, log_pi_old) crosses the device→host
/// boundary via mapped-pinned `memcpy_dtoh` calls — accepted cost
/// of host-side PER bookkeeping (the device-side hot path stays
/// pure; only PER's control-plane sees host traffic, per the
/// canonical Phase R7d design call documented in the rebuild
/// plan A9 + `feedback_always_per`).
fn push_to_replay(&mut self, b_size: usize) -> Result<()> {
use crate::rl::common::{N_ACTIONS, Transition};
// Per-batch metadata via mapped-pinned staging (the only
// permitted CPU↔GPU path per
// `feedback_no_htod_htoh_only_mapped_pinned`). Each helper
// allocates a mapped-pinned buffer, issues a DtoD copy from
// the device source, syncs, and returns a host Vec read from
// the page-mapped host_ptr.
let actions_host = read_slice_i32_d(&self.stream, &self.actions_d, b_size)
.context("push_to_replay: read actions_d")?;
let rewards_host = read_slice_d(&self.stream, &self.rewards_d, b_size)
.context("push_to_replay: read rewards_d")?;
let dones_host = read_slice_d(&self.stream, &self.dones_d, b_size)
.context("push_to_replay: read dones_d")?;
let log_pi_old_host = read_slice_d(&self.stream, &self.log_pi_old_d, b_size)
.context("push_to_replay: read log_pi_old_d")?;
// For each batch index, alloc per-transition device buffers
// and DtoD-copy the per-batch slice of h_t / h_tp1 in.
let hidden_nbytes = HIDDEN_DIM * std::mem::size_of::<f32>();
for b in 0..b_size {
let mut h_t_per_b = self
.stream
.alloc_zeros::<f32>(HIDDEN_DIM)
.context("push_to_replay: alloc h_t per-batch")?;
let mut h_tp1_per_b = self
.stream
.alloc_zeros::<f32>(HIDDEN_DIM)
.context("push_to_replay: alloc h_tp1 per-batch")?;
// Two separate inner scopes for the per-buffer DtoD so
// each `SyncOnDrop` guard drops before the next
// `device_ptr_mut` borrow / before the move into
// `Transition`. Without this, the borrow checker rejects
// the moves into `Transition { h_t, next_h_t, .. }` as
// overlapping the guard lifetimes (canonical borrow-checker
// dance for the cudarc raw-pointer API).
let off = (b * HIDDEN_DIM * std::mem::size_of::<f32>()) as u64;
unsafe {
let s = self.stream.cu_stream();
let (h_t_base, _g1) = self.perception.h_t_view().device_ptr(&self.stream);
let (dst, _gd) = h_t_per_b.device_ptr_mut(&self.stream);
cudarc::driver::result::memcpy_dtod_async(
dst, h_t_base + off, hidden_nbytes, s,
)
.context("push_to_replay: DtoD per-batch h_t slice")?;
}
unsafe {
let s = self.stream.cu_stream();
let (h_tp1_base, _g2) = self.h_tp1_d.device_ptr(&self.stream);
let (dst, _gd) = h_tp1_per_b.device_ptr_mut(&self.stream);
cudarc::driver::result::memcpy_dtod_async(
dst, h_tp1_base + off, hidden_nbytes, s,
)
.context("push_to_replay: DtoD per-batch h_tp1 slice")?;
}
self.replay.push(Transition {
h_t: h_t_per_b,
action: actions_host[b] as u32,
reward: rewards_host[b],
next_h_t: h_tp1_per_b,
done: dones_host[b] > 0.5,
log_pi_old: log_pi_old_host[b],
q_value_old: [0.0; N_ACTIONS],
});
}
Ok(())
}
/// Phase R7d: sample `b_size` indices from PER (priority^α from
/// `ISV[RL_PER_ALPHA_INDEX=405]`) and gather the corresponding
/// transitions' h_t / h_tp1 device payloads + action / reward /
/// done metadata into trainer-owned `sampled_*_d` buffers via
/// per-batch DtoD + DtoH→HtoD round-trips. Returns the indices
/// so the caller can pass them back to
/// `replay.update_priorities(indices, td_per_sample_host)` after
/// the Q backward writes per-sample CE into `self.td_per_sample_d`.
fn sample_and_gather(&mut self, b_size: usize) -> Result<Vec<usize>> {
let per_alpha = self.isv_host[crate::rl::isv_slots::RL_PER_ALPHA_INDEX];
// R1 bootstrap = 0.6; controller can drift it. Bound-check at
// the consumer site so a transient zero doesn't degenerate the
// sampling. ReplayBuffer.sample_indices already falls back to
// uniform when total weights are zero, so this is layered
// defense per `pearl_kernel_input_filter_must_match_validation_surface`.
let per_alpha_safe = if per_alpha > 0.0 { per_alpha } else { 0.6 };
let indices = self.replay.sample_indices(b_size, per_alpha_safe);
if indices.len() != b_size {
// Buffer hasn't been pushed yet (cold start, before
// push_to_replay) — return empty to signal "skip Q
// backward this step". Caller handles by zeroing λ_q
// for the off-policy path.
return Ok(indices);
}
// Gather per-batch metadata into HtoD-stagable Vecs.
let mut actions_host = vec![0i32; b_size];
let mut rewards_host = vec![0.0f32; b_size];
let mut dones_host = vec![0.0f32; b_size];
for (i, &idx) in indices.iter().enumerate() {
let t = &self.replay.transitions[idx];
actions_host[i] = t.action as i32;
rewards_host[i] = t.reward;
dones_host[i] = if t.done { 1.0 } else { 0.0 };
}
// Mapped-pinned staging — the only permitted CPU→GPU path
// per `feedback_no_htod_htoh_only_mapped_pinned`.
write_slice_i32_d(&self.stream, &actions_host, &mut self.sampled_actions_d)
.context("sample_and_gather: write sampled_actions_d")?;
write_slice_f32_d(&self.stream, &rewards_host, &mut self.sampled_rewards_d)
.context("sample_and_gather: write sampled_rewards_d")?;
write_slice_f32_d(&self.stream, &dones_host, &mut self.sampled_dones_d)
.context("sample_and_gather: write sampled_dones_d")?;
// Per-batch DtoD: per-transition h_t / h_tp1 device buffers →
// contiguous `sampled_h_t_d` / `sampled_h_tp1_d` at slot i.
let hidden_nbytes = HIDDEN_DIM * std::mem::size_of::<f32>();
for (i, &idx) in indices.iter().enumerate() {
unsafe {
let s = self.stream.cu_stream();
let off = (i * HIDDEN_DIM * std::mem::size_of::<f32>()) as u64;
let (src_ht, _g1) = self.replay.transitions[idx]
.h_t
.device_ptr(&self.stream);
let (src_htp1, _g2) = self.replay.transitions[idx]
.next_h_t
.device_ptr(&self.stream);
let (dst_ht_base, _gd1) =
self.sampled_h_t_d.device_ptr_mut(&self.stream);
cudarc::driver::result::memcpy_dtod_async(
dst_ht_base + off, src_ht, hidden_nbytes, s,
)
.context("sample_and_gather: DtoD per-batch h_t")?;
let (dst_htp1_base, _gd2) =
self.sampled_h_tp1_d.device_ptr_mut(&self.stream);
cudarc::driver::result::memcpy_dtod_async(
dst_htp1_base + off, src_htp1, hidden_nbytes, s,
)
.context("sample_and_gather: DtoD per-batch h_tp1")?;
}
}
Ok(indices)
}
/// Reduce `var(x) / max(|mean(x)|, 1e-6)` over `input_d[b_size]`
/// into `self.ema_input_scratch_d[1]`. Single block,
/// next-pow2(b_size) threads, shared mem reused across the two
/// passes inside the kernel.
fn launch_var_over_abs_mean(
&mut self,
input_d: &CudaSlice<f32>,
b_size: usize,
) -> Result<()> {
debug_assert!(input_d.len() >= b_size);
// Single-thread streaming kernel; folds across STEPS (not batch).
// Writes var/|mean| directly into the controller-input ISV slot
// — caller does NOT need a downstream ema_update_per_step.
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let out_slot: i32 =
crate::rl::isv_slots::RL_ADVANTAGE_VAR_RATIO_EMA_INDEX as i32;
let mean_slot: i32 =
crate::rl::isv_slots::RL_ADV_VAR_STREAM_MEAN_INDEX as i32;
let m2_slot: i32 =
crate::rl::isv_slots::RL_ADV_VAR_STREAM_M2_INDEX as i32;
let clamp_slot: i32 =
crate::rl::isv_slots::RL_ADV_VAR_RATIO_CLAMP_INDEX as i32;
let mut launch = self.stream.launch_builder(
&self.rl_var_over_abs_mean_streaming_fn,
);
launch
.arg(input_d)
.arg(&mut self.isv_d)
.arg(&b_size_i)
.arg(&out_slot)
.arg(&mean_slot)
.arg(&m2_slot)
.arg(&clamp_slot);
unsafe {
launch
.launch(cfg)
.context("rl_var_over_abs_mean_streaming launch")?;
}
Ok(())
}
/// Reduce `‖x‖₂ = sqrt(Σ x²)` over `input_d[n]` into
/// `self.ema_input_scratch_d[1]`. Single block, 256 threads,
/// grid-stride loop covers arbitrary n via per-thread partial
/// accumulation + shared-mem tree-reduce. Used by the LR
/// controller's grad-norm input path (per-head grad_w_d L2 norm
/// fed to ema_update_per_step → ISV[424..426]).
fn launch_l2_norm(
&mut self,
input_d: &CudaSlice<f32>,
n: usize,
) -> Result<()> {
debug_assert!(input_d.len() >= n);
if n == 0 {
return Ok(());
}
let block_dim: u32 = 256;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (block_dim, 1, 1),
shared_mem_bytes: (block_dim as usize * std::mem::size_of::<f32>()) as u32,
};
let n_i = n as i32;
let mut launch = self.stream.launch_builder(&self.rl_l2_norm_fn);
launch
.arg(input_d)
.arg(&mut self.ema_input_scratch_d)
.arg(&n_i);
unsafe {
launch.launch(cfg).context("rl_l2_norm launch")?;
}
Ok(())
}
/// Streaming kurtosis `M4/M2²` that folds ACROSS STEPS (Welford-EMA
/// on per-batch means) rather than across batch. Writes the
/// smoothed estimate directly into `RL_TD_KURTOSIS_EMA_INDEX` —
/// caller does NOT need a downstream ema_update_per_step. Replaces
/// the prior per-batch reduction which returned 0 at b_size=1
/// (kurtosis undefined) and left the rl_per_alpha controller blind.
fn launch_kurtosis(
&mut self,
input_d: &CudaSlice<f32>,
b_size: usize,
) -> Result<()> {
debug_assert!(input_d.len() >= b_size);
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let out_slot: i32 =
crate::rl::isv_slots::RL_TD_KURTOSIS_EMA_INDEX as i32;
let mean_slot: i32 =
crate::rl::isv_slots::RL_TD_KURT_STREAM_MEAN_INDEX as i32;
let m2_slot: i32 =
crate::rl::isv_slots::RL_TD_KURT_STREAM_M2_INDEX as i32;
let m4_slot: i32 =
crate::rl::isv_slots::RL_TD_KURT_STREAM_M4_INDEX as i32;
let clamp_slot: i32 =
crate::rl::isv_slots::RL_TD_KURTOSIS_CLAMP_INDEX as i32;
let mut launch = self.stream.launch_builder(
&self.rl_kurtosis_streaming_fn,
);
launch
.arg(input_d)
.arg(&mut self.isv_d)
.arg(&b_size_i)
.arg(&out_slot)
.arg(&mean_slot)
.arg(&m2_slot)
.arg(&m4_slot)
.arg(&clamp_slot);
unsafe {
launch
.launch(cfg)
.context("rl_kurtosis_streaming launch")?;
}
Ok(())
}
/// Launch `rl_lr_controller` to emit per-head learning rates into
/// `ISV[412..417]` via ReduceLROnPlateau-style monotone decay.
/// Per-head observed loss (l_q / l_pi / l_v from step_synthetic's
/// stats this step) feeds the controller's slow loss EMA; the
/// controller halves LR when 1000 consecutive steps elapse
/// without a 1% improvement. BCE / AUX use placeholder zero
/// losses (the plateau function early-returns for those because
/// loss_ema_slot = -1).
///
/// Loss observations are passed as scalar floats (host-side
/// values from the trainer's last step). Per-head state lives
/// in ISV slots 427..439 (loss_ema, best, counter, warmup ×3).
fn launch_rl_lr_controller(
&self,
observed_loss_q: f32,
observed_loss_pi: f32,
observed_loss_v: f32,
) -> Result<()> {
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let observed_loss_bce: f32 = 0.0;
let observed_loss_aux: f32 = 0.0;
let q_loss_ema_slot: i32 =
crate::rl::isv_slots::RL_LR_Q_LOSS_EMA_INDEX as i32;
let q_best_slot: i32 =
crate::rl::isv_slots::RL_LR_Q_BEST_LOSS_INDEX as i32;
let q_counter_slot: i32 =
crate::rl::isv_slots::RL_LR_Q_STEPS_SINCE_BEST_INDEX as i32;
let q_warmup_slot: i32 =
crate::rl::isv_slots::RL_LR_Q_WARMUP_COUNTER_INDEX as i32;
let pi_loss_ema_slot: i32 =
crate::rl::isv_slots::RL_LR_PI_LOSS_EMA_INDEX as i32;
let pi_best_slot: i32 =
crate::rl::isv_slots::RL_LR_PI_BEST_LOSS_INDEX as i32;
let pi_counter_slot: i32 =
crate::rl::isv_slots::RL_LR_PI_STEPS_SINCE_BEST_INDEX as i32;
let pi_warmup_slot: i32 =
crate::rl::isv_slots::RL_LR_PI_WARMUP_COUNTER_INDEX as i32;
let v_loss_ema_slot: i32 =
crate::rl::isv_slots::RL_LR_V_LOSS_EMA_INDEX as i32;
let v_best_slot: i32 =
crate::rl::isv_slots::RL_LR_V_BEST_LOSS_INDEX as i32;
let v_counter_slot: i32 =
crate::rl::isv_slots::RL_LR_V_STEPS_SINCE_BEST_INDEX as i32;
let v_warmup_slot: i32 =
crate::rl::isv_slots::RL_LR_V_WARMUP_COUNTER_INDEX as i32;
let mut launch = self.stream.launch_builder(&self.rl_lr_controller_fn);
launch
.arg(&self.isv_d)
.arg(&observed_loss_bce)
.arg(&observed_loss_q)
.arg(&observed_loss_pi)
.arg(&observed_loss_v)
.arg(&observed_loss_aux)
.arg(&q_loss_ema_slot)
.arg(&q_best_slot)
.arg(&q_counter_slot)
.arg(&q_warmup_slot)
.arg(&pi_loss_ema_slot)
.arg(&pi_best_slot)
.arg(&pi_counter_slot)
.arg(&pi_warmup_slot)
.arg(&v_loss_ema_slot)
.arg(&v_best_slot)
.arg(&v_counter_slot)
.arg(&v_warmup_slot);
unsafe {
launch.launch(cfg).context("rl_lr_controller launch")?;
}
Ok(())
}
/// Launch the reduce_axis0 kernel:
/// per_batch: [b_size × n_tail] → out: [n_tail]
fn launch_reduce_axis0(
&self,
per_batch: &CudaSlice<f32>,
b_size: usize,
n_tail: usize,
out: &mut CudaSlice<f32>,
) -> Result<()> {
debug_assert_eq!(per_batch.len(), b_size * n_tail);
debug_assert_eq!(out.len(), n_tail);
let n_batch_i = b_size as i32;
let n_tail_i = n_tail as i32;
let cfg = LaunchConfig {
grid_dim: ((n_tail as u32).div_ceil(32), 1, 1),
block_dim: (32, 8, 1),
shared_mem_bytes: 0,
};
let mut launch = self.stream.launch_builder(&self.reduce_axis0_fn);
launch
.arg(per_batch)
.arg(&n_batch_i)
.arg(&n_tail_i)
.arg(out);
unsafe {
launch.launch(cfg).context("reduce_axis0 launch")?;
}
Ok(())
}
/// Phase E.2 helper: combine a per-head grad_h_t into the encoder's
/// accumulator slot via `grad_h_encoder[i] += λ × grad_h_head[i]`.
/// Public method delegates to the free function `accumulate_grad_h`
/// so the same launch path is reachable both from external callers
/// and from `step_synthetic` (where the borrow checker forbids a
/// `&self` + `&mut self.grad_h_t_combined_d` combination).
pub fn launch_grad_h_accumulate(
&self,
grad_h_head: &CudaSlice<f32>,
lambda: f32,
grad_h_encoder: &mut CudaSlice<f32>,
) -> Result<()> {
accumulate_grad_h(
&self.stream,
&self.grad_h_accumulate_fn,
grad_h_head,
lambda,
grad_h_encoder,
)
}
/// Phase E.3b evaluation helper: forward the encoder on `snapshots`,
/// forward the C51 Q-head, and return the expected Q-value E[Q(s, a)]
/// per action, averaged across the batch. Lets the toy-bandit gate
/// tests inspect the trained Q-distribution's argmax without
/// re-implementing the device-to-host readback inside the test
/// crate.
///
/// Returns a `Vec<f32>` of length `N_ACTIONS = 9`. Index = action id
/// (see `crate::rl::common::Action`). Caller asserts on `argmax`.
pub fn eval_expected_q_per_action(
&mut self,
snapshots: &[Mbp10RawInput],
) -> Result<Vec<f32>> {
let b_size = self.cfg.perception.n_batch;
let _ = self
.perception
.forward_encoder(snapshots)
.context("eval_expected_q: forward_encoder")?;
let h_t = self.perception.h_t_view();
let k = N_ACTIONS * Q_N_ATOMS;
let mut logits_d = self.stream.alloc_zeros::<f32>(b_size * k)?;
self.dqn_head
.forward(h_t, b_size, &mut logits_d)
.context("eval_expected_q: dqn_head.forward")?;
let logits_host = read_slice_d(&self.stream, &logits_d, b_size * k)?;
let atom_step = (Q_V_MAX - Q_V_MIN) / ((Q_N_ATOMS - 1) as f32);
let atom_supports: Vec<f32> = (0..Q_N_ATOMS)
.map(|i| Q_V_MIN + (i as f32) * atom_step)
.collect();
let mut eq = vec![0.0_f32; N_ACTIONS];
for b in 0..b_size {
for a in 0..N_ACTIONS {
let off = b * k + a * Q_N_ATOMS;
let row = &logits_host[off..off + Q_N_ATOMS];
let max_l = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mut sum = 0.0_f32;
let mut probs = [0.0_f32; Q_N_ATOMS];
for (i, &l) in row.iter().enumerate() {
probs[i] = (l - max_l).exp();
sum += probs[i];
}
if sum > 0.0 {
for p in probs.iter_mut() {
*p /= sum;
}
}
let mut ev = 0.0_f32;
for i in 0..Q_N_ATOMS {
ev += probs[i] * atom_supports[i];
}
eq[a] += ev;
}
}
for e in eq.iter_mut() {
*e /= b_size as f32;
}
Ok(eq)
}
/// Phase E.3b evaluation helper: forward the encoder on `snapshots`,
/// forward the PPO policy head, return softmax(π) per action,
/// averaged across the batch. Used by the `ppo_toy` gate test to
/// inspect the trained policy's mode without re-implementing the
/// device-to-host readback in the test crate.
pub fn eval_policy_probs_per_action(
&mut self,
snapshots: &[Mbp10RawInput],
) -> Result<Vec<f32>> {
let b_size = self.cfg.perception.n_batch;
let _ = self
.perception
.forward_encoder(snapshots)
.context("eval_policy_probs: forward_encoder")?;
let h_t = self.perception.h_t_view();
let mut logits_d = self.stream.alloc_zeros::<f32>(b_size * N_ACTIONS)?;
self.policy_head
.forward_logits(h_t, b_size, &mut logits_d)
.context("eval_policy_probs: policy_head.forward_logits")?;
let logits_host = read_slice_d(&self.stream, &logits_d, b_size * N_ACTIONS)?;
let mut probs = vec![0.0_f32; N_ACTIONS];
for b in 0..b_size {
let off = b * N_ACTIONS;
let row = &logits_host[off..off + N_ACTIONS];
let max_l = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mut sum = 0.0_f32;
let mut p = [0.0_f32; N_ACTIONS];
for (i, &l) in row.iter().enumerate() {
p[i] = (l - max_l).exp();
sum += p[i];
}
if sum > 0.0 {
for x in p.iter_mut() {
*x /= sum;
}
}
for i in 0..N_ACTIONS {
probs[i] += p[i];
}
}
for p in probs.iter_mut() {
*p /= b_size as f32;
}
Ok(probs)
}
}
/// Free-function entry point for the `grad_h_accumulate_scaled` kernel.
/// Lives outside the impl so callers can hold a `&mut` reference to a
/// trainer-owned grad slot at the same time as a `&self.stream` /
/// `&self.grad_h_accumulate_fn` borrow.
fn accumulate_grad_h(
stream: &Arc<CudaStream>,
grad_h_accumulate_fn: &CudaFunction,
grad_h_head: &CudaSlice<f32>,
lambda: f32,
grad_h_encoder: &mut CudaSlice<f32>,
) -> Result<()> {
let n = grad_h_head.len();
debug_assert_eq!(grad_h_encoder.len(), n);
let n_i = n as i32;
let cfg = LaunchConfig {
grid_dim: ((n as u32).div_ceil(256), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = stream.launch_builder(grad_h_accumulate_fn);
launch
.arg(grad_h_head)
.arg(&lambda)
.arg(&n_i)
.arg(grad_h_encoder);
unsafe {
launch.launch(cfg).context("grad_h_accumulate launch")?;
}
Ok(())
}
// ── helpers ──────────────────────────────────────────────────────────
//
// Phase R7b: `upload_f32`, `upload_i32`, and `argmax_f32` are deleted
// because all step_with_lobsim host loops they served are GPU-resident
// now (R3/R4/R5/R6/R7a kernels). Per `feedback_no_hiding` the
// retired helpers are removed, not `#[allow(dead_code)]`'d. The
// remaining `read_*_d` helpers are still load-bearing for the per-step
// loss-scalar readback inside step_synthetic's Q + V backward chain.
/// Read `n` `i32`s from a device buffer into a host `Vec<i32>` via
/// mapped-pinned staging + DtoD + stream sync. Same pattern as
/// `read_slice_d` (f32 version) — the only permitted CPU↔GPU path
/// per `feedback_no_htod_htoh_only_mapped_pinned`. Used by R7d's
/// `push_to_replay` to read the per-step actions device buffer for
/// PER metadata staging.
pub fn read_slice_i32_d_pub(
stream: &Arc<CudaStream>,
src: &CudaSlice<i32>,
n: usize,
) -> Result<Vec<i32>> {
read_slice_i32_d(stream, src, n)
}
pub fn read_slice_d_pub(
stream: &Arc<CudaStream>,
src: &CudaSlice<f32>,
n: usize,
) -> Result<Vec<f32>> {
read_slice_d(stream, src, n)
}
fn read_slice_i32_d(
stream: &Arc<CudaStream>,
src: &CudaSlice<i32>,
n: usize,
) -> Result<Vec<i32>> {
debug_assert!(src.len() >= n);
if n == 0 {
return Ok(Vec::new());
}
let mut staging = unsafe { MappedI32Buffer::new(n) }
.map_err(|e| anyhow::anyhow!("read_slice_i32_d staging: {e}"))?;
unsafe {
let (src_ptr, _g) = src.device_ptr(stream);
cudarc::driver::result::memcpy_dtod_async(
staging.dev_ptr,
src_ptr,
n * std::mem::size_of::<i32>(),
stream.cu_stream(),
)
.context("read_slice_i32_d DtoD")?;
}
stream.synchronize().context("read_slice_i32_d sync")?;
Ok(staging.host_slice_mut().to_vec())
}
/// Write a host `&[f32]` slice into a device buffer via mapped-pinned
/// staging + DtoD. The only permitted CPU→GPU path per
/// `feedback_no_htod_htoh_only_mapped_pinned` — `stream.memcpy_htod`
/// on a regular `&[T]` is forbidden (the source slice is not
/// page-locked, so the driver does an internal HtoD blocking copy).
/// Mapped-pinned staging keeps the host write zero-copy on the
/// device side.
fn write_slice_f32_d(
stream: &Arc<CudaStream>,
src: &[f32],
dst: &mut CudaSlice<f32>,
) -> Result<()> {
debug_assert!(dst.len() >= src.len());
let n = src.len();
if n == 0 {
return Ok(());
}
let staging = unsafe { MappedF32Buffer::new(n) }
.map_err(|e| anyhow::anyhow!("write_slice_f32_d staging: {e}"))?;
staging.write_from_slice(src);
unsafe {
let (dst_ptr, _g) = dst.device_ptr_mut(stream);
cudarc::driver::result::memcpy_dtod_async(
dst_ptr,
staging.dev_ptr,
n * std::mem::size_of::<f32>(),
stream.cu_stream(),
)
.context("write_slice_f32_d DtoD")?;
}
stream.synchronize().context("write_slice_f32_d sync")?;
Ok(())
}
/// Write a host `&[i32]` slice into a device buffer via mapped-pinned
/// staging + DtoD. See `write_slice_f32_d` for rationale.
fn write_slice_i32_d(
stream: &Arc<CudaStream>,
src: &[i32],
dst: &mut CudaSlice<i32>,
) -> Result<()> {
debug_assert!(dst.len() >= src.len());
let n = src.len();
if n == 0 {
return Ok(());
}
let mut staging = unsafe { MappedI32Buffer::new(n) }
.map_err(|e| anyhow::anyhow!("write_slice_i32_d staging: {e}"))?;
staging.host_slice_mut()[..n].copy_from_slice(src);
unsafe {
let (dst_ptr, _g) = dst.device_ptr_mut(stream);
cudarc::driver::result::memcpy_dtod_async(
dst_ptr,
staging.dev_ptr,
n * std::mem::size_of::<i32>(),
stream.cu_stream(),
)
.context("write_slice_i32_d DtoD")?;
}
stream.synchronize().context("write_slice_i32_d sync")?;
Ok(())
}
fn read_scalar_d(stream: &Arc<CudaStream>, src: &CudaSlice<f32>) -> Result<f32> {
debug_assert!(src.len() >= 1);
let staging = unsafe { MappedF32Buffer::new(1) }
.map_err(|e| anyhow::anyhow!("read_scalar_d staging: {e}"))?;
unsafe {
let (src_ptr, _g) = src.device_ptr(stream);
cudarc::driver::result::memcpy_dtod_async(
staging.dev_ptr,
src_ptr,
std::mem::size_of::<f32>(),
stream.cu_stream(),
)
.context("read_scalar_d DtoD")?;
}
stream.synchronize().context("read_scalar_d sync")?;
Ok(unsafe { std::ptr::read_volatile(staging.host_ptr) })
}
/// Phase E.3b helper: read `n` floats from a device buffer into a host
/// `Vec<f32>` via mapped-pinned staging + DtoD copy + stream sync. Used
/// by `step_with_lobsim` to pull Q-logits / V / π-logits to host for
/// Thompson sampling. Per `feedback_no_htod_htoh_only_mapped_pinned`:
/// the staging buffer is mapped-pinned, the device → staging copy uses
/// `memcpy_dtod_async`, and the host read happens after a stream sync.
fn read_slice_d(
stream: &Arc<CudaStream>,
src: &CudaSlice<f32>,
n: usize,
) -> Result<Vec<f32>> {
debug_assert_eq!(src.len(), n);
let staging = unsafe { MappedF32Buffer::new(n) }
.map_err(|e| anyhow::anyhow!("read_slice_d staging: {e}"))?;
if n > 0 {
unsafe {
let (src_ptr, _g) = src.device_ptr(stream);
cudarc::driver::result::memcpy_dtod_async(
staging.dev_ptr,
src_ptr,
n * std::mem::size_of::<f32>(),
stream.cu_stream(),
)
.context("read_slice_d DtoD")?;
}
}
stream.synchronize().context("read_slice_d sync")?;
Ok(staging.read_all())
}
// Phase R7b: `argmax_f32` deleted — R4's `argmax_expected_q` kernel
// is the canonical Bellman-target argmax now.