diff --git a/crates/ml/tests/fxcache_roundtrip_test.rs b/crates/ml/tests/fxcache_roundtrip_test.rs new file mode 100644 index 000000000..0309ed4a7 --- /dev/null +++ b/crates/ml/tests/fxcache_roundtrip_test.rs @@ -0,0 +1,237 @@ +//! Roundtrip tests for the `.fxcache` binary format. +//! +//! Covers f64/bf16 serialization, cache-key lookup, header validation, +//! and the empty-bars error path. + +use ml::fxcache::{find_fxcache, load_fxcache, write_fxcache, FxCacheData}; +use tempfile::TempDir; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/// Deterministic feature vector for bar `i`. +fn make_features(n: usize) -> Vec<[f64; 42]> { + (0..n) + .map(|i| { + let mut row = [0.0_f64; 42]; + for j in 0..42 { + row[j] = (i * 42 + j) as f64 * 0.01; + } + row + }) + .collect() +} + +/// Deterministic target vector for bar `i`. +fn make_targets(n: usize) -> Vec<[f64; 4]> { + (0..n) + .map(|i| { + let mut row = [0.0_f64; 4]; + for j in 0..4 { + row[j] = 5000.0 + (i * 4 + j) as f64 * 0.25; + } + row + }) + .collect() +} + +/// Deterministic OFI vector for bar `i`. +fn make_ofi(n: usize) -> Vec<[f64; 8]> { + (0..n) + .map(|i| { + let mut row = [0.0_f64; 8]; + for j in 0..8 { + row[j] = (i * 8 + j) as f64 * 0.001; + } + row + }) + .collect() +} + +/// A fixed 32-byte cache key for tests. +fn test_cache_key() -> [u8; 32] { + let mut key = [0u8; 32]; + for (i, slot) in key.iter_mut().enumerate() { + *slot = (i as u8).wrapping_mul(7).wrapping_add(13); + } + key +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +/// Write 100 bars as f64 (version 1), read back, verify bit-exact match. +#[test] +fn test_fxcache_f64_roundtrip() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("test_f64.fxcache"); + + let n = 100; + let features = make_features(n); + let targets = make_targets(n); + let ofi = make_ofi(n); + let key = test_cache_key(); + + let bytes_written = + write_fxcache(&path, &features, &targets, &ofi, key, false).unwrap(); + assert!(bytes_written > 0, "expected nonzero bytes written"); + + let data: FxCacheData = load_fxcache(&path).unwrap(); + assert_eq!(data.bar_count, n); + assert_eq!(data.cache_key, key); + assert_eq!(data.features.len(), n); + assert_eq!(data.targets.len(), n); + assert_eq!(data.ofi.len(), n); + + // Bit-exact match for f64 roundtrip. + for i in 0..n { + assert_eq!( + data.features[i], features[i], + "feature mismatch at bar {i}" + ); + assert_eq!( + data.targets[i], targets[i], + "target mismatch at bar {i}" + ); + assert_eq!(data.ofi[i], ofi[i], "ofi mismatch at bar {i}"); + } +} + +/// Write 50 bars as bf16 (version 2), read back, verify within tolerance. +/// bf16 has limited precision: ~3 significant digits. +#[test] +fn test_fxcache_bf16_roundtrip() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("test_bf16.fxcache"); + + let n = 50; + let features = make_features(n); + let targets = make_targets(n); + let ofi = make_ofi(n); + let key = test_cache_key(); + + let bytes_written = + write_fxcache(&path, &features, &targets, &ofi, key, true).unwrap(); + assert!(bytes_written > 0, "expected nonzero bytes written"); + + let data: FxCacheData = load_fxcache(&path).unwrap(); + assert_eq!(data.bar_count, n); + assert_eq!(data.cache_key, key); + + // bf16 tolerance checks. + for i in 0..n { + for j in 0..42 { + let diff = (data.features[i][j] - features[i][j]).abs(); + assert!( + diff < 0.02, + "feature[{i}][{j}]: expected {}, got {}, diff={diff}", + features[i][j], + data.features[i][j] + ); + } + for j in 0..4 { + let diff = (data.targets[i][j] - targets[i][j]).abs(); + // bf16 at ~5000 has resolution of ~4, so allow up to 4.0. + assert!( + diff <= 4.0, + "target[{i}][{j}]: expected {}, got {}, diff={diff}", + targets[i][j], + data.targets[i][j] + ); + } + for j in 0..8 { + let diff = (data.ofi[i][j] - ofi[i][j]).abs(); + assert!( + diff < 0.02, + "ofi[{i}][{j}]: expected {}, got {}, diff={diff}", + ofi[i][j], + data.ofi[i][j] + ); + } + } +} + +/// Write a file with a known cache key, verify `find_fxcache` locates it. +/// Also verify that a different key returns `None`. +#[test] +fn test_fxcache_find() { + let dir = TempDir::new().unwrap(); + let key = test_cache_key(); + let hex_key = hex::encode(key); + let path = dir.path().join(format!("{hex_key}.fxcache")); + + let features = make_features(5); + let targets = make_targets(5); + let ofi = make_ofi(5); + + write_fxcache(&path, &features, &targets, &ofi, key, false).unwrap(); + + // Should find the file. + let found = find_fxcache(dir.path(), &key); + assert!(found.is_some(), "expected to find fxcache file"); + assert_eq!(found.unwrap(), path); + + // Wrong key should miss. + let mut wrong_key = [0xFFu8; 32]; + wrong_key[0] = 0x00; + let miss = find_fxcache(dir.path(), &wrong_key); + assert!(miss.is_none(), "expected miss for wrong key"); +} + +/// Write garbage bytes to a file, verify `load_fxcache` returns an error +/// whose message contains "magic". +#[test] +fn test_fxcache_header_validation() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("garbage.fxcache"); + + // Write 128 bytes of garbage (more than header size). + std::fs::write(&path, vec![0xABu8; 128]).unwrap(); + + let err = load_fxcache(&path).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.to_lowercase().contains("magic"), + "expected error about magic bytes, got: {msg}" + ); +} + +/// Attempting to write 0 bars should fail (writer rejects empty data). +/// Attempting to read a hand-crafted header with bar_count=0 should also fail. +#[test] +fn test_fxcache_empty() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("empty.fxcache"); + + // Writer rejects 0 bars. + let features: Vec<[f64; 42]> = vec![]; + let targets: Vec<[f64; 4]> = vec![]; + let ofi: Vec<[f64; 8]> = vec![]; + let key = test_cache_key(); + + let err = write_fxcache(&path, &features, &targets, &ofi, key, false).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("0 bars") || msg.contains("empty"), + "expected empty/0-bars error from writer, got: {msg}" + ); + + // Reader also rejects a header with bar_count=0. + // Hand-craft a valid-magic header with bar_count=0. + let mut header = [0u8; 64]; + header[0..8].copy_from_slice(b"FXCACHE\0"); + header[8..10].copy_from_slice(&1u16.to_le_bytes()); // version=1 + header[10..12].copy_from_slice(&42u16.to_le_bytes()); // feat_dim + header[12..14].copy_from_slice(&4u16.to_le_bytes()); // target_dim + header[14..16].copy_from_slice(&8u16.to_le_bytes()); // ofi_dim + header[16..24].copy_from_slice(&0u64.to_le_bytes()); // bar_count=0 + // cache_key + reserved stay zeroed. + + let path_reader = dir.path().join("empty_header.fxcache"); + std::fs::write(&path_reader, &header).unwrap(); + + let err = load_fxcache(&path_reader).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("zero") || msg.contains("bar_count"), + "expected bar_count/zero error from reader, got: {msg}" + ); +}