//! Self-improving enrichment module — 8 enrichment functions.
//!
//! `EnrichmentState` persists across epochs. `EvalTrade` is the per-trade
//! record produced by `GpuBacktestEvaluator::read_per_trade_tape()` after
//! each validation backtest pass — single source of truth, re-exported
//! here for module-local readability. `run_enrichments` dispatches all 8
//! functions and returns an `EnrichmentResult` summarising the next-epoch
//! adjustments.
//!
//! All pure Rust, no CUDA kernels. The per-trade GPU tape is the only
//! input — the prior `extract_eval_trades_from_metrics` synthesizer
//! (which fabricated a trade distribution from aggregate WindowMetrics)
//! was deleted in SP21 T2.2 Phase 2 (2026-05-11) per `feedback_no_stubs`.

use tracing::info;

use crate::cuda_pipeline::mapped_pinned::{
    MappedF32Buffer, MappedI32Buffer, MappedU32Buffer,
};

/// SP21 T2.2 Phase 6.5b (2026-05-11) — cap on synthetic hindsight
/// inserts per epoch. Prevents synthetic experiences from dominating
/// the PER buffer; chosen large enough to surface signal but small
/// enough to keep the scratch alloc bounded (32 × STATE_DIM × 4
/// bytes ≈ 13 KB pinned per buffer, ≈ 30 KB total across 7 SoA
/// buffers).
pub(crate) const MAX_SYNTHETIC_HINDSIGHT: usize = 32;

/// SP21 T2.2 Phase 6.5b (2026-05-11) — trainer-owned mapped-pinned
/// scratch for synthetic hindsight experience injection.
///
/// Sized for `MAX_SYNTHETIC_HINDSIGHT` rows. Host writes via
/// `host_ptr` (volatile writes for compiler-reorder safety); kernel
/// reads via `dev_ptr`. Per `feedback_no_htod_htoh_only_mapped_pinned`,
/// zero-copy CPU→GPU.
///
/// Lifecycle:
///   1. Lazy-allocated on the first non-empty `result.hindsight`.
///   2. Re-used across epochs (write_volatile overwrites prior data;
///      `count` controls how many rows the insert kernel scatters).
///   3. Dropped when the trainer is dropped (`MappedF32Buffer::Drop`
///      releases the pinned pages).
pub(crate) struct HindsightScratch {
    /// State row buffer `[MAX × STATE_DIM]` f32.
    pub states: MappedF32Buffer,
    /// Next-state row buffer `[MAX × STATE_DIM]` f32. Set to zeros
    /// (done=1 means the kernel ignores next_state for the Bellman
    /// target, so the values don't matter — but the scatter still
    /// copies them into the ring buffer).
    pub next_states: MappedF32Buffer,
    /// Factored action `[MAX]` u32.
    pub actions: MappedU32Buffer,
    /// Reward `[MAX]` f32 (counterfactual_pnl from
    /// `HindsightExperience`).
    pub rewards: MappedF32Buffer,
    /// Done flag `[MAX]` f32. All 1.0 — terminal Bellman target =
    /// reward, so the synthetic tuple injects a pure reward signal
    /// at `(state, action)` without requiring a valid next_state.
    pub dones: MappedF32Buffer,
    /// Aux 30-bar sign label `[MAX]` i32 (-1/0/+1). Mapped from
    /// `optimal_direction`: 0=Short → -1, 1=Hold → 0, 2=Long → +1.
    pub aux_sign: MappedI32Buffer,
    /// Aux confidence `[MAX]` f32. All 0.0 — synthetic experiences
    /// carry no aux-head confidence signal (the aux head wasn't run
    /// on these counterfactual transitions).
    pub aux_conf: MappedF32Buffer,
}

impl HindsightScratch {
    /// Allocate all 7 mapped-pinned buffers sized for
    /// `MAX_SYNTHETIC_HINDSIGHT × state_dim` rows. Caller must hold
    /// an active CUDA context on the current thread (the trainer's
    /// constructor satisfies this).
    pub(crate) fn allocate(state_dim: usize) -> Result<Self, String> {
        let row_count = MAX_SYNTHETIC_HINDSIGHT;
        let state_elems = row_count * state_dim;
        // Safety: caller-side contract — CUDA context active.
        unsafe {
            Ok(Self {
                states: MappedF32Buffer::new(state_elems)?,
                next_states: MappedF32Buffer::new(state_elems)?,
                actions: MappedU32Buffer::new(row_count)?,
                rewards: MappedF32Buffer::new(row_count)?,
                dones: MappedF32Buffer::new(row_count)?,
                aux_sign: MappedI32Buffer::new(row_count)?,
                aux_conf: MappedF32Buffer::new(row_count)?,
            })
        }
    }
}

// ---------------------------------------------------------------------------
// Data structures
// ---------------------------------------------------------------------------

/// Per-trade record from the GPU per-trade tape. Single source of truth
/// owned by `gpu_backtest_evaluator`; re-exported `pub(crate)` so the
/// enrichment functions below can name it without redundant duplication.
/// Definition: `bar_index`, `pnl`, `predicted_q` (Phase 1.5 entry_q
/// snapshot), `holding_bars`, `direction`, `magnitude`. See
/// `cuda_pipeline::gpu_backtest_evaluator::EvalTrade` for field docs.
pub(crate) use crate::cuda_pipeline::gpu_backtest_evaluator::EvalTrade;

/// Synthetic experience from hindsight replay (E7).
#[derive(Debug, Clone)]
pub(crate) struct HindsightExperience {
    /// SP21 T2.2 Phase 6.5 (2026-05-11) — eval-window membership.
    /// Set from the source `EvalTrade.window_index`; used by the
    /// hindsight injection path to look up the val state vector
    /// at `(window_index, bar_index)` from the retained-states
    /// buffer on `GpuBacktestEvaluator`.
    pub window_index: u32,
    /// Bar index of the losing trade.
    pub bar_index: usize,
    /// Optimal direction (counterfactual).
    pub optimal_direction: u8,
    /// Optimal magnitude (counterfactual).
    pub optimal_magnitude: u8,
    /// Estimated counterfactual P&L.
    pub counterfactual_pnl: f32,
}

/// Output of one enrichment cycle.
#[derive(Debug, Clone)]
pub(crate) struct EnrichmentResult {
    /// E1: additive correction to Q-targets (clipped ±10.0).
    pub q_correction: f32,
    /// E2: next-epoch epsilon.
    pub epsilon: f32,
    /// E3: suggested gamma (None = keep current).
    pub gamma: Option<f32>,
    /// E4: per-branch LR scaling [dir, mag, order, urgency].
    pub branch_lr_scale: [f32; 4],
    /// E5: updated ensemble agreement threshold.
    pub agreement_threshold: f32,
    /// E6: bar indices of top-10% trades (priority replay).
    pub winner_indices: Vec<usize>,
    /// SP21 T2.2 Phase 5+6 (2026-05-11) — winner P&L concentration:
    /// `top_decile_mean_pnl / max(all_trade_mean_pnl, EPS)`. > 1.0 when
    /// the top decile drives more profit per trade than average. Cold
    /// start: trades.is_empty() returns 0.0 sentinel. Consumed by
    /// `per_update_pa` via ISV[WINNER_CONCENTRATION_INDEX=525] for
    /// per-sample alpha boost.
    pub winner_concentration: f32,
    /// E7: hindsight labels for worst losers.
    pub hindsight: Vec<HindsightExperience>,
    /// SP21 T2.2 Phase 5+6 (2026-05-11) — mean |counterfactual_pnl|
    /// across hindsight experiences. Higher magnitude = larger missed
    /// opportunities. Cold start: hindsight.is_empty() returns 0.0
    /// sentinel. Consumed by `per_update_pa` via
    /// ISV[HINDSIGHT_MAGNITUDE_INDEX=526] for per-sample alpha boost.
    pub hindsight_magnitude: f32,
    /// E8: curriculum weights (one per segment).
    pub curriculum_weights: Vec<f32>,
    /// SP21 T2.2 Phase 7 (2026-05-11) — `1 - entropy/log(n_segments)`
    /// ∈ `[0, 1]`. 0 = curriculum weights are uniform (segments equally
    /// hard); 1 = one segment dominates (highly concentrated difficulty).
    /// Cold start: empty curriculum returns 0.0 sentinel. Consumed by
    /// `per_update_pa` via ISV[CURRICULUM_CONCENTRATION_INDEX=527] for
    /// per-sample alpha boost (composed multiplicatively with
    /// `winner_concentration` + `hindsight_magnitude`).
    pub curriculum_concentration: f32,
}

/// Persistent state across epochs for the enrichment module.
#[derive(Debug, Clone)]
pub(crate) struct EnrichmentState {
    /// Current epsilon (updated each enrichment cycle).
    pub epsilon: f32,
    /// Current ensemble agreement threshold.
    pub agreement_threshold: f32,
    /// Number of enrichment cycles executed so far.
    pub cycle_count: u32,
    /// Number of curriculum segments.
    pub n_segments: usize,
}

impl EnrichmentState {
    pub(crate) fn new(initial_epsilon: f32) -> Self {
        Self {
            epsilon: initial_epsilon,
            agreement_threshold: 1.0,
            cycle_count: 0,
            n_segments: 8,
        }
    }
}

// ---------------------------------------------------------------------------
// Enrichment functions (E1–E8)
// ---------------------------------------------------------------------------

/// E1: Q-value reality check — computes additive bias correction.
fn compute_q_correction(trades: &[EvalTrade]) -> f32 {
    if trades.is_empty() {
        return 0.0;
    }
    let bias: f32 = trades
        .iter()
        .map(|t| t.predicted_q - t.pnl)
        .sum::<f32>()
        / trades.len() as f32;
    (-bias).clamp(-10.0, 10.0)
}

/// E2: Adaptive epsilon — performance-driven exploration rate.
///
/// **Phase 8 refactor (2026-05-11)** — both ANCHORS (val_sharpe
/// brackets) and GAINS (multiplicative step sizes) are now ISV-
/// driven from `val_sharpe_var_ema` (slot 351). The brackets are
/// scaled to multiples of `val_sharpe_std = √var_ema` so the
/// "excellent / good / poor" thresholds adapt to the run's noise
/// floor instead of using static `2.0 / 0.5 / -0.5` constants.
/// The gain magnitude scales with `val_sharpe_std` too — the
/// controller responds more aggressively when val Sharpe is
/// noisier (sharp adjustments) and gently when stable.
/// Cold-start (`var_ema == 0`): pass-through (no-op) per
/// `pearl_first_observation_bootstrap`. The `[0.05, 0.30]` clamp
/// on gain magnitude is an Invariant 1 carve-out — single-step
/// gains outside that range produce instability; structural
/// floor/cap mirrors `pearl_wiener_alpha_floor_for_nonstationary`.
fn compute_adaptive_epsilon(current: f32, val_sharpe: f32, val_sharpe_var_ema: f32) -> f32 {
    let val_sharpe_std = val_sharpe_var_ema.max(0.0).sqrt();
    if val_sharpe_std <= 1e-6 {
        // Cold-start: no signal yet, return current unchanged.
        return current.clamp(0.01, 0.15);
    }
    // ISV-driven anchors: brackets scale with val_sharpe noise floor.
    let high_anchor = 2.0 * val_sharpe_std;
    let good_anchor = 0.5 * val_sharpe_std;
    let poor_anchor = -0.5 * val_sharpe_std;
    // ISV-driven gain magnitude (Invariant 1 stability carve-out).
    let gain_mag = val_sharpe_std.clamp(0.05, 0.30);
    let scale = if val_sharpe > high_anchor {
        1.0 - gain_mag           // excellent → reduce exploration
    } else if val_sharpe > good_anchor {
        1.0 - 0.5 * gain_mag     // good → slight reduction
    } else if val_sharpe < poor_anchor {
        1.0 + gain_mag           // poor → increase exploration
    } else {
        1.0                       // mediocre → unchanged
    };
    (current * scale).clamp(0.01, 0.15)
}

/// E3: Dynamic gamma from profitable trade durations.
fn compute_dynamic_gamma(trades: &[EvalTrade]) -> Option<f32> {
    let holds: Vec<f32> = trades
        .iter()
        .filter(|t| t.pnl > 0.0 && t.holding_bars > 0)
        .map(|t| t.holding_bars as f32)
        .collect();
    if holds.is_empty() {
        return None;
    }
    let mean = holds.iter().sum::<f32>() / holds.len() as f32;
    Some((1.0 - 1.0 / mean.max(5.0)).clamp(0.85, 0.98))
}

/// E4: Trade autopsy — per-branch error rates to LR scaling.
fn compute_branch_lr_scale(trades: &[EvalTrade]) -> [f32; 4] {
    let losers: Vec<&EvalTrade> = trades.iter().filter(|t| t.pnl < 0.0).collect();
    if losers.is_empty() {
        return [1.0; 4];
    }
    let total = losers.len() as f32;
    let mut errors = [0u32; 4];
    for t in &losers {
        // Direction errors: went Long and lost, or went Short and lost
        if (t.direction == 2 && t.pnl < 0.0) || (t.direction == 0 && t.pnl < 0.0) {
            errors[0] += 1;
        }
        // Magnitude errors: oversized and significant loss
        if t.magnitude >= 1 && t.pnl < -0.001 {
            errors[1] += 1;
        }
        // Urgency errors: very short hold and lost
        if t.holding_bars < 3 && t.pnl < 0.0 {
            errors[3] += 1;
        }
    }
    let rates: [f32; 4] = std::array::from_fn(|i| errors[i] as f32 / total);
    let mean_rate = rates.iter().sum::<f32>() / 4.0;
    std::array::from_fn(|d| (1.0 + 0.5 * (rates[d] - mean_rate)).clamp(0.5, 2.0))
}

/// E5: Ensemble agreement threshold tuning — quartile-spread Sharpe with
/// ISV-driven significance anchor.
///
/// **Phase 2 refactor (2026-05-11)**: prior implementation filtered
/// trades by `t.ensemble_var < threshold` and compared the agreed
/// subset's Sharpe to the full-set Sharpe. The val backtest is a
/// SINGLE deterministic forward — no ensemble, no per-trade ensemble
/// variance. Replacement reads the per-trade pnl tape and computes
/// the Sharpe spread between the top-quartile and bottom-quartile
/// trades: a wide spread means the policy clearly separates winning
/// from losing trades (its agreement signal is informative — trust
/// it more, tighten the threshold); a narrow spread means the
/// agreement signal isn't predictive (loosen the threshold).
///
/// **Anchor source (option 2 of the open question — "use ISV for
/// EMA")**: the noise floor for "significant spread" is read from
/// `ISV[VAL_SHARPE_VAR_EMA_INDEX=351]` (the per-epoch EMA of val
/// Sharpe variance, already produced by the early-stopping pipeline
/// per SP21 T1.1a). `√var` puts the val_sharpe noise into a unit
/// directly comparable to the spread. Per
/// `pearl_controller_anchors_isv_driven` and
/// `feedback_isv_for_adaptive_bounds`: the anchor is signal-driven,
/// not a hardcoded `1.5` constant.
///
/// **Bootstrap**: when `val_sharpe_var_ema` is sentinel (`0.0` — first
/// few epochs before the EMA is populated by early-stopping), the
/// anchor collapses to `0` and the function returns `current`
/// unchanged. Matches the cold-start "no signal yet" semantics
/// already used in T1.2 and `pearl_first_observation_bootstrap`.
fn compute_agreement_threshold(
    trades: &[EvalTrade],
    current: f32,
    val_sharpe_var_ema: f32,
) -> f32 {
    if trades.len() < 4 {
        return current;
    }
    // Bootstrap-safe: max(0,·) before sqrt — defensive against any
    // floating-point negatives in the upstream EMA (should be ≥ 0
    // by construction since it's a variance EMA, but the guard
    // costs ~1 instruction and avoids NaN cascades downstream).
    let val_sharpe_std = val_sharpe_var_ema.max(0.0).sqrt();

    // **Phase 8.1 (2026-05-11)** — signal-driven clamp bounds via
    // `val_sharpe_std`. The smoke training run for commit `1d2dd38a1`
    // produced 9 consecutive enrichment cycles where `agree_thr`
    // monotonically loosened from 1.30 → 10.00, hitting the upper
    // ceiling on cycle 9. Hardcoded `[0.01, 10.0]` bounds were the
    // last non-signal-driven anchor in this function.
    //
    // Bound expansion is multiplicative on a `scale ∈ [1.0, 5.0]`
    // factor derived from `val_sharpe_std`. The scale ITSELF is
    // bounded by an Invariant 1 stability carve-out (mirrors
    // `pearl_wiener_alpha_floor_for_nonstationary` — bounds on
    // bounds prevent runaway expansion under transient outliers in
    // the upstream variance EMA):
    //   - Cold-start (std ≈ 0): `scale = 1.0` → bounds unchanged
    //     `[0.01, 10.0]`, matches pre-Phase-8.1 behaviour.
    //   - Stable (std = 0.05): `scale ≈ 1.10` → bounds ≈
    //     `[0.009, 11.0]` — modest expansion.
    //   - Noisy (std = 0.30): `scale ≈ 1.60` → bounds ≈
    //     `[0.006, 16.0]` — appreciable headroom.
    //   - Very noisy (std ≥ 2.0): `scale = 5.0` clamped → bounds
    //     `[0.002, 50.0]` — Invariant 1 ceiling.
    //
    // The 2.0× multiplier on `val_sharpe_std` and the `[1.0, 5.0]`
    // clamp on `scale` are themselves hardcoded but explicitly
    // Invariant 1 carve-outs (numerical-stability bounds on the
    // bound formula, NOT controller anchors). Further signal-driving
    // them would require a meta-meta-bound and so on; the recursion
    // terminates at structural floors/ceilings per the same
    // discipline `pearl_wiener_alpha_floor_for_nonstationary`
    // applies to the Wiener-α floor.
    let scale = (1.0 + val_sharpe_std * 2.0).clamp(1.0, 5.0);
    let lo = 0.01 / scale;
    let hi = 10.0 * scale;

    if val_sharpe_std <= 1e-6 {
        // Cold start — ISV signal not yet populated. No update.
        // `scale = 1.0` here, so `lo = 0.01` and `hi = 10.0` —
        // matches pre-Phase-8.1 cold-start exactly.
        return current.clamp(lo, hi);
    }

    let mut pnls: Vec<f32> = trades.iter().map(|t| t.pnl).collect();
    pnls.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    let q1_end = pnls.len() / 4;
    let q4_start = 3 * pnls.len() / 4;
    let q1 = &pnls[..q1_end.max(1)];
    let q4 = &pnls[q4_start.min(pnls.len() - 1)..];
    let sharpe_of = |slice: &[f32]| -> f32 {
        let mean = slice.iter().sum::<f32>() / slice.len() as f32;
        let var = slice
            .iter()
            .map(|x| (x - mean).powi(2))
            .sum::<f32>()
            / slice.len() as f32;
        if var > 1e-12 {
            mean / var.sqrt()
        } else {
            0.0
        }
    };
    let q1_sharpe = sharpe_of(q1);
    let q4_sharpe = sharpe_of(q4);
    let spread = q4_sharpe - q1_sharpe;

    // ISV-driven anchor: spread > 1σ of val_sharpe noise = significant
    // separation between high-conviction and low-conviction trades →
    // tighten threshold (trust agreement more). Spread within the
    // noise floor → loosen threshold toward unity.
    //
    // **Phase 8 refactor (2026-05-11)** — tighten/loosen GAINS are
    // now ISV-driven too: `gain_mag = val_sharpe_std.clamp(0.05,
    // 0.30)` replaces the prior hardcoded 0.9 / 1.1 multipliers.
    // Controller responds more aggressively when val Sharpe is
    // noisier (large σ → ~30% adjustment) and gently when stable
    // (small σ → ~5% adjustment). Matches E2's adaptive-gain
    // pattern; same Invariant 1 carve-out justification for the
    // `[0.05, 0.30]` stability clamp.
    let gain_mag = val_sharpe_std.clamp(0.05, 0.30);
    let new = if spread > val_sharpe_std {
        current * (1.0 - gain_mag)   // tighten
    } else {
        current * (1.0 + gain_mag)   // loosen
    };
    new.clamp(lo, hi)
}

/// SP21 T2.2 Phase 5+6 (2026-05-11) — winner-decile P&L separation.
///
/// **Phase 8.4-fix (2026-05-12)** — rewrote from
/// `top_decile_mean / all_mean` (with losing-strategy guard) to
/// `(top_decile_mean - all_mean) / pnl_std` (z-score separation).
/// The old formula short-circuited to `0.0` whenever the policy was
/// losing (all_mean ≤ 0): v8 smoke (PF=0.94..1.01) had `win_conc=0`
/// across all 9 cycles even though the top decile clearly outperformed.
/// PER alpha boost path was dark for any policy that hadn't crossed
/// PF=1 — exactly the cold-start regime where the boost matters most.
///
/// New semantic: "how many `pnl_std`s above the overall mean does the
/// top decile sit?" Bounded `[0, ∞)`, scale-invariant via `pnl_std`,
/// works for both winning and losing policies (top decile is by
/// construction above the mean, even when the mean is negative).
///
/// Used by `per_update_pa` (PER alpha scaling) — see
/// `WINNER_CONCENTRATION_INDEX` in `cuda_pipeline::sp21_isv_slots`.
///
/// Cold-start sentinel: empty trades OR `pnl_std=0` → `0.0` (no
/// signal, no boost). The `.max(0.0)` floor is defensive — by
/// construction `top_decile_mean ≥ all_mean`, so the raw separation
/// is non-negative anyway.
fn compute_winner_concentration(trades: &[EvalTrade], pnl_std: f32) -> f32 {
    if trades.is_empty() || pnl_std <= 0.0 {
        return 0.0;
    }
    let mut sorted: Vec<f32> = trades.iter().map(|t| t.pnl).collect();
    sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
    let top_n = (sorted.len() / 10).max(1);
    let top_mean: f32 = sorted[..top_n].iter().sum::<f32>() / top_n as f32;
    let all_mean: f32 = sorted.iter().sum::<f32>() / sorted.len() as f32;
    ((top_mean - all_mean) / pnl_std).max(0.0)
}

/// SP21 T2.2 Phase 5+6 (2026-05-11) — hindsight magnitude.
///
/// Mean of `|counterfactual_pnl|` across all hindsight experiences.
/// Higher magnitude = larger missed opportunities (the policy
/// produced losing trades whose optimal counterfactual would have
/// been substantially profitable). Used by `per_update_pa` —
/// see `HINDSIGHT_MAGNITUDE_INDEX` in `cuda_pipeline::sp21_isv_slots`.
fn compute_hindsight_magnitude(hindsight: &[HindsightExperience]) -> f32 {
    if hindsight.is_empty() {
        return 0.0;
    }
    hindsight.iter().map(|h| h.counterfactual_pnl.abs()).sum::<f32>()
        / hindsight.len() as f32
}

/// SP21 T2.2 Phase 7 (2026-05-11) — curriculum-weight concentration.
///
/// **Phase 8.4 (2026-05-12)** — replaced entropy-deficit
/// (`1 - entropy/log(n)`) with **normalized coefficient of variation**
/// `CV(weights) / sqrt(n − 1)`. The entropy-deficit was too compressive
/// near the uniform regime: with 8 contiguous segments of a healthy
/// policy, per-segment Sharpes hover in a tight band, normalised
/// weights stay within ~0.5% of `1/n`, and the deficit collapses
/// to <1e-4. v6 smoke confirmed this: across 9 cycles, only cycle 9
/// reached 0.0001 — the signal was technically alive but functionally
/// dark for `per_update_pa` alpha boost composition.
///
/// CV is more sensitive in the near-uniform regime:
/// - uniform weights → CV=0 → concentration=0 (cold-start preserved)
/// - one-hot weights → CV=sqrt(n−1) → concentration=1
/// - tiny segment-skew → CV proportional to (weight_std / weight_mean),
///   which scales like `sqrt(n)` × normalized-weight-std rather than
///   entropy-deficit's `~weight_std²` near uniform.
///
/// Used by `per_update_pa` for PER alpha boost composition (Phase 7).
/// Cold start: empty input → 0.0 sentinel (no-op per
/// `pearl_first_observation_bootstrap`).
fn compute_curriculum_concentration(weights: &[f32]) -> f32 {
    if weights.is_empty() {
        return 0.0;
    }
    let n = weights.len() as f32;
    if n < 2.0 {
        // Single-segment curriculum is trivially "concentrated"; return 1.0.
        return 1.0;
    }
    let mean = weights.iter().sum::<f32>() / n;
    if mean <= 1e-12 {
        // Degenerate (all-zero weights) — no signal.
        return 0.0;
    }
    let var: f32 = weights
        .iter()
        .map(|&w| (w - mean).powi(2))
        .sum::<f32>()
        / n;
    let std = var.max(0.0).sqrt();
    let cv = std / mean;
    // Theoretical max CV for n-segment one-hot weights: sqrt(n − 1).
    // Normalise to [0, 1] then clamp for floating-point safety.
    let max_cv = (n - 1.0).sqrt();
    if max_cv <= 1e-12 {
        return 0.0;
    }
    (cv / max_cv).clamp(0.0, 1.0)
}

/// E6: Winner distillation — bar indices of top-10% trades by P&L.
fn compute_winner_indices(trades: &[EvalTrade]) -> Vec<usize> {
    if trades.is_empty() {
        return Vec::new();
    }
    let mut sorted: Vec<(usize, f32)> = trades
        .iter()
        .enumerate()
        .map(|(i, t)| (i, t.pnl))
        .collect();
    sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
    let top_n = (sorted.len() / 10).max(1);
    sorted[..top_n]
        .iter()
        .map(|(i, _)| trades[*i].bar_index as usize)
        .collect()
}

/// E7: Hindsight optimal labels for losing trades.
///
/// **Phase 8.2 (2026-05-11)** — losing-trade filter threshold is
/// signal-driven (`-0.5 × pnl_std`) instead of hardcoded `-0.001`.
/// Volume bars produce per-trade `pnl` in `1e-7..1e-5` range — the
/// constant filter never matched, so hindsight emitted `0` synthetic
/// experiences every cycle (the Phase 6.5 injection path was dark).
/// `0.5 × pnl_std` selects trades that lost at least half a typical
/// trade-magnitude (clearly losing rather than noise), bar-resolution
/// invariant per `pearl_controller_anchors_isv_driven`.
fn compute_hindsight_labels(trades: &[EvalTrade], pnl_std: f32) -> Vec<HindsightExperience> {
    let take_n = (trades.len() / 10).max(1);
    // Sentinel guard: pnl_std=0 (degenerate or pre-warmup) → fall back
    // to a tiny absolute floor (-1e-9). Any negative pnl will pass,
    // including legitimate sub-bps losers; once pnl_std > 0 the
    // adaptive threshold dominates.
    let threshold = (-0.5 * pnl_std).min(-1e-9);
    trades
        .iter()
        .filter(|t| t.pnl < threshold)
        .take(take_n)
        .map(|t| HindsightExperience {
            window_index: t.window_index,
            bar_index: t.bar_index as usize,
            optimal_direction: if t.direction == 2 {
                0
            } else if t.direction == 0 {
                2
            } else {
                1
            },
            optimal_magnitude: 0,
            counterfactual_pnl: (-t.pnl).max(0.0),
        })
        .collect()
}

/// E8: Curriculum weights — z-score-normalised exp(-sharpe) per segment.
///
/// **Phase 8.6 (2026-05-12)** — rewrote from `1/sharpe.max(0.01)` to
/// `exp(-(sharpe - mean_sharpe) / std_sharpe)`. The old formula
/// saturated at the upper clamp (100.0) whenever per-segment Sharpe
/// was near zero or negative — and with `pnl_mean ≈ 1e-7` for a
/// PF<1 policy, EVERY segment had `|sharpe| < 0.01` → all saturated
/// to 100 → uniform weights → CV=0. v8 cycle 1 (commit 5694eb4df)
/// confirmed `curric_conc=0.0000` despite the wider Phase 8.2 clamp
/// and the more sensitive Phase 8.4 CV formula.
///
/// The new formula is z-score-normalised on per-segment Sharpe:
///
///   w_i = exp(-(sharpe_i - sharpe_mean) / sharpe_std.max(eps))
///
/// Properties:
///   - **Scale-invariant**: z-score normalisation removes the
///     per-segment Sharpe magnitude (the killer of the old formula
///     near zero).
///   - **Monotonic & smooth**: weight grows as Sharpe drops below
///     the segment-mean (harder segment → higher curriculum weight),
///     no saturation cliff.
///   - **Differential-difficulty preserved at any scale**: weights
///     spread regardless of whether per-segment Sharpes are
///     `~1.0` (healthy) or `~1e-3` (degenerate cold-start).
///   - **Uniform-input handled**: `sharpe_std → 0` falls back to
///     uniform weights (correct cold-start sentinel).
///
/// z-clamp at [-3, 3] before exp keeps weights in `[exp(-3), exp(3)] =
/// [0.05, 20]` — well-conditioned for normalisation without losing
/// the differential signal.
///
/// Per `pearl_controller_anchors_isv_driven` and
/// `feedback_isv_for_adaptive_bounds`, the normalisation anchor is
/// the observed cycle-scoped `sharpe_std` (signal-driven), not a
/// hardcoded constant.
fn compute_curriculum_weights(trades: &[EvalTrade], n_segments: usize) -> Vec<f32> {
    if trades.is_empty() || n_segments == 0 {
        return Vec::new();
    }
    let seg_size = (trades.len() / n_segments).max(1);

    // Pass 1: per-segment Sharpe.
    let sharpes: Vec<f32> = (0..n_segments)
        .map(|seg| {
            let start = seg * seg_size;
            let end = ((seg + 1) * seg_size).min(trades.len());
            if start >= trades.len() {
                return 0.0;
            }
            let slice = &trades[start..end];
            let mean: f32 =
                slice.iter().map(|t| t.pnl).sum::<f32>() / slice.len().max(1) as f32;
            let var: f32 = slice
                .iter()
                .map(|t| (t.pnl - mean).powi(2))
                .sum::<f32>()
                / slice.len().max(1) as f32;
            mean / var.sqrt().max(1e-6)
        })
        .collect();

    let n = sharpes.len() as f32;
    let sharpe_mean: f32 = sharpes.iter().sum::<f32>() / n;
    let sharpe_var: f32 = sharpes
        .iter()
        .map(|s| (s - sharpe_mean).powi(2))
        .sum::<f32>()
        / n;
    let sharpe_std = sharpe_var.sqrt().max(1e-6);

    // Pass 2: z-score normalised exp weights.
    let mut weights: Vec<f32> = sharpes
        .iter()
        .map(|&s| {
            // Negate so HARDER segments (lower Sharpe) get larger weights.
            let z = -(s - sharpe_mean) / sharpe_std;
            // z-clamp to keep exp range well-conditioned.
            z.clamp(-3.0, 3.0).exp()
        })
        .collect();
    let total: f32 = weights.iter().sum();
    if total > 1e-6 {
        for w in &mut weights {
            *w /= total;
        }
    }
    weights
}

/// SP21 T2.2 Phase 8.2 (2026-05-11) — per-trade pnl standard deviation.
///
/// Single-pass std computed via Welford-style numerically-stable
/// algorithm over the eval-trade tape's `pnl` column. Used as the
/// signal-driven scale for adaptive thresholds in
/// `compute_winner_concentration` and `compute_hindsight_labels`,
/// honouring `feedback_isv_for_adaptive_bounds`.
///
/// Returns `0.0` for empty input. With single-element input returns
/// `|pnl|` as a coarse magnitude (variance is mathematically zero
/// but the producers need a non-zero scale to escape sentinel).
fn compute_pnl_std(trades: &[EvalTrade]) -> f32 {
    if trades.is_empty() {
        return 0.0;
    }
    if trades.len() == 1 {
        return trades[0].pnl.abs();
    }
    let n = trades.len() as f32;
    let mean: f32 = trades.iter().map(|t| t.pnl).sum::<f32>() / n;
    let var: f32 = trades
        .iter()
        .map(|t| (t.pnl - mean).powi(2))
        .sum::<f32>()
        / n;
    var.max(0.0).sqrt()
}

// ---------------------------------------------------------------------------
// Main dispatcher
// ---------------------------------------------------------------------------

/// Run all 8 enrichment functions, update persistent state, and return
/// results. `val_sharpe_var_ema` comes from
/// `ISV[VAL_SHARPE_VAR_EMA_INDEX=351]` per call — caller reads it once
/// from the trainer's ISV signals (same slot the early-stopping
/// pipeline produces in `metrics.rs:442`) and threads it in as the
/// E5 noise-floor anchor.
pub(crate) fn run_enrichments(
    state: &mut EnrichmentState,
    eval_trades: &[EvalTrade],
    val_sharpe: f32,
    val_sharpe_var_ema: f32,
) -> EnrichmentResult {
    state.cycle_count += 1;

    // SP21 T2.2 Phase 8.2 (2026-05-11): cycle-scoped pnl_std, used as
    // the data-scale anchor for E6/E7 thresholds (replaces bar-
    // resolution-dependent constants 1e-6 and -1e-3). Computed once
    // per cycle and threaded through to producers; honours
    // `pearl_controller_anchors_isv_driven`.
    let pnl_std = compute_pnl_std(eval_trades);

    // E1: Q-value bias correction
    let q_correction = compute_q_correction(eval_trades);

    // E2: Adaptive epsilon (SP21 Phase 8: gains+anchors signal-driven
    // via val_sharpe_var_ema, same ISV slot E5 uses for its anchor).
    let epsilon = compute_adaptive_epsilon(state.epsilon, val_sharpe, val_sharpe_var_ema);
    state.epsilon = epsilon;

    // E3: Dynamic gamma
    let gamma = compute_dynamic_gamma(eval_trades);

    // E4: Per-branch LR scaling
    let branch_lr_scale = compute_branch_lr_scale(eval_trades);

    // E5: Ensemble agreement threshold
    let agreement_threshold = compute_agreement_threshold(
        eval_trades,
        state.agreement_threshold,
        val_sharpe_var_ema,
    );
    state.agreement_threshold = agreement_threshold;

    // E6: Winner distillation
    let winner_indices = compute_winner_indices(eval_trades);
    // SP21 T2.2 Phase 5+6 (2026-05-11) — scalar signals for PER alpha
    // scaling, derived from the same per-trade tape that feeds E6/E7.
    // Phase 8.2 (2026-05-11): pnl_std threads through as data-scale
    // anchor — replaces the hardcoded 1e-6 short-circuit.
    let winner_concentration = compute_winner_concentration(eval_trades, pnl_std);

    // E7: Hindsight labels
    // Phase 8.2 (2026-05-11): adaptive `-0.5×pnl_std` loser filter
    // replaces the hardcoded `-1e-3` (which silently excluded all
    // volume-bar losers).
    let hindsight = compute_hindsight_labels(eval_trades, pnl_std);
    let hindsight_magnitude = compute_hindsight_magnitude(&hindsight);

    // E8: Curriculum weights
    let curriculum_weights = compute_curriculum_weights(eval_trades, state.n_segments);
    // SP21 T2.2 Phase 7 (2026-05-11) — scalar concentration signal
    // derived from the curriculum-weight entropy. Feeds PER alpha boost
    // alongside E6/E7 signals (see WINNER_CONCENTRATION_INDEX,
    // HINDSIGHT_MAGNITUDE_INDEX in cuda_pipeline::sp21_isv_slots).
    let curriculum_concentration = compute_curriculum_concentration(&curriculum_weights);

    info!(
        "Enrichment cycle {} complete: q_corr={:.4}, eps={:.4}, gamma={:?}, \
         branch_lr={:?}, agree_thr={:.4}, pnl_std={:.2e}, winners={}, win_conc={:.4}, \
         hindsight={}, hindsight_mag={:.2e}, curriculum_segs={}, curric_conc={:.4}",
        state.cycle_count,
        q_correction,
        epsilon,
        gamma,
        branch_lr_scale,
        agreement_threshold,
        pnl_std,
        winner_indices.len(),
        winner_concentration,
        hindsight.len(),
        hindsight_magnitude,
        curriculum_weights.len(),
        curriculum_concentration,
    );

    EnrichmentResult {
        q_correction,
        epsilon,
        gamma,
        branch_lr_scale,
        agreement_threshold,
        winner_indices,
        winner_concentration,
        hindsight,
        hindsight_magnitude,
        curriculum_weights,
        curriculum_concentration,
    }
}
