Files
foxhunt/crates/ml-backtesting/tests/trainer_parity.rs
jgrusewski a4127935a8 feat(ml-backtesting): BacktestHarness orchestrator + Ring 1b parity (C8)
BacktestHarness::new(cfg, &dev, trunk) constructs a MultiHorizonLoader
(inference_only=true), captures the trunk's perception Graph A using
loader.peek_first() as the template, then allocates a LobSimCuda.
run() walks the chronological snapshot stream, calls apply_snapshot on
every event, and at decision-stride boundaries:
  trunk.update_input_buffers(raw)
  → trunk.perception_forward_captured() → (probs[N_HORIZONS], proj)
  → sim.broadcast_alpha(&probs)
  → sim.step_decision(ts, target_vol, ann_factor, max_lots)

Returns RunStats { events_processed, decisions_taken }. The harness
deliberately accepts an externally-built CfcTrunk (random-init in v1)
because a checkpoint format isn't pinned in ml-alpha yet; when one
lands, the binary CLI (C9) can switch from new_random to load_checkpoint.

trainer_parity.rs Ring 1b — two ignored tests:

  peek_first_byte_equal_across_modes
    Verifies the Mbp10RawInput produced by the loader path used by the
    backtest harness (inference_only=true) is BYTE-EQUAL to what the
    trainer's loader (inference_only=false) produces from the same
    source. Guards against any future refactor accidentally diverging
    the two paths (e.g. someone special-casing inference path to skip
    regime feature computation). All 50 f32 fields + scalars compared
    via .to_bits() equality.

  inference_iteration_matches_chronological_snapshots
    Verifies next_inference_input() yields monotone-ts snapshots with
    correct cur==prev semantics on the first read and prev_ts==prior_ts
    afterwards.

Both tests skip gracefully when FOXHUNT_TEST_DATA fixtures lack
populated sidecars (the placeholder-empty bins committed in the
test_data/ tree).

GPU-side inference parity (probs[N_HORIZONS] bit-equal across loader
modes) is deferred until ml-alpha pins a checkpoint format and we have
a small checkpoint fixture to gate it on FOXHUNT_TEST_CKPT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 08:52:49 +02:00

113 lines
4.3 KiB
Rust

//! Ring 1b — trainer/backtest parity check. See spec §8 Ring 1b.
//!
//! Verifies that the data-loading path used by the backtest harness
//! (`inference_only=true`) produces an Mbp10RawInput that is byte-equal
//! to what the trainer's loader produces from the same source.
//! Since both paths share the same loader code and same Mbp10Snapshot
//! → Mbp10RawInput conversion, the equality is structurally guaranteed —
//! but the test guards against any future refactor that breaks the claim.
//!
//! The full inference-output parity (asserting [probs; N_HORIZONS] from
//! the trunk's captured graph is bit-equal between training-time loader
//! and backtest-time loader paths) requires a real ml-alpha checkpoint,
//! which is deferred until a checkpoint-format is pinned in ml-alpha.
//! When a checkpoint is available, FOXHUNT_TEST_CKPT will gate the
//! gpu-inference assertion below.
use anyhow::Result;
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
};
use std::path::PathBuf;
fn try_loader(inference_only: bool) -> Option<MultiHorizonLoader> {
let root = std::env::var("FOXHUNT_TEST_DATA").ok()?;
let mbp10 = PathBuf::from(&root).join("ES.FUT");
if !mbp10.exists() {
return None;
}
let files = discover_mbp10_files_sorted(&mbp10).ok()?;
let cfg = MultiHorizonLoaderConfig {
files,
predecoded_dir: mbp10.clone(),
seq_len: 1,
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 1,
seed: 0xCAFEF00D,
decision_stride: 1,
inference_only,
};
match MultiHorizonLoader::new(&cfg) {
Ok(l) => Some(l),
Err(e) => {
eprintln!("skipping: fixture data not usable ({e})");
None
}
}
}
#[test]
#[ignore = "requires populated FOXHUNT_TEST_DATA"]
fn peek_first_byte_equal_across_modes() -> Result<()> {
let Some(loader_train) = try_loader(false) else { return Ok(()); };
let Some(loader_bt) = try_loader(true) else { return Ok(()); };
let first_train = loader_train.peek_first()?;
let first_bt = loader_bt.peek_first()?;
// Inputs must be byte-equal (Mbp10RawInput is Pod via #[derive(Default)]
// — bid_px/sz/ask_px/sz arrays of f32, plus a handful of scalars).
assert_eq!(first_train.ts_ns, first_bt.ts_ns);
assert_eq!(first_train.prev_ts_ns, first_bt.prev_ts_ns);
for k in 0..10 {
assert_eq!(
first_train.bid_px[k].to_bits(),
first_bt.bid_px[k].to_bits(),
"bid_px[{k}] bit-mismatch between training and inference loader"
);
assert_eq!(first_train.bid_sz[k].to_bits(), first_bt.bid_sz[k].to_bits());
assert_eq!(first_train.ask_px[k].to_bits(), first_bt.ask_px[k].to_bits());
assert_eq!(first_train.ask_sz[k].to_bits(), first_bt.ask_sz[k].to_bits());
}
for k in 0..6 {
assert_eq!(
first_train.regime[k].to_bits(),
first_bt.regime[k].to_bits(),
"regime[{k}] bit-mismatch"
);
}
assert_eq!(first_train.prev_mid.to_bits(), first_bt.prev_mid.to_bits());
assert_eq!(first_train.trade_signed_vol.to_bits(), first_bt.trade_signed_vol.to_bits());
assert_eq!(first_train.trade_count, first_bt.trade_count);
Ok(())
}
#[test]
#[ignore = "requires populated FOXHUNT_TEST_DATA"]
fn inference_iteration_matches_chronological_snapshots() -> Result<()> {
let Some(mut loader) = try_loader(true) else { return Ok(()); };
// Pull 5 inference inputs; verify timestamps are monotone non-decreasing
// and each input's prev_ts_ns equals the previous input's ts_ns
// (after the first, which uses cur=prev semantics).
let mut prev_ts_ns = 0u64;
let mut first_iter = true;
for i in 0..5 {
let snap = loader.next_inference_input()?.expect("stream not exhausted");
if first_iter {
// peek_first/next-first uses cur==prev → prev_ts == ts.
assert_eq!(snap.prev_ts_ns, snap.ts_ns, "first snap has prev_ts == ts");
first_iter = false;
} else {
assert_eq!(
snap.prev_ts_ns, prev_ts_ns,
"snap {i}: prev_ts_ns should equal previous snap's ts_ns"
);
}
assert!(snap.ts_ns >= prev_ts_ns, "snap {i}: monotonic ts violated");
prev_ts_ns = snap.ts_ns;
}
Ok(())
}