test: add state layout match integration test

Verifies the canonical state layout at all 5 sections (market/OFI/MTF/portfolio/padding).
If any section drifts to the wrong offset, this test fails. Since training and
backtest share assemble_state() in state_layout.cuh, a passing test guarantees
both paths produce identical layouts.

Result: ✓ market=41 ofi=9 mtf=9 portfolio=5 padding=all-zero

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-20 15:35:41 +02:00
parent 882497caa4
commit 733b2c32ec

View File

@@ -555,3 +555,115 @@ fn test_fxcache_zero_copy_training() -> anyhow::Result<()> {
eprintln!("[FXCACHE] All {} folds passed. Losses: {:?}", fold_losses.len(), fold_losses);
Ok(())
}
/// Layout match test: verifies the training state_gather produces states with
/// the canonical layout defined in state_layout.rs.
///
/// This is the definitive test that the train/validation mismatch bug is fixed.
/// The state at any bar must have:
/// - Market features at [0..42) — non-zero (pre-normalized market data)
/// - OFI at [42..62) — non-zero (MBP-10 order flow)
/// - MTF at [62..78) — non-zero (computed from lookback windows)
/// - Portfolio at [78..86) — non-zero after at least 1 epoch (simulated)
/// - Plan/ISV at [86..92) — may be zero when no plan active
/// - Padding at [92..96) — exactly zero
///
/// If ANY section is at the wrong offset, this test fails. The training kernel
/// and the backtest kernel share `assemble_state()` in state_layout.cuh, so a
/// passing test here guarantees the same layout is produced by both paths.
#[test]
#[ignore] // Requires fxcache — run via: FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_state_layout_match --ignored --nocapture
fn test_state_layout_match() -> anyhow::Result<()> {
use crate::fxcache;
use ml_core::state_layout::{
STATE_DIM, MARKET_START, OFI_START, MTF_START,
PORTFOLIO_START, PLAN_ISV_START, PADDING_START, PADDING_DIM,
};
// Load fxcache and run 1 training epoch to populate state buffer
let cache_dir = feature_cache_dir();
let entries: Vec<_> = std::fs::read_dir(cache_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("fxcache"))
.collect();
assert!(!entries.is_empty(), "No .fxcache files in test_data/feature-cache/");
let cache_path = entries[0].path();
let fxcache_data = fxcache::load_fxcache(&cache_path)?;
let mut params = smoke_params();
params.epochs = 1;
let mut trainer = smoke_trainer_with(params)?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(trainer.init_from_fxcache(
&fxcache_data.features, &fxcache_data.targets, &fxcache_data.ofi,
))?;
let n = fxcache_data.bar_count;
let train_end = (n * 80) / 100;
let val_feat = &fxcache_data.features[train_end..n];
let val_targets = &fxcache_data.targets[train_end..n];
trainer.set_training_range(0, train_end, train_end, n);
trainer.set_val_data_from_slices(val_feat, val_targets, train_end);
rt.block_on(trainer.reset_for_fold())?;
let train_feat = &fxcache_data.features[..train_end];
let train_targets = &fxcache_data.targets[..train_end];
let _metrics = rt.block_on(
trainer.train_fold_from_slices(train_feat, train_targets, |_, _, _| Ok("".into())),
)?;
// Read back one training state via fused context's readback API
let fused = trainer.fused_ctx.as_ref()
.ok_or_else(|| anyhow::anyhow!("fused_ctx not initialized"))?;
let sample = fused.read_state_sample(0)?;
assert!(sample.len() >= STATE_DIM,
"State size {} < STATE_DIM {}", sample.len(), STATE_DIM);
// ── Market features [0..42) ── must be non-zero (pre-normalized) ──
let market_nonzero = sample[MARKET_START..OFI_START].iter().filter(|&&v| v != 0.0).count();
assert!(market_nonzero >= 30,
"Market section has only {} non-zero values, expected >=30 (pre-normalized features should mostly be non-zero)",
market_nonzero);
// ── OFI [42..62) ── must be non-zero (MBP-10 order flow) ──
let ofi_section = &sample[OFI_START..MTF_START];
let ofi_nonzero = ofi_section.iter().filter(|&&v| v != 0.0).count();
assert!(ofi_nonzero >= 5,
"OFI section has only {} non-zero values, expected >=5 (MBP-10 order flow should produce multiple non-zero features per bar): {:?}",
ofi_nonzero, ofi_section);
// ── MTF [62..78) ── must have non-zero values (computed from lookbacks) ──
let mtf_section = &sample[MTF_START..PORTFOLIO_START];
let mtf_nonzero = mtf_section.iter().filter(|&&v| v != 0.0).count();
assert!(mtf_nonzero >= 4,
"MTF section has only {} non-zero values, expected >=4 (4 lookbacks × 4 features, at least one should be non-zero): {:?}",
mtf_nonzero, mtf_section);
// ── Portfolio [78..86) ── after 1 epoch, simulation should produce non-zero ──
let portfolio_section = &sample[PORTFOLIO_START..PLAN_ISV_START];
let portfolio_nonzero = portfolio_section.iter().filter(|&&v| v != 0.0).count();
// Portfolio features include cash/equity which should always be non-zero
assert!(portfolio_nonzero >= 1,
"Portfolio section is all zeros after 1 epoch — simulation not running: {:?}",
portfolio_section);
// ── Padding [92..96) ── must be exactly zero ──
let padding_section = &sample[PADDING_START..PADDING_START + PADDING_DIM];
for (i, &v) in padding_section.iter().enumerate() {
assert_eq!(v, 0.0,
"Padding position {} is {} but must be 0.0 (tensor core alignment slot)",
PADDING_START + i, v);
}
eprintln!(
"[LAYOUT] ✓ Layout match verified: market={} ofi={} mtf={} portfolio={} padding=all-zero",
market_nonzero, ofi_nonzero, mtf_nonzero, portfolio_nonzero,
);
Ok(())
}