diff --git a/crates/ml/examples/train_baseline_rl.rs b/crates/ml/examples/train_baseline_rl.rs index cdd5ea513..94c61613f 100644 --- a/crates/ml/examples/train_baseline_rl.rs +++ b/crates/ml/examples/train_baseline_rl.rs @@ -625,6 +625,24 @@ fn run_training(args: &Args) -> Result> { std::fs::create_dir_all(&args.output_dir) .with_context(|| format!("Failed to create output dir: {}", args.output_dir.display()))?; + // Resolve the shared norm_stats.json sibling of the fxcache. precompute_features + // writes one norm_stats per cache key — RL training uses the same stats for + // every fold (unlike supervised which recomputes per-fold). evaluate_baseline + // expects per-fold files, so we copy the shared file into output_dir once per + // fold below. Falls back silently if the cache_key is zero (DBN-fallback load) + // or the stats file doesn't exist — evaluate will warn rather than fail the + // workflow. + let shared_norm_stats_src = if fxcache.cache_key == [0u8; 32] { + None + } else { + ml::fxcache::norm_stats_path_for_key( + &args.data_dir, + cache_dir_override.as_deref(), + &fxcache.cache_key, + ) + .filter(|p| p.exists()) + }; + // 3. Create tokio runtime ONCE, create DQN trainer ONCE, upload fxcache to GPU ONCE let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -721,6 +739,20 @@ fn run_training(args: &Args) -> Result> { range.val_end - range.val_start, ); + // Persist per-fold norm_stats copy for evaluate_baseline. Source is the + // single `.norm_stats.json` emitted by precompute_features; destination + // path matches the supervised training convention so evaluate finds it + // at `/norm_stats_fold{N}.json`. + if let Some(src) = &shared_norm_stats_src { + let dst = args.output_dir.join(format!("norm_stats_fold{}.json", range.fold)); + if let Err(e) = std::fs::copy(src, &dst) { + warn!( + " norm_stats_fold{} copy failed ({} -> {}): {}", + range.fold, src.display(), dst.display(), e + ); + } + } + // Slice features/targets for this fold (zero-copy from fxcache arrays) let train_feat = &fxcache.features[range.train_start..range.train_end]; let val_feat = &fxcache.features[range.val_start..range.val_end]; diff --git a/crates/ml/src/fxcache.rs b/crates/ml/src/fxcache.rs index dfa148c5c..5c2ff36e7 100644 --- a/crates/ml/src/fxcache.rs +++ b/crates/ml/src/fxcache.rs @@ -526,6 +526,25 @@ pub fn resolve_cache_dir(data_dir: &Path, override_dir: Option<&Path>) -> Option None } +/// Returns the path to the `{hex_key}.norm_stats.json` file that sits +/// alongside the `.fxcache` for the given cache key. +/// +/// `precompute_features` writes this file at the same time as the fxcache +/// (see `precompute_features.rs:607`). Supervised training writes a per-fold +/// copy into the output dir for evaluation; RL training should use this +/// helper to produce the same per-fold copies (`norm_stats_fold{N}.json`) +/// so `evaluate_baseline` can find them. Returns `None` if the cache +/// directory can't be resolved. +pub fn norm_stats_path_for_key( + data_dir: &Path, + cache_dir_override: Option<&Path>, + cache_key: &[u8; 32], +) -> Option { + let cache_dir = resolve_cache_dir(data_dir, cache_dir_override)?; + let hex_key = hex::encode(cache_key); + Some(cache_dir.join(format!("{hex_key}.norm_stats.json"))) +} + /// Discover and load an fxcache file. Single source of truth for all callers. /// /// Cache dir priority: `cache_dir_override` > `FOXHUNT_FEATURE_CACHE_DIR` env > walk-up sibling.