//! Smoke test: magnitude distribution (Track 1 gate from policy-quality spec).
//!
//! The 4-branch factored action space has 3 magnitude bins (Quarter=0.25×,
//! Half=0.50×, Full=1.00×). Baseline observation (pre-audit): model collapses
//! to Quarter ≈ 99% with F_Half/F_Full each < 1%. That's mechanical bias,
//! not strategy — the policy cannot size positions.
//!
//! This test asserts the smoke-level gate: each of F_Half and F_Full ≥ 5%
//! (the production gate at spec §2 is 15%; smoke is relaxed to catch only
//! severe collapse). After Phase 2 fixes land, the test should pass at a
//! tightened threshold.
//!
//! Run: `FOXHUNT_TEST_DATA=test_data/futures-baseline \
//!   cargo test -p ml --release --lib -- magnitude_distribution --ignored --nocapture`

use super::helpers::*;
use anyhow::Result;

#[test]
#[ignore] // Requires fxcache — run via FOXHUNT_TEST_DATA=test_data/futures-baseline
fn test_magnitude_distribution() -> Result<()> {
    let data = load_smoke_fxcache().expect("fxcache required — run precompute_features first");
    let mut params = smoke_params();
    params.epochs = 20;
    params.early_stopping_enabled = false;
    params.min_epochs_before_stopping = 20;

    let mut trainer = smoke_trainer_with(params)?;
    init_trainer_from_fxcache(&mut trainer, &data, 200_000)?;

    let data_dir = test_data_dir().expect("FOXHUNT_TEST_DATA or test_data/ must exist");
    let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
    let _metrics = rt.block_on(trainer.train(
        &data_dir,
        "ES.FUT",
        |_epoch, _bytes, _best| Ok("skip".to_owned()),
    ))?;

    let [q, h, f] = trainer.magnitude_action_dist_final();
    println!("[MAG_DIST] Quarter={:.3} Half={:.3} Full={:.3}", q, h, f);

    // Smoke gate: each of Half, Full ≥ 5%. Production gate (spec §2.1) is 15%.
    assert!(
        h >= 0.05,
        "F_Half {:.3} < 0.05 smoke threshold — magnitude branch under-explored. \
         Quarter={:.3} Full={:.3}",
        h, q, f
    );
    assert!(
        f >= 0.05,
        "F_Full {:.3} < 0.05 smoke threshold — magnitude branch under-explored. \
         Quarter={:.3} Half={:.3}",
        f, q, h
    );

    // Task 2.2 H10 regression — eval-mode distribution must include Half + Full.
    // Pre-fix baseline (HEAD at 7e78bf4f8) was eval_dist [eq=1.000 eh=0.000 ef=0.000];
    // after the Philox tie-break fix the eval argmax samples tied indices
    // uniformly, so eh + ef should approach training-time magnitude spread.
    // Threshold 0.30 is the user-specified gate from Task 2.9 (`eval_dist eh + ef ≥ 0.3`).
    // Do NOT relax — failure here means the magnitude branch isn't yet differentiating
    // Quarter / Half / Full enough to warrant the branch's existence. Follow-up fixes
    // (per-magnitude reward shaping, per-bin advantage weighting, magnitude curriculum,
    // state-vector enrichment) are scoped to make the branch useful. Per
    // feedback_no_functionality_removal.md, the branch is never deleted as a fallback.
    let [eq, eh, ef] = trainer.last_eval_magnitude_dist();
    println!("[EVAL_DIST] Quarter={:.3} Half={:.3} Full={:.3}", eq, eh, ef);

    // Diagnostic — pure-policy magnitude intent BEFORE Kelly/margin caps and
    // BEFORE the Hold/Flat dir gate force mag=0. When EVAL_DIST collapses to
    // Quarter=1 because kelly_position_cap's warmup_floor pins abs_pos ≤
    // 0.375×max_pos for the first ~10 trades, this line reveals whether the
    // magnitude Q-head actually learned state-dependent preferences.
    let [iq, ih, if_] = trainer.last_eval_intent_magnitude_dist();
    println!(
        "[EVAL_INTENT_MAG_DIST] Quarter={:.3} Half={:.3} Full={:.3}",
        iq, ih, if_
    );

    // Task 2.X diagnostic — eval direction distribution. When eval collapses
    // to Quarter==1 AND Hold+Flat dominate, the root cause is direction-side
    // (magnitude gets forced to 0 by kernel for Hold/Flat), so a magnitude-
    // only bin weight cannot shift eval_dist. Surfacing this lets the
    // operator attribute the failure to direction vs magnitude.
    let eval_dir_dist = trainer.last_eval_direction_dist();
    if let Some(dir_dist) = eval_dir_dist {
        println!(
            "[EVAL_DIR_DIST] Short={:.3} Hold={:.3} Long={:.3} Flat={:.3}",
            dir_dist[0], dir_dist[1], dir_dist[2], dir_dist[3]
        );
    }

    // Task 2.X diagnostic — ISV per-magnitude-bin Q-mean EMAs + |Q|-scale
    // reference. The bin weight fires when Full is NOT the argmax bin of
    // (Quarter / Half / Full), so the training-policy's Q-means directly
    // drive the adaptive correction.
    if let Some((qq, qh, qf, q_abs_ref)) = trainer.last_isv_magnitude_bin_q_means() {
        let max_mean = qq.max(qh).max(qf);
        let bias_gap_full = (max_mean - qf).max(0.0);
        let collapse_frac_full = if q_abs_ref > 1e-6 {
            (bias_gap_full / q_abs_ref).min(1.0)
        } else {
            0.0
        };
        println!(
            "[ISV_BIN_MEANS] q_q={:.4} q_h={:.4} q_f={:.4} q_abs_ref={:.4} \
             max_mean={:.4} bias_gap_full={:.4} collapse_frac_full={:.3}",
            qq, qh, qf, q_abs_ref, max_mean, bias_gap_full, collapse_frac_full
        );
    }

    // Task 2.Y diagnostic — ISV per-direction-bin Q-mean EMAs + |Q|-scale
    // reference. Mirror of the magnitude diagnostic above, applied to the
    // direction axis. The direction bin weight fires when the argmax of
    // (Short/Hold/Long/Flat) differs from the taken direction, AND the
    // taken direction is not Flat (Flat is never amplified — it is the
    // already-over-estimated low-variance bin the mechanism corrects
    // AGAINST). collapse_frac_dir_hold and collapse_frac_dir_flat report
    // the bias-vs-argmax fraction for Hold and Flat respectively; these
    // are the bins whose over-selection at eval the fix must reduce.
    if let Some((qs, qh, ql, qf, q_dir_abs_ref)) =
        trainer.last_isv_direction_bin_q_means()
    {
        let max_mean_dir = qs.max(qh).max(ql).max(qf);
        let bias_gap_hold = (max_mean_dir - qh).max(0.0);
        let bias_gap_flat = (max_mean_dir - qf).max(0.0);
        let collapse_frac_dir_hold = if q_dir_abs_ref > 1e-6 {
            (bias_gap_hold / q_dir_abs_ref).min(1.0)
        } else {
            0.0
        };
        let collapse_frac_dir_flat = if q_dir_abs_ref > 1e-6 {
            (bias_gap_flat / q_dir_abs_ref).min(1.0)
        } else {
            0.0
        };
        println!(
            "[ISV_DIR_MEANS] q_s={:.4} q_h={:.4} q_l={:.4} q_f={:.4} \
             q_dir_abs_ref={:.4} max_mean_dir={:.4} \
             collapse_frac_dir_hold={:.3} collapse_frac_dir_flat={:.3}",
            qs, qh, ql, qf, q_dir_abs_ref, max_mean_dir,
            collapse_frac_dir_hold, collapse_frac_dir_flat
        );
    }
    assert!(
        eh + ef >= 0.30,
        "H10 regression: eval Half + Full share {:.3} < 0.30 (eq={:.3} eh={:.3} ef={:.3}) \
         — argmax tie-break did not prevent collapse. Train-mode dist_mag should show \
         similar spread; if training dist is also low, the next fix step is \
         per-magnitude reward shaping / per-bin advantage weighting / curriculum — \
         make the magnitude branch useful, not remove it.",
        eh + ef, eq, eh, ef
    );

    // Task 2.X "make Full useful" — the adaptive ISV-driven C51 bin weight
    // (see gpu_dqn_trainer.rs Q_MAG_SPREAD_INDEX / Q_DIR_SPREAD_INDEX and
    // c51_loss_kernel.cu get_magnitude_bin_weight) must push Q(Full) high
    // enough that the eval-mode strict-argmax actually selects Full at
    // least 5% of the time.
    //
    // Asserted on EVAL_INTENT (pre-Kelly-cap) per
    // `project_magnitude_eval_collapse_kelly_capped`: this metric reflects
    // policy-learning success — what the policy chooses BEFORE the Kelly
    // cap warmup safety floor pins actual magnitude to <= Half. Asserting
    // on EVAL_DIST (post-Kelly-cap) conflates "Q-head learned to prefer
    // Full" with "Kelly cap warmup completed", which on the local-laptop
    // smoke (1 quarter MBP-10) never finishes within the training horizon.
    // The diagnostic split landed in #212 (HEALTH_DIAG `intent_dist` vs
    // `eval_dist`).
    //
    // Pre-fix baseline observed if=0.000 because Q(Half) > Q(Full) + 1e-6
    // deterministically across all states after Task-2.2 H10 tie-break.
    // The fix is signal-driven (deficit between magnitude and direction
    // Q-spread) with a bin-proportional weight, so it self-disables once
    // the magnitude head differentiates.
    assert!(
        if_ >= 0.05,
        "Task 2.X adaptive magnitude: eval-intent Full share {:.3} < 0.05 \
         (iq={:.3} ih={:.3} if={:.3}; eval-post-Kelly: eq={:.3} eh={:.3} ef={:.3}) \
         — ISV bin-weight mechanism failed to lift Q(Full) above Q(Half) + 1e-6. \
         Investigate: \
         (a) ISV slots [13]/[14] populated each stats cadence (check \
         q_branch_spread_reduce + isv_signal_update wiring), \
         (b) spread_deficit positive during training (magnitude-branch \
         Q-spread collapsed below direction-branch reference), \
         (c) bin_weight applied consistently to branch_ce (c51_loss_batched) \
         AND gradient (c51_grad_kernel d==1 block). Do NOT fall back to \
         static tuning knobs — per feedback_adaptive_not_tuned.md the \
         escalation path is richer ISV signals / kernel-side arithmetic. \
         (The post-Kelly-cap eval_dist Full=0 is a known Kelly-cold-start \
         pathology — see project_magnitude_eval_collapse_kelly_capped — \
         and is structurally separate from the Q-head learning measured \
         here.)",
        if_, iq, ih, if_, eq, eh, ef
    );

    // Task 2.Y "make direction branch useful at eval" — the ISV-adaptive
    // C51 direction bin weight (see c51_loss_kernel.cu::
    // get_direction_bin_weight and c51_grad_kernel.cu's d==0 block) must
    // prevent the direction-branch strict-argmax from collapsing onto
    // Hold or Flat. Pre-fix baseline on HEAD fa8d54661 across 3 runs
    // observed Hold+Flat ∈ {0.000, 0.847, 0.452} — hyper-variant because
    // C51's expected-Q bias flattens direction Q-values into a tight band
    // that argmax picks from arbitrarily. The fix pushes the tradable
    // directions (Short/Long) up by an ISV-driven bin weight; Flat is
    // never amplified. Self-disables when direction Q-values differentiate
    // healthily.
    //
    // Per-run gate is lenient (≤ 0.60) because direction collapse is
    // hyper-variant across seeds — operator should run 3 times and compute
    // the median to assess real mechanism engagement (scoping §7 / §8).
    if let Some(dir_dist) = eval_dir_dist {
        let hold_plus_flat = dir_dist[1] + dir_dist[3];
        assert!(
            hold_plus_flat <= 0.60,
            "Task 2.Y adaptive direction: eval Hold+Flat share {:.3} > 0.60 \
             (Short={:.3} Hold={:.3} Long={:.3} Flat={:.3}) — ISV direction \
             bin-weight mechanism failed to lift tradable-direction Q-values \
             above the Hold/Flat argmax threshold. Investigate: \
             (a) ISV slots [17..21] populated each stats cadence (check \
             q_dir_bin_means_reduce + isv_signal_update wiring), \
             (b) get_direction_bin_weight fires in C51 loss (branch_ce scale) \
             AND c51_grad_kernel d==0 block (consistent with forward), \
             (c) dir_bias_signal layout {{Short=1, Hold=0.5, Long=1, Flat=0}} — \
             Flat must NEVER be amplified. Note: direction collapse is \
             hyper-variant across seeds; single-run failure on the tail of \
             the distribution is acceptable per scoping §7 if median over 3 \
             independent runs ≤ 0.50. Do NOT fall back to static tuning \
             knobs — per feedback_adaptive_not_tuned.md escalation is richer \
             ISV signals / kernel-side arithmetic.",
            hold_plus_flat, dir_dist[0], dir_dist[1], dir_dist[2], dir_dist[3]
        );
    }
    Ok(())
}
