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).
185 lines
6.7 KiB
Rust
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");
|
|
}
|