//! Smoke test: Plan 4 Task 3 (E.3) IQN multi-quantile diagnostic ISV slots.
//!
//! Validates that the new `iqn_quantile_ema_update` producer kernel populates
//! ISV[IQN_Q_P05_EMA_INDEX..=IQN_Q_P95_EMA_INDEX] (slots 99..103) with sane,
//! non-degenerate, finite values after training has run long enough for the
//! IQN forward to write meaningful per-quantile estimates into
//! `GpuIqnHead::save_q_online`.
//!
//! ## Pass criteria
//!
//! 1. All four off-median slots (Q_p05/Q_p25/Q_p75/Q_p95) finish with FINITE
//!    values — no NaN/Inf.  Cold-start is 0.0 across all four; the producer
//!    kernel runs once per step at α=0.05 (≈13-batch half-life), so a
//!    finite-but-zero value would indicate the kernel never fired.  We
//!    therefore additionally require at least one of the four values to be
//!    strictly positive — that proves the EMA actually accumulated mean |Q|
//!    rather than staying pinned at the cold-start 0.
//!
//! 2. The values are non-degenerate as a set — `max - min > 0`.  Strict
//!    per-(s,a) quantile monotonicity (Q(τ=0.05) ≤ Q(τ=0.25) ≤ ... ≤ Q(τ=0.95)
//!    for every state-action pair) is a well-known soft property of IQN that
//!    fails on a noticeable fraction of (s,a) pairs even on converged
//!    policies, and these slots hold mean |Q| over (B × tba) which doesn't
//!    preserve per-pair monotonicity anyway.  Asserting "the four values
//!    aren't all equal" is the loaded-bearing check at the ISV-aggregate
//!    level: if the producer kernel reduced the same column for every τ,
//!    the four EMAs would converge to the same number and we'd catch the
//!    bug here.
//!
//! Run:
//! ```bash
//! FOXHUNT_TEST_DATA=test_data/futures-baseline SQLX_OFFLINE=true \
//!   cargo test --release -p ml --lib -- \
//!   iqn_multi_quantile_heads_produce_monotonic_estimates \
//!   --ignored --nocapture
//! ```

use super::helpers::*;
use crate::cuda_pipeline::gpu_dqn_trainer::{
    IQN_Q_P05_EMA_INDEX, IQN_Q_P25_EMA_INDEX,
    IQN_Q_P75_EMA_INDEX, IQN_Q_P95_EMA_INDEX,
};

#[test]
#[ignore] // GPU + fxcache data
fn iqn_multi_quantile_heads_produce_monotonic_estimates() -> anyhow::Result<()> {
    use crate::fxcache;

    let cache_dir = feature_cache_dir();
    assert!(
        cache_dir.exists(),
        "feature-cache not found at {:?} — run precompute_features first",
        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 {cache_dir:?}");

    let fxcache_data = fxcache::load_fxcache(&entries[0].path())?;
    // 5000 bars at smoke-config batch_size=64 ≈ 62 train batches/epoch; a single
    // epoch is enough to fire the producer kernel ~50× and saturate the α=0.05
    // EMA toward the measured per-quantile mean |Q|. More epochs would slow
    // the test without changing the assertion outcome.
    let n = fxcache_data.bar_count.min(5_000);
    let train_end = (n * 80) / 100;

    let mut params = smoke_params();
    params.epochs = 1;
    params.early_stopping_enabled = false;
    // The smoke profile already sets iqn_lambda > 0 (= 0.25), so the IQN
    // head is constructed and `execute_training_pipeline` runs each step.
    // Defensive assertion below verifies the assumption rather than silently
    // skipping the test if the profile ever flips iqn_lambda to 0.
    assert!(
        params.iqn_lambda > 0.0,
        "IQN must be enabled (iqn_lambda > 0) for this test; smoke profile \
         currently has iqn_lambda={}",
        params.iqn_lambda,
    );

    let mut trainer = smoke_trainer_with(params)?;
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()?;

    let features = &fxcache_data.features[..n];
    let targets = &fxcache_data.targets[..n];
    let ofi = &fxcache_data.ofi[..n.min(fxcache_data.ofi.len())];
    let train_feat = &features[..train_end];
    let train_targets = &targets[..train_end];
    let val_feat = &features[train_end..n];
    let val_targets = &targets[train_end..n];

    rt.block_on(trainer.init_from_fxcache(features, targets, ofi))?;
    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())?;

    rt.block_on(trainer.train_fold_from_slices(
        train_feat,
        train_targets,
        |_epoch, _bytes, _best| Ok("skip".to_owned()),
    ))?;

    // ── Read the four off-median quantile EMA slots ──────────────────────
    let fused = trainer
        .fused_ctx_mut()
        .ok_or_else(|| anyhow::anyhow!("fused_ctx absent — IQN test requires GPU trainer"))?;
    let q_p05 = fused.read_isv_signal_at(IQN_Q_P05_EMA_INDEX);
    let q_p25 = fused.read_isv_signal_at(IQN_Q_P25_EMA_INDEX);
    let q_p75 = fused.read_isv_signal_at(IQN_Q_P75_EMA_INDEX);
    let q_p95 = fused.read_isv_signal_at(IQN_Q_P95_EMA_INDEX);

    eprintln!(
        "[IQN_QUANTILE_EMA] Q_p05={q_p05:.6} Q_p25={q_p25:.6} \
         Q_p75={q_p75:.6} Q_p95={q_p95:.6}"
    );

    // 1. All four values must be finite — NaN/Inf would mean the producer
    //    kernel divided by zero or read out-of-bounds.
    for (name, v) in [
        ("Q_p05", q_p05),
        ("Q_p25", q_p25),
        ("Q_p75", q_p75),
        ("Q_p95", q_p95),
    ] {
        assert!(
            v.is_finite(),
            "{name} EMA is not finite: {v} — iqn_quantile_ema_update kernel is \
             reading garbage or dividing by zero",
        );
    }

    // 2. At least one slot must be > 0 — proves the kernel actually fired
    //    and EMA-accumulated mean |Q|. Cold-start is 0.0 for all four; the
    //    α=0.05 EMA over ~50 batches at any non-zero |Q| signal pulls these
    //    to strictly positive values. A pinned-at-zero result here means the
    //    producer kernel never executed (training_loop wiring broken).
    let max_val = q_p05.max(q_p25).max(q_p75).max(q_p95);
    assert!(
        max_val > 0.0,
        "All four IQN quantile EMAs are still 0.0 after training — the \
         iqn_quantile_ema_update kernel did not fire (or save_q_online is \
         all zero, suggesting GpuIqnHead::execute_training_pipeline never \
         ran). Q_p05={q_p05} Q_p25={q_p25} Q_p75={q_p75} Q_p95={q_p95}",
    );

    // 3. The four slots must not all be equal. Identical values across all
    //    four off-median quantiles would mean the producer kernel was reading
    //    the same `tau_idx` for every block — a layout bug. A spread of just
    //    1e-9 is enough to prove distinctness; use a larger floor (1e-6) to
    //    avoid floating-point ties on a degenerate (all-zero |Q|) input
    //    that should already have been caught by check 2.
    let min_val = q_p05.min(q_p25).min(q_p75).min(q_p95);
    let spread = max_val - min_val;
    assert!(
        spread > 1e-6,
        "IQN quantile EMAs collapsed to a single value (spread={spread:.3e}, \
         max={max_val} min={min_val}). The producer kernel likely reads the \
         same tau_idx for every block — check the blockIdx → tau_idx switch \
         in iqn_quantile_ema_kernel.cu.",
    );

    Ok(())
}
