//! Phase A data loader.
//!
//! Reads predecoded MBP-10 sidecars (already on disk from earlier session
//! work — see `crates/ml-features/src/predecoded.rs`). Per spec Section 4:
//! yields multi-resolution windows of `Mbp10RawInput` (length =
//! `multi_resolution.total_positions()`) plus N_HORIZONS binary labels
//! computed via `multi_horizon_labels::generate_labels`.
//!
//! Memory discipline: snapshots stay on the host side here (CPU control
//! flow). The trainer (Task 13) uploads them into GPU buffers via the
//! `CfcTrunk::update_input_buffers` mapped-pinned path before each
//! captured-graph replay.

use std::path::PathBuf;

use anyhow::{Context, Result};
use cudarc::driver::CudaSlice;
use data::providers::databento::mbp10::{BidAskPair, Mbp10Snapshot};
use ml_features::predecoded::load_or_predecode_mbp10;

/// Re-export so downstream crates (ml-backtesting, tests) can construct a
/// `MultiHorizonLoaderConfig` without taking a direct dependency on `data`.
pub use data::providers::databento::dbn_parser::InstrumentFilter;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use rayon::prelude::*;

use crate::cfc::snap_features::{Mbp10RawInput, BOOK_LEVELS, ES_TICK_SIZE, REGIME_DIM};
use crate::heads::N_HORIZONS;
use crate::multi_horizon_labels::{generate_labels, generate_outcome_labels_ab};

/// Cached on-disk shape of per-file label + regime arrays. Sidecar lives
/// alongside the predecoded MBP-10 snapshot sidecar in `cfg.predecoded_dir`.
///
/// Cache key (encoded in the sidecar filename) includes (horizons,
/// outcome_label_cost, instrument_filter) because labels depend on these.
/// The existing predecoded-MBP-10 sidecar's `instrument_filter` part of
/// its filename plus the horizons/cost suffix here give a complete cache
/// key.
///
/// Refilled on schema-mismatch errors (deserialize failure) — the bincode
/// payload's struct shape is itself the schema check. The load path also
/// checks `regime_full.len() == snapshots.len()` as a defensive length
/// guard against silently-stale caches (the underlying MBP-10 sidecar is
/// invalidated by mtime+size mismatch in `ml_features::predecoded`, but
/// that doesn't propagate to a label-length check here without this
/// guard).
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedLabels {
    labels_full: [Vec<f32>; N_HORIZONS],
    outcome_prof_long_full: [Vec<f32>; N_HORIZONS],
    outcome_prof_short_full: [Vec<f32>; N_HORIZONS],
    outcome_size_long_full: [Vec<f32>; N_HORIZONS],
    outcome_size_short_full: [Vec<f32>; N_HORIZONS],
    sigma_k_full: [Vec<f32>; N_HORIZONS],
    pos_fraction: Vec<f32>,
    regime_full: Vec<[f32; REGIME_DIM]>,
}

/// Cache-key suffix for the labels sidecar. Encodes (horizons, cost,
/// instrument_filter) so changes to any of these properties invalidate
/// the cache. Format: `labels_h<H0>_<H1>_<H2>_cost<HEX>_<FILTER>`.
/// The cost is encoded as the f32's raw bit pattern in lowercase hex to
/// keep '.' out of the filename (portable across filesystems) and to
/// preserve exact-float identity (0.5 != 0.50000001 → different bits →
/// different cache key).
fn labels_cache_suffix(
    horizons: &[usize; N_HORIZONS],
    cost_price_units: f32,
    filter: InstrumentFilter,
) -> String {
    let cost_bits = cost_price_units.to_bits();
    let filter_tag = match filter {
        InstrumentFilter::All => String::from("all"),
        InstrumentFilter::Id(id) => format!("instr{id}"),
        InstrumentFilter::FrontMonth => String::from("front_month"),
    };
    format!(
        "labels_h{}_{}_{}_cost{:08x}_{}",
        horizons[0], horizons[1], horizons[2], cost_bits, filter_tag
    )
}

/// Path to the labels-cache sidecar for `source` under `predecoded_dir`.
/// Layout: `<predecoded_dir>/<source_stem>.<suffix>.predecoded.bin`.
fn labels_cache_path(
    source: &std::path::Path,
    predecoded_dir: &std::path::Path,
    horizons: &[usize; N_HORIZONS],
    cost_price_units: f32,
    filter: InstrumentFilter,
) -> PathBuf {
    let suffix = labels_cache_suffix(horizons, cost_price_units, filter);
    let stem = source
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("unknown");
    predecoded_dir.join(format!("{stem}.{suffix}.predecoded.bin"))
}

/// Try to read cached labels. Returns `Ok(Some(...))` on hit,
/// `Ok(None)` on miss (file does not exist), and `Err` only on IO /
/// decode errors. Callers treat `Err` the same as a miss (recompute)
/// but log a warning.
fn try_read_labels_cache(path: &std::path::Path) -> Result<Option<CachedLabels>> {
    use std::fs::File;
    use std::io::BufReader;
    if !path.exists() {
        return Ok(None);
    }
    let f = File::open(path).with_context(|| format!("open labels cache {}", path.display()))?;
    let reader = BufReader::new(f);
    let cached: CachedLabels = bincode::deserialize_from(reader)
        .with_context(|| format!("decode labels cache {}", path.display()))?;
    Ok(Some(cached))
}

/// Write labels cache to disk. Best-effort: failures are logged but don't
/// halt the training run.
fn write_labels_cache(path: &std::path::Path, cached: &CachedLabels) -> Result<()> {
    use std::fs::File;
    use std::io::BufWriter;
    let f = File::create(path).with_context(|| format!("create labels cache {}", path.display()))?;
    let writer = BufWriter::new(f);
    bincode::serialize_into(writer, cached)
        .with_context(|| format!("encode labels cache {}", path.display()))?;
    Ok(())
}

/// EMA smoothing factors. Half-life ≈ ln(2)/α snapshots.
///
/// Empirical ES MBP-10 median inter-event Δt = 74µs (NOT 250ms as the
/// previous comments claimed — mean Δt is 55ms but the distribution is
/// extremely heavy-tailed so the median dominates EMA dynamics). At that
/// rate the half-lives in wall-clock are roughly:
///
///   * `ALPHA_FAST_2P6MS`  (α=0.02)   → ~35 snapshots × 74µs ≈ 2.6ms
///                                       — microstructure-burst scale
///   * `ALPHA_MED_100MS`   (α=0.0005) → ~1400 snapshots × 74µs ≈ 100ms
///                                       — 1-2-second clustering scale
///
/// Values preserved (validated by all prior training); the names are
/// updated to reflect what they actually do. True macro-regime EMAs
/// (multi-second / multi-minute half-lives) would need α ~5e-6 or
/// smaller and are deliberately NOT added here — that's a separate
/// feature-engineering decision.
const ALPHA_FAST_2P6MS: f32 = 0.02;
const ALPHA_MED_100MS: f32 = 0.0005;
const REGIME_EPS: f32 = 1e-6;

/// Compute per-snapshot regime features via a single forward EMA pass
/// over the file. Returns `[regime; REGIME_DIM]` aligned to `snapshots`.
///
/// Sentinel-bootstrap (per [[pearl_first_observation_bootstrap]]): the
/// first observation replaces the EMA directly (no zero-bias warmup).
/// Variance EMAs use Welford-style: track ema_x and ema_x² then
/// var = max(ema_x² - ema_x², 0). All transforms are monotone /
/// log-compressed so no tuned constants leak in.
fn compute_regime_features(snapshots: &[Mbp10Snapshot]) -> Vec<[f32; REGIME_DIM]> {
    let n = snapshots.len();
    let mut out: Vec<[f32; REGIME_DIM]> = vec![[0.0; REGIME_DIM]; n];
    if n == 0 { return out; }

    let mut ema_mid_fast = f32::NAN;
    let mut ema_mid_med = f32::NAN;
    let mut ema_mid2_fast = f32::NAN;   // E[mid²] for var estimate
    let mut ema_mid2_med = f32::NAN;
    let mut ema_spread = f32::NAN;     // ticks
    let mut ema_trades = f32::NAN;     // per-snapshot delta count
    let mut prev_trade_count: u32 = 0;

    for (k, snap) in snapshots.iter().enumerate() {
        let mid = mid_price_f32(snap);
        let l0 = snap.levels.first().copied().unwrap_or_else(BidAskPair::empty);
        let bid_p = BidAskPair::price_to_f64(l0.bid_px) as f32;
        let ask_p = BidAskPair::price_to_f64(l0.ask_px) as f32;
        let spread_t = if ask_p > bid_p { (ask_p - bid_p) / ES_TICK_SIZE } else { 0.0 };
        let trades: u32 = if k == 0 { 0 } else { snap.trade_count.saturating_sub(prev_trade_count) };
        prev_trade_count = snap.trade_count;

        // Update EMAs with sentinel-bootstrap.
        if ema_mid_fast.is_nan() {
            ema_mid_fast = mid; ema_mid_med = mid;
            ema_mid2_fast = mid * mid; ema_mid2_med = mid * mid;
            ema_spread = spread_t; ema_trades = trades as f32;
        } else {
            ema_mid_fast = (1.0 - ALPHA_FAST_2P6MS) * ema_mid_fast + ALPHA_FAST_2P6MS * mid;
            ema_mid_med  = (1.0 - ALPHA_MED_100MS)  * ema_mid_med  + ALPHA_MED_100MS  * mid;
            ema_mid2_fast = (1.0 - ALPHA_FAST_2P6MS) * ema_mid2_fast + ALPHA_FAST_2P6MS * mid * mid;
            ema_mid2_med  = (1.0 - ALPHA_MED_100MS)  * ema_mid2_med  + ALPHA_MED_100MS  * mid * mid;
            ema_spread = (1.0 - ALPHA_FAST_2P6MS) * ema_spread + ALPHA_FAST_2P6MS * spread_t;
            ema_trades = (1.0 - ALPHA_FAST_2P6MS) * ema_trades + ALPHA_FAST_2P6MS * trades as f32;
        }

        let var_fast = (ema_mid2_fast - ema_mid_fast * ema_mid_fast).max(0.0);
        let var_med  = (ema_mid2_med  - ema_mid_med  * ema_mid_med ).max(0.0);
        let std_fast = (var_fast + REGIME_EPS).sqrt();
        let std_med  = (var_med + REGIME_EPS).sqrt();

        out[k][0] = (mid - ema_mid_fast) / std_fast;                 // z-score fast (~2.6ms)
        out[k][1] = (mid - ema_mid_med)  / std_med;                  // z-score med  (~100ms)
        out[k][2] = (ema_mid_fast - ema_mid_med) / std_med;          // trend signal
        out[k][3] = (1.0 + var_med.sqrt() * 1e4).ln();               // log-compressed med vol
        out[k][4] = (1.0 + ema_spread).ln();                         // liquidity regime
        out[k][5] = (1.0 + ema_trades * 100.0).ln();                 // activity regime
    }
    out
}

#[derive(Clone, Debug)]
pub struct MultiHorizonLoaderConfig {
    /// Explicit list of .dbn / .dbn.zst MBP-10 files to load. Order
    /// is preserved (no internal shuffle) so callers can use file
    /// ordering to control temporal train / val splits. Use
    /// [`discover_mbp10_files_sorted`] to enumerate a directory in
    /// chronological-filename order.
    pub files: Vec<PathBuf>,
    /// Directory where predecoded sidecars live (`load_or_predecode_mbp10`
    /// arg — typically the same dir as `files` or a sibling).
    pub predecoded_dir: PathBuf,
    /// Multi-resolution input window. `total_positions()` defines the
    /// sequence length the trainer sees; `required_lookback_ticks()` is
    /// the per-anchor pre-edge snapshot count the loader needs from each
    /// source file.
    pub multi_resolution: crate::data::aggregation::MultiResolutionConfig,
    /// Forward-horizon offsets in snapshots. Length tracks
    /// `ml_alpha::heads::N_HORIZONS` so the loader's label layout matches
    /// the per-horizon heads.
    pub horizons: [usize; N_HORIZONS],
    /// Soft cap on total sequences yielded across all source files.
    pub n_max_sequences: usize,
    /// Deterministic seed for sequence-anchor selection within files.
    /// (Files themselves are taken in the order given in `files`; this
    /// seed only randomises which snapshot inside each file becomes
    /// the start of each yielded sequence.)
    pub seed: u64,
    /// When `true`, skip per-file forward-label precomputation (saves
    /// ~half the file-load cost) and enable chronological inference
    /// streaming via `next_inference_input()`. Used by the ml-backtesting
    /// LOB backtest harness; trainer always passes `false`. See
    /// `docs/superpowers/specs/2026-05-18-real-lob-integration-design.md` §1.
    pub inference_only: bool,
    /// Round-trip cost in price units used for D-style outcome label
    /// generation (Phase B aux supervision). ES default: 0.5 (= 2 ticks
    /// × $0.25 per tick). The cost is subtracted from realized PnL before
    /// the 1.5×|max-adverse-excursion| penalty; setting it to 0.0 makes
    /// the aux head learn a cost-free outcome and rarely matches the
    /// inference-time trading objective.
    pub outcome_label_cost: f32,
    /// Selects which `instrument_id` records the MBP-10 predecoder keeps.
    /// See [`InstrumentFilter`] for variants.
    ///
    /// ES.FUT DBN archives are parent-symbol expansions: the same file
    /// carries the front-month contract (e.g. `ESH4` for Q1 2024,
    /// instrument_id 17077), back months (`ESM4`, etc.), plus calendar
    /// spreads. Without a filter the loader interleaves all of them and
    /// computes ΔP across instruments — silently inflating K-step labels
    /// by thousands of dollars at the contract boundaries. For ES,
    /// `FrontMonth` auto-detects the dominant id per file (handles the
    /// quarterly roll); single-instrument datasets can use `All`.
    pub instrument_filter: InstrumentFilter,

    /// SP20 P3 F.5 — FRD head forward-return horizons in tick units.
    /// Determines `compute_frd_labels`'s lookahead distances. Default
    /// is `crate::rl::common::FRD_HORIZON_TICKS = [60, 300, 1800]` —
    /// the spec-recommended microstructure / minute / hour timescales.
    /// The trainer-side ISV slots (`RL_FRD_HORIZON_{1,2,3}_TICKS_INDEX`,
    /// slots 500/501/502) seed the SAME defaults so loader + trainer
    /// agree out of the box. Override here to retune both the
    /// supervised labels AND the ISV seeds (caller is responsible for
    /// the latter via `RL_FRD_HORIZON_*` writes if they want runtime
    /// alignment).
    pub frd_horizon_ticks: [usize; crate::rl::common::FRD_N_HORIZONS],

    /// SP20 P3 F.5 — FRD bucket-range in σ units. The atom span is
    /// `[-range_σ, +range_σ]` mapped onto `FRD_N_ATOMS=21` atoms.
    /// Default = `crate::rl::common::FRD_BUCKET_RANGE_SIGMA = 3.0`
    /// (matches the ISV seed at `RL_FRD_BUCKET_RANGE_SIGMA_INDEX`
    /// slot 503). Per `pearl_glm_fitter_link_must_match_inference`:
    /// the label generator AND the trainer-side ISV-driven runtime
    /// MUST stay aligned — caller is responsible for matching ISV
    /// slot 503 if overriding here.
    pub frd_bucket_range_sigma: f32,
}

/// Default round-trip cost (price units) baked into D-style outcome labels
/// for ES contracts. 2 ticks × $0.25/tick = $0.50 in price units.
pub const DEFAULT_OUTCOME_LABEL_COST_ES: f32 = 0.5;

/// Discover MBP-10 files under `root` and return them sorted by filename.
/// Filenames follow the `ES.FUT_<YEAR>-Q<n>.dbn.zst` convention, so
/// lexicographic sort = chronological. Caller can then slice for
/// walk-forward CV.
pub fn discover_mbp10_files_sorted(root: &std::path::Path) -> Result<Vec<PathBuf>> {
    let mut files: Vec<PathBuf> = std::fs::read_dir(root)
        .with_context(|| format!("read mbp10 root {}", root.display()))?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            let s = p.to_string_lossy();
            s.ends_with(".dbn.zst") || s.ends_with(".dbn")
        })
        .collect();
    anyhow::ensure!(!files.is_empty(), "no DBN files under {}", root.display());
    files.sort();
    Ok(files)
}

pub struct LabeledSequence {
    /// `multi_resolution.total_positions()` raw or aggregated snapshot inputs
    /// in chronological (oldest-first) order.
    pub snapshots: Vec<Mbp10RawInput>,
    /// `[N_HORIZONS][total_positions]` BCE labels. NaN at positions where the
    /// forward window is outside the source file (right edge) or where
    /// the move is a tied price (drop, mirror `generate_labels` semantics).
    pub labels: [Vec<f32>; N_HORIZONS],
    /// `[N_HORIZONS][total_positions]` Candidate-B prof-binary labels for the
    /// long direction (1.0 if `ΔP_K - 2×cost > 0`, 0.0 otherwise, NaN at right
    /// edge or non-finite price). See [`crate::multi_horizon_labels::generate_outcome_labels_ab`].
    pub outcome_prof_long: [Vec<f32>; N_HORIZONS],
    /// `[N_HORIZONS][total_positions]` Candidate-B prof-binary labels for the
    /// short direction.
    pub outcome_prof_short: [Vec<f32>; N_HORIZONS],
    /// `[N_HORIZONS][total_positions]` Candidate-B σ-normalized signed-return
    /// targets for the long direction. NaN where prof_long==0 or σ_K not yet warmed.
    pub outcome_size_long: [Vec<f32>; N_HORIZONS],
    /// `[N_HORIZONS][total_positions]` Candidate-B σ-normalized signed-return
    /// targets for the short direction. Same NaN semantics as `outcome_size_long`.
    pub outcome_size_short: [Vec<f32>; N_HORIZONS],
    /// `[N_HORIZONS][total_positions]` rolling 1000-step Welford std of K-step
    /// log-returns at each snapshot. NaN during the warmup window. Used
    /// downstream for σ-normalized size targets and any in-trainer scaling.
    pub sigma_k: [Vec<f32>; N_HORIZONS],
    /// SP20 P3 F.5 — per-(horizon, snapshot) FRD return-bucket label
    /// indices `[0, FRD_N_ATOMS)`. Sentinel `-1` at the rightmost edge
    /// where `snapshot_idx + h_ticks >= snapshots.len()`. Length per
    /// horizon equals `snapshots.len()`. Caller writes the row at
    /// the anchor index into `trainer.frd_labels_d` before each
    /// `step_with_lobsim`. See `compute_frd_labels` for the bucket
    /// math; the trainer-side softmax+CE interpretation MUST match
    /// (same `FRD_BUCKET_RANGE_SIGMA` const).
    pub frd_labels: [Vec<i32>; crate::rl::common::FRD_N_HORIZONS],
    /// Per-file class imbalance for `pos_weight` BCE balancing.
    /// Length = `2 * N_HORIZONS`, laid out as
    /// `[long_h0, long_h1, ..., long_hN, short_h0, ..., short_hN]`.
    /// File-level statistic — every sequence yielded from the same source
    /// file carries the same vector.
    pub pos_fraction: Vec<f32>,
}

/// Loaded-file cache. Holds the deserialized snapshots + pre-computed
/// per-horizon labels so subsequent `next_sequence()` calls on the
/// same file avoid the multi-second bincode deserialization.
struct LoadedFile {
    snapshots: Vec<Mbp10Snapshot>,
    /// `labels_full[h]` holds the full-stream BCE label vector for horizon
    /// `cfg.horizons[h]`, indexed the same as `snapshots`. Sliced per
    /// anchor on each `next_sequence()` call.
    labels_full: [Vec<f32>; N_HORIZONS],
    /// `outcome_prof_long_full[h]` holds the full-stream Candidate-B
    /// prof-binary long-direction label vector for horizon `cfg.horizons[h]`.
    /// NaN at right-edge positions. Sliced per anchor alongside `labels_full`.
    outcome_prof_long_full: [Vec<f32>; N_HORIZONS],
    /// `outcome_prof_short_full[h]` — short-direction counterpart to
    /// `outcome_prof_long_full`.
    outcome_prof_short_full: [Vec<f32>; N_HORIZONS],
    /// `outcome_size_long_full[h]` holds the full-stream Candidate-B
    /// σ-normalized size target for the long direction. NaN where
    /// prof_long==0 OR σ_K not yet warmed.
    outcome_size_long_full: [Vec<f32>; N_HORIZONS],
    /// `outcome_size_short_full[h]` — short-direction counterpart.
    outcome_size_short_full: [Vec<f32>; N_HORIZONS],
    /// `sigma_k_full[h]` rolling 1000-step Welford std of K-step log-returns.
    /// NaN during the per-horizon warmup window.
    sigma_k_full: [Vec<f32>; N_HORIZONS],
    /// SP20 P3 F.5 — full-file FRD per-horizon bucket-index labels.
    /// See `LabeledSequence::frd_labels` doc for shape + semantics.
    /// Sliced per anchor on each `next_sequence()` call.
    frd_labels_full: [Vec<i32>; crate::rl::common::FRD_N_HORIZONS],
    /// File-level positive-class fraction per (direction, horizon) for BCE
    /// pos_weight balancing. Length = `2 * N_HORIZONS`, laid out as
    /// `[long_h0..hN, short_h0..hN]`. Copied verbatim into every yielded
    /// `LabeledSequence`.
    pos_fraction: Vec<f32>,
    /// Per-snapshot regime context computed once per file via a forward
    /// EMA cascade. See [`compute_regime_features`]. Sliced per anchor.
    regime_full: Vec<[f32; REGIME_DIM]>,
}

pub struct MultiHorizonLoader {
    cfg: MultiHorizonLoaderConfig,
    rng: ChaCha8Rng,
    yielded: usize,
    /// All MBP-10 files preloaded into memory at construction. Each
    /// `LoadedFile` carries its deserialized snapshots + precomputed
    /// per-horizon labels + regime features. Per-epoch iteration is
    /// pure memory access — no disk IO after the initial preload.
    ///
    /// Memory cost at full ES.FUT dataset (9 quarters, ~45M snapshots
    /// total): ~13-15 GB. Worth it: per-epoch wall time drops from
    /// ~8 min (file-IO-bound) to ~1 min (compute-bound) on L40S, a
    /// ~5-7× speedup that compounds for longer runs.
    files_loaded: Vec<LoadedFile>,
    /// Chronological cursor for `next_inference_input()`. Only meaningful
    /// when `cfg.inference_only == true`.
    inference_file_idx: usize,
    inference_snap_idx: usize,
}

impl MultiHorizonLoader {
    /// Construct + preload the MBP-10 files listed in `cfg.files`,
    /// in the order given (no shuffle — file order is the caller's
    /// responsibility, see [`discover_mbp10_files_sorted`]). Slow
    /// (~50s × N_files), done once at construction. Subsequent
    /// `next_sequence` calls are pure memory.
    pub fn new(cfg: &MultiHorizonLoaderConfig) -> Result<Self> {
        anyhow::ensure!(
            !cfg.files.is_empty(),
            "MultiHorizonLoaderConfig.files is empty — pass at least one file"
        );
        let files: Vec<PathBuf> = cfg.files.clone();
        let rng = ChaCha8Rng::seed_from_u64(cfg.seed);

        let max_horizon = *cfg.horizons.iter().max().expect("non-empty horizons");
        // Multi-resolution lookback: each anchor needs `required_lookback_ticks`
        // raw snapshots BEFORE the forward edge to fill all aggregation scales.
        let lookback = cfg.multi_resolution.required_lookback_ticks();
        let min_size = if cfg.inference_only {
            lookback.max(1)
        } else {
            lookback + max_horizon + 1
        };
        // SPEED-A (2026-05-22): parallel per-file load + label generation.
        // Each file's load_or_predecode + generate_labels + generate_outcome_labels_ab +
        // compute_regime_features is pure CPU work over disjoint inputs (snapshots,
        // cfg.horizons, cfg.outcome_label_cost). par_iter gives ~4-8x speedup on
        // typical 4-8 core machines. Files emitting a "too few snapshots" warning
        // are filtered out post-collection (via Option<LoadedFile>).
        let load_one = |path: &PathBuf| -> Result<Option<LoadedFile>> {
            let snapshots = load_or_predecode_mbp10(path, &cfg.predecoded_dir, cfg.instrument_filter)
                .with_context(|| format!(
                    "load mbp10 {} (filter={:?})", path.display(), cfg.instrument_filter
                ))?;
            if snapshots.len() < min_size {
                tracing::warn!(
                    path = %path.display(),
                    snapshots = snapshots.len(),
                    min_size,
                    "skipping file (too few snapshots for required_lookback_ticks + max_horizon)",
                );
                return Ok(None);
            }
            // SPEED-B (2026-05-22): on-disk cache for the per-file label +
            // regime arrays so subsequent training runs skip the multi-second
            // label-generation pass. Cache key encodes (horizons, cost,
            // instrument_filter). Inference-only runs skip the cache WRITE
            // (they don't compute labels) but DO honor a hit if present —
            // the cached arrays would just go unused in that mode.
            let labels_cache = labels_cache_path(
                path,
                &cfg.predecoded_dir,
                &cfg.horizons,
                cfg.outcome_label_cost,
                cfg.instrument_filter,
            );
            let cached = match try_read_labels_cache(&labels_cache) {
                Ok(Some(c)) if c.regime_full.len() == snapshots.len() => {
                    tracing::info!(
                        path = %labels_cache.display(),
                        n_snapshots = snapshots.len(),
                        "labels cache HIT",
                    );
                    Some(c)
                }
                Ok(Some(c)) => {
                    // Stale length — the underlying MBP-10 sidecar was
                    // refreshed (re-downloaded quarter) but the labels cache
                    // wasn't. Treat as miss, recompute, overwrite.
                    tracing::warn!(
                        path = %labels_cache.display(),
                        cached_n = c.regime_full.len(),
                        actual_n = snapshots.len(),
                        "labels cache length mismatch — recomputing",
                    );
                    None
                }
                Ok(None) => None,
                Err(e) => {
                    tracing::warn!(
                        path = %labels_cache.display(),
                        err = %e,
                        "labels cache read failed — recomputing",
                    );
                    None
                }
            };

            let (
                labels_full,
                outcome_prof_long_full,
                outcome_prof_short_full,
                outcome_size_long_full,
                outcome_size_short_full,
                sigma_k_full,
                pos_fraction,
                regime_full,
            ) = if let Some(c) = cached {
                (
                    c.labels_full,
                    c.outcome_prof_long_full,
                    c.outcome_prof_short_full,
                    c.outcome_size_long_full,
                    c.outcome_size_short_full,
                    c.sigma_k_full,
                    c.pos_fraction,
                    c.regime_full,
                )
            } else {
                let mut labels_full: [Vec<f32>; N_HORIZONS] = Default::default();
                let mut outcome_prof_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
                let mut outcome_prof_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
                let mut outcome_size_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
                let mut outcome_size_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
                let mut sigma_k_full: [Vec<f32>; N_HORIZONS] = Default::default();
                // Default pos_fraction is zero per direction/horizon; populated below
                // in training mode. Length is fixed so downstream broadcast is uniform.
                let mut pos_fraction: Vec<f32> = vec![0.0_f32; 2 * N_HORIZONS];
                if !cfg.inference_only {
                    let prices: Vec<f32> = snapshots.iter().map(mid_price_f32).collect();
                    for (h_idx, &h) in cfg.horizons.iter().enumerate() {
                        let mut full = vec![f32::NAN; snapshots.len()];
                        let raw = generate_labels(&prices, h);
                        for (i, &t) in raw.valid_indices.iter().enumerate() {
                            full[t] = raw.labels[i];
                        }
                        labels_full[h_idx] = full;
                    }
                    // Candidate-B aux supervision: paired prof-binary + σ-normalized
                    // size-regression targets per (direction, horizon). Replaces the
                    // old D-style asymmetric loss-aversion labels.
                    let outcome = generate_outcome_labels_ab(
                        &prices,
                        &cfg.horizons,
                        cfg.outcome_label_cost,
                    )
                    .with_context(|| format!(
                        "generate_outcome_labels_ab for {} (cost={})",
                        path.display(),
                        cfg.outcome_label_cost,
                    ))?;
                    outcome_prof_long_full = outcome.y_prof_long;
                    outcome_prof_short_full = outcome.y_prof_short;
                    outcome_size_long_full = outcome.y_size_long;
                    outcome_size_short_full = outcome.y_size_short;
                    sigma_k_full = outcome.sigma_k;
                    pos_fraction = outcome.pos_fraction;
                }
                let regime_full = compute_regime_features(&snapshots);

                // Best-effort write to cache for next run. Skipped when
                // inference_only since labels_full/outcome_* are all-default
                // and a stored cache there would mis-serve a later
                // non-inference run that hit it.
                if !cfg.inference_only {
                    let payload = CachedLabels {
                        labels_full: labels_full.clone(),
                        outcome_prof_long_full: outcome_prof_long_full.clone(),
                        outcome_prof_short_full: outcome_prof_short_full.clone(),
                        outcome_size_long_full: outcome_size_long_full.clone(),
                        outcome_size_short_full: outcome_size_short_full.clone(),
                        sigma_k_full: sigma_k_full.clone(),
                        pos_fraction: pos_fraction.clone(),
                        regime_full: regime_full.clone(),
                    };
                    if let Err(e) = write_labels_cache(&labels_cache, &payload) {
                        tracing::warn!(
                            path = %labels_cache.display(),
                            err = %e,
                            "labels cache write failed (training continues)",
                        );
                    } else {
                        tracing::info!(
                            path = %labels_cache.display(),
                            n_snapshots = snapshots.len(),
                            "labels cache WRITE",
                        );
                    }
                }

                (
                    labels_full,
                    outcome_prof_long_full,
                    outcome_prof_short_full,
                    outcome_size_long_full,
                    outcome_size_short_full,
                    sigma_k_full,
                    pos_fraction,
                    regime_full,
                )
            };
            let frd_labels_full = compute_frd_labels(
                &snapshots,
                cfg.frd_horizon_ticks,
                cfg.frd_bucket_range_sigma,
            );
            Ok(Some(LoadedFile {
                snapshots,
                labels_full,
                outcome_prof_long_full,
                outcome_prof_short_full,
                outcome_size_long_full,
                outcome_size_short_full,
                sigma_k_full,
                frd_labels_full,
                pos_fraction,
                regime_full,
            }))
        };

        let files_loaded_results: Vec<Result<Option<LoadedFile>>> =
            files.par_iter().map(load_one).collect();
        let mut files_loaded: Vec<LoadedFile> = Vec::with_capacity(files.len());
        for result in files_loaded_results {
            match result? {
                Some(lf) => files_loaded.push(lf),
                None => {} // Already warned in load_one.
            }
        }
        anyhow::ensure!(
            !files_loaded.is_empty(),
            "no files had enough snapshots ({}+ required) across {} input files",
            min_size, cfg.files.len()
        );
        tracing::info!(
            n_files = files_loaded.len(),
            total_snapshots = files_loaded.iter().map(|lf| lf.snapshots.len()).sum::<usize>(),
            "MultiHorizonLoader preloaded all files",
        );
        Ok(Self {
            cfg: cfg.clone(),
            rng,
            yielded: 0,
            files_loaded,
            inference_file_idx: 0,
            inference_snap_idx: 0,
        })
    }

    /// Reset per-epoch state: re-seed RNG (so anchor sampling differs
    /// each epoch) and zero the yielded counter. The preloaded file
    /// inventory is preserved — no disk IO.
    pub fn reset(&mut self, seed: u64) {
        self.rng = ChaCha8Rng::seed_from_u64(seed);
        self.yielded = 0;
        self.inference_file_idx = 0;
        self.inference_snap_idx = 0;
    }

    /// Return a clone of the first chronological snapshot from the
    /// first loaded file as an `Mbp10RawInput`. Used by the LOB
    /// backtest harness to seed the first-call eager warmup of
    /// `PerceptionTrainer::forward_only` (P5 ships CUDA Graph
    /// capture of the inference dispatch chain). Pure read — no
    /// cursor mutation. Available in both training and inference
    /// modes.
    pub fn peek_first(&self) -> Result<Mbp10RawInput> {
        let first_file = self.files_loaded.first()
            .context("loader has no loaded files")?;
        let first_snap = first_file.snapshots.first()
            .context("first loaded file has no snapshots")?;
        // No previous snapshot exists; pass the first as both cur/prev.
        // This yields prev_mid == cur_mid + trade_signed_vol == 0 + prev_ts_ns == ts_ns,
        // which is the natural "stream start" semantics for inference seeding.
        Ok(convert(first_snap, first_snap, first_file.regime_full[0]))
    }

    /// Upload ALL pre-converted snapshots + FRD labels to GPU as a
    /// single flat buffer. Returns a [`GpuDataset`] that the
    /// [`GpuDataLoader`] kernel reads from each step — zero CPU work
    /// in the training hot path.
    ///
    /// This method is the CPU-side half of the GPU-resident data loader:
    ///   1. Pre-converts every `Mbp10Snapshot` → `Mbp10RawInput` (calls
    ///      `convert()` once per snapshot at init, not per step).
    ///   2. Concatenates all files into flat arrays.
    ///   3. Uploads via `htod_copy` (one-time bulk transfer).
    ///   4. Builds per-file offset + size tables on device.
    ///   5. Initializes per-batch PRNG seeds on device.
    ///
    /// Per `feedback_no_htod_htoh_only_mapped_pinned.md`: the init-time
    /// `htod_copy` is a one-shot outside the captured-graph hot path.
    /// The per-step path is pure device→device (kernel reads dataset
    /// buffers, writes SoA buffers).
    ///
    /// [`GpuDataset`]: super::gpu_dataset::GpuDataset
    /// [`GpuDataLoader`]: super::gpu_dataset::GpuDataLoader
    pub fn upload_to_gpu(
        &self,
        dev: &ml_core::device::MlDevice,
        batch_size: usize,
        seed: u64,
    ) -> Result<super::gpu_dataset::GpuDataset> {
        use crate::cfc::snap_features::MBP10_RAW_INPUT_BYTES;
        use crate::rl::common::FRD_N_HORIZONS;

        let stream = dev.cuda_stream().context("upload_to_gpu: CUDA stream")?.clone();

        // ── 1. Pre-convert ALL snapshots → Mbp10RawInput + build offsets ──
        let n_files = self.files_loaded.len();
        let total_snaps: usize = self.files_loaded.iter().map(|f| f.snapshots.len()).sum();
        tracing::info!(
            n_files,
            total_snaps,
            bytes = total_snaps * MBP10_RAW_INPUT_BYTES,
            "upload_to_gpu: pre-converting snapshots",
        );

        let mut file_offsets: Vec<i32> = Vec::with_capacity(n_files);
        let mut file_sizes: Vec<i32> = Vec::with_capacity(n_files);
        let mut offset: usize = 0;
        for lf in &self.files_loaded {
            file_offsets.push(offset as i32);
            file_sizes.push(lf.snapshots.len() as i32);
            offset += lf.snapshots.len();
        }

        // ── 2. Upload snapshots per-file (streaming) ────────────────────
        // Allocate the full GPU buffer upfront, then convert + upload one
        // file at a time. Peak host memory = one file's worth of converted
        // snapshots (~1-2GB) instead of all files (~10GB).
        let total_bytes = total_snaps * MBP10_RAW_INPUT_BYTES;
        let snapshots_d = stream
            .alloc_zeros::<u8>(total_bytes)
            .context("upload_to_gpu: snapshots alloc")?;
        let mut gpu_offset_bytes: usize = 0;
        for (f_idx, lf) in self.files_loaded.iter().enumerate() {
            let n = lf.snapshots.len();
            let mut file_raws: Vec<Mbp10RawInput> = Vec::with_capacity(n);
            for (idx, snap) in lf.snapshots.iter().enumerate() {
                let prev = if idx == 0 { snap } else { &lf.snapshots[idx - 1] };
                file_raws.push(convert(snap, prev, lf.regime_full[idx]));
            }
            let file_bytes = n * MBP10_RAW_INPUT_BYTES;
            unsafe {
                cudarc::driver::sys::cuMemcpyHtoD_v2(
                    snapshots_d.raw_ptr() + gpu_offset_bytes as u64,
                    file_raws.as_ptr() as *const std::ffi::c_void,
                    file_bytes,
                );
            }
            gpu_offset_bytes += file_bytes;
            tracing::info!(
                file = f_idx,
                n_snapshots = n,
                gpu_offset_mb = gpu_offset_bytes / (1024 * 1024),
                "upload_to_gpu: file uploaded",
            );
            drop(file_raws);
        }
        tracing::info!(
            gpu_bytes = total_bytes,
            "upload_to_gpu: snapshots uploaded",
        );

        // ── 3. Upload file offsets + sizes ───────────────────────────────
        let file_offsets_d = stream
            .alloc_zeros::<i32>(n_files)
            .context("upload_to_gpu: file_offsets alloc")?;
        unsafe {
            cudarc::driver::sys::cuMemcpyHtoD_v2(
                file_offsets_d.raw_ptr(),
                file_offsets.as_ptr() as *const std::ffi::c_void,
                n_files * 4,
            );
        }
        let file_sizes_d = stream
            .alloc_zeros::<i32>(n_files)
            .context("upload_to_gpu: file_sizes alloc")?;
        unsafe {
            cudarc::driver::sys::cuMemcpyHtoD_v2(
                file_sizes_d.raw_ptr(),
                file_sizes.as_ptr() as *const std::ffi::c_void,
                n_files * 4,
            );
        }

        // ── 4. Upload FRD labels [FRD_N_HORIZONS, total_snaps] row-major ──
        let frd_total = FRD_N_HORIZONS * total_snaps;
        let mut frd_flat: Vec<i32> = vec![-1_i32; frd_total];
        let mut global_offset: usize = 0;
        for lf in &self.files_loaded {
            let n = lf.snapshots.len();
            for h in 0..FRD_N_HORIZONS {
                let src = &lf.frd_labels_full[h];
                let dst_start = h * total_snaps + global_offset;
                frd_flat[dst_start..dst_start + n].copy_from_slice(&src[..n]);
            }
            global_offset += n;
        }
        let frd_labels_d = stream
            .alloc_zeros::<i32>(frd_total)
            .context("upload_to_gpu: frd_labels alloc")?;
        unsafe {
            cudarc::driver::sys::cuMemcpyHtoD_v2(
                frd_labels_d.raw_ptr(),
                frd_flat.as_ptr() as *const std::ffi::c_void,
                frd_total * 4,
            );
        }

        // ── 5. Upload supervised BCE labels [N_HORIZONS, total_snaps] row-major ──
        //
        // Same row-major layout as FRD labels: `all_labels[h * total_snaps + snap_idx]`.
        // Six float arrays: labels, outcome_prof_{long,short}, outcome_size_{long,short},
        // sigma_k. Plus per-file pos_fraction [n_files, 2 * N_HORIZONS].
        let label_total = N_HORIZONS * total_snaps;
        let upload_f32_labels = |name: &str, accessor: &dyn Fn(&LoadedFile, usize) -> &Vec<f32>| -> Result<CudaSlice<f32>> {
            let mut flat: Vec<f32> = vec![f32::NAN; label_total];
            let mut g_off: usize = 0;
            for lf in &self.files_loaded {
                let n = lf.snapshots.len();
                for h in 0..N_HORIZONS {
                    let src = accessor(lf, h);
                    let dst_start = h * total_snaps + g_off;
                    flat[dst_start..dst_start + n].copy_from_slice(&src[..n]);
                }
                g_off += n;
            }
            let buf = stream
                .alloc_zeros::<f32>(label_total)
                .with_context(|| format!("upload_to_gpu: {name} alloc"))?;
            unsafe {
                cudarc::driver::sys::cuMemcpyHtoD_v2(
                    buf.raw_ptr(),
                    flat.as_ptr() as *const std::ffi::c_void,
                    label_total * 4,
                );
            }
            Ok(buf)
        };

        let labels_d = upload_f32_labels("labels", &|lf, h| &lf.labels_full[h])?;
        let outcome_prof_long_d = upload_f32_labels("outcome_prof_long", &|lf, h| &lf.outcome_prof_long_full[h])?;
        let outcome_prof_short_d = upload_f32_labels("outcome_prof_short", &|lf, h| &lf.outcome_prof_short_full[h])?;
        let outcome_size_long_d = upload_f32_labels("outcome_size_long", &|lf, h| &lf.outcome_size_long_full[h])?;
        let outcome_size_short_d = upload_f32_labels("outcome_size_short", &|lf, h| &lf.outcome_size_short_full[h])?;
        let sigma_k_d = upload_f32_labels("sigma_k", &|lf, h| &lf.sigma_k_full[h])?;

        // Per-file pos_fraction: [n_files, 2 * N_HORIZONS].
        let pf_total = n_files * 2 * N_HORIZONS;
        let mut pf_flat: Vec<f32> = vec![0.0_f32; pf_total];
        for (f_idx, lf) in self.files_loaded.iter().enumerate() {
            let base = f_idx * 2 * N_HORIZONS;
            let src = &lf.pos_fraction;
            pf_flat[base..base + src.len()].copy_from_slice(src);
        }
        let pos_fraction_d = stream
            .alloc_zeros::<f32>(pf_total)
            .context("upload_to_gpu: pos_fraction alloc")?;
        unsafe {
            cudarc::driver::sys::cuMemcpyHtoD_v2(
                pos_fraction_d.raw_ptr(),
                pf_flat.as_ptr() as *const std::ffi::c_void,
                pf_total * 4,
            );
        }

        tracing::info!(
            label_total,
            pf_total,
            "upload_to_gpu: supervised labels uploaded",
        );

        // ── 6. Initialize per-batch PRNG seeds ──────────────────────────
        let prng_seeds: Vec<u32> = (0..batch_size as u64)
            .map(|b| {
                // Mix seed + batch index to get distinct per-batch seeds.
                // Ensures different batch elements sample different files/anchors.
                let mixed = seed.wrapping_mul(2654435761).wrapping_add(b);
                (mixed & 0xFFFF_FFFF) as u32
            })
            .collect();
        let prng_d = stream
            .alloc_zeros::<u32>(batch_size)
            .context("upload_to_gpu: prng alloc")?;
        unsafe {
            cudarc::driver::sys::cuMemcpyHtoD_v2(
                prng_d.raw_ptr(),
                prng_seeds.as_ptr() as *const std::ffi::c_void,
                batch_size * 4,
            );
        }

        let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");

        tracing::info!(
            n_files,
            total_snaps,
            max_horizon,
            batch_size,
            "upload_to_gpu: dataset ready",
        );

        Ok(super::gpu_dataset::GpuDataset {
            snapshots_d,
            file_offsets_d,
            file_sizes_d,
            frd_labels_d,
            prng_d,
            labels_d,
            outcome_prof_long_d,
            outcome_prof_short_d,
            outcome_size_long_d,
            outcome_size_short_d,
            sigma_k_d,
            pos_fraction_d,
            n_files,
            total_snapshots: total_snaps,
            max_horizon,
        })
    }

    /// Iterator-style accessor for inference mode: walks every loaded
    /// snapshot across every loaded file in chronological order.
    /// Returns `Ok(None)` when the stream is exhausted. Unlike
    /// [`next_sequence`], does NOT slice fixed-length windows.
    /// A1: decision_stride removed; every snapshot is yielded consecutively.
    /// Mutates the inference cursor. Errors if `cfg.inference_only` is false.
    pub fn next_inference_input(&mut self) -> Result<Option<Mbp10RawInput>> {
        anyhow::ensure!(
            self.cfg.inference_only,
            "next_inference_input requires MultiHorizonLoaderConfig.inference_only = true"
        );
        loop {
            let Some(file) = self.files_loaded.get(self.inference_file_idx) else {
                return Ok(None);
            };
            if self.inference_snap_idx >= file.snapshots.len() {
                self.inference_file_idx += 1;
                self.inference_snap_idx = 0;
                continue;
            }
            let cur = &file.snapshots[self.inference_snap_idx];
            // For the very first snapshot, use itself as prev (matches peek_first).
            let prev = if self.inference_snap_idx == 0 {
                cur
            } else {
                &file.snapshots[self.inference_snap_idx - 1]
            };
            let regime = file.regime_full[self.inference_snap_idx];
            let out = convert(cur, prev, regime);
            self.inference_snap_idx += 1;
            self.yielded += 1;
            return Ok(Some(out));
        }
    }

    pub fn n_files(&self) -> usize {
        self.files_loaded.len()
    }

    pub fn yielded(&self) -> usize {
        self.yielded
    }

    pub fn next_sequence(&mut self) -> Result<Option<LabeledSequence>> {
        self.next_sequence_random()
    }

    /// Phase R2 (was G fix): return a `(s_t, s_{t+1})` pair of
    /// consecutive sequences at adjacent anchors in the same file.
    /// The first sequence ends one snapshot earlier than the second —
    /// the canonical RL Bellman-target requirement that
    /// `IntegratedTrainer::step_with_lobsim` consumes as
    /// `(snapshots, next_snapshots)`.
    ///
    /// Both yielded sequences come from the SAME random file so the
    /// underlying market regime / time-of-day context is consistent.
    /// The anchor for `s_t` is sampled uniformly from `[min_anchor,
    /// max_anchor − 1)` so `anchor + 1` also satisfies the
    /// upper-bound constraint. Counts as ONE yielded sequence against
    /// `n_max_sequences` (the loader views the pair as one training
    /// step's worth of input).
    pub fn next_sequence_pair(
        &mut self,
    ) -> Result<Option<(LabeledSequence, LabeledSequence)>> {
        if self.yielded >= self.cfg.n_max_sequences {
            return Ok(None);
        }
        let n_files = self.files_loaded.len();
        let file_idx = self.rng.gen_range(0..n_files);
        let lf = &self.files_loaded[file_idx];

        let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
        let total = self.cfg.multi_resolution.total_positions();
        let lookback = self.cfg.multi_resolution.required_lookback_ticks();
        let min_anchor = lookback.saturating_sub(total);
        // Reserve one extra event past the standard max_anchor so
        // `anchor + 1`'s window also fits within the file.
        let max_anchor = lf
            .snapshots
            .len()
            .saturating_sub(total + max_horizon + 1);
        anyhow::ensure!(
            max_anchor > min_anchor,
            "file too short for paired-window sampling (total={total}, lookback={lookback}, max_horizon={max_horizon}): \
             snapshots.len()={} cannot span [{min_anchor}, {max_anchor})",
            lf.snapshots.len()
        );
        let anchor_t: usize = self.rng.gen_range(min_anchor..max_anchor);

        let s_t = self.build_sequence_at(lf, anchor_t)?;
        let s_tp1 = self.build_sequence_at(lf, anchor_t + 1)?;

        self.yielded += 1;
        Ok(Some((s_t, s_tp1)))
    }

    fn next_sequence_random(&mut self) -> Result<Option<LabeledSequence>> {
        if self.yielded >= self.cfg.n_max_sequences {
            return Ok(None);
        }
        let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
        // A1: decision_stride removed; training sequences are always consecutive
        // snapshots (stride = 1). dt_s = 1.0 in all CfC forward passes.

        // Pick a random file from the preloaded inventory, then a random
        // anchor within that file. Uniform-across-files sampling — each
        // file contributes ~n_max_sequences / n_files samples per epoch.
        let n_files = self.files_loaded.len();
        let file_idx = self.rng.gen_range(0..n_files);
        let lf = &self.files_loaded[file_idx];
        // Multi-resolution anchor sampling has TWO bounds:
        //   * Upper:  anchor + total + max_horizon < snapshots.len()
        //             (room for the forward-label window after the input edge)
        //   * Lower:  anchor + total >= lookback
        //             (room to look back across coarsest aggregation scale)
        // For the default 3-scale config (total=32, lookback=1510) the lower
        // bound is binding: anchor must be ≥ lookback - total = 1478.
        let total = self.cfg.multi_resolution.total_positions();
        let lookback = self.cfg.multi_resolution.required_lookback_ticks();
        let min_anchor = lookback.saturating_sub(total);
        let max_anchor = lf.snapshots.len().saturating_sub(total + max_horizon);
        anyhow::ensure!(
            max_anchor > min_anchor,
            "file too short for multi-resolution(total={total}, lookback={lookback}) max_horizon={max_horizon}: \
             snapshots.len()={} cannot span [{min_anchor}, {max_anchor})",
            lf.snapshots.len()
        );
        let anchor: usize = self.rng.gen_range(min_anchor..max_anchor);

        let seq = self.build_sequence_at(lf, anchor)?;
        self.yielded += 1;
        Ok(Some(seq))
    }

    /// Construct a `LabeledSequence` at a specific `(file, anchor)`
    /// pair. Pure function of the loader config + file contents; does
    /// not advance the loader's `yielded` counter (callers update it).
    ///
    /// Phase R2: this helper exists so `next_sequence_random` (random
    /// anchor) and `next_sequence_pair` (paired adjacent anchors)
    /// share the multi-resolution windowing logic verbatim per
    /// `feedback_single_source_of_truth_no_duplicates`.
    fn build_sequence_at(
        &self,
        lf: &LoadedFile,
        anchor: usize,
    ) -> Result<LabeledSequence> {
        let mut labels: [Vec<f32>; N_HORIZONS] = Default::default();
        let mut outcome_prof_long: [Vec<f32>; N_HORIZONS] = Default::default();
        let mut outcome_prof_short: [Vec<f32>; N_HORIZONS] = Default::default();
        let mut outcome_size_long: [Vec<f32>; N_HORIZONS] = Default::default();
        let mut outcome_size_short: [Vec<f32>; N_HORIZONS] = Default::default();
        let mut sigma_k: [Vec<f32>; N_HORIZONS] = Default::default();
        for h in 0..N_HORIZONS {
            let mut row = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
            let mut row_prof_long = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
            let mut row_prof_short = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
            let mut row_size_long = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
            let mut row_size_short = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
            let mut row_sigma = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
            for k in 0..self.cfg.multi_resolution.total_positions() {
                row.push(lf.labels_full[h][anchor + k]);
                row_prof_long.push(lf.outcome_prof_long_full[h][anchor + k]);
                row_prof_short.push(lf.outcome_prof_short_full[h][anchor + k]);
                row_size_long.push(lf.outcome_size_long_full[h][anchor + k]);
                row_size_short.push(lf.outcome_size_short_full[h][anchor + k]);
                row_sigma.push(lf.sigma_k_full[h][anchor + k]);
            }
            labels[h] = row;
            outcome_prof_long[h] = row_prof_long;
            outcome_prof_short[h] = row_prof_short;
            outcome_size_long[h] = row_size_long;
            outcome_size_short[h] = row_size_short;
            sigma_k[h] = row_sigma;
        }
        // Multi-resolution window construction. The forward edge is
        // `anchor + total_positions` exclusive (the snapshot AT
        // `anchor + total_positions - 1` is the newest position in the
        // emitted sequence). We walk fine→coarse from the edge backward
        // so each scale's positions cover earlier-in-time windows than
        // the previous scale, then reverse so the emitted sequence is
        // chronological (oldest-first).
        let scales = self.cfg.multi_resolution.scales();
        let total = self.cfg.multi_resolution.total_positions();
        let edge = anchor + total; // exclusive upper bound

        let mut sequence_rev: Vec<Mbp10RawInput> = Vec::with_capacity(total);
        let mut cursor = edge;
        for &(scale, count) in scales {
            for _ in 0..count {
                let window_end = cursor;            // exclusive
                let window_start = window_end - scale; // inclusive
                let mut window_raws: Vec<Mbp10RawInput> = Vec::with_capacity(scale);
                for idx in window_start..window_end {
                    let cur = &lf.snapshots[idx];
                    let prev_idx = if idx == 0 { 0 } else { idx - 1 };
                    let prev = &lf.snapshots[prev_idx];
                    window_raws.push(convert(cur, prev, lf.regime_full[idx]));
                }
                if window_raws.len() == 1 {
                    sequence_rev.push(window_raws.into_iter().next().unwrap());
                } else {
                    sequence_rev.push(
                        crate::data::aggregation::aggregate_window(window_raws.as_slice()),
                    );
                }
                cursor = window_start;
            }
        }
        let sequence: Vec<Mbp10RawInput> = sequence_rev.into_iter().rev().collect();
        debug_assert_eq!(sequence.len(), total);

        // SP20 P3 F.5 — FRD labels for THIS sequence. The encoder
        // forward emits h_t aligned to the newest snapshot (= the
        // last position in `sequence` = file index `edge - 1`).
        // That single per-horizon bucket index becomes the
        // supervised target the trainer writes into frd_labels_d
        // for the current batch slot.
        let newest_idx = edge - 1;
        let mut frd_labels: [Vec<i32>; crate::rl::common::FRD_N_HORIZONS] = Default::default();
        for h in 0..crate::rl::common::FRD_N_HORIZONS {
            // Yield a length-1 vec per horizon (the trainer
            // consumes labels[K-1] only — FRD supervises h_t at
            // the rightmost K position, not the full K window).
            let v = lf
                .frd_labels_full
                .get(h)
                .and_then(|col| col.get(newest_idx).copied())
                .unwrap_or(-1);
            frd_labels[h] = vec![v];
        }

        Ok(LabeledSequence {
            snapshots: sequence,
            labels,
            outcome_prof_long,
            outcome_prof_short,
            outcome_size_long,
            outcome_size_short,
            sigma_k,
            frd_labels,
            pos_fraction: lf.pos_fraction.clone(),
        })
    }
}

fn mid_price_f32(s: &Mbp10Snapshot) -> f32 {
    let l = s.levels.first().copied().unwrap_or_else(BidAskPair::empty);
    let bid = BidAskPair::price_to_f64(l.bid_px);
    let ask = BidAskPair::price_to_f64(l.ask_px);
    (0.5 * (bid + ask)) as f32
}

/// SP20 P3 F.5 — per-file FRD label generation.
///
/// For each snapshot index `i` and each FRD horizon `h_ticks ∈
/// FRD_HORIZON_TICKS = [60, 300, 1800]`, compute the σ-normalized
/// forward return:
///
/// ```text
///   r = (mid[i + h_ticks] - mid[i]) / sigma_per_step
/// ```
///
/// where `sigma_per_step` is the file-level sample std of single-
/// tick mid increments. Bucket `r` into `FRD_N_ATOMS = 21` atoms
/// uniform over `[-FRD_BUCKET_RANGE_SIGMA, +FRD_BUCKET_RANGE_SIGMA]`
/// (default ±3σ) — clip to `[0, FRD_N_ATOMS-1]` for tail returns.
///
/// Sentinel `-1` marks `i + h_ticks >= snapshots.len()` (the
/// forward return isn't realized at the rightmost edge of the
/// file) — the `rl_frd_softmax_ce_grad` kernel zeros loss + grad
/// for those rows.
///
/// Output shape: `[FRD_N_HORIZONS][snapshots.len()]` row-major
/// per horizon. The caller slices into this at the anchor index
/// when yielding a sequence (same pattern as the BCE labels).
///
/// Per `pearl_glm_fitter_link_must_match_inference`: the bucket
/// edges here MUST match the trainer-side FRD head's softmax + CE
/// interpretation (atom `a` = bucket center `(a / (N-1) × 2 - 1) ×
/// range_σ`). The `range_sigma` argument and trainer-side ISV slot
/// 503 MUST stay aligned — caller is responsible for keeping them
/// in sync (default path: both come from `FRD_BUCKET_RANGE_SIGMA`).
///
/// Per `feedback_isv_for_adaptive_bounds`: horizons + range come in
/// as parameters (sourced from `MultiHorizonLoaderConfig`, defaults
/// from the canonical consts) — NOT hardcoded literals in the
/// function body. Single source of truth: `rl::common`.
fn compute_frd_labels(
    snapshots: &[Mbp10Snapshot],
    horizon_ticks: [usize; crate::rl::common::FRD_N_HORIZONS],
    range_sigma: f32,
) -> [Vec<i32>; crate::rl::common::FRD_N_HORIZONS] {
    use crate::rl::common::{FRD_N_ATOMS, FRD_N_HORIZONS};

    let n = snapshots.len();
    let mids: Vec<f32> = snapshots.iter().map(mid_price_f32).collect();

    // File-level sample std of single-tick mid increments. Bootstraps
    // off the loaded file alone (no warmup blackout — for the very
    // first file this is the only signal). At ~3M ticks per file the
    // sample is statistically reliable.
    let sigma_per_step: f32 = {
        if mids.len() < 2 {
            // Pathological 1-snapshot file — sentinel everything.
            1.0
        } else {
            let mut sum_sq = 0.0_f64;
            let mut cnt = 0_usize;
            for i in 1..mids.len() {
                let d = (mids[i] - mids[i - 1]) as f64;
                if d.is_finite() {
                    sum_sq += d * d;
                    cnt += 1;
                }
            }
            if cnt == 0 {
                1.0
            } else {
                ((sum_sq / cnt as f64).sqrt() as f32).max(1e-6)
            }
        }
    };

    let mut out: [Vec<i32>; FRD_N_HORIZONS] = Default::default();
    let n_atoms_f = FRD_N_ATOMS as f32;
    let half = (FRD_N_ATOMS - 1) as f32 / 2.0;
    let scale = half / range_sigma.max(1e-6);

    for (h_idx, &h_ticks) in horizon_ticks.iter().enumerate() {
        // sqrt(h_ticks) Brownian scaling — variance of an h-step
        // sum of iid increments is h × per-step variance, so std
        // scales with sqrt(h).
        let sigma_h = sigma_per_step * (h_ticks as f32).sqrt();
        let mut labels = vec![-1_i32; n];
        if h_ticks < n {
            for i in 0..(n - h_ticks) {
                let r = (mids[i + h_ticks] - mids[i]) / sigma_h.max(1e-6);
                // Bucket center mapping: atom `a` → center `(a - half) / scale`.
                // Inverse: `a = round(r × scale + half)`.
                let a_raw = (r * scale + half).round() as i32;
                let a_clamped = a_raw.clamp(0, (FRD_N_ATOMS - 1) as i32);
                labels[i] = a_clamped;
            }
        }
        // Final safety — guard against pathological NaN propagation.
        for v in labels.iter_mut() {
            if *v < -1 || *v >= n_atoms_f as i32 {
                *v = -1;
            }
        }
        out[h_idx] = labels;
    }
    out
}

fn convert(
    cur: &Mbp10Snapshot,
    prev: &Mbp10Snapshot,
    regime: [f32; REGIME_DIM],
) -> Mbp10RawInput {
    let mut bid_px = [0.0f32; BOOK_LEVELS];
    let mut bid_sz = [0.0f32; BOOK_LEVELS];
    let mut ask_px = [0.0f32; BOOK_LEVELS];
    let mut ask_sz = [0.0f32; BOOK_LEVELS];
    for (i, lvl) in cur.levels.iter().take(BOOK_LEVELS).enumerate() {
        bid_px[i] = BidAskPair::price_to_f64(lvl.bid_px) as f32;
        bid_sz[i] = lvl.bid_sz as f32;
        ask_px[i] = BidAskPair::price_to_f64(lvl.ask_px) as f32;
        ask_sz[i] = lvl.ask_sz as f32;
    }
    let prev_mid = mid_price_f32(prev);
    let trade_count = cur.trade_count.saturating_sub(prev.trade_count);
    let trade_signed_vol = infer_signed_trade_flow(cur, prev);
    Mbp10RawInput {
        bid_px,
        bid_sz,
        ask_px,
        ask_sz,
        prev_mid,
        trade_signed_vol,
        trade_count,
        ts_ns: cur.timestamp,
        prev_ts_ns: prev.timestamp,
        regime,
    }
}

/// Infer signed trade flow at L1 from MBP-10 snapshot deltas without
/// the separate trades stream. Convention matches `Mbp10RawInput::
/// trade_signed_vol`: positive = buyer-initiated (aggressive cross of
/// the ask), negative = seller-initiated (aggressive hit of the bid).
///
/// Heuristic (Databento-standard tick rule applied to L1):
///   * ask_px[0] unchanged AND ask_sz[0] decreased
///     → aggressive buys consumed ask depth; add (prev.ask_sz - cur.ask_sz).
///   * bid_px[0] unchanged AND bid_sz[0] decreased
///     → aggressive sells consumed bid depth; subtract (prev.bid_sz - cur.bid_sz).
///   * ask_px[0] moved up (best ask cleared completely)
///     → infer full prev.ask_sz lifted; add prev.ask_sz.
///   * bid_px[0] moved down (best bid cleared completely)
///     → infer full prev.bid_sz hit; subtract prev.bid_sz.
///   * Pure cancellations (size shrank but price moved away from us)
///     ambiguous; ignore.
///
/// This is a lower-bound estimator — won't catch trades that crossed
/// multiple levels (those show up only via the deeper-level deltas)
/// nor trades against hidden / off-book liquidity. Acceptable for v1
/// queue-decay signal; production deployments should layer in the
/// trades-stream loader for ground-truth signed flow.
fn infer_signed_trade_flow(cur: &Mbp10Snapshot, prev: &Mbp10Snapshot) -> f32 {
    let cur_l1 = cur.levels.first().copied().unwrap_or_else(BidAskPair::empty);
    let prev_l1 = prev.levels.first().copied().unwrap_or_else(BidAskPair::empty);

    let cur_bid_px = BidAskPair::price_to_f64(cur_l1.bid_px) as f32;
    let cur_bid_sz = cur_l1.bid_sz as f32;
    let cur_ask_px = BidAskPair::price_to_f64(cur_l1.ask_px) as f32;
    let cur_ask_sz = cur_l1.ask_sz as f32;
    let prev_bid_px = BidAskPair::price_to_f64(prev_l1.bid_px) as f32;
    let prev_bid_sz = prev_l1.bid_sz as f32;
    let prev_ask_px = BidAskPair::price_to_f64(prev_l1.ask_px) as f32;
    let prev_ask_sz = prev_l1.ask_sz as f32;

    let mut buyer_init = 0.0_f32;
    let mut seller_init = 0.0_f32;

    // Same-price size decrement = aggressive cross at that price.
    if (cur_ask_px - prev_ask_px).abs() < f32::EPSILON && cur_ask_sz < prev_ask_sz {
        buyer_init += prev_ask_sz - cur_ask_sz;
    }
    if (cur_bid_px - prev_bid_px).abs() < f32::EPSILON && cur_bid_sz < prev_bid_sz {
        seller_init += prev_bid_sz - cur_bid_sz;
    }
    // Best price moved away — infer the previous level fully cleared.
    if cur_ask_px > prev_ask_px && prev_ask_sz > 0.0 {
        buyer_init += prev_ask_sz;
    }
    if cur_bid_px < prev_bid_px && prev_bid_sz > 0.0 {
        seller_init += prev_bid_sz;
    }

    buyer_init - seller_init
}

#[cfg(test)]
mod trade_flow_tests {
    use super::*;

    fn snap_with_l1(ts: u64, bid_px: f64, bid_sz: u32, ask_px: f64, ask_sz: u32, trade_count: u32) -> Mbp10Snapshot {
        let mut levels = vec![BidAskPair::empty(); BOOK_LEVELS];
        levels[0] = BidAskPair {
            bid_px: BidAskPair::price_from_f64(bid_px),
            bid_sz,
            bid_ct: 1,
            ask_px: BidAskPair::price_from_f64(ask_px),
            ask_sz,
            ask_ct: 1,
        };
        Mbp10Snapshot::new("ES.FUT".into(), ts, levels, 0, trade_count)
    }

    #[test]
    fn pure_cancel_no_size_change_reports_zero_flow() {
        let prev = snap_with_l1(0, 5500.00, 100, 5500.25, 100, 0);
        let cur  = snap_with_l1(1, 5500.00, 100, 5500.25, 100, 0);
        assert_eq!(infer_signed_trade_flow(&cur, &prev), 0.0);
    }

    #[test]
    fn ask_size_shrank_unchanged_price_reports_positive_buyer_flow() {
        let prev = snap_with_l1(0, 5500.00, 100, 5500.25, 100, 0);
        let cur  = snap_with_l1(1, 5500.00, 100, 5500.25,  60, 5);
        // 100 → 60 = aggressive buys consumed 40 lots at the ask.
        assert_eq!(infer_signed_trade_flow(&cur, &prev), 40.0);
    }

    #[test]
    fn bid_size_shrank_unchanged_price_reports_negative_seller_flow() {
        let prev = snap_with_l1(0, 5500.00, 100, 5500.25, 100, 0);
        let cur  = snap_with_l1(1, 5500.00,  70, 5500.25, 100, 3);
        assert_eq!(infer_signed_trade_flow(&cur, &prev), -30.0);
    }

    #[test]
    fn ask_price_moved_up_reports_full_prev_ask_as_buyer_flow() {
        // best ask cleared — infer prev.ask_sz fully lifted.
        let prev = snap_with_l1(0, 5500.00, 100, 5500.25,  50, 0);
        let cur  = snap_with_l1(1, 5500.00, 100, 5500.50, 100, 1);
        assert_eq!(infer_signed_trade_flow(&cur, &prev), 50.0);
    }

    #[test]
    fn bid_price_moved_down_reports_full_prev_bid_as_seller_flow() {
        let prev = snap_with_l1(0, 5500.00,  80, 5500.25, 100, 0);
        let cur  = snap_with_l1(1, 5499.75, 100, 5500.25, 100, 1);
        assert_eq!(infer_signed_trade_flow(&cur, &prev), -80.0);
    }

    #[test]
    fn mixed_buyer_and_seller_within_same_snapshot_nets_correctly() {
        let prev = snap_with_l1(0, 5500.00, 100, 5500.25, 100, 0);
        // ask shrank by 25 (aggressive buys), bid shrank by 10 (aggressive sells).
        let cur  = snap_with_l1(1, 5500.00,  90, 5500.25,  75, 3);
        assert_eq!(infer_signed_trade_flow(&cur, &prev), 15.0);
    }
}

#[cfg(test)]
mod frd_label_tests {
    use super::*;
    use crate::rl::common::{FRD_BUCKET_RANGE_SIGMA, FRD_HORIZON_TICKS, FRD_N_ATOMS, FRD_N_HORIZONS};

    /// Construct a synthetic snapshot stream with a deterministic
    /// mid-price function. Only L1 bid/ask matter — `compute_frd_labels`
    /// reads `mid_price_f32` which uses only the first level.
    fn snap_with_mid(mid: f64) -> Mbp10Snapshot {
        let half_spread = 0.005;
        let bid = mid - half_spread;
        let ask = mid + half_spread;
        let mut levels = vec![BidAskPair::empty(); BOOK_LEVELS];
        levels[0] = BidAskPair {
            bid_px: BidAskPair::price_from_f64(bid),
            bid_sz: 1,
            bid_ct: 1,
            ask_px: BidAskPair::price_from_f64(ask),
            ask_sz: 1,
            ask_ct: 1,
        };
        Mbp10Snapshot::new("ES.FUT".into(), 0, levels, 0, 0)
    }

    #[test]
    fn frd_labels_flat_price_maps_to_mid_bucket() {
        // Constant mid → all forward returns 0 → bucket = round(half) = 10.
        let snaps: Vec<Mbp10Snapshot> = (0..2500).map(|_| snap_with_mid(100.0)).collect();
        let labels = compute_frd_labels(&snaps, FRD_HORIZON_TICKS, FRD_BUCKET_RANGE_SIGMA);
        let mid_bucket = (FRD_N_ATOMS as i32 - 1) / 2;
        for h in 0..FRD_N_HORIZONS {
            let h_ticks = FRD_HORIZON_TICKS[h];
            // Non-sentinel range: [0, n - h_ticks)
            for i in 0..(snaps.len() - h_ticks) {
                assert_eq!(
                    labels[h][i], mid_bucket,
                    "flat-price horizon {} idx {} should be mid bucket {}; got {}",
                    h, i, mid_bucket, labels[h][i]
                );
            }
            // Sentinel range: [n - h_ticks, n)
            for i in (snaps.len() - h_ticks)..snaps.len() {
                assert_eq!(
                    labels[h][i], -1,
                    "right-edge sentinel at h={} idx={} should be -1; got {}",
                    h, i, labels[h][i]
                );
            }
        }
    }

    #[test]
    fn frd_labels_monotonic_ramp_lands_in_upper_buckets() {
        // Monotone ramp: mid[i] = 100 + i × 0.01. Forward return at
        // each horizon is positive → bucket > mid (= 10). Exact bucket
        // depends on σ_per_step (= 0.01 here, std of constant
        // increments), so a 60-tick forward return is +0.6 ticks.
        // σ_h = 0.01 × sqrt(60) ≈ 0.0775 → r = 0.6 / 0.0775 ≈ 7.75.
        // 7.75 is way > range_σ=3, so bucket clamps to FRD_N_ATOMS-1 = 20.
        let snaps: Vec<Mbp10Snapshot> =
            (0..2500).map(|i| snap_with_mid(100.0 + 0.01 * i as f64)).collect();
        let labels = compute_frd_labels(&snaps, FRD_HORIZON_TICKS, FRD_BUCKET_RANGE_SIGMA);
        let max_bucket = (FRD_N_ATOMS - 1) as i32;
        for h in 0..FRD_N_HORIZONS {
            let h_ticks = FRD_HORIZON_TICKS[h];
            for i in 0..(snaps.len() - h_ticks) {
                assert_eq!(
                    labels[h][i], max_bucket,
                    "monotonic-up horizon {} idx {} should clamp to max bucket {}; got {}",
                    h, i, max_bucket, labels[h][i]
                );
            }
        }
    }

    #[test]
    fn frd_labels_short_input_below_h_ticks_all_sentinel() {
        // n=10 < h_ticks[0]=60 → every label sentinel for h0; same for
        // h1 (=300) and h2 (=1800).
        let snaps: Vec<Mbp10Snapshot> = (0..10).map(|_| snap_with_mid(100.0)).collect();
        let labels = compute_frd_labels(&snaps, FRD_HORIZON_TICKS, FRD_BUCKET_RANGE_SIGMA);
        for h in 0..FRD_N_HORIZONS {
            for (i, v) in labels[h].iter().enumerate() {
                assert_eq!(*v, -1, "sub-h_ticks input must be all -1; got {} at h={} i={}", v, h, i);
            }
        }
    }
}

#[cfg(test)]
mod inference_mode_tests {
    use super::*;

    fn test_fixture_cfg(inference_only: bool) -> Option<MultiHorizonLoaderConfig> {
        let root = std::env::var("FOXHUNT_TEST_DATA").ok()?;
        let mbp10 = std::path::PathBuf::from(&root).join("ES.FUT");
        if !mbp10.exists() { return None; }
        let files = discover_mbp10_files_sorted(&mbp10).ok()?;
        Some(MultiHorizonLoaderConfig {
            files,
            predecoded_dir: mbp10.clone(),
            multi_resolution: crate::data::aggregation::MultiResolutionConfig::from_scales(vec![(1, 1)]).unwrap(),
            horizons: crate::heads::HORIZONS,
            n_max_sequences: 1,
            seed: 0xCAFEF00D,
            inference_only,
            outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES,
            instrument_filter: InstrumentFilter::All,
            frd_horizon_ticks: crate::rl::common::FRD_HORIZON_TICKS,
            frd_bucket_range_sigma: crate::rl::common::FRD_BUCKET_RANGE_SIGMA,
        })
    }

    /// Try to construct a loader; return None if no usable data on disk
    /// (placeholder fixtures = empty sidecars = 0 snapshots per file).
    /// Treat that as "skip — real-data path".
    fn try_loader(inference_only: bool) -> Option<MultiHorizonLoader> {
        let cfg = test_fixture_cfg(inference_only)?;
        match MultiHorizonLoader::new(&cfg) {
            Ok(l) => Some(l),
            Err(e) => {
                eprintln!("skipping: fixture data not usable ({e})");
                None
            }
        }
    }

    /// Task 1.1 verification: inference_only=true must NOT precompute labels.
    #[test]
    #[ignore = "requires populated FOXHUNT_TEST_DATA"]
    fn inference_only_skips_label_precompute() {
        let Some(loader) = try_loader(true) else { return };
        assert!(loader.files_loaded.iter().all(|f| f.labels_full.iter().all(|v| v.is_empty())),
            "inference_only=true must leave labels_full empty");
    }

    /// Task 1.1 paired check: inference_only=false populates labels.
    #[test]
    #[ignore = "requires populated FOXHUNT_TEST_DATA"]
    fn training_mode_populates_labels() {
        let Some(loader) = try_loader(false) else { return };
        assert!(loader.files_loaded.iter().any(|f| f.labels_full.iter().any(|v| !v.is_empty())),
            "inference_only=false must precompute labels for at least one file");
    }

    /// Task 1.2 verification: peek_first returns the first snapshot
    /// converted to Mbp10RawInput with non-zero ts_ns + cur==prev semantics.
    #[test]
    #[ignore = "requires populated FOXHUNT_TEST_DATA"]
    fn peek_first_returns_first_chronological_snapshot() {
        let Some(loader) = try_loader(true) else { return };
        let first = loader.peek_first().expect("peek_first ok");
        assert!(first.ts_ns > 0);
        assert_eq!(first.ts_ns, first.prev_ts_ns);
        assert_eq!(first.trade_signed_vol, 0.0);
    }

    /// Task 1.3 verification: chronological iteration in inference mode.
    #[test]
    #[ignore = "requires populated FOXHUNT_TEST_DATA"]
    fn next_inference_input_yields_chronological() {
        let Some(mut loader) = try_loader(true) else { return };
        let snap0 = loader.next_inference_input().expect("snap 0").expect("not none");
        let snap1 = loader.next_inference_input().expect("snap 1").expect("not none");
        let snap2 = loader.next_inference_input().expect("snap 2").expect("not none");
        assert!(snap0.ts_ns <= snap1.ts_ns);
        assert!(snap1.ts_ns <= snap2.ts_ns);
        assert_eq!(snap0.ts_ns, snap0.prev_ts_ns);
        assert_eq!(snap1.prev_ts_ns, snap0.ts_ns);
        assert_eq!(snap2.prev_ts_ns, snap1.ts_ns);
    }

    /// Negative case: next_inference_input on a non-inference loader must error.
    /// Tests can always construct an empty-files config to exercise this
    /// branch without depending on real data.
    #[test]
    fn next_inference_input_errors_when_not_inference_mode() {
        // We don't need real data here — the assertion fires before file load.
        // But we DO need a non-empty files list to satisfy the constructor's
        // ensure!. Stub with a path that may or may not exist; if construction
        // fails for IO reasons, that's fine — the test cares only about the
        // mode check.
        let cfg = MultiHorizonLoaderConfig {
            files: vec![std::path::PathBuf::from("/tmp/__nonexistent_foxhunt.dbn.zst")],
            predecoded_dir: std::path::PathBuf::from("/tmp"),
            multi_resolution: crate::data::aggregation::MultiResolutionConfig::from_scales(vec![(1, 1)]).unwrap(),
            horizons: crate::heads::HORIZONS,
            n_max_sequences: 1,
            seed: 0,
            inference_only: false,
            outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES,
            instrument_filter: InstrumentFilter::All,
            frd_horizon_ticks: crate::rl::common::FRD_HORIZON_TICKS,
            frd_bucket_range_sigma: crate::rl::common::FRD_BUCKET_RANGE_SIGMA,
        };
        if let Ok(mut loader) = MultiHorizonLoader::new(&cfg) {
            let err = loader.next_inference_input();
            assert!(err.is_err(), "must error when inference_only=false");
        }
        // If new() failed (no file), the assertion is moot — covered elsewhere.
    }
}

#[cfg(test)]
mod labels_cache_tests {
    use super::*;

    #[test]
    fn labels_cache_suffix_is_deterministic_and_keys_on_inputs() {
        let h1 = [100, 300, 1000];
        let h2 = [100, 300, 1001]; // different last horizon
        let c1 = 0.5_f32;
        let c2 = 0.25_f32;
        let f1 = InstrumentFilter::All;
        let f2 = InstrumentFilter::FrontMonth;

        // Same inputs → same suffix
        assert_eq!(
            labels_cache_suffix(&h1, c1, f1),
            labels_cache_suffix(&h1, c1, f1)
        );
        // Different horizon → different suffix
        assert_ne!(
            labels_cache_suffix(&h1, c1, f1),
            labels_cache_suffix(&h2, c1, f1)
        );
        // Different cost → different suffix
        assert_ne!(
            labels_cache_suffix(&h1, c1, f1),
            labels_cache_suffix(&h1, c2, f1)
        );
        // Different filter → different suffix
        assert_ne!(
            labels_cache_suffix(&h1, c1, f1),
            labels_cache_suffix(&h1, c1, f2)
        );
    }
}
