Files
foxhunt/crates/ml-alpha/tests/encoder_gradient.rs
jgrusewski 9ec43fdb9d feat(rl): expose forward_encoder() for RL head consumption (Phase B)
Adds PerceptionTrainer::forward_encoder() returning a borrowed slice
to h_t — the CfC h_new at the FINAL window position (K-1), where the
trade decision is made. The integrated RL trainer (ml_alpha::rl,
Phase E) consumes this to dispatch its 5 loss heads (BCE direction,
C51 Q, PPO pi, PPO V, aux prof+size) on the same encoder
representation that a supervised step() would have used.

Implementation strategy: reuse the existing forward_only() captured
graph (snap_features -> VSN -> Mamba2 x2 -> CfC K-loop -> BCE GRN
heads) rather than splitting the encoder forward out of the captured
graph. The BCE heads still run but their output (probs_per_k_d) is
discarded; the overhead is one fused GRN kernel per k (small vs.
Mamba2+CfC) and avoids the risk of breaking
pearl_cudarc_disable_event_tracking_for_graph_capture or
pearl_no_host_branches_in_captured_graph. Phase E end-to-end
profiling can revisit if needed.

A new dedicated h_t_d: CudaSlice<f32> field of size [B * HIDDEN_DIM]
receives a stream-ordered DtoD copy from h_new_per_k_d at slot K-1
after each forward_encoder() call. This gives RL callers a stable
borrowed reference even if a subsequent forward_encoder() overwrites
the per-K scratch.

Phase B (this commit) lands forward only. The backward path
(per-head loss -> lambda-weighted combine -> encoder backward via
existing step_backward_* machinery) is wired in Phase E once the
heads (Phases C+D) exist.

Existing step()/step_batched()/forward_only() semantics are
unchanged — the new field is initialised once at construction and
written only by forward_encoder(). All 56 ml-alpha lib tests still
pass; the new tests/encoder_gradient.rs locks the API contract
(borrow length B*HIDDEN_DIM, captured-graph determinism across two
calls, finite values, at least one non-zero element).
2026-05-22 22:28:19 +02:00

185 lines
6.7 KiB
Rust

//! RL Phase B API-surface test: `PerceptionTrainer::forward_encoder()`.
//!
//! Contract under test: the encoder forward returns a borrowed device
//! slice of length `B * HIDDEN_DIM` (= the CfC h_new at the FINAL
//! window position, where the trade decision is made) and the slice
//! contains finite f32 values that match `read_h_new_per_k_last`
//! (which reads the same K-1 slot via a parallel readback path). The
//! integrated RL trainer (Phase E) relies on this exact contract:
//! arbitrary head losses can be applied to forward_encoder's output
//! and the same encoder representation a supervised step() would have
//! seen is what feeds the heads.
//!
//! This test exercises the API only — it does NOT verify gradient
//! flow through the encoder. The backward integration lands in Phase
//! E (per the plan); Phase B is the forward contract. The test name
//! ("encoder_gradient") reflects the end-goal — the public hook the
//! gradient path will plug into — not the verification scope of this
//! commit.
//!
//! Requires a working CUDA device (the trainer's captured-graph
//! infrastructure is CUDA-only). Skips gracefully when CUDA is
//! unavailable so `cargo test -p ml-alpha` on CPU-only CI hosts does
//! not regress.
use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::heads::{HIDDEN_DIM, N_HORIZONS};
use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig};
use ml_core::device::MlDevice;
fn try_cuda() -> Option<MlDevice> {
MlDevice::cuda(0).ok()
}
/// Build a deterministic single-direction price ramp matching the
/// shape consumed by `perception_overfit.rs::synthetic_seq` — every
/// snapshot moves `+0.25` so the encoder sees a clean monotone
/// trajectory. forward_encoder doesn't need realistic labels (it
/// skips the BCE loss kernel anyway) but the snapshot field shapes
/// must match what `snap_feature_assemble_batched` expects.
fn synthetic_window(seq_len: usize) -> Vec<Mbp10RawInput> {
let mut out = Vec::with_capacity(seq_len);
let mut prev_mid = 5500.0_f32;
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: 1.0,
trade_count: 1,
ts_ns,
prev_ts_ns: prev_ts,
regime: [0.0; 6],
});
prev_mid = next_mid;
}
out
}
fn test_cfg(seq_len: usize) -> PerceptionTrainerConfig {
PerceptionTrainerConfig {
seq_len,
mamba2_state_dim: 8,
lr_cfc: 3e-3,
lr_mamba2: 1e-3,
seed: 0x4242,
horizon_weights: [1.0; N_HORIZONS],
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
lr_aux: 3e-3,
}
}
#[test]
fn forward_encoder_returns_borrowed_slice_at_k_minus_1() {
let dev = match try_cuda() {
Some(d) => d,
None => {
eprintln!("forward_encoder test skipped: no CUDA device available");
return;
}
};
let cfg = test_cfg(16);
let mut trainer = match PerceptionTrainer::new(&dev, &cfg) {
Ok(t) => t,
Err(e) => {
// Trainer construction can fail on undersized GPUs (e.g.
// the local RTX 3050 Ti has 4 GB; the full perception
// trainer's working set may not fit alongside other CUDA
// tests). Skip rather than fail so the test suite stays
// green on dev boxes.
eprintln!("forward_encoder test skipped: trainer init failed: {e}");
return;
}
};
let window = synthetic_window(cfg.seq_len);
// First call exercises the warmup path (no captured graph yet).
let h_t_len = {
let h_t = trainer
.forward_encoder(&window)
.expect("forward_encoder warmup call");
h_t.len()
};
assert_eq!(
h_t_len,
cfg.n_batch * HIDDEN_DIM,
"h_t length must equal B * HIDDEN_DIM"
);
// Second call exercises the captured-graph replay path. Bit-
// identical inputs MUST yield bit-identical h_t — the same
// captured graph runs, and the DtoD copy is deterministic.
let h_t_first_readback = trainer
.read_h_new_per_k_last()
.expect("read h_new_per_k_last after warmup");
// Run a second forward_encoder. After the call returns, the
// dedicated h_t_d buffer holds slot K-1 of the most recent run.
// We can't read h_t_d directly (no public DtoH accessor — that's
// intentional, Phase E consumes it via head kernels on the same
// stream), but read_h_new_per_k_last reads the same slot from
// h_new_per_k_d which is the SOURCE of the DtoD into h_t_d.
let h_t_second_len = {
let h_t = trainer
.forward_encoder(&window)
.expect("forward_encoder replay call");
h_t.len()
};
assert_eq!(h_t_second_len, h_t_len);
let h_t_second_readback = trainer
.read_h_new_per_k_last()
.expect("read h_new_per_k_last after replay");
assert_eq!(
h_t_first_readback.len(),
cfg.n_batch * HIDDEN_DIM,
"h_new_per_k_last readback length"
);
assert_eq!(h_t_second_readback.len(), h_t_first_readback.len());
// Captured-graph determinism: identical inputs → identical h_t.
// This is the contract Phase E relies on for the RL replay
// buffer (h_t computed at action-selection time must equal h_t
// re-computed at gradient time for the same window).
for (i, (a, b)) in h_t_first_readback
.iter()
.zip(h_t_second_readback.iter())
.enumerate()
{
assert!(
a.is_finite() && b.is_finite(),
"h_t[{i}] must be finite: warmup={a} replay={b}"
);
assert!(
(a - b).abs() <= 1e-5,
"h_t[{i}] must be deterministic across calls: warmup={a} replay={b}"
);
}
// Sanity: at least one element must be non-zero. The CfC h_new
// is tanh-activated, so values live in (-1, 1); a uniformly zero
// output would indicate the encoder forward never ran or the
// DtoD copy missed the buffer.
let any_nonzero = h_t_first_readback.iter().any(|&v| v.abs() > 1e-6);
assert!(any_nonzero, "h_t must have at least one non-zero element");
}