feat(rl): R7d — PER wired + off-policy DQN with stop-grad on encoder

Closes plan A9 (rebuild plan's "PER wiring" R7 scope second half;
R7c-data shipped the first half last commit). The `ReplayBuffer` in
`src/rl/replay.rs` has sat as dead code since Phase C — this commit
makes it load-bearing per `feedback_always_per` ("PER always enabled;
non-PER paths are dead code").

## Architecture: off-policy Q + on-policy PPO + V + stop-grad encoder

Shared-encoder pattern with the canonical off-policy + shared-encoder
discipline: the Q head trains from PER-sampled past transitions,
PPO + V train on current-step on-policy data, and the encoder receives
gradient signal ONLY from PPO + V (and BCE/aux via the perception
trainer's separate `step_batched` path). Standard pattern in SAC,
R2D2, IMPALA.

Stop-grad is implemented by computing Q's `grad_h_t` (via
`backward_to_w_b_h(sampled_h_t, ...)`) but NOT accumulating it into
`grad_h_t_combined_d` — the encoder backward only sees π + V
contributions. Per `feedback_no_hiding` the discarded buffer is
allocated and written (the kernel API requires the writeback target);
the discard is a deliberate design call documented at the
accumulation site.

## Wiring summary

### Kernel: `dqn_distributional_q_bwd`
* New `loss_per_batch [B]` output. Atom 0 of each block writes the
  per-sample CE loss (non-atomic — single writer per batch).
  `loss_out [1]` continues to atomicAdd the scalar sum for the
  diagnostic total. Build.rs cache bust v30.

### `DqnHead::backward_logits` (Rust wrapper)
* New `loss_per_batch: &mut CudaSlice<f32>` arg. Migrated atomically
  in the same commit per `feedback_no_partial_refactor` — only
  caller is the integrated trainer.

### `IntegratedTrainerConfig`
* New `per_capacity: usize` (default 4096, matches `replay.rs` doc
  ceiling for naive O(N) sampling).
* New `per_seed: u64` (default 0x9E37_79B9_7F4A_7C15).
* `Default` impl added so test fixtures forward-compat via
  `..IntegratedTrainerConfig::default()`. All 5 existing test
  fixtures migrated.

### `IntegratedTrainer`
* New fields: `replay: ReplayBuffer`, `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`.
* New methods: `push_to_replay(b_size)` — DtoH per-batch metadata
  (action/reward/done/log_pi_old) + alloc per-transition
  `CudaSlice<f32>(HIDDEN_DIM)` ×2 + DtoD per-batch slice copies +
  push to `ReplayBuffer`. `sample_and_gather(b_size)` — read
  per_α from ISV[405], call `replay.sample_indices`, gather sampled
  transitions' h_t/h_tp1 device payloads via per-batch DtoD into
  `sampled_h_t_d` / `sampled_h_tp1_d`, HtoD upload action/reward/done.

### `step_with_lobsim` orchestration
After `compute_advantage_return` and BEFORE `step_synthetic`:
  1. DtoH full ISV slice to refresh `isv_host` (so PER reads ISV[405]
     for per_α).
  2. `push_to_replay(b_size)` — push current step's transitions.
  3. `sample_and_gather(b_size)` — return `per_indices` for the
     priority update.
  4. `step_synthetic(snapshots)` — runs π + V on current-step h_t,
     Q on SAMPLED h_t (off-policy).
  5. DtoH `td_per_sample_d` → host; `replay.update_priorities(
     per_indices, td_per_sample_host)`.
  6. Target-net soft update (unchanged from R5).

### `step_synthetic` redirects (Q path → sampled, π/V stay on-policy)
* Q forward: `forward(&self.sampled_h_t_d)` (was `h_t_borrow`).
* New: forward online Q on `&self.sampled_h_tp1_d` → local scratch +
  `argmax_expected_q` → `self.sampled_next_actions_d`. The
  Double-DQN argmax MUST be recomputed each step (online net weights
  drift faster than transitions recycle through replay; storing
  argmax at push time would feed stale-action data into the
  projection).
* `forward_target(&self.sampled_h_tp1_d)` (was `&self.h_tp1_d`).
* `select_action_atoms(..., &self.sampled_next_actions_d, ...)`
  (was `&self.next_actions_d`).
* `project_bellman_target(..., &self.sampled_rewards_d,
  &self.sampled_dones_d, ...)` (was `rewards_d` / `dones_d`).
* `backward_logits(..., &self.sampled_actions_d, ...,
  &mut self.td_per_sample_d, ...)` (added per-sample loss output).
* `backward_to_w_b_h(&self.sampled_h_t_d, ...)` (was `h_t_borrow`).
* Q grad_h_t accumulation REMOVED from Step 10 (stop-grad).

## Test: r7d_per_wiring.rs (gate G6)
Three invariants per `pearl_tests_must_prove_not_lock_observations`:
  1. `replay.len()` grows by exactly `b_size` per `step_with_lobsim`
     call (push semantics).
  2. `sample_indices(b_size, α)` returns vec of length `b_size` on a
     non-empty buffer.
  3. Buffer caps at `per_capacity` (ring-with-random-replacement).
Drives 15 steps with `per_capacity=8`, asserts growth 0→5→8 across
the cap boundary.

## Acceptable host traffic this commit adds
* Per-step DtoH of 4 × b_size scalars (action/reward/done/log_pi_old)
  for PER push metadata.
* Per-step DtoH of b_size floats (td_per_sample_d) for
  update_priorities.
* Per-step HtoD of 3 × b_size scalars (sampled action/reward/done)
  for sampled metadata gather.
* Per-step DtoD of 2 × b_size × HIDDEN_DIM floats (per-batch h_t /
  h_tp1 slices) for PER push + gather.
PER bookkeeping is a control-plane operation by design (host-side
priority/index management); the device-side training hot path
(encoder, Q/π/V forward/backward, Adam) stays GPU-pure. GPU sum-tree
+ device-resident transitions are a Phase R-future optimization
flagged in `replay.rs`'s doc.

## What's NOT in this commit
* Q `loss_per_batch [B]` is now wired through `backward_logits` but
  the DtoH happens inside step_with_lobsim (not inside
  step_synthetic). Earlier R7d sketches considered a separate
  `dqn_offpolicy_step` method; the in-step_synthetic redirect
  approach landed because it touches fewer lines + reuses the
  existing scratch buffer allocations + matches the trainer's
  established λ-weighted multi-head pattern. A future refactor
  could split for clarity.

Local sm_86 smoke gates: `cargo test -p ml-alpha --test
r7d_per_wiring -- --ignored --nocapture` (G6) +
`integrated_trainer_smoke` (end-to-end). Cluster smoke deferred to
R9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-23 12:59:42 +02:00
parent acde2e8932
commit c7ccf0c301
10 changed files with 583 additions and 56 deletions

View File

@@ -57,16 +57,15 @@ const KERNELS: &[&str] = &[
"abs_copy", // RL Phase R7a: element-wise dst[b] = fabsf(src[b]); feeds |reward| into ema_update_on_done for the MEAN_ABS_PNL_EMA slot
];
// Cache bust v29 (2026-05-23): RL Phase R6 rebuild — three new GPU-pure
// kernels close the last host-roundtrip violations in step_with_lobsim's
// hot path. extract_realized_pnl_delta reads pos.realized_pnl + position_lots
// from the device Pos array and writes per-batch (reward delta, done flag);
// apply_reward_scale multiplies rewards by ISV[406] on device;
// actions_to_market_targets translates the 9-action grid (with conditional
// flat-from-long/short reading current position_lots) into LobSim's
// market_targets format. Together with R3 EMA + R4 Thompson/argmax/log_pi +
// R5 controllers + soft-update, the trainer's step is GPU-pure: only HtoD
// for incoming snapshots (boundary data) + HEALTH_DIAG ISV readback.
// Cache bust v30 (2026-05-23): RL Phase R7d — dqn_distributional_q_bwd
// kernel signature change. Adds a new `loss_per_batch [B]` output so the
// trainer can DtoH per-sample CE loss into ReplayBuffer.update_priorities
// after each off-policy Q backward (feedback_always_per wiring). Single
// writer per batch (atom 0 only), so the new output is non-atomic — the
// existing scalar `loss_out` still accumulates via atomicAdd for the
// diagnostic total. All callers (the integrated trainer's
// dqn_offpolicy_step) migrate atomically in the same R7d commit per
// feedback_no_partial_refactor.
fn main() {
println!("cargo:rerun-if-changed=build.rs");

View File

@@ -125,12 +125,13 @@ extern "C" __global__ void dqn_distributional_q_fwd(
// actions get zero grad.
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void dqn_distributional_q_bwd(
const float* __restrict__ logits, // [B * N_ACTIONS * Q_N_ATOMS]
const float* __restrict__ target_dist, // [B * Q_N_ATOMS]
const int* __restrict__ actions_taken, // [B]
const float* __restrict__ logits, // [B * N_ACTIONS * Q_N_ATOMS]
const float* __restrict__ target_dist, // [B * Q_N_ATOMS]
const int* __restrict__ actions_taken, // [B]
int B,
float* __restrict__ loss_out, // [1]
float* __restrict__ grad_logits // [B * N_ACTIONS * Q_N_ATOMS]
float* __restrict__ loss_out, // [1]
float* __restrict__ loss_per_batch, // [B] — R7d: per-sample CE for PER priority update
float* __restrict__ grad_logits // [B * N_ACTIONS * Q_N_ATOMS]
) {
const int batch = blockIdx.x;
const int atom = threadIdx.x;
@@ -148,6 +149,12 @@ extern "C" __global__ void dqn_distributional_q_bwd(
for (int a = 0; a < N_ACTIONS; ++a) {
grad_logits[base_all + a * Q_N_ATOMS + atom] = 0.0f;
}
// R7d: zero per-sample CE for invalid-action samples so the
// PER priority update sees a real (zero) magnitude rather than
// stale buffer contents — bounded-input pearl applies.
if (atom == 0) {
loss_per_batch[batch] = 0.0f;
}
return;
}
@@ -216,6 +223,11 @@ extern "C" __global__ void dqn_distributional_q_bwd(
ce -= tz * logf(pz_c);
}
atomicAdd(loss_out, ce);
// R7d: per-sample CE for PER priority update. Single writer per
// batch (atom 0 only), so non-atomic store. Feeds
// `replay.update_priorities(per_indices, td_per_sample)` via a
// mapped-pinned DtoH after the backward.
loss_per_batch[batch] = ce;
}
}

View File

@@ -327,12 +327,18 @@ impl DqnHead {
actions_taken: &CudaSlice<i32>,
b_size: usize,
loss_out: &mut CudaSlice<f32>,
loss_per_batch: &mut CudaSlice<f32>,
grad_logits: &mut CudaSlice<f32>,
) -> Result<()> {
debug_assert_eq!(logits.len(), b_size * N_ACTIONS * Q_N_ATOMS);
debug_assert_eq!(target_dist.len(), b_size * Q_N_ATOMS);
debug_assert_eq!(actions_taken.len(), b_size);
debug_assert_eq!(loss_out.len(), 1);
debug_assert_eq!(
loss_per_batch.len(),
b_size,
"R7d: per-sample CE buffer must match batch size for PER priority update"
);
debug_assert_eq!(grad_logits.len(), b_size * N_ACTIONS * Q_N_ATOMS);
let b_i = b_size as i32;
@@ -348,6 +354,7 @@ impl DqnHead {
.arg(actions_taken)
.arg(&b_i)
.arg(loss_out)
.arg(loss_per_batch)
.arg(grad_logits);
unsafe {
launch.launch(cfg).context("dqn_distributional_q_bwd launch")?;

View File

@@ -186,6 +186,30 @@ pub struct IntegratedTrainerConfig {
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.
@@ -383,6 +407,45 @@ pub struct IntegratedTrainer {
/// 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
@@ -696,6 +759,38 @@ impl IntegratedTrainer {
.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,
@@ -764,6 +859,14 @@ impl IntegratedTrainer {
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,
step_counter: 0,
@@ -1369,9 +1472,16 @@ impl IntegratedTrainer {
// 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(h_t_borrow, b_size, &mut q_logits_d)
.context("dqn_head.forward")?;
.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")?;
@@ -1379,6 +1489,35 @@ impl IntegratedTrainer {
.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).
{
@@ -1404,37 +1543,37 @@ impl IntegratedTrainer {
}
// ── Step 6a: Bellman target build (item 2) ───────────────────
// target-net forward at h_{t+1} → full atom logits → per-batch
// selected action's atom row → projection reading γ from ISV.
// 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: was using `h_t` as proxy for
// s_{t+1}'s encoder representation since Phase E.2. Now reads
// the trainer-owned self.h_tp1_d populated by
// step_with_lobsim's `forward_encoder(next_snapshots)` +
// DtoD copy out of perception.h_t_d (R7c.A). Pairs with the
// Double-DQN argmax on h_{t+1} that step_with_lobsim writes
// into self.next_actions_d (also R7c — was on h_t since R4).
// 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.h_tp1_d, b_size, &mut q_target_full_d)
.context("dqn_head.forward_target(h_tp1)")?;
.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.next_actions_d,
&self.sampled_next_actions_d,
b_size,
&mut q_target_action_d,
)
.context("dqn_head.select_action_atoms")?;
.context("dqn_head.select_action_atoms (sampled_next_actions)")?;
self.dqn_head
.project_bellman_target(
&q_target_action_d,
&self.rewards_d,
&self.dones_d,
&self.sampled_rewards_d,
&self.sampled_dones_d,
&self.isv_d,
b_size,
&mut target_dist_d,
)
.context("dqn_head.project_bellman_target")?;
.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;
@@ -1442,24 +1581,35 @@ impl IntegratedTrainer {
.backward_logits(
&q_logits_d,
&target_dist_d,
&self.actions_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")?;
.context("dqn_head.backward_logits (sampled_actions) [R7d off-policy]")?;
let l_q_host = read_scalar_d(&self.stream, &q_loss_d_mut)?;
self.dqn_head
.backward_to_w_b_h(
h_t_borrow,
&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")?;
.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)?;
@@ -1537,7 +1687,16 @@ impl IntegratedTrainer {
.context("value_b_adam.step")?;
// ── Step 10: encoder grad combine (Phase E.2-DEFER item 1) ───
// Zero the combined slot, fold Q+π+V grad_h_t with λ-weights.
// 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
@@ -1545,13 +1704,6 @@ impl IntegratedTrainer {
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,
&q_grad_h_t_d,
lambdas.q,
&mut self.grad_h_t_combined_d,
)?;
accumulate_grad_h(
&self.stream,
&self.grad_h_accumulate_fn,
@@ -2053,22 +2205,67 @@ impl IntegratedTrainer {
// post-R7b (the bootstrap-fallback host read of γ that the
// flawed branch did is gone with the host advantage loop).
// ── Step 7: delegate to step_synthetic. ───────────────────────
// step_synthetic now reads trainer-owned device buffers
// (actions_d, rewards_d, dones_d, next_actions_d, advantages_d,
// returns_d, log_pi_old_d) populated above. No host-slice
// arguments, no DtoH at the boundary.
// ── 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.
//
// step_synthetic re-runs forward_encoder (a compute redundancy
// the flawed F.4 already noted; lifting requires a deeper
// refactor and is a Phase R-future item).
// 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.
self.stream
.memcpy_dtoh(&self.isv_d, self.isv_host.as_mut_slice())
.context("step_with_lobsim: isv dtoh (pre-PER)")?;
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 mut td_per_sample_host = vec![0.0f32; b_size];
self.stream
.memcpy_dtoh(&self.td_per_sample_d, td_per_sample_host.as_mut_slice())
.context("step_with_lobsim: dtoh 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] (R5 reflects this step's q_divergence EMA if
// R7b wires the EMA producer; until then τ stays at R1's
// bootstrap 0.005).
// from ISV[401].
let isv_d_clone = self.isv_d.clone();
self.dqn_head
.soft_update_target(&isv_d_clone)
@@ -2077,6 +2274,168 @@ impl IntegratedTrainer {
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};
// DtoH per-batch metadata. 4 × b_size floats/ints total.
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];
let mut log_pi_old_host = vec![0.0f32; b_size];
self.stream
.memcpy_dtoh(&self.actions_d, actions_host.as_mut_slice())
.context("push_to_replay: dtoh actions_d")?;
self.stream
.memcpy_dtoh(&self.rewards_d, rewards_host.as_mut_slice())
.context("push_to_replay: dtoh rewards_d")?;
self.stream
.memcpy_dtoh(&self.dones_d, dones_host.as_mut_slice())
.context("push_to_replay: dtoh dones_d")?;
self.stream
.memcpy_dtoh(&self.log_pi_old_d, log_pi_old_host.as_mut_slice())
.context("push_to_replay: dtoh 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 };
}
self.stream
.memcpy_htod(&actions_host, &mut self.sampled_actions_d)
.context("sample_and_gather: htod sampled_actions_d")?;
self.stream
.memcpy_htod(&rewards_host, &mut self.sampled_rewards_d)
.context("sample_and_gather: htod sampled_rewards_d")?;
self.stream
.memcpy_htod(&dones_host, &mut self.sampled_dones_d)
.context("sample_and_gather: htod 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)
}
/// Launch `rl_lr_controller` to emit per-head learning rates into
/// `ISV[412..417]`. Phase E.2-DEFER item 3. Diagnostic-input args
/// are currently zero (the controller emits the bootstrap target

View File

@@ -77,6 +77,7 @@ fn integrated_trainer_step_with_lobsim_runs_without_panic() {
perception,
dqn_seed: 0xC0DE,
ppo_seed: 0xC0DF,
..IntegratedTrainerConfig::default()
};
let mut trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
let snapshots = synthetic_window(4);

View File

@@ -62,6 +62,7 @@ fn g1_isv_bootstrap_writes_canonical_values() {
},
dqn_seed: 0xCAFE,
ppo_seed: 0xBEEF,
..IntegratedTrainerConfig::default()
};
let trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");

View File

@@ -47,6 +47,7 @@ fn build_trainer() -> Option<(MlDevice, IntegratedTrainer)> {
},
dqn_seed: 0xA1,
ppo_seed: 0xA2,
..IntegratedTrainerConfig::default()
};
let trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
Some((dev, trainer))

View File

@@ -50,6 +50,7 @@ fn build_trainer() -> Option<(MlDevice, IntegratedTrainer)> {
},
dqn_seed: 0xB4,
ppo_seed: 0xB5,
..IntegratedTrainerConfig::default()
};
let trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
Some((dev, trainer))

View File

@@ -63,6 +63,7 @@ fn build_trainer() -> Option<(MlDevice, IntegratedTrainer)> {
},
dqn_seed: 0xB7,
ppo_seed: 0xB8,
..IntegratedTrainerConfig::default()
};
let trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
Some((dev, trainer))

View File

@@ -0,0 +1,145 @@
//! Phase R7d gate G6: PER buffer wired into `step_with_lobsim`.
//!
//! Asserts the load-bearing invariants that distinguish a wired PER
//! buffer from R7c's dead `src/rl/replay.rs` struct:
//!
//! 1. `trainer.replay.len()` grows by exactly `b_size` per
//! `step_with_lobsim` call (the per-batch push order documented
//! in `IntegratedTrainer::push_to_replay`).
//! 2. `replay.sample_indices(b_size, α)` returns a vec of length
//! `b_size` after the first step (buffer non-empty post-push).
//! 3. After N steps with N × b_size ≤ capacity, `replay.len() ==
//! N × b_size` (no replacement). After N steps with N × b_size
//! > capacity, `replay.len() == capacity` (ring-with-replacement
//! capped at capacity).
//!
//! Per `pearl_tests_must_prove_not_lock_observations`: asserts
//! buffer-mechanism invariants (length growth, sample size), NOT
//! observed Q values or losses — those vary across runs and lock
//! the test against any future change to Q init or PRNG state.
//!
//! Run with:
//! `cargo test -p ml-alpha --test r7d_per_wiring -- --ignored --nocapture`
use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::trainer::integrated::{IntegratedTrainer, IntegratedTrainerConfig};
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
use ml_backtesting::sim::LobSimCuda;
use ml_core::device::MlDevice;
fn synthetic_window(seq_len: usize, base_mid: f32) -> Vec<Mbp10RawInput> {
let mut out = Vec::with_capacity(seq_len);
let mut prev_mid = base_mid;
let mut ts_ns = 1_000_000_u64;
for _ in 0..seq_len {
let next_mid = prev_mid + 0.25;
let mut bid_px = [0.0_f32; 10];
let mut bid_sz = [0.0_f32; 10];
let mut ask_px = [0.0_f32; 10];
let mut ask_sz = [0.0_f32; 10];
for i in 0..10 {
bid_px[i] = next_mid - 0.125 - 0.25 * i as f32;
ask_px[i] = next_mid + 0.125 + 0.25 * i as f32;
bid_sz[i] = 10.0;
ask_sz[i] = 10.0;
}
let prev_ts = ts_ns;
ts_ns += 20_000_000;
out.push(Mbp10RawInput {
bid_px,
bid_sz,
ask_px,
ask_sz,
prev_mid,
trade_signed_vol: 0.0,
trade_count: 0,
ts_ns,
prev_ts_ns: prev_ts,
regime: [0.0; 6],
});
prev_mid = next_mid;
}
out
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn r7d_per_buffer_grows_one_per_step_at_b_size_1() {
let dev = match MlDevice::cuda(0) {
Ok(d) => d,
Err(_) => {
eprintln!("CUDA 0 not available — skipping r7d_per_wiring");
return;
}
};
// b_size = 1, small capacity so we can also verify the ring-cap
// invariant in the same test (no need for a separate fixture).
let per_capacity = 8usize;
let cfg = IntegratedTrainerConfig {
perception: PerceptionTrainerConfig {
seq_len: 4,
n_batch: 1,
..PerceptionTrainerConfig::default()
},
per_capacity,
..IntegratedTrainerConfig::default()
};
let mut trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
let mut sim = LobSimCuda::new(1, &dev).expect("LobSimCuda::new");
// Invariant 1: buffer starts empty.
assert_eq!(
trainer.replay.len(),
0,
"PER buffer must start empty before any step_with_lobsim call"
);
// Drive N=5 steps; assert linear growth from 0 → 5.
for step in 1..=5usize {
let snapshots = synthetic_window(4, 5500.0 + step as f32 * 0.25);
let next_snapshots = synthetic_window(4, 5500.0 + (step + 1) as f32 * 0.25);
let _stats = trainer
.step_with_lobsim(&snapshots, &next_snapshots, &mut sim)
.unwrap_or_else(|e| panic!("step_with_lobsim step {step}: {e:?}"));
assert_eq!(
trainer.replay.len(),
step,
"PER buffer must grow by exactly 1 per step at b_size=1 (step {step})"
);
}
// Invariant 2: sample at α=0.6 returns batch size 1.
let sample = trainer.replay.sample_indices(1, 0.6);
assert_eq!(
sample.len(),
1,
"sample_indices(1, 0.6) on a non-empty buffer must return 1 index"
);
assert!(
sample[0] < trainer.replay.len(),
"sampled index must be in [0, replay.len()) — got {} for len {}",
sample[0],
trainer.replay.len()
);
// Invariant 3: drive past capacity, buffer caps at `per_capacity`.
// Already at len=5; drive 10 more (total 15 transitions pushed,
// capacity=8 → buffer ends at exactly 8).
for step in 6..=15usize {
let snapshots = synthetic_window(4, 5500.0 + step as f32 * 0.25);
let next_snapshots = synthetic_window(4, 5500.0 + (step + 1) as f32 * 0.25);
trainer
.step_with_lobsim(&snapshots, &next_snapshots, &mut sim)
.unwrap_or_else(|e| panic!("step_with_lobsim step {step}: {e:?}"));
}
assert_eq!(
trainer.replay.len(),
per_capacity,
"PER buffer must cap at per_capacity = {per_capacity} (ring-with-replacement)"
);
eprintln!(
"R7d G6 OK — buffer grew 0→5→{per_capacity} across 15 push cycles; sample returns expected size"
);
}