fix(rl): R7c-data — true h_{t+1}/V(s_{t+1}) closes Bellman approximation

Closes the long-standing "h_t as proxy for s_{t+1}'s encoder
representation" approximation introduced in Phase E.2's Bellman target
build (canonical comment at the call site: "A future enhancement
(Phase E.3 LobSim integration) will pass next_h_t separately"). The
approximation also leaked into compute_advantage_return's V(s_{t+1})
input — R7b's `v_tp1_d_ref = &v_pred_d` alias — and into R4's
argmax_expected_q kernel call, which had been computing the
Double-DQN argmax on online Q at h_t since R4 first wired it.

Three downstream consumers now read TRUE h_{t+1}:

  * `value_head.forward(&self.h_tp1_d) → v_pred_tp1_d`, fed to
    `compute_advantage_return` as the canonical TD target V(s_{t+1}).
    Was an alias of v_pred_d (V(s_t)) — bootstrap was wrong by one
    time index.
  * `dqn_head.forward(&self.h_tp1_d) → q_logits_tp1_d`, fed to
    `argmax_expected_q` for `next_actions_d` (Double-DQN online-Q
    argmax on h_{t+1}, not h_t).
  * `dqn_head.forward_target(&self.h_tp1_d)` inside step_synthetic's
    Bellman target build. Replaces the h_t-as-proxy comment with the
    R7c data-correctness lift inline-doc.

Wiring mechanics:

  * New trainer field `h_tp1_d: CudaSlice<f32>` (`[B × HIDDEN_DIM]`).
    Zero-initialised; populated each step by step_with_lobsim.
  * `step_with_lobsim` signature gains a `next_snapshots:
    &[Mbp10RawInput]` parameter (caller — R8's CLI binary — uses
    `MultiHorizonLoader::next_sequence_pair` from R2 to load adjacent
    `(s_t, s_{t+1})` windows from real MBP-10 data).
  * Encoder is now called TWICE in step_with_lobsim:
    1. `forward_encoder(next_snapshots)` first → DtoD copy
       `perception.h_t_d → self.h_tp1_d` immediately (before any
       consumer reads the slot).
    2. `forward_encoder(snapshots)` second → leaves perception's
       internal forward state (`h_new_per_k_d`, CfC `h_state_d`)
       primed for step_synthetic's encoder backward (which still
       redundantly re-runs `forward_encoder(snapshots)` per the
       pre-existing pattern — separate compute-redundancy fuse for
       Phase R-future).
  * Three encoder forwards total per step (down from R7b's 2: one
    in step_with_lobsim, one in step_synthetic — R7c adds the
    second-snapshot forward in step_with_lobsim). The CfC encoder is
    deterministic given its input window (per the canonical
    step_with_lobsim header comment), so calling forward_encoder
    twice on different inputs in a row yields independent h_t and
    h_{t+1} via the trainer's own DtoD copy.

Test impact:

  * `integrated_trainer_smoke.rs` passes a `next_snapshots` second
    window (synthesised as a +1-tick shift of the snapshot window
    — production callers will use R2's `next_sequence_pair` on real
    MBP-10 data). Smoke continues to assert finite losses across the
    five heads.

Scope split note: this commit handles ONLY the data-correctness
half of plan A9 (rebuild plan's "PER buffer + true V(s_{t+1})
Bellman target" R7 scope). The off-policy DQN-via-PER half lands
in the next commit (R7d): Replay-buffer push, sample,
stop-grad-on-encoder Q redirect, and per-sample |TD| → update_priorities.
Split is per `pearl_no_deferrals_for_complementary_fixes`'s
sequencing-with-architectural-justification carve-out: R7d's PER
push requires the correct h_{t+1} this commit provides, so it MUST
sequence after — and the data-correctness fix is independently
useful (the on-policy DQN path now produces correct Bellman
targets even without the off-policy replay).

Local sm_86 smoke: `cargo test -p ml-alpha --test
integrated_trainer_smoke -- --ignored --nocapture` is the gate.
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:42:04 +02:00
parent c34650a241
commit acde2e8932
2 changed files with 123 additions and 30 deletions

View File

@@ -78,7 +78,8 @@ use std::sync::Arc;
use anyhow::{Context, Result};
use cudarc::driver::{
CudaFunction, CudaModule, CudaSlice, CudaStream, DevicePtr, LaunchConfig, PushKernelArg,
CudaFunction, CudaModule, CudaSlice, CudaStream, DevicePtr, DevicePtrMut, LaunchConfig,
PushKernelArg,
};
use ml_core::device::MlDevice;
@@ -360,6 +361,28 @@ pub struct IntegratedTrainer {
/// `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>,
/// 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
@@ -665,6 +688,14 @@ impl IntegratedTrainer {
.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")?;
Ok(Self {
cfg,
perception,
@@ -732,6 +763,7 @@ impl IntegratedTrainer {
log_pi_old_d,
advantages_d,
returns_d,
h_tp1_d,
grad_h_t_combined_d,
last_pi_loss: 0.0,
step_counter: 0,
@@ -1372,16 +1404,19 @@ impl IntegratedTrainer {
}
// ── Step 6a: Bellman target build (item 2) ───────────────────
// target-net forward at h_t → full atom logits → per-batch
// target-net forward at h_{t+1} → full atom logits → per-batch
// selected action's atom row → projection reading γ from ISV.
//
// For the synthetic-data smoke we use `h_t` as the proxy for
// s_{t+1}'s encoder representation. A future enhancement (Phase
// E.3 LobSim integration) will pass next_h_t separately. The
// projection itself is correct regardless of this approximation.
// 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).
self.dqn_head
.forward_target(h_t_borrow, b_size, &mut q_target_full_d)
.context("dqn_head.forward_target")?;
.forward_target(&self.h_tp1_d, b_size, &mut q_target_full_d)
.context("dqn_head.forward_target(h_tp1)")?;
self.dqn_head
.select_action_atoms(
&q_target_full_d,
@@ -1643,6 +1678,7 @@ impl IntegratedTrainer {
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;
@@ -1652,27 +1688,71 @@ impl IntegratedTrainer {
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 1: encoder forward to land h_t at slot K-1. ──────────
// ── 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")?;
.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")?;
.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")?;
.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)?;
@@ -1716,9 +1796,16 @@ impl IntegratedTrainer {
}
}
// argmax over expected Q for next_actions (Bellman-target
// action selection — distinct from Thompson per
// `pearl_thompson_for_distributional_action_selection`).
// 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),
@@ -1728,14 +1815,14 @@ impl IntegratedTrainer {
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(&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)")?;
.context("argmax_expected_q launch (Bellman target on h_tp1)")?;
}
}
@@ -1844,15 +1931,13 @@ impl IntegratedTrainer {
}
}
// ── Step 6 (Phase R7b): GPU-pure post-fill pipeline. ──────────
// ── 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) stays in v_pred_d throughout;
// R7c adds next_snapshots + forward_encoder(next_snapshots)
// for true V(s_{t+1}). Until then v_tp1 reuses v_pred_d (the
// h_t approximation — compute_advantage_return still produces
// a valid TD target, just with the bootstrap approximation).
let v_tp1_d_ref = &v_pred_d;
// 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
@@ -1953,7 +2038,7 @@ impl IntegratedTrainer {
.arg(&self.rewards_d)
.arg(&self.dones_d)
.arg(&v_pred_d)
.arg(v_tp1_d_ref)
.arg(&v_pred_tp1_d)
.arg(&mut self.returns_d)
.arg(&mut self.advantages_d)
.arg(&b_size_i);

View File

@@ -82,12 +82,20 @@ fn integrated_trainer_step_with_lobsim_runs_without_panic() {
let snapshots = synthetic_window(4);
let mut sim = LobSimCuda::new(1, &dev).expect("LobSimCuda::new");
// One step end-to-end (encoder fwd, Q/π/V fwd, host Thompson +
// actions upload, actions_to_market_targets kernel,
// step_fill_from_market_targets, extract_realized_pnl_delta,
// host advantage/return, step_synthetic delegation).
// One step end-to-end (encoder fwd ×2 [snapshots + next_snapshots
// for R7c data correctness], Q/π/V fwd on h_t + Q/V fwd on h_tp1,
// GPU Thompson + Double-DQN argmax kernels, actions_to_market_targets
// kernel, step_fill_from_market_targets, extract_realized_pnl_delta,
// GPU compute_advantage_return with true V(s_{t+1}), step_synthetic
// delegation).
//
// next_snapshots is the same window shifted by one step. R8's CLI
// binary uses MultiHorizonLoader::next_sequence_pair (R2) to load
// adjacent (s_t, s_{t+1}) windows from real MBP-10 data; for the
// smoke we synthesise a +1-tick window directly.
let next_snapshots = synthetic_window(4);
let stats = trainer
.step_with_lobsim(&snapshots, &mut sim)
.step_with_lobsim(&snapshots, &next_snapshots, &mut sim)
.expect("step_with_lobsim");
// Loss components are finite.