//! Smoke test: reward component audit (Track 2 from policy-quality spec).
//!
//! Asserts the per-term reward contribution diagnostic in HEALTH_DIAG produces
//! finite, machine-readable values across an epoch's worth of training. The
//! actual triage (which terms get DELETE / KEEP per spec §5.2 V7 audit) is
//! Phase 1 work performed against logs from a multi-fold L40S run — this
//! smoke gates that the diagnostic infrastructure runs cleanly.
//!
//! The audit covers the live reward terms split into 3 measurement classes
//! per spec §4.1:
//!   - Additive (R1 step_return, R5 micro-reward): fraction of |total reward|
//!   - Transform (R2 PopArt): |delta| / |pre| ratio
//!   - Sample-selector (R3 CF-flip, R4 trailing-stop): firing rate
//!
//! History: R7 segment-patience slot was deleted in Task 0.8 (kernel never
//! wired it). R6 loss-aversion slot was deleted in Task 2.4 — the R6
//! asymmetric-soft-clamp was relocated from the reward layer to the C51
//! Bellman target smoothing (`c51_loss_kernel.cu::block_bellman_project_f`),
//! so the reward-layer diagnostic no longer has a source signal.
//!
//! Run: `FOXHUNT_TEST_DATA=test_data/futures-baseline \
//!   cargo test -p ml --release --lib -- reward_component_audit --ignored --nocapture`

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

#[test]
#[ignore] // Requires fxcache
fn test_reward_components_contribute() -> 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()),
    ))?;

    // Reward-contribution diagnostic comes from HEALTH_DIAG `reward_contrib` group.
    // Populated by Task 0.8's kernel-side instrumentation: popart drift from
    // host-side FusedTrainingCtx, cf_flip/trail firing rates from collector
    // per-sample buffers, micro additive ratio. All finite and non-negative
    // by construction (absolute-value sums, rate = a/b with b >= 0).
    //
    // Task 2.4: `loss_aversion` slot removed — R6 clamp relocated to
    // c51_loss_kernel.cu::block_bellman_project_f (Q-target smoothing).
    // Reward-layer diagnostic no longer has a source signal; tuple shrank 5→4.
    //
    // Real triage uses log-grep against a 6-fold × 50-epoch L40S run (Phase 1).
    // The smoke's job is to ensure the reporting scaffolding never regresses
    // with NaN/Inf values, which would break log parsing downstream.
    let summary = trainer.reward_component_audit_summary();
    let [popart, cf_flip, trail_r, micro] = summary;
    println!(
        "[REWARD_AUDIT] popart={:.4} cf_flip={:.4} trail={:.4} micro={:.4}",
        popart, cf_flip, trail_r, micro
    );

    // ── Finite + non-negative gate (structural, unchanged intent) ──
    // Magnitudes should be unsigned by construction (abs-sums, a/b with b≥0).
    let names = ["popart", "cf_flip", "trail", "micro"];
    for (i, &v) in summary.iter().enumerate() {
        assert!(
            v.is_finite(),
            "reward_contrib[{}] (={}) is non-finite — diagnostic infra broken",
            names[i], v
        );
        assert!(
            v >= 0.0,
            "reward_contrib[{}] (={}) is negative — magnitudes should be unsigned",
            names[i], v
        );
    }

    // ── Meaningful-signal gate ──────────────────────────────────────────
    // Passes-on-zeros was the old weakness: a broken wiring that silently
    // returned all-0 would have passed. Assert that the slots known to be
    // wired in smoke config are *actually populated*.
    //
    // cf_flip: with `cf_ratio` default 0.5 in HEALTH_DIAG, ~half of the
    // counterfactual slots should flip per epoch. The reducer normalizes
    // against `n_touched` (reached slots), not N*L — so the rate is
    // stable around 0.5 regardless of early termination. 20 epochs at
    // batch_size from the smoke TOML gives >>1k samples, well past any
    // small-N sampling noise. Assert ≥ 0.1 as a generous floor (10× below
    // the ~0.5 target); anything lower means CF-flip is decoupled from
    // the per-sample counter. If this fails, investigate the
    // `cf_flip_per_sample` kernel write + `slot_live_per_sample` counter.
    assert!(
        cf_flip > 0.1,
        "reward_contrib cf_flip = {:.4} ≤ 0.1 — CF-flip diagnostic is \
         effectively zero across 20 epochs. Either (a) CF path disabled, \
         (b) `cf_flip_per_sample` kernel write broke, or (c) \
         `slot_live_per_sample` counter is wrong. summary={:?}",
        cf_flip, summary
    );

    // trail_r: the magnitude_distribution smoke shows `trail [fire_q=0.5-0.7]`
    // on default smoke config, so some fraction of trades will hit the
    // trailing-stop over 20 epochs. The reducer is fire_count / traded_count,
    // gated by `traded > 0`. Assert ≥ 0.01 as a floor — anything below that
    // means trail-stop never fires on trades, which would be a regression of
    // the Task 0.5 wiring (`trail_triggered_per_sample` /
    // `traded_per_sample` buffers).
    assert!(
        trail_r > 0.01,
        "reward_contrib trail = {:.4} ≤ 0.01 — trailing-stop diagnostic is \
         effectively zero across 20 epochs. Per magnitude_distribution, \
         fire_q should be 0.5-0.7. Investigate `trail_triggered_per_sample` \
         or `traded_per_sample` wiring in experience_env_step. summary={:?}",
        trail_r, summary
    );

    // popart and micro are known-zero in smoke config:
    // - popart: requires multi-epoch variance drift; first few epochs see
    //   `prev_var == 0` so slot[0] is honestly 0.0.
    // - micro: smoke TOML sets `micro_reward_scale = 0` in td_propagation,
    //   though default smoke may have a small value. Still dominated by
    //   main reward, so the ratio is small.
    // Assert they are finite + non-negative only (already covered above).
    // No additional floor: these slots legitimately stay near zero in smoke.
    let _ = (popart, micro);

    // Task 2.4: the old `loss_aversion` slot was dropped. R6 was the
    // asymmetric-soft-clamp on `base_reward` that fed the reward-layer
    // diagnostic. The invariant (protect Q-targets from catastrophic-loss
    // gradient dominance) is now enforced one layer deeper at
    // `c51_loss_kernel.cu::block_bellman_project_f` — a Huber-style
    // compression on the projected Bellman target before the v_min/v_max
    // clamp. Same protection, structurally-correct location; no reward-layer
    // slot remains to assert against.

    Ok(())
}
