From 395e0d30004f0c374bf1d230cb9b172474d7ab36 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Tue, 19 May 2026 09:06:26 +0200 Subject: [PATCH] refactor(ml-backtesting): drive forward via PerceptionTrainer.forward_only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BacktestHarness now owns a PerceptionTrainer (in inference role) instead of a raw CfcTrunk. The sliding K-window of recent snapshots accumulates in the harness; at each decision-stride boundary (and only once the window has reached cfg.seq_len), the harness calls trainer.forward_only(&window) and broadcasts the last K position's per-horizon probs to the LobSim. fxt-backtest's main.rs constructs the trainer via PerceptionTrainer::from_checkpoint when --checkpoint is supplied (else random init for noise baseline). Why this shape: PerceptionTrainer's evaluate_batched already runs the full inference chain (snap → vsn → mamba2 → ln → mamba2 → ln → attn_pool → cfc K-loop → grn heads) correctly. Duplicating that 400-line forward chain on CfcTrunk would double the surface area for the same result — the trunk's role is weight-source-of-truth (achieved in X1-X9), not kernel-launch orchestration. End-to-end status: alpha_train emits Checkpoint files via X14 wiring; fxt-backtest now loads those Checkpoints via from_checkpoint and drives forward via forward_only. Phase 2 (Argo runtime: training → smoke → threshold pre-reg → 560-cell deployability sweep → verdict) is unblocked. Adds PerceptionTrainer::config() accessor so the harness can read seq_len. Verification: ml-alpha + ml-backtesting + fxt-backtest all build clean. --- bin/fxt-backtest/src/main.rs | 31 ++++++--- crates/ml-alpha/src/trainer/perception.rs | 7 ++ crates/ml-backtesting/src/harness.rs | 83 ++++++++++++++++------- 3 files changed, 89 insertions(+), 32 deletions(-) diff --git a/bin/fxt-backtest/src/main.rs b/bin/fxt-backtest/src/main.rs index 28165e26f..ad57c1920 100644 --- a/bin/fxt-backtest/src/main.rs +++ b/bin/fxt-backtest/src/main.rs @@ -14,7 +14,9 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use ml_alpha::cfc::trunk::{CfcConfig, CfcTrunk}; +use ml_alpha::cfc::trunk::CfcConfig; +use ml_alpha::heads::N_HORIZONS; +use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig}; use ml_backtesting::aggregate::aggregate_sweep_dir; use ml_backtesting::harness::{BacktestHarness, BacktestHarnessConfig}; use ml_backtesting::policy::Strategy; @@ -267,17 +269,30 @@ fn run(args: RunArgs) -> Result<()> { }; let dev = MlDevice::cuda(0).map_err(|e| anyhow::anyhow!("MlDevice::cuda(0): {e}"))?; - let trunk = if let Some(ckpt_path) = &args.checkpoint { - tracing::info!(checkpoint = %ckpt_path.display(), "loading CfcTrunk checkpoint"); - CfcTrunk::load_checkpoint(&dev, &CfcConfig::default(), ckpt_path) + // X11 inference path: backtester drives forward via PerceptionTrainer + // (in inference role). The trainer wraps a CfcTrunk loaded from a + // Checkpoint file; forward kernels live on the trainer and read + // weights from `self.trunk` (the post-X1-X9 source of truth). + let trainer_cfg = PerceptionTrainerConfig { + n_batch: 1, + seq_len: 32, + ..Default::default() + }; + let trainer = if let Some(ckpt_path) = &args.checkpoint { + tracing::info!(checkpoint = %ckpt_path.display(), "loading PerceptionTrainer from checkpoint"); + PerceptionTrainer::from_checkpoint(&dev, &trainer_cfg, ckpt_path) .with_context(|| format!("load checkpoint {}", ckpt_path.display()))? } else { tracing::warn!( seed = args.seed, - "no --checkpoint provided; using new_random trunk (random init — backtest results are NOISE)" + "no --checkpoint provided; using random-init PerceptionTrainer (backtest results are NOISE)" ); - CfcTrunk::new_random(&dev, &CfcConfig::default(), args.seed) - .context("CfcTrunk::new_random")? + let _ = CfcConfig::default; // CfcConfig used transitively via trunk_cfg + let _ = N_HORIZONS; + let mut cfg2 = trainer_cfg.clone(); + cfg2.seed = args.seed; + PerceptionTrainer::new(&dev, &cfg2) + .context("PerceptionTrainer::new")? }; let cfg = BacktestHarnessConfig { @@ -296,7 +311,7 @@ fn run(args: RunArgs) -> Result<()> { std::fs::create_dir_all(&args.out) .with_context(|| format!("create out dir {}", args.out.display()))?; - let mut harness = BacktestHarness::new(cfg, &dev, trunk).context("BacktestHarness::new")?; + let mut harness = BacktestHarness::new(cfg, &dev, trainer).context("BacktestHarness::new")?; let stats = harness.run().context("BacktestHarness::run")?; harness.write_artifacts(&args.out).context("write_artifacts")?; diff --git a/crates/ml-alpha/src/trainer/perception.rs b/crates/ml-alpha/src/trainer/perception.rs index 1d0f8c446..3566adbcf 100644 --- a/crates/ml-alpha/src/trainer/perception.rs +++ b/crates/ml-alpha/src/trainer/perception.rs @@ -2316,6 +2316,13 @@ impl PerceptionTrainer { self.trunk.save_checkpoint(path).context("trunk save_checkpoint") } + /// X11: accessor for the trainer's `cfg` field — read-only view of + /// the construction-time configuration. Used by BacktestHarness to + /// size its snapshot window from `seq_len`. + pub fn config(&self) -> &PerceptionTrainerConfig { + &self.cfg + } + /// X11 inference entry point: forward-only pass over `snapshots` /// (length `cfg.n_batch * cfg.seq_len` per the trainer's batching). /// Returns per-horizon probabilities flattened as `[K, B, N_HORIZONS]` diff --git a/crates/ml-backtesting/src/harness.rs b/crates/ml-backtesting/src/harness.rs index b67154e0e..6113c42ac 100644 --- a/crates/ml-backtesting/src/harness.rs +++ b/crates/ml-backtesting/src/harness.rs @@ -1,20 +1,24 @@ //! BacktestHarness — orchestrator wiring ml-alpha's MultiHorizonLoader -//! and CfcTrunk into the LobSimCuda. See spec §7. +//! and PerceptionTrainer (in inference role) into the LobSimCuda. //! -//! Design: the harness reuses the trainer's loader + captured graph -//! verbatim. Train-vs-deploy skew is structurally impossible because the -//! input struct (`Mbp10RawInput`), feature-assembly cubin -//! (`snap_feature_assemble`), and graph capture (`capture_graph_a` → -//! `perception_forward_captured`) are the same code paths used in -//! training. The harness's only job is to walk the chronological -//! snapshot stream and drive the sim's decision loop. +//! Design: the harness reuses the trainer's loader + forward chain +//! (PerceptionTrainer::evaluate_batched, accessed via `forward_only`) +//! so train-vs-deploy skew is structurally impossible. The input struct +//! (`Mbp10RawInput`), feature-assembly cubin, and full perception +//! forward are the same code paths used in training. The harness's +//! only job is to walk the chronological snapshot stream, maintain a +//! sliding K-window for the recurrent Mamba2 context, drive the sim's +//! decision loop with the trainer's per-horizon probability output. use anyhow::{Context, Result}; -use ml_alpha::cfc::trunk::CfcTrunk; +use ml_alpha::cfc::snap_features::Mbp10RawInput; use ml_alpha::data::loader::{ discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig, }; +use ml_alpha::heads::N_HORIZONS; +use ml_alpha::trainer::perception::PerceptionTrainer; use ml_core::device::MlDevice; +use std::collections::VecDeque; use std::path::PathBuf; use crate::sim::LobSimCuda; @@ -46,7 +50,17 @@ pub struct BacktestHarnessConfig { pub struct BacktestHarness { cfg: BacktestHarnessConfig, loader: MultiHorizonLoader, - trunk: CfcTrunk, + /// PerceptionTrainer in inference role — owns the trunk (loaded from + /// Checkpoint) and the kernel-launch scratches. Forward driven via + /// `forward_only` at decision-stride boundaries. + trainer: PerceptionTrainer, + /// Sliding K-window of recent snapshots for the recurrent forward. + /// At every decision-stride boundary, when the window has reached + /// `seq_len` entries, we call `trainer.forward_only(&window)` and + /// take the last K position's probs. + snapshot_window: VecDeque, + /// Window capacity = trainer's seq_len, captured at construction. + seq_len: usize, sim: LobSimCuda, decision_count: u64, event_count: u64, @@ -56,13 +70,14 @@ pub struct BacktestHarness { } impl BacktestHarness { - /// Construct with an externally-built MlDevice + trunk. v1 ships - /// with random-init trunk; loading from a checkpoint file is a - /// follow-up (no checkpoint format pinned in ml-alpha yet). + /// Construct with an externally-built MlDevice + PerceptionTrainer. + /// Caller loads the trainer from a Checkpoint via + /// `PerceptionTrainer::from_checkpoint`. The harness reads + /// `trainer.config().seq_len` to size its sliding snapshot window. pub fn new( cfg: BacktestHarnessConfig, dev: &MlDevice, - mut trunk: CfcTrunk, + trainer: PerceptionTrainer, ) -> Result { if !cfg.strategies.is_empty() { anyhow::ensure!( @@ -85,10 +100,12 @@ impl BacktestHarness { }; let loader = MultiHorizonLoader::new(&loader_cfg)?; - // Capture the trunk's perception graph once using the first snapshot - // as a template — required before any perception_forward_captured call. - let first = loader.peek_first().context("peek_first")?; - trunk.capture_graph_a(&first).context("capture_graph_a")?; + let seq_len = trainer.config().seq_len; + anyhow::ensure!( + trainer.config().n_batch == 1, + "BacktestHarness requires PerceptionTrainer with n_batch=1 (got {})", + trainer.config().n_batch + ); let mut sim = LobSimCuda::new(cfg.n_parallel, dev)?; @@ -103,7 +120,9 @@ impl BacktestHarness { Ok(Self { cfg, loader, - trunk, + trainer, + snapshot_window: VecDeque::with_capacity(seq_len), + seq_len, sim, decision_count: 0, event_count: 0, @@ -129,11 +148,27 @@ impl BacktestHarness { // buyer-initiated, negative = seller-initiated. self.sim.step_resting_orders(raw.ts_ns, raw.trade_signed_vol)?; - // At decision-stride boundaries: run trunk inference + sim decision. - if self.event_count % stride == 0 { - self.trunk.update_input_buffers(&raw)?; - let (probs, _proj) = self.trunk.perception_forward_captured()?; - self.sim.broadcast_alpha(&probs)?; + // Maintain the sliding K-window for the recurrent forward. + // Push the current snapshot, evict the oldest if at capacity. + if self.snapshot_window.len() == self.seq_len { + self.snapshot_window.pop_front(); + } + self.snapshot_window.push_back(raw.clone()); + + // At decision-stride boundaries: run forward inference + sim + // decision. Skip until the window is full (insufficient + // context for Mamba2's recurrent state). + if self.event_count % stride == 0 && self.snapshot_window.len() == self.seq_len { + let window: Vec = self.snapshot_window.iter().cloned().collect(); + let probs_all = self.trainer.forward_only(&window) + .context("trainer.forward_only")?; + // probs_all is [K * B * N_HORIZONS] with B=1; take the + // LAST K position's probs as the decision signal. + let last_probs_start = (self.seq_len - 1) * N_HORIZONS; + let last_probs: [f32; N_HORIZONS] = probs_all[last_probs_start..] + .try_into() + .context("slice last K probs")?; + self.sim.broadcast_alpha(&last_probs)?; self.sim.step_decision_with_latency( raw.ts_ns, self.cfg.target_annual_vol_units,