diff --git a/crates/ml/src/cuda_pipeline/batched_forward.rs b/crates/ml/src/cuda_pipeline/batched_forward.rs index cbd4d1339..5afae819c 100644 --- a/crates/ml/src/cuda_pipeline/batched_forward.rs +++ b/crates/ml/src/cuda_pipeline/batched_forward.rs @@ -449,6 +449,141 @@ impl CublasGemmSet { /// /// Weight pointers are raw u64 into the flat F32 `params_buf` at GOFF_* offsets. #[allow(clippy::too_many_arguments)] + /// Run only the trunk encoder (h_s1 + h_s2) GEMMs. + /// + /// Plan 4 Task 4 (E.4) extraction point: the same dispatch `forward_online_raw` + /// uses for the trunk-encoder portion. Exposed publicly so the + /// `StateEncoder` Rust wrapper can drive the encoder forward in + /// isolation; existing `forward_online_raw` callers route through + /// this method internally (lossless extraction — same launch + /// sequence, same workspace usage). + /// + /// `w_ptrs[0]`/`w_ptrs[1]` = `W_s1` / `b_s1`; `w_ptrs[2]`/`w_ptrs[3]` = `W_s2` / `b_s2`. + /// Caller-owned device buffers: `states_ptr`, `h_s1_ptr`, `h_s2_ptr`. + pub fn encoder_forward_only( + &self, + stream: &Arc, + states_ptr: u64, + w_ptrs: &[u64; super::gpu_dqn_trainer::NUM_WEIGHT_TENSORS], + h_s1_ptr: u64, + h_s2_ptr: u64, + ) -> Result<(), MLError> { + let b = self.batch_size; + let ws = self.handle.lt_workspace_ptr; + let wss = self.handle.lt_workspace_size; + + // First layer: ldb = state_dim_padded (CUTLASS K-tile alignment). + // Try fused GEMM+bias+ReLU epilogue, fall back to separate kernels. + if self.sgemm_f32_fused_relu_bias(stream, w_ptrs[0], states_ptr, h_s1_ptr, w_ptrs[1], + self.shared_h1, b, self.s1_input_dim, self.s1_ldb, ws, wss, "h_s1").is_err() + { + self.sgemm_f32_ldb(stream, w_ptrs[0], states_ptr, h_s1_ptr, self.shared_h1, b, self.s1_input_dim, self.s1_ldb, "h_s1")?; + self.launch_add_bias_relu_f32_raw(stream, h_s1_ptr, w_ptrs[1], self.shared_h1, b)?; + } + + if self.sgemm_f32_fused_relu_bias(stream, w_ptrs[2], h_s1_ptr, h_s2_ptr, w_ptrs[3], + self.shared_h2, b, self.shared_h1, self.shared_h1, ws, wss, "h_s2").is_err() + { + self.sgemm_f32(stream, w_ptrs[2], h_s1_ptr, h_s2_ptr, self.shared_h2, b, self.shared_h1, "h_s2")?; + self.launch_add_bias_relu_f32_raw(stream, h_s2_ptr, w_ptrs[3], self.shared_h2, b)?; + } + + Ok(()) + } + + /// Run a single advantage branch's decoder (FC head + adv-logits) on the + /// caller-supplied stream, sequentially. + /// + /// Plan 4 Task 4 (E.4) attachment point for the per-branch `ValueDecoder` + /// Rust wrapper. **Additive** to `forward_online_raw`: this helper runs + /// the same GEMM sequence the per-branch loop body in `forward_online_raw` + /// runs (when `use_vsn_glu == false`), but on a single stream — no fork- + /// join, no event handshake. It is intended for callers that drive the + /// branches one at a time (e.g. validation paths, future per-branch + /// experiments). Production training stays on the existing multi-stream + /// `forward_online_raw` path until/unless a follow-up explicitly + /// migrates it. + /// + /// `branch_idx ∈ [0, 4)` selects which branch's weights/buffers to use. + /// `h_s2_ptr` is the encoder output (read-only). For `branch_idx == 1` + /// (magnitude), pass the pre-built mag_concat pointer to use the wider + /// `[B, SH2 + 3]` input; pass 0 for the legacy `[B, SH2]` input. + /// `branch_h_ptr` is the per-branch hidden activation buffer; the + /// `adv_logits_ptr` is the per-branch slice into the flat + /// `b_logits_buf` at byte offset + /// `sum_{j, + branch_idx: usize, + h_s2_ptr: u64, + mag_concat_ptr: u64, + w_ptrs: &[u64; super::gpu_dqn_trainer::NUM_WEIGHT_TENSORS], + branch_h_ptr: u64, + adv_logits_ptr: u64, + ) -> Result<(), MLError> { + if branch_idx >= 4 { + return Err(MLError::ModelError(format!( + "decoder_forward_only: branch_idx {branch_idx} out of range [0, 4)" + ))); + } + let b = self.batch_size; + let ws = self.handle.lt_workspace_ptr; + let wss = self.handle.lt_workspace_size; + let na = self.num_atoms; + let branch_w_base = [8_usize, 12, 16, 20]; + let w_fc_idx = branch_w_base[branch_idx]; + let n_d = self.branch_q_dim_internal(branch_idx); + + let use_vsn_glu = self.vsn_kernel.is_some(); + + if use_vsn_glu { + // VSN bottleneck + GLU gating path (sequential — main stream). + self.launch_vsn_glu_branch( + stream, branch_idx, h_s2_ptr, mag_concat_ptr, w_ptrs, + branch_h_ptr, + ws, wss, + false, "decoder", + )?; + } else { + // Legacy GEMM+bias+ReLU FC head. + let (fc_input, fc_k) = if branch_idx == 1 && mag_concat_ptr != 0 { + (mag_concat_ptr, self.mag_concat_dim) + } else { + (h_s2_ptr, self.shared_h2) + }; + if self.sgemm_f32_fused_relu_bias(stream, w_ptrs[w_fc_idx], fc_input, branch_h_ptr, w_ptrs[w_fc_idx + 1], + self.adv_h, b, fc_k, fc_k, ws, wss, "decoder_h_bd").is_err() + { + self.sgemm_f32(stream, w_ptrs[w_fc_idx], fc_input, branch_h_ptr, self.adv_h, b, fc_k, "decoder_h_bd")?; + self.launch_add_bias_relu_f32_raw(stream, branch_h_ptr, w_ptrs[w_fc_idx + 1], self.adv_h, b)?; + } + } + + // Output GEMM + bias (linear): adv_logits = h_bd @ W_out^T + b_out + self.sgemm_f32(stream, w_ptrs[w_fc_idx + 2], branch_h_ptr, adv_logits_ptr, n_d * na, b, self.adv_h, "decoder_adv_logits")?; + self.launch_add_bias_f32_raw(stream, adv_logits_ptr, w_ptrs[w_fc_idx + 3], n_d * na, b)?; + + Ok(()) + } + + /// Per-branch advantage dimension (action count for that branch — pre + /// num_atoms multiplication). Internal so `ValueDecoder` can report + /// `q_per_action_dim` without exposing the four `branch_k_size` fields. + pub(crate) fn branch_q_dim_internal(&self, branch_idx: usize) -> usize { + match branch_idx { + 0 => self.branch_0_size, + 1 => self.branch_1_size, + 2 => self.branch_2_size, + 3 => self.branch_3_size, + _ => 0, + } + } + + /// Distributional atoms per branch action (C51 support size). + pub(crate) fn num_atoms(&self) -> usize { self.num_atoms } + /// Graph-safe forward: takes pre-resolved u64 buffer pointers (no device_ptr calls). /// Use CachedPtrs from GpuDqnTrainer for all buffer addresses. #[allow(clippy::too_many_arguments)] @@ -463,24 +598,13 @@ impl CublasGemmSet { mag_concat_ptr: u64, ) -> Result<(), MLError> { let b = self.batch_size; - - // First layer: ldb = state_dim_padded (CUTLASS K-tile alignment). - // Try fused GEMM+bias+ReLU epilogue, fall back to separate kernels. let ws = self.handle.lt_workspace_ptr; let wss = self.handle.lt_workspace_size; - if self.sgemm_f32_fused_relu_bias(stream, w_ptrs[0], states_ptr, h_s1_ptr, w_ptrs[1], - self.shared_h1, b, self.s1_input_dim, self.s1_ldb, ws, wss, "h_s1").is_err() - { - self.sgemm_f32_ldb(stream, w_ptrs[0], states_ptr, h_s1_ptr, self.shared_h1, b, self.s1_input_dim, self.s1_ldb, "h_s1")?; - self.launch_add_bias_relu_f32_raw(stream, h_s1_ptr, w_ptrs[1], self.shared_h1, b)?; - } - if self.sgemm_f32_fused_relu_bias(stream, w_ptrs[2], h_s1_ptr, h_s2_ptr, w_ptrs[3], - self.shared_h2, b, self.shared_h1, self.shared_h1, ws, wss, "h_s2").is_err() - { - self.sgemm_f32(stream, w_ptrs[2], h_s1_ptr, h_s2_ptr, self.shared_h2, b, self.shared_h1, "h_s2")?; - self.launch_add_bias_relu_f32_raw(stream, h_s2_ptr, w_ptrs[3], self.shared_h2, b)?; - } + // Trunk encoder (h_s1, h_s2) — extracted to `encoder_forward_only` so + // the new `StateEncoder` Rust wrapper (Plan 4 Task 4 / E.4) can reuse + // the identical dispatch sequence. Behaviorally a no-op refactor. + self.encoder_forward_only(stream, states_ptr, w_ptrs, h_s1_ptr, h_s2_ptr)?; if self.sgemm_f32_fused_relu_bias(stream, w_ptrs[4], h_s2_ptr, h_v_ptr, w_ptrs[5], self.value_h, b, self.shared_h2, self.shared_h2, ws, wss, "h_v").is_err() diff --git a/crates/ml/src/cuda_pipeline/mod.rs b/crates/ml/src/cuda_pipeline/mod.rs index 5d94b850b..03ba147c3 100644 --- a/crates/ml/src/cuda_pipeline/mod.rs +++ b/crates/ml/src/cuda_pipeline/mod.rs @@ -29,6 +29,8 @@ pub mod gpu_dqn_trainer; pub mod shared_cublas_handle; pub mod cublas_algo_deterministic; pub mod batched_forward; +pub mod state_encoder; +pub mod value_decoder; pub mod batched_backward; pub mod gpu_her; #[cfg(test)] diff --git a/crates/ml/src/cuda_pipeline/state_encoder.rs b/crates/ml/src/cuda_pipeline/state_encoder.rs new file mode 100644 index 000000000..dd31c7bc8 --- /dev/null +++ b/crates/ml/src/cuda_pipeline/state_encoder.rs @@ -0,0 +1,106 @@ +//! Plan 4 Task 4 (E.4): explicit state-encoder boundary for the DQN trunk. +//! +//! `StateEncoder` is a thin Rust API wrapper around `BatchedForward`'s trunk +//! encoder portion (the two shared layers `h_s1` + `h_s2`). It owns no +//! parameters and no buffers — it borrows a `BatchedForward` and forwards +//! caller-owned device pointers through the existing +//! `BatchedForward::encoder_forward_only` dispatch. +//! +//! Design intent (E.4 spec): +//! - Provide a clean type boundary so Plan 4 Task 1 (Full VSN, attaches +//! pre-encoder), Task 3 (multi-Q IQN, attaches at decoder), and Task 6 +//! (auxiliary heads, attaches at decoder peer) have a stable attachment +//! point. +//! - **Additive** to the existing monolithic `forward_online_raw` — that +//! function still routes through `encoder_forward_only` internally, so +//! call sites remain unchanged. +//! - Pure Rust API refactor: no kernel changes, no parameter changes, no +//! checkpoint break. + +use std::sync::Arc; + +use cudarc::driver::CudaStream; + +use crate::MLError; +use crate::cuda_pipeline::batched_forward::CublasGemmSet; + +/// Output of one `StateEncoder::forward` call. +/// +/// Carries the device pointer the encoder wrote to (`h_s2_dev_ptr`), the +/// shared-trunk output dimension, and the batch size. `h_s2_dev_ptr` is +/// always equal to the pointer the caller passed in — the type exists to +/// document the contract for downstream `ValueDecoder` consumers. +#[derive(Debug, Clone, Copy)] +pub struct EncoderOutput { + pub h_s2_dev_ptr: u64, + pub h_s2_dim: usize, + pub batch_size: usize, +} + +/// Plan 4 Task 4 (E.4) state-encoder façade. +/// +/// Borrows a `CublasGemmSet` (`BatchedForward`) and exposes the trunk +/// encoder (`h_s1` + `h_s2` GEMMs) as a separately-callable forward step. +/// The encoder runs on the caller's stream; callers own all device +/// buffers (`states_ptr`, `h_s1_ptr`, `h_s2_ptr`). +/// +/// Owns no parameters of its own — references the trainer's params via +/// the `weight_ptrs` slice the caller passes in (positions 0..=3 are the +/// trunk encoder weights `W_s1, b_s1, W_s2, b_s2`). +#[allow(missing_debug_implementations)] // CublasGemmSet borrow is not Debug +pub struct StateEncoder<'a> { + batched: &'a CublasGemmSet, +} + +impl<'a> StateEncoder<'a> { + /// Wrap an existing `BatchedForward` (`CublasGemmSet`) without taking + /// ownership of any GPU resources. The returned encoder is a borrow, + /// so multiple decoders can run after one encoder forward without + /// conflicting with the trainer's primary call sites. + pub fn new(batched: &'a CublasGemmSet) -> Self { + Self { batched } + } + + /// Forward-pass through the trunk encoder (`h_s1` + `h_s2` GEMMs). + /// + /// Reuses `BatchedForward::encoder_forward_only`, which is the same + /// dispatch the existing `forward_online_raw` runs for the encoder + /// portion. Behavior is byte-identical to the legacy path. + /// + /// `weight_ptrs` is the full DQN weight pointer array; only positions + /// `[0, 1, 2, 3]` (W_s1 / b_s1 / W_s2 / b_s2) are read. + pub fn forward( + &self, + stream: &Arc, + state_dev_ptr: u64, + weight_ptrs: &[u64; super::gpu_dqn_trainer::NUM_WEIGHT_TENSORS], + h_s1_dev_ptr: u64, + h_s2_dev_ptr: u64, + batch_size: usize, + ) -> Result { + if batch_size != self.batched.batch_size() { + return Err(MLError::ModelError(format!( + "StateEncoder::forward: batch_size {} != BatchedForward batch_size {}", + batch_size, + self.batched.batch_size(), + ))); + } + self.batched.encoder_forward_only( + stream, + state_dev_ptr, + weight_ptrs, + h_s1_dev_ptr, + h_s2_dev_ptr, + )?; + Ok(EncoderOutput { + h_s2_dev_ptr, + h_s2_dim: self.batched.shared_h2(), + batch_size, + }) + } + + /// Output activation dimension (`shared_h2`). + pub fn h_s2_dim(&self) -> usize { + self.batched.shared_h2() + } +} diff --git a/crates/ml/src/cuda_pipeline/value_decoder.rs b/crates/ml/src/cuda_pipeline/value_decoder.rs new file mode 100644 index 000000000..a6d828ecc --- /dev/null +++ b/crates/ml/src/cuda_pipeline/value_decoder.rs @@ -0,0 +1,164 @@ +//! Plan 4 Task 4 (E.4): explicit per-branch value-decoder boundary. +//! +//! `ValueDecoder` is a thin Rust API wrapper around one of the four DQN +//! advantage branches (Direction, Magnitude, Order, Urgency). Each +//! instance forwards an encoder's `h_s2` activation through its branch's +//! FC + adv-logits heads via +//! `BatchedForward::decoder_forward_only`. It owns no parameters of its +//! own — references the trainer's per-branch weights via the +//! `weight_ptrs` slice the caller passes in. +//! +//! Plan 2 D.3 (V_short + V_long horizon-decomposed value head) is +//! already landed on the value-head side; the decoder API surfaces the +//! `BranchDecoderOutput::v_short_dev_ptr` / `v_long_dev_ptr` fields so +//! Plan 4 Task 3 (multi-Q IQN) and Task 6 (auxiliary heads) can attach +//! cleanly. For decoders that do not own a value head, callers may set +//! the `v_short` / `v_long` pointers to 0 — they are not consumed by +//! `decoder_forward_only` itself today (they pass through into the +//! struct so downstream code sees a uniform shape). +//! +//! Design intent (E.4 spec): +//! - **Additive** to `BatchedForward::forward_online_raw` — that +//! monolithic dispatch keeps the existing multi-stream branch +//! fork/join. `ValueDecoder::forward` is for callers that need to +//! drive a single branch on a single stream (validation paths, +//! per-branch experiments, future migrations). No call-site +//! migration required for production training. +//! - Pure Rust API refactor: no kernel changes, no parameter changes, +//! no checkpoint break. + +use std::sync::Arc; + +use cudarc::driver::CudaStream; + +use crate::MLError; +use crate::cuda_pipeline::batched_forward::CublasGemmSet; + +/// Identifies one of the four DQN advantage branches. The `repr(usize)` is +/// chosen so `Branch::Dir as usize == 0` etc., matching the +/// `branch_idx` convention used throughout `BatchedForward`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(usize)] +pub enum Branch { + Dir = 0, + Mag = 1, + Ord = 2, + Urg = 3, +} + +impl Branch { + /// Branch index in `[0, 4)`, matching `BatchedForward`'s internal layout. + pub fn idx(self) -> usize { self as usize } + + /// Short label, useful for diagnostics. + pub fn label(self) -> &'static str { + match self { + Branch::Dir => "dir", + Branch::Mag => "mag", + Branch::Ord => "ord", + Branch::Urg => "urg", + } + } +} + +/// Output of one `ValueDecoder::forward` call. +/// +/// Carries the device pointer for the per-action Q-distribution +/// (`q_per_action_dev_ptr`, shape `[B, branch_q_dim * num_atoms]` in +/// f32) plus the Plan 2 D.3 horizon-decomposed value pointers. The +/// value pointers are pass-through fields — the decoder does not own +/// them, the caller supplies them so downstream consumers see a uniform +/// type. Pass `0` for either pointer if the caller does not maintain a +/// horizon-decomposed value head for this branch. +#[derive(Debug, Clone, Copy)] +pub struct BranchDecoderOutput { + pub q_per_action_dev_ptr: u64, + pub q_per_action_dim: usize, + /// Number of distributional atoms per action (C51 support size). + pub num_atoms: usize, + pub v_short_dev_ptr: u64, + pub v_long_dev_ptr: u64, + pub batch_size: usize, +} + +/// Plan 4 Task 4 (E.4) per-branch value-decoder façade. +/// +/// One instance per branch. Borrows a `CublasGemmSet` (`BatchedForward`) +/// and dispatches `decoder_forward_only` for the configured branch. +#[allow(missing_debug_implementations)] // CublasGemmSet borrow is not Debug +pub struct ValueDecoder<'a> { + batched: &'a CublasGemmSet, + branch: Branch, +} + +impl<'a> ValueDecoder<'a> { + /// Construct a decoder bound to a specific branch. + pub fn new(batched: &'a CublasGemmSet, branch: Branch) -> Self { + Self { batched, branch } + } + + /// Branch this decoder targets. + pub fn branch(&self) -> Branch { self.branch } + + /// Number of actions per branch (pre-num_atoms multiplication). + pub fn q_per_action_dim(&self) -> usize { + self.batched.branch_q_dim_internal(self.branch.idx()) + } + + /// Forward `h_s2` through this branch's decoder heads. + /// + /// Reuses `BatchedForward::decoder_forward_only`, which mirrors the + /// per-branch loop body in `forward_online_raw` (sequential + /// single-stream variant). Behaviorally a no-op for callers that + /// keep using `forward_online_raw` directly. + /// + /// Pointer ownership (caller-supplied): + /// - `h_s2_dev_ptr` : `[B, shared_h2]` encoder output (read-only) + /// - `mag_concat_dev_ptr` : `[B, shared_h2 + 3]` magnitude branch + /// wider input; pass `0` for legacy `[B, shared_h2]` input + /// - `branch_h_dev_ptr` : `[B, adv_h]` per-branch hidden scratch + /// - `q_per_action_dev_ptr`: `[B, branch_q_dim * num_atoms]` output + /// - `v_short_dev_ptr`, + /// `v_long_dev_ptr` : optional horizon-decomposed value + /// pointers, pass `0` if not used. + #[allow(clippy::too_many_arguments)] + pub fn forward( + &self, + stream: &Arc, + h_s2_dev_ptr: u64, + mag_concat_dev_ptr: u64, + weight_ptrs: &[u64; super::gpu_dqn_trainer::NUM_WEIGHT_TENSORS], + branch_h_dev_ptr: u64, + q_per_action_dev_ptr: u64, + v_short_dev_ptr: u64, + v_long_dev_ptr: u64, + batch_size: usize, + ) -> Result { + if batch_size != self.batched.batch_size() { + return Err(MLError::ModelError(format!( + "ValueDecoder::forward: batch_size {} != BatchedForward batch_size {}", + batch_size, + self.batched.batch_size(), + ))); + } + + self.batched.decoder_forward_only( + stream, + self.branch.idx(), + h_s2_dev_ptr, + mag_concat_dev_ptr, + weight_ptrs, + branch_h_dev_ptr, + q_per_action_dev_ptr, + )?; + + Ok(BranchDecoderOutput { + q_per_action_dev_ptr, + q_per_action_dim: self.batched.branch_q_dim_internal(self.branch.idx()), + num_atoms: self.batched.num_atoms(), + v_short_dev_ptr, + v_long_dev_ptr, + batch_size, + }) + } +} diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index ee80f3288..c3bbdd3a1 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -86,6 +86,8 @@ | `cuda_pipeline/gpu_dqn_trainer.rs` (`GpuDqnTrainer`) | `fused_training.rs`, `trainer/mod.rs` | Wired | Core GPU-side DQN weight / forward / grad ops | — | | `cuda_pipeline/fused_training.rs` wrapper (`FusedTrainingCtx`) | `trainer/training_loop.rs` | Wired | Wires all GPU ops into epoch loop | — | | `cuda_pipeline/batched_forward.rs` | `gpu_dqn_trainer.rs` — forward graph node | Wired | Batched SGEMM forward pass | — | +| `cuda_pipeline/state_encoder.rs` (`StateEncoder`) | Borrows `CublasGemmSet` and routes through `BatchedForward::encoder_forward_only`; coexists with the monolithic `forward_online_raw` path used by `gpu_dqn_trainer.rs` | Wired (additive — coexists with `forward_online_raw` monolithic path) | Plan 4 Task 4 E.4 — explicit trunk-encoder boundary; attachment point for Plan 4 Task 1 (Full VSN, pre-encoder) and follow-up per-branch experiments | — | +| `cuda_pipeline/value_decoder.rs` (`ValueDecoder`, `Branch`, `BranchDecoderOutput`) | Borrows `CublasGemmSet` and routes through `BatchedForward::decoder_forward_only`; one instance per branch (Dir/Mag/Ord/Urg). Coexists with the multi-stream branch fork/join in `forward_online_raw` | Wired (additive — coexists with `forward_online_raw` monolithic path) | Plan 4 Task 4 E.4 — per-branch value-decoder boundary; attachment point for Plan 4 Task 3 (multi-Q IQN) and Task 6 (auxiliary heads). Surfaces Plan 2 D.3 V_short / V_long pointers via `BranchDecoderOutput` | — | | `cuda_pipeline/batched_backward.rs` | `gpu_dqn_trainer.rs` — backward graph node | Wired | Batched SGEMM backward pass (KAN gate included) | — | | `cuda_pipeline/shared_cublas_handle.rs` | `fused_training.rs`, `gpu_iqn_head.rs`, `gpu_iql_trainer.rs`, `gpu_attention.rs`, `gpu_curiosity_trainer.rs` (10 consumers) | Wired | Shared cuBLAS/cuBLASLt handle | — | | `cuda_pipeline/cublas_algo_deterministic.rs` | `gpu_dqn_trainer.rs`, `gpu_curiosity_trainer.rs`, `gpu_iqn_head.rs` (7 consumers) | Wired | Deterministic cuBLASLt algo selection | — | @@ -273,14 +275,16 @@ Plan 2 Task 6B D.3 (2026-04-24): IQL value head widened from 1 to 2 outputs (V_s Plan 4 Task 5 Mode A E.5 (2026-04-24): `attention_focus_ema_kernel.cu` + `AttentionMonitor` added. 3 new ISV slots [87] VSN_MAG_EMA, [88] VSN_DIR_EMA, [89] MAMBA2_RETENTION_EMA tail-appended; fingerprint shifted [85..87) → [90..92); `ISV_TOTAL_DIM` 87 → 92. Light, ISV-diagnostic-only; no model parameters added, no checkpoint break. Single-thread cold-path EMA kernel takes 3 host scalars by value at the per-epoch HEALTH_DIAG site. New `mamba2_retention_mean(batch_size)` accessor on `GpuDqnTrainer`/`FusedTrainingCtx` mirrors `per_branch_vsn_mean` (cold-path host-side dtoh + mean abs of `mamba2_h_enriched`). Adaptive α convention matches Plan 3 producers. All three slots FoldReset to 0.0 (mirror constructor cold-start). 2 new Wired rows. Mode B (full per-feature-group VSN ISV) blocks on Plan 4 Task 1 (E.1). +Plan 4 Task 4 E.4 (2026-04-24): `state_encoder.rs` + `value_decoder.rs` Rust API split. PURE Rust API refactor — no kernel changes, no parameter changes, no checkpoint break, no new ISV slot. New types: `StateEncoder` (wraps trunk encoder `h_s1` + `h_s2`), `ValueDecoder` (per-branch FC + adv-logits, one instance per `Branch::{Dir,Mag,Ord,Urg}`), `EncoderOutput`, `BranchDecoderOutput` (carries `q_per_action_dev_ptr` + Plan 2 D.3 `v_short_dev_ptr` / `v_long_dev_ptr` pass-through fields). `BatchedForward` gains `encoder_forward_only(...)` and `decoder_forward_only(branch_idx, ...)` helpers, both lossless extractions of the existing dispatch sequence in `forward_online_raw`. The monolithic `forward_online_raw` stays callable and now routes through `encoder_forward_only` internally for the trunk portion (multi-stream branch fork/join unchanged). The new types are ADDITIVE attachment points for Plan 4 Task 1 (Full VSN, pre-encoder), Task 3 (multi-Q IQN, decoder), and Task 6 (auxiliary heads, decoder peer). 2 new Wired rows. + | Classification | Count | |---|---| -| Wired | 86 | +| Wired | 88 | | Partial | 10 | | Orphan (held for follow-up) | 3 | | Ghost | 0 | | OUT-of-DQN-scope | 17 | -| **Total** | **114** | +| **Total** | **116** | The 3 remaining Orphan rows are: - `cuda_pipeline/gpu_statistics.rs` + `statistics_kernel.cu` — held for Plan 2 D.2 wire-or-delete decision.