Closes the literal/const drift gap that F.5 introduced. Per
feedback_isv_for_adaptive_bounds + feedback_single_source_of_truth_no_duplicates:
adaptive bounds belong in ISV (or in a single canonical const that
ISV references), never duplicated as literals across modules.
Single canonical source: `crate::rl::common::FRD_HORIZON_TICKS` +
`FRD_BUCKET_RANGE_SIGMA` (already declared in F.5).
Producer-side fixes:
* Trainer ISV bootstrap (integrated.rs): the seed values for slots
500-503 now dereference the canonical consts instead of hardcoded
60.0/300.0/1800.0/3.0 literals. Future tuning of the consts
automatically propagates to both ISV seeds and loader-side
labels — no manual sync required, no drift possible.
* compute_frd_labels (loader.rs): takes `horizon_ticks` and
`range_sigma` as parameters instead of reading consts directly.
Caller (the file-load closure) sources them from the new
MultiHorizonLoaderConfig fields.
Consumer-side fixes — 8 MultiHorizonLoaderConfig literal sites now
provide the two new fields, all defaulting to the canonical consts:
* crates/ml-alpha/src/data/loader.rs (2 internal test-fixture sites)
* crates/ml-alpha/tests/multi_horizon_loader.rs (2 sites)
* crates/ml-alpha/examples/alpha_train.rs (2 sites)
* crates/ml-alpha/examples/alpha_rl_train.rs (2 sites)
* crates/ml-backtesting/src/harness.rs (1 site)
* crates/ml-backtesting/tests/{trainer_parity,ring3_replay}.rs (2 sites)
The "optimal by default" property is preserved: every caller that
doesn't explicitly override gets the spec-recommended 60/300/1800
ticks + ±3σ. Callers that need to retune set the config fields, and
the trainer's ISV slots provide a runtime knob for the same numerics.
Verification (RTX 3050 Ti):
* cargo check -p ml-alpha -p ml-backtesting --examples --tests → clean
* cargo test --lib (6/6 unit tests for FRD label gen + loss_balance) → pass
* frd_head 10/10 + integrated_trainer_smoke 1/1 + trade_mgmt 5/5 → pass
* audit-rust-consts → 0 flags
The two new MultiHorizonLoaderConfig fields are required (no Default
impl) — callers MUST opt in to the FRD label-generation contract by
naming the fields. This is the same discipline applied across other
config consumers; making them Option<...> would silently default to
"no FRD labels" and break F.4's expected supervised signal.
116 lines
4.7 KiB
Rust
116 lines
4.7 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, InstrumentFilter, 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(),
|
|
multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig::single_scale_32(),
|
|
horizons: ml_alpha::heads::HORIZONS,
|
|
n_max_sequences: 1,
|
|
seed: 0xCAFEF00D,
|
|
inference_only,
|
|
outcome_label_cost: ml_alpha::data::loader::DEFAULT_OUTCOME_LABEL_COST_ES,
|
|
instrument_filter: InstrumentFilter::All,
|
|
frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS,
|
|
frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA,
|
|
};
|
|
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(())
|
|
}
|