#![allow(unsafe_code)] // Required for safetensors f32-to-u8 slice serialization
//! DQN Trainer Implementation
//!
//! Main training loop and execution logic for Deep Q-Network.

use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;

use anyhow::{Context, Result};
use ml_core::device::MlDevice;
use cudarc::driver::{CudaEvent, CudaSlice, CudaStream};
use crate::cuda_pipeline::DqnGpuData;
use crate::cuda_pipeline::gpu_experience_collector::TradeStats;
use ml_core::fill_simulator::FillSimulator;
use risk::drawdown_monitor::DrawdownMonitor;
use risk::safety::position_limiter::HybridPositionLimiter;
use tokio::sync::RwLock;
use tracing::info;
use uuid::Uuid;

/// OFI dimension — mirrors `ml_core::state_layout::OFI_DIM`.
const OFI_DIM: usize = ml_core::state_layout::OFI_DIM;

use crate::dqn::action_space::FactoredAction;
use crate::dqn::circuit_breaker::CircuitBreaker;
use crate::dqn::curiosity::CuriosityModule;
use crate::dqn::logging::{LoggingConfig, MetricsAggregator};
use crate::dqn::portfolio_tracker::PortfolioTracker;
use crate::dqn::reward::RewardFunction;
use crate::TrainingMetrics;
use crate::features::extraction::FeatureVector;
use crate::features::microstructure_features::*;
use crate::labeling::triple_barrier::TripleBarrierEngine;

use super::config::{DQNAgentType, DQNHyperparameters};
use super::statistics::{FeatureStatistics, QValueStats};
use super::StateResetRegistry;

mod action;
mod metrics;
mod state;
mod constructor;
mod training_loop;
pub(crate) mod enrichment;
pub(crate) mod monitoring;

#[cfg(test)]
mod tests;

/// Saved training state for trajectory backtracking.
pub(crate) struct TrajectoryCheckpoint {
    pub epoch: usize,
    pub val_sharpe: f32,                   // val_Sharpe at save time — used for ranking
    pub improvement_rate: f32,             // d(val_Sharpe)/d(epoch) at save time
    pub params_gpu: CudaSlice<f32>,        // DtoD copy of params_buf — GPU resident
    pub target_params_gpu: CudaSlice<f32>, // DtoD copy of target_params_buf — GPU resident
    pub adam_step: i32,                    // optimizer step counter
    pub eval_q_mean_ema: f32,
    pub eval_q_std_ema: f32,
    pub per_branch_q_gap_ema: [f32; 4],
}

/// Perturbation strategy for a rewind route.
#[derive(Debug, Clone, Copy)]
pub(crate) enum PerturbationStrategy {
    /// Reset Adam momentum (m=0, v=0) -- fresh gradient estimates
    ResetAdam,
    /// Shrink-perturb branch heads only (sigma=0.1)
    ShrinkPerturbBranches,
    /// Temperature boost (2x Boltzmann + SARSA tau for 5 epochs, then cool)
    TemperatureBoost,
    /// Learning rate x2 for 10 epochs then restore
    LearningRateBoost,
}

/// Backtracking state machine.
pub(crate) struct BacktrackingState {
    /// Top-3 checkpoints by improvement rate.
    pub checkpoints: Vec<TrajectoryCheckpoint>,
    /// Maximum checkpoints to keep.
    pub max_checkpoints: usize,
    /// Current rewind count (max 3).
    pub rewind_count: usize,
    /// Max rewinds before PLATEAU_EXHAUSTED.
    pub max_rewinds: usize,
    /// Current route index within a rewind (0-3).
    pub route_idx: usize,
    /// Routes per rewind (4 perturbation strategies).
    pub routes_per_rewind: usize,
    /// Consecutive plateau epochs (Q-gap velocity ~ 0).
    pub plateau_epochs: usize,
    /// Epochs required to trigger a rewind.
    pub plateau_threshold: usize,
    /// Minimum epochs between rewinds.
    pub min_rewind_interval: usize,
    /// Epoch of last rewind.
    pub last_rewind_epoch: usize,
    /// Whether a route is currently active (post-rewind trial).
    pub route_active: bool,
    /// Epoch when the current route started.
    pub route_start_epoch: usize,
    /// Epochs to evaluate a route before deciding.
    pub route_eval_epochs: usize,
    /// Val Sharpe at route start (to measure improvement).
    pub route_start_sharpe: f32,
    /// Minimum improvement rate to consider a route successful.
    pub min_improvement_rate: f32,
    /// Best val_Sharpe seen across all attempts.
    pub best_sharpe: f32,
    /// Epoch of best val_Sharpe.
    pub best_epoch: usize,
    /// Temperature boost active (decrements each epoch).
    pub temp_boost_remaining: usize,
    /// LR boost active (decrements each epoch).
    pub lr_boost_remaining: usize,
    /// Original LR (saved before LR boost).
    pub original_lr: f64,
    /// Previous epoch's val_Sharpe for freeze detection (NOT training Sharpe).
    pub prev_val_sharpe: f32,
    /// Cosine LR epoch offset — reset on rewind so LR schedule restarts.
    pub cosine_epoch_offset: usize,
}

impl BacktrackingState {
    pub(crate) fn new() -> Self {
        Self {
            checkpoints: Vec::new(),
            max_checkpoints: 3,
            rewind_count: 0,
            max_rewinds: 3,
            route_idx: 0,
            routes_per_rewind: 4,
            plateau_epochs: 0,
            plateau_threshold: 5,  // 5 consecutive plateau epochs
            min_rewind_interval: 10,
            last_rewind_epoch: 0,
            route_active: false,
            route_start_epoch: 0,
            route_eval_epochs: 10,
            route_start_sharpe: 0.0,
            min_improvement_rate: 0.1,  // min 0.1 val_Sharpe/epoch improvement
            best_sharpe: f32::NEG_INFINITY,
            best_epoch: 0,
            prev_val_sharpe: 0.0,
            cosine_epoch_offset: 0,
            temp_boost_remaining: 0,
            lr_boost_remaining: 0,
            original_lr: 0.0,
        }
    }

    /// The 4 perturbation strategies, tried in order.
    pub(crate) fn current_strategy(&self) -> PerturbationStrategy {
        match self.route_idx % 4 {
            0 => PerturbationStrategy::ResetAdam,
            1 => PerturbationStrategy::ShrinkPerturbBranches,
            2 => PerturbationStrategy::TemperatureBoost,
            _ => PerturbationStrategy::LearningRateBoost,
        }
    }

    /// Check if all rewinds and routes are exhausted.
    pub(crate) fn is_exhausted(&self) -> bool {
        self.rewind_count >= self.max_rewinds && self.route_idx >= self.routes_per_rewind
    }
}

/// Task 0.9 — prior-epoch values for adaptive-controller fire detection.
/// NaN sentinel means "no prior epoch" — first real value treated as fire.
#[derive(Debug, Clone, Copy)]
pub(crate) struct ControllerPrevValues {
    pub lr: f64,
    pub tau: f32,
    pub gamma: f32,
    pub grad_clip: f32,
    pub cql_alpha: f32,
    pub cost_anneal: f32,
}

impl Default for ControllerPrevValues {
    fn default() -> Self {
        Self {
            lr: f64::NAN,
            tau: f32::NAN,
            gamma: f32::NAN,
            grad_clip: f32::NAN,
            cql_alpha: f32::NAN,
            cost_anneal: f32::NAN,
        }
    }
}

/// Task 0.9 — running count of how many epochs each controller fired
/// (i.e. changed the value it controls).
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct ControllerFireCounts {
    pub anti_lr: u32,
    pub tau: u32,
    pub gamma: u32,
    pub grad_clip: u32,
    pub cql_alpha: u32,
    pub cost_anneal: u32,
}

/// Boxed checkpoint callback — owns its closure data, can be moved into
/// async tasks. The 'static bound is required by `tokio::task::spawn` for
/// the async-checkpoint worker; all production callers use `move` closures
/// over owned state (PathBuf, trial_id, fold_idx) and are already 'static.
pub(crate) type BoxedCheckpointCallback =
    Box<dyn FnMut(usize, Vec<u8>, bool) -> Result<String> + Send + 'static>;

/// Shared checkpoint callback handle. Wrapped in an Arc<Mutex<>> so the
/// async-checkpoint worker can hold a clone alongside the synchronous
/// epoch-loop callsites (early-stop, periodic, plateau-exhausted) which
/// still invoke the callback inline. The mutex is `std::sync::Mutex` (sync
/// lock, not tokio) because the worker is `tokio::task::spawn_blocking`-
/// wrapped CPU work that cannot `.await`. Lock contention is structural
/// zero — the worker holds the lock only while invoking the callback;
/// the main thread invokes only on cold paths (periodic / early-stop)
/// that are mutually exclusive with the worker by epoch.
pub(crate) type CheckpointCallbackHandle =
    Arc<std::sync::Mutex<BoxedCheckpointCallback>>;

/// Per-tensor metadata captured at snapshot time. Holds the f32 offset
/// (within the snapshot's flat byte buffer), element count, shape, and the
/// safetensors entry name. The f32_offset is computed by accumulating
/// element counts in `named_weight_slices` order; multiplied by
/// `sizeof(f32)` to derive the byte offset in `host_bytes`.
#[derive(Debug, Clone)]
pub(crate) struct TensorSnapshotEntry {
    pub(crate) name: String,
    pub(crate) f32_offset: usize,
    pub(crate) f32_len: usize,
    pub(crate) shape: Vec<usize>,
}

/// A self-contained snapshot of the BranchingDuelingQNetwork's named
/// weight tensors at one instant in training. `Send + 'static` — safe to
/// move into `tokio::task::spawn_blocking` for off-thread safetensors
/// construction.
///
/// The `host_bytes` come from a mapped-pinned host buffer that the GPU
/// wrote into via `cuMemcpyDtoDAsync` to its aliased device pointer
/// (`cuMemHostAlloc DEVICEMAP`). The pinned buffer is dropped after the
/// snapshot is built; `host_bytes` owns the data.
#[derive(Debug)]
pub(crate) struct CheckpointSnapshot {
    pub(crate) tensors: Vec<TensorSnapshotEntry>,
    pub(crate) host_bytes: Vec<u8>,
    pub(crate) arch_metadata: Option<std::collections::HashMap<String, String>>,
}

pub struct DQNTrainer {
    /// DQN agent
    pub(crate) agent: Arc<RwLock<DQNAgentType>>,
    /// Training hyperparameters
    pub(crate) hyperparams: DQNHyperparameters,
    /// MlDevice (GPU or CPU)
    pub(crate) device: MlDevice,
    /// Single forked CudaStream shared by ALL GPU components (experience collector,
    /// fused trainer, portfolio sim, monitoring). Forked once in constructor —
    /// eliminates dual-stream event tracking conflicts and Candle default stream
    /// usage in the hot path. None on CPU devices.
    pub(crate) cuda_stream: Option<Arc<CudaStream>>,
    /// Dedicated CUDA stream for async validation (overlaps with next epoch).
    pub(crate) validation_stream: Option<Arc<CudaStream>>,
    /// Pending validation result from previous epoch (computed asynchronously).
    pub(crate) pending_val_loss: Option<f64>,
    /// Cached async validation loss from previous epoch (one-epoch delay).
    pub(crate) cached_async_val_loss: Option<f64>,
    /// Training metrics
    pub(crate) metrics: Arc<RwLock<TrainingMetrics>>,
    /// Loss history for plateau detection
    pub(crate) loss_history: Vec<f64>,
    /// Q-value history for floor detection
    pub(crate) q_value_history: Vec<f64>,
    /// Best validation loss achieved so far
    pub(crate) best_val_loss: f64,
    /// Validation data for computing validation loss
    pub(crate) val_data: Vec<(FeatureVector, Vec<f64>)>,
    /// Validation loss history for early stopping
    pub(crate) val_loss_history: Vec<f64>,
    /// C4: Sharpe history for Sharpe-based early stopping
    pub(crate) sharpe_history: Vec<f64>,
    /// C4: Best Sharpe ratio achieved so far (higher = better)
    pub(crate) best_sharpe: f64,
    /// Epoch with best validation loss
    pub(crate) best_epoch: usize,
    /// Step counter for gradient logging (logs every 10 steps)
    pub(crate) gradient_logging_step: usize,
    /// Original buffer_size before AutoReplaySizer (for gradient collapse warmup)
    pub(crate) collapse_warmup_buffer_size: usize,
    /// Portfolio state tracker for P&L-based rewards (Bug #2 fix)
    pub portfolio_tracker: PortfolioTracker,
    /// Feature normalization statistics (WAVE 3 FIX #2)
    /// None during stats collection phase (epochs 0-10), Some during normalization phase (epochs 11+)
    pub feature_stats: Option<FeatureStatistics>,
    /// Sliding window of recent actions for reward calculation (max 100)
    pub(crate) recent_actions: VecDeque<FactoredAction>,
    /// Reward function for calculating rewards with recent actions
    pub(crate) reward_fn: RewardFunction,

    // WAVE 16S: Adaptive Risk Management Components
    /// Kelly criterion optimizer for position sizing (None if disabled)
    pub(crate) kelly_optimizer: Option<Arc<crate::risk::kelly_optimizer::KellyCriterionOptimizer>>,
    /// Trade history for Kelly calculation (wins/losses)
    pub(crate) trade_history: VecDeque<f64>,
    /// Volatility tracker for epsilon adjustment (None if disabled)
    pub(crate) volatility_returns: VecDeque<f64>,
    /// Per-epoch trade statistics from GPU portfolio_states reduction.
    /// Replaces pnl_history — real trade metrics instead of averaged rewards.
    pub(crate) trade_stats_history: Vec<TradeStats>,

    /// Training Sharpe EMA — diagnostic signal formerly used by deleted
    /// w_dsr reward term. Now lives as a monitoring metric in HEALTH_DIAG
    /// for model selection, early stopping, and exploration gating. Not
    /// added to reward (see 2026-04-21-phase1-reward-inventory.md).
    pub(crate) training_sharpe_ema: f32,
    /// Whether training_sharpe_ema has been initialized.
    pub(crate) training_sharpe_ema_initialized: bool,
    /// Per-epoch action-distribution entropy (normalized to [0,1]).
    /// Diagnostic signal formerly used by deleted position_entropy_weight
    /// reward. Low entropy = policy narrowing; surfaced in HEALTH_DIAG and
    /// available as a gate for plasticity injection (shrink_perturb).
    pub(crate) last_action_entropy: Option<f32>,

    /// Task 0.3 — per-magnitude action distribution from the last epoch that
    /// executed. Used by magnitude_distribution smoke test to assert the
    /// policy uses Half/Full magnitudes, not just Quarter. Layout: [quarter, half, full]
    /// summed across all direction bins, normalized to [0, 1].
    pub(crate) last_magnitude_dist: [f32; 3],

    /// Plan C Task 5 — fraction of training-rollout actions where direction ∈
    /// {Short, Long} (i.e. the policy committed to a directional bet rather
    /// than Hold/Flat). Counterpart to the kernel-derived val_active_frac
    /// emitted alongside the val [...] block in `consume_validation_loss`.
    /// Required for Plan D's L3 verification gate (target > 0.40 = at least
    /// 40% of training-rollout actions are Long or Short, evidence Thompson
    /// is generating directional exploration during training).
    ///
    /// SP8 (Fix 36, 2026-05-03): canary value migrated to GPU. The
    /// `train_active_frac` signal now lives at `ISV[TRAIN_ACTIVE_FRAC_INDEX]`
    /// (Pearls A+D smoothed; produced by
    /// `train_active_frac_compute_kernel` after each epoch's monitoring
    /// reduce). Read via `last_train_active_frac()` accessor below — no
    /// host-side cached field is kept per `feedback_no_cpu_compute_strict.md`.
    /// Pre-Fix-36 the host computed this from `monitor.action_counts[12]`
    /// at the deleted site `training_loop.rs:3392-3396`.

    /// Task 0.10 — per-epoch magnitude-branch action entropy history. Each
    /// entry is (epoch_0_indexed, normalized_entropy). Used by the
    /// exploration_coverage smoke test to assert the magnitude branch
    /// doesn't collapse to a single bin too fast.
    pub(crate) explore_entropy_mag_history: Vec<(u32, f32)>,

    /// Task 0.7 — per-magnitude action distribution observed during the
    /// most recent VALIDATION backtest (eval-mode rollout, distinct from
    /// training-mode action_dist). H10 detection signal: training-mode
    /// entropy may look uniform while eval-mode argmax collapses to Quarter.
    /// Layout: [Quarter, Half, Full] in [0, 1].
    pub(crate) last_eval_magnitude_dist: [f32; 3],

    /// Pure-policy magnitude-intent distribution from the most recent
    /// VALIDATION backtest (eval-mode rollout), read from the parallel
    /// `intent_mag_buf` populated by the action-select kernel BEFORE the
    /// Hold/Flat dir_idx forcing and BEFORE Kelly/margin caps mask the
    /// realised `actual_mag` inside env_step. Layout: [Quarter, Half, Full]
    /// in [0, 1]. Diagnostic-only — used by the magnitude_distribution
    /// smoke test to surface what the magnitude head WOULD pick independent
    /// of the Kelly cold-start cap (kelly_position_cap warmup_floor=0.5 +
    /// safety=0.5+0.5×health pins abs_pos ≤ 0.375×max_pos).
    pub(crate) last_eval_intent_magnitude_dist: [f32; 3],

    /// Plan 5 Task 5 Phase A — flat aggregate of validation-backtest metrics
    /// from the most recent `compute_validation_loss` invocation. Sourced from
    /// the GPU `WindowMetrics` reduction (single-window val path); kept on the
    /// trainer so the next HEALTH_DIAG emit can publish them as a `val [...]`
    /// block consumable by `scripts/aggregate-multi-seed-metrics.py` + the
    /// Plan 5 Task 4 tier-2/tier-3 check scripts.
    ///
    /// Layout (matches the HEALTH_DIAG `val [...]` key order — see
    /// `metrics.rs::compute_validation_loss`):
    ///   `[sharpe_annualised, sortino, win_rate, max_drawdown, trade_count,
    ///     calmar, omega_ratio, total_pnl, var_95, cvar_95,
    ///     buy_count, sell_count, hold_count, window_bars]`.
    ///
    /// `None` until the first validation backtest finishes (epoch 0 with
    /// async stream, or epoch 0 sync). `[bars_per_day]` is implied as a
    /// hyperparameter; downstream emitters derive `trades_per_bar`,
    /// `active_frac`, `dir_entropy`, and `profit_factor` from these 14 floats
    /// without re-running the kernel.
    pub(crate) last_val_metrics: Option<[f32; 14]>,

    /// Task 0.9 — prior-epoch values of adaptive-controller outputs, used to
    /// detect "fire" = value changed from the prior epoch. Controllers audited:
    /// anti-LR, adaptive tau, adaptive gamma, adaptive grad-clip, CQL alpha
    /// schedule, cost anneal schedule. Initialized to sentinel NaN so the
    /// first epoch's comparison treats any non-NaN value as a fire.
    pub(crate) prev_controller_values: ControllerPrevValues,

    /// Task 0.9 — running per-controller fire counts + total-epochs counter.
    /// Used by controller_activity smoke test to assert no controller fires in
    /// > 50% of epochs (load-bearing controllers are a production risk).
    pub(crate) controller_fire_counts: ControllerFireCounts,
    pub(crate) controller_total_epochs: u32,

    /// Per-epoch latch: did the adaptive grad-clip controller actually CLAMP a
    /// gradient this epoch (i.e. raw_grad_norm > adaptive_clip_value at some
    /// training step)? This is the V7-audit definition of a "fire": adaptive
    /// grad clip fires iff it made an intervention, not merely because its
    /// EMA-smoothed output value ticked. Set true in `run_training_steps_slices`
    /// when the guard observation shows a clamp, reset in `reset_epoch_state`.
    pub(crate) grad_clip_kicked_this_epoch: bool,

    /// Task 0.8 — previous-epoch PopArt variance reading used to compute the
    /// HEALTH_DIAG reward_contrib[popart] drift signal. Kept separate from
    /// FusedTrainingCtx::prev_popart_var (which the tau controller mutates on
    /// its own cadence) so the two readings don't interfere. Updated once per
    /// epoch in the HEALTH_DIAG block.
    pub(crate) prev_reward_contrib_popart_var: f32,

    /// Task 0.8 — most recent reward-term contribution summary [popart,
    /// cf_flip, trail, micro]. Updated once per HEALTH_DIAG emission; exposed
    /// via reward_component_audit_summary for the Track 2 smoke test.
    /// `segment_patience` slot removed (Task 0.8) — the kernel never wired a
    /// patience term, so the slot was a stub.
    /// `loss_aversion` slot removed (Task 2.4) — R6 clamp relocated to
    /// c51_loss_kernel.cu::block_bellman_project_f so the reward-layer
    /// diagnostic has no source signal.
    pub(crate) last_reward_contrib: [f32; 4],

    // Wave 16 Portfolio Features (action masking is always active).
    /// Maximum position size for action masking (default: 2.0)
    pub max_position: f64,
    /// Current epoch (for Q-gap warm-up ramp)
    pub(crate) current_epoch: usize,

    // SP14 L1+L2 follow-up (2026-05-05): `fold_step_counter` field deleted
    // per `feedback_isv_for_adaptive_bounds`. Was driving the
    // `warmup_gate = clamp(fold_step_counter / WARMUP_STEPS_FALLBACK, 0, 1)`
    // ramp passed to `launch_sp14_alpha_grad_compute`. Both the counter
    // and the warmup_gate parameter are gone — variance-driven k_aux/k_q
    // (ISV[388, 389]) provide warmup behavior intrinsically.
    /// Entropy regularizer for preventing policy collapse (None if disabled)
    // entropy_regularizer removed — SAC-style entropy is computed directly on Q-value tensors in DQN::compute_loss_internal
    /// Multi-asset portfolio tracker (None if single-asset mode)
    pub multi_asset_portfolio: Option<Arc<crate::dqn::multi_asset::MultiAssetPortfolioTracker>>,
    /// Stress tester for robustness validation (None if disabled)
    pub stress_tester: Option<Arc<crate::dqn::stress_testing::DQNStressTester>>,

    // Wave 16 Core Risk Features Integration
    /// Drawdown monitor for tracking portfolio drawdowns (15% max drawdown)
    pub drawdown_monitor: Option<Arc<DrawdownMonitor>>,
    /// Position limiter with 3-tier limits (±10.0 absolute, 1M notional, 10% concentration)
    pub position_limiter: Option<Arc<HybridPositionLimiter>>,
    /// Circuit breaker for stopping training on consecutive failures
    pub circuit_breaker: Option<Arc<CircuitBreaker>>,

    // WAVE 3.10: Microstructure Feature Calculators (12 features)
    pub(crate) micro_high_low_spread: HighLowSpread,
    pub(crate) micro_vw_spread: VolumeWeightedSpread,
    pub(crate) micro_tick_count: TickCount,
    pub(crate) micro_inter_arrival: InterArrivalTime,
    pub(crate) micro_buy_sell_imbalance: BuySellImbalance,
    pub(crate) micro_kyle_lambda: KyleLambda,
    pub(crate) micro_price_impact: PriceImpact,
    pub(crate) micro_variance_ratio: VarianceRatio,
    // Note: Roll Measure, Corwin-Schultz, Amihud, VPIN already exist in ml/src/microstructure/
    // We'll integrate those in the update logic
    /// Track last timestamp for inter-arrival time calculation
    pub(crate) last_timestamp_ns: u64,
    /// Track last close price for microstructure calculations
    pub(crate) last_close: f64,

    // WAVE 1.1: Triple Barrier Integration
    /// Triple barrier engine for position exit labeling
    pub(crate) triple_barrier: Arc<RwLock<TripleBarrierEngine>>,
    /// Active position tracker ID (None = no active position)
    pub(crate) active_position_tracker: Option<Uuid>,
    /// WAVE P3: Track previous simulated position for barrier tracking continuity
    pub(crate) previous_simulated_position: f32,

    // WAVE 1.2: Safety Infrastructure Integration (8 Systems)
    /// Loss history window for spike detection (size: 30)
    pub(crate) safety_loss_history: VecDeque<f32>,
    /// Loss plateau counter for anomaly detection
    pub(crate) safety_loss_plateau_counter: usize,
    /// Action counts for diversity monitoring (5 exposure actions)
    pub(crate) safety_action_counts: std::collections::HashMap<usize, usize>,
    /// Memory manager for GPU OOM risk monitoring
    pub(crate) safety_memory_manager: Arc<RwLock<crate::safety::memory_manager::SafeMemoryManager>>,
    /// Safety enforcement level (Strict/Normal/Permissive)
    pub(crate) safety_level: crate::safety::SafetyLevel,
    /// Step counter for periodic safety checks
    pub(crate) safety_step_counter: usize,

    /// Optional path to feature cache directory for faster hyperopt
    pub(crate) feature_cache_dir: Option<PathBuf>,

    /// Previous epoch's mean Q-value for overestimation detection
    pub(crate) prev_epoch_q_mean: f64,
    /// Current effective tau (may be temporarily increased if Q-values drift)
    pub(crate) adaptive_tau: f64,

    /// Rolling window of recent epoch q_means for the Q-drift kill criterion's
    /// MAD-deviation test (Plan C Phase 2 follow-up H, 2026-04-29). Bounded
    /// to `Q_DRIFT_WINDOW_SIZE` (declared near the kill criterion in
    /// `training_loop.rs`); push_back / pop_front keep the most recent
    /// epochs. Cleared at fold boundary by `reset_for_fold` so cross-fold
    /// q-mean histories don't poison the median/MAD computation — folds are
    /// independent training runs with different policies and replay
    /// distributions, the same rationale as `prev_epoch_q_mean`'s reset.
    pub(crate) q_mean_window: VecDeque<f64>,

    /// Plan C Phase 2 follow-up K (2026-04-29): the most recent
    /// cosine-scheduled "base" LR (re-set at epoch boundary by the cosine
    /// LR block in `training_loop.rs`). Per-step the warmup-factor consumer
    /// derives `lr_eff = cosine_effective_lr_base × max(MIN_WARMUP_LR_FRAC,
    /// ISV[FOLD_WARMUP_FACTOR_INDEX])` and writes it via `set_lr`. Storing
    /// the cosine value here decouples per-epoch scheduling from per-step
    /// warmup damping (without this the per-step `set_lr` would clobber
    /// the cosine-scheduled value, and the next epoch's cosine update would
    /// fight the warmup factor at every step). Initialised to
    /// `hyperparams.learning_rate` and updated alongside the existing
    /// `set_lr(effective_lr)` site.
    pub(crate) cosine_effective_lr_base: f32,

    /// Plan C T11 follow-up — F1 ep2 explosion diagnostic (2026-04-29).
    /// 0-based fold index, set at the top of `train_walk_forward`'s fold
    /// loop and used by the per-step FOLD_EXPLOSION_DIAG block to gate
    /// log emission to fold >= 1 (the fold whose epoch-2 reproducibly
    /// explodes despite 6+ defensive layers). Diagnostic-only field;
    /// remove once root cause is identified. No reset in `reset_for_fold`
    /// — overwritten unconditionally at fold-loop entry.
    pub(crate) current_fold: usize,

    /// Plan C T11 follow-up — F1 ep2 explosion diagnostic (2026-04-29).
    /// Previous step's `gr.raw_grad_norm` (None at fold start, populated
    /// every guard step). The per-step diagnostic block fires when
    /// `raw > 5 × prev` (jump trigger) or `raw > 1000` (threshold trigger),
    /// then continues logging for STEPS_AFTER_EXPLOSION steps to capture
    /// the trajectory. Reset to None in `reset_for_fold` so prev-step
    /// ratios are fold-local. Diagnostic-only field; remove with the
    /// emit block once root cause is identified.
    pub(crate) last_logged_grad_norm: Option<f32>,

    /// Plan C T11 follow-up — F1 ep2 explosion diagnostic (2026-04-29).
    /// Decrementing counter for "log next K steps after first trigger".
    /// Set to STEPS_AFTER_EXPLOSION when the trigger fires; decremented
    /// each guard step. While > 0 the diagnostic block emits unconditionally
    /// to capture the explosion trajectory. Reset to 0 in `reset_for_fold`.
    /// Diagnostic-only field; remove with the emit block once root cause
    /// is identified.
    pub(crate) explosion_diag_steps_remaining: u32,

    /// WAVE 24 (Agent 17): Patience-based early stopping for anti-overfitting
    pub(crate) early_stopping: super::early_stopping::EarlyStopping,

    /// WAVE 26 P0.6: Learning rate scheduler with warmup
    pub(crate) lr_scheduler: super::lr_scheduler::LRScheduler,

    /// WAVE 26 P1.8: Curiosity module for intrinsic rewards (None if curiosity_weight = 0.0)
    pub(crate) curiosity_module: Option<CuriosityModule>,

    // WAVE 26 P1: Advanced DQN Features Integration
    // P1.3: Sharpe Ratio Reward Component
    /// Rolling buffer of returns for Sharpe ratio calculation (max: sharpe_window)
    pub(crate) returns_history: VecDeque<f64>,
    /// Sharpe reward weight (0.0 = disabled)
    pub(crate) sharpe_weight: f64,

    // P1.6: Adaptive Dropout Scheduling
    /// Optional dropout scheduler (None if disabled)
    pub(crate) dropout_scheduler: Option<crate::dqn::network::DropoutScheduler>,

    // P1.9: Generalized Advantage Estimation (GAE)
    /// Optional GAE calculator (None if disabled)
    pub(crate) gae_calculator: Option<crate::dqn::gae::GAECalculator>,

    // P1.11: Noisy Network Sigma Scheduling
    /// Optional noisy sigma scheduler (None if disabled)
    pub(crate) noisy_sigma_scheduler: Option<crate::dqn::noisy_sigma_scheduler::NoisySigmaScheduler>,

    // WAVE 30: Structured Logging Integration
    /// Logging configuration for training metrics
    pub(crate) logging_config: LoggingConfig,
    /// Metrics aggregator for windowed training statistics
    pub(crate) metrics_aggregator: MetricsAggregator,

    /// Current effective batch size (may be reduced by OOM recovery)
    pub(crate) current_batch_size: usize,

    /// Cached Q-value estimate for periodic monitoring (avoids extra forward pass every step)
    pub(crate) cached_avg_q: f64,
    /// Counter for Q-value estimation frequency (estimate every N training steps)
    pub(crate) q_estimation_counter: u64,
    /// Epoch-level Q-value extremes accumulated from per-step compute_q_stats
    pub(crate) epoch_q_min: f32,
    pub(crate) epoch_q_max: f32,
    /// Max Q-variance across states (state discrimination signal)
    pub(crate) epoch_q_variance: f32,
    /// Max Q-gap: avg_max_q - q_mean (action discrimination signal)
    pub(crate) epoch_q_gap: f32,
    /// Atom entropy [0,1] and utilization [0,1] from C51 distributional component
    pub(crate) epoch_atom_entropy: f32,
    pub(crate) epoch_atom_utilization: f32,
    /// Observed reward std from experience collector (for adaptive v_range floor).
    pub(crate) observed_reward_std: f32,
    /// G13: Observed reward mean from experience collector (for Sharpe-aware shaping).
    pub(crate) observed_reward_mean: f32,
    /// Running ratio of low-drawdown epochs (EMA of binary signal)
    pub(crate) low_dd_ratio: f64,
    /// Whether adversarial regime is active this epoch
    pub(crate) adversarial_active: bool,
    /// EMA of max drawdown — adaptive threshold for adversarial regime
    pub(crate) max_dd_ema: f64,

    /// #33 Adversarial self-play saboteur
    pub(crate) saboteur: super::adversarial_self_play::AdversarialSaboteur,

    /// #23 Causal feature mask for current epoch (None = no masking)
    pub(crate) epoch_feature_mask: Option<Vec<f32>>,
    /// #13 Vol normalizer for current epoch (0.0 = disabled)
    pub(crate) epoch_vol_normalizer: f32,

    /// Pre-uploaded GPU training data (set once, reused across epochs)
    pub(crate) gpu_data: Option<DqnGpuData>,

    /// Raw targets buffer for CUDA kernel (parallel to raw features buffer).
    /// `MappedF32Buffer` (cuMemHostAlloc DEVICEMAP): host writes via `host_ptr`
    /// at fxcache load; kernels read via `dev_ptr`. No DtoD copy at upload —
    /// satisfies `feedback_no_htod_htoh_only_mapped_pinned` and is graph-
    /// capture safe.
    pub(crate) targets_raw_cuda: Option<crate::cuda_pipeline::mapped_pinned::MappedF32Buffer>,

    /// Raw features buffer for CUDA experience kernel `[num_bars * market_dim]`.
    /// Same `MappedF32Buffer` storage pattern as `targets_raw_cuda` above.
    pub(crate) features_raw_cuda: Option<crate::cuda_pipeline::mapped_pinned::MappedF32Buffer>,

    /// GPU experience collector for zero-roundtrip CUDA kernel (Phase 2b)
    pub(crate) gpu_experience_collector: Option<crate::cuda_pipeline::gpu_experience_collector::GpuExperienceCollector>,

    /// GPU-fused epsilon-greedy action selector (eliminates argmax GPU->CPU sync barrier)
    pub(crate) gpu_action_selector: Option<crate::cuda_pipeline::gpu_action_selector::GpuActionSelector>,

    /// GPU training guard for zero-sync safety checks (loss clip, NaN, grad collapse)
    pub(crate) training_guard: Option<crate::cuda_pipeline::gpu_training_guard::GpuTrainingGuard>,

    /// GPU monitoring reducer — accumulates reward/action stats across kernel launches
    pub(crate) gpu_monitoring: Option<crate::cuda_pipeline::gpu_monitoring::GpuMonitoringReducer>,

    /// GPU reduction kernels for fused stats/argmax/sum (lazy-init, reused across epochs)
    pub(crate) reduction_kernels: Option<ml_core::cuda_autograd::ReductionKernels>,

    /// GPU-resident walk-forward data (entire dataset on GPU, per-fold views via index ranges)
    pub(crate) gpu_walk_forward: Option<crate::cuda_pipeline::gpu_walk_forward::GpuWalkForwardData>,

    /// Multi-GPU configuration for data-parallel training (None = single GPU)
    pub(crate) multi_gpu: Option<crate::cuda_pipeline::multi_gpu::MultiGpuConfig>,


    /// Pre-computed OFI features per bar (indexed by global bar position).
    /// Populated during data loading when MBP-10 order book data is available.
    /// Passed as `regime_features` in `TradingState::from_normalized()`.
    /// Arc-shared to avoid copy per hyperopt trial.
    pub ofi_features: Option<Arc<[[f64; OFI_DIM]]>>,
    /// Number of training bars (OFI offset for validation data).
    /// val_data[i] corresponds to ofi_features[ofi_val_offset + i].
    pub(crate) ofi_val_offset: usize,

    /// SP15 Phase 1.6: dev-validation slice (Q8 by default), populated by
    /// `train_walk_forward` BEFORE fold generation. Consumed by end-of-training
    /// dev evaluation (deferred to a follow-up commit per
    /// `feedback_no_partial_refactor`).
    pub(crate) dev_features: Option<Vec<[f64; 42]>>,
    /// Targets paired with `dev_features` (parallel index, same length).
    pub(crate) dev_targets: Option<Vec<[f64; 6]>>,
    /// SP15 Phase 1.6: sealed holdout slice (Q9 by default), populated by
    /// `train_walk_forward` BEFORE fold generation. NEVER consumed by any
    /// training path (`debug_assert` guard below); the legitimate consumer is
    /// the Phase 4.3 `argo-eval-final.sh` separate eval-only workflow.
    pub(crate) holdout_features: Option<Vec<[f64; 42]>>,
    /// Targets paired with `holdout_features` (parallel index, same length).
    pub(crate) holdout_targets: Option<Vec<[f64; 6]>>,

    /// SP15 Phase 1.7: per-fold walk-forward test slice (12.5% of train data
    /// by default per `wf_test_fraction`), populated each fold via
    /// `set_test_data_from_slices` from `features[fold.test_start..fold.test_end]`.
    /// Pre-SP15 the trainer at the fold loop only consumed train+val and
    /// silently dropped the generator-emitted `test_start..test_end` range —
    /// see plan §6.7 / Task 1.7. The actual `evaluate_dqn_graphed` invocation
    /// on this slice is deferred to a follow-up commit per
    /// `feedback_no_partial_refactor` and the Phase 1.5 / 1.6 precedent
    /// (kernel/state-stash lands first; consumer migration follows once the
    /// parallel `GpuBacktestEvaluator` instance + TLOB sync infrastructure is
    /// wired). The observer hook below lets unit tests verify that the per-fold
    /// stash fires with the correct `[test_start..test_end)` range without
    /// requiring a live GPU eval to run.
    pub(crate) test_features: Option<Vec<[f64; 42]>>,
    /// Targets paired with `test_features` (parallel index, same length).
    pub(crate) test_targets: Option<Vec<[f64; 6]>>,
    /// Absolute bar index where the current fold's test slice starts (relative
    /// to the train-only `features` array — i.e. `fold.test_start`).
    pub(crate) test_start_bar: usize,
    /// Absolute bar index where the current fold's test slice ends (exclusive).
    pub(crate) test_end_bar: usize,
    /// Optional observer fired on every `set_test_data_from_slices` call with
    /// `(test_start_bar, test_end_bar)`. Test-only hook to verify the fold loop
    /// stashes the correct range — production callers leave this `None`. Boxed
    /// to allow heterogeneous closures and `Send + Sync` because the trainer
    /// is moved between threads via async checkpoint workers.
    pub(crate) test_data_observer: Option<Box<dyn Fn(usize, usize) + Send + Sync>>,

    /// GPU backtest evaluator for validation loss (replaces CPU argmax+Sharpe path).
    /// Lazy-initialized on first `compute_validation_loss()` call from val_data.
    /// Invalidated (set to None) when val_data changes (fold transitions).
    pub(crate) gpu_evaluator: Option<crate::cuda_pipeline::gpu_backtest_evaluator::GpuBacktestEvaluator>,

    // Phase C: Fill simulation and smart order routing
    /// Fill simulator for order type-dependent execution modeling
    pub(crate) fill_simulator: FillSimulator,
    /// EMA of bar volatility (|close log return|) for OrderRouter routing decisions
    pub(crate) vol_ema: f64,
    /// Running median volatility estimate (slowly adapting EMA)
    pub(crate) median_vol: f64,

    /// Fused CUDA training context: pre-allocated batch buffers + single entry point
    /// for CUDA Graph capture. Only active for Standard DQN on CUDA devices.
    /// Lazy-initialized on first training step; dropped and recreated if batch_size changes.
    pub(crate) fused_ctx: Option<super::fused_training::FusedTrainingCtx>,

    /// Per-fold adaptive regime replay decay override (Task 15).
    /// Set before each fold's training in `train_walk_forward()`.
    /// When < 1.0, `run_training_steps_slices()` calls `sample_regime_biased()` instead of `sample()`.
    /// Reset to 1.0 (no bias) outside walk-forward.
    pub(crate) regime_replay_decay_override: f32,

    /// Dominant regime in the current fold's validation data (0=Trending, 1=Ranging, 2=Volatile).
    /// Used as `current_regime` parameter for `sample_regime_biased()`.
    pub(crate) fold_dominant_regime: u8,

    /// CUDA event recorded after experience collection completes on the forked stream.
    /// Checked at the start of `run_training_steps_slices` — allows CPU-side training setup
    /// (lock acquisition, variable initialization) to overlap with the tail end of
    /// experience collection GPU kernels.
    pub(crate) experience_done_event: Option<CudaEvent>,

    /// In-flight async best-checkpoint workers. Each `JoinHandle` represents
    /// a `tokio::task::spawn_blocking` task that owns a clone of the
    /// `CheckpointCallbackHandle` (Arc<Mutex<F>>) and serialises a
    /// `CheckpointSnapshot` to safetensors bytes + invokes the callback.
    /// Populated by `handle_epoch_checkpoints_and_early_stopping` on
    /// val-Sharpe improvement epochs; drained by
    /// `await_pending_checkpoint_handles` at training end (success +
    /// early-stop branches) and immediately before any synchronous
    /// checkpoint write to keep disk ordering deterministic.
    pub(crate) pending_checkpoint_handles:
        Vec<tokio::task::JoinHandle<Result<()>>>,

    /// Current fold's training data range in the GPU-resident arrays.
    /// Set by `set_training_range()`, used by `collect_gpu_experiences_slices()`.
    /// Default (0, 0) = use full dataset (legacy path).
    pub(crate) fold_train_start: usize,
    pub(crate) fold_train_end: usize,
    pub(crate) fold_val_start: usize,
    pub(crate) fold_val_end: usize,

    /// Cached GPU n_episodes (computed once, reused across epochs/folds).
    pub(crate) cached_n_episodes: Option<i32>,

    /// Task 10: Trajectory backtracking state machine for plateau recovery.
    pub(crate) backtracking: BacktrackingState,

    /// G2: Transaction cost curriculum anneal factor (0.0=no costs, 1.0=full costs).
    pub(crate) cost_anneal_factor: f32,

    /// G3: Walk-forward validation window Sharpe ratios.
    pub(crate) wf_window_sharpes: Vec<f64>,
    /// G3: Number of walk-forward windows (K=6).
    pub(crate) wf_num_windows: usize,
    /// G3: Purge gap in bars between train and validation.
    pub(crate) wf_purge_bars: usize,
    /// G3: Min-Sharpe across walk-forward windows (used for checkpoint selection).
    pub(crate) wf_min_sharpe: f64,

    /// Self-improving enrichment state (persists across epochs).
    pub(crate) enrichment: enrichment::EnrichmentState,

    // ── A3: LearningHealth (Layer 1) ─────────────────────────────────
    /// Unified training health scalar [0.2, 0.95] — EMA-smoothed composition of
    /// Q-gap, Q-variance, atom utilization, grad stability, ensemble agreement,
    /// gradient consistency, and spectral gap. Broadcast to ISV[12] each epoch.
    pub(crate) learning_health: crate::cuda_pipeline::learning_health::LearningHealth,
    /// Rolling EMA trackers for LearningHealth input signals.
    pub(crate) health_ema: metrics::HealthEmaTrackers,
    /// Last computed Q-gap signal (mean of 4 per-branch EMA gaps). Cached for A4/B1.
    pub(crate) last_q_gap: Option<f32>,
    /// Last computed Q-variance proxy (epoch_q_max - epoch_q_min). Cached for A4.
    pub(crate) last_q_var: Option<f32>,
    /// Last grad_norm EMA fed into LearningHealth. Cached for A4/B4.
    pub(crate) last_grad_norm: Option<f32>,
    /// Last ensemble disagreement value (placeholder 0.1 until D6/N6). Cached for A4.
    pub(crate) last_ens_disagreement: Option<f32>,
    /// Last atom utilization from q_readback_pinned[6]. Cached for A4/G4.
    pub(crate) last_atom_util: Option<f32>,

    // ── A4: Extended HEALTH_DIAG placeholders ─────────────────────────────
    // All fields start as None; B/C/D tasks populate them with real values.
    /// B1/G2 — CQL alpha effective value
    pub(crate) last_cql_alpha_eff: Option<f32>,
    /// B4/G5 — per-component gradient budget allocations
    pub(crate) last_iqn_budget_eff: Option<f32>,
    pub(crate) last_cql_budget_eff: Option<f32>,
    pub(crate) last_c51_budget_eff: Option<f32>,
    /// SP6 Pearl 2 — per-branch budget arrays (dir=0, mag=1, ord=2, urg=3)
    pub(crate) last_c51_budget_per_branch: Option<[f32; 4]>,
    pub(crate) last_iqn_budget_per_branch: Option<[f32; 4]>,
    pub(crate) last_cql_budget_per_branch: Option<[f32; 4]>,
    /// B2/G3 — tau effective
    pub(crate) last_tau_eff: Option<f32>,
    /// B3/G4 — Expected SARSA temperature scaling factor
    pub(crate) last_sarsa_tau_factor: Option<f32>,
    /// C4/P4 — adaptive gamma
    pub(crate) last_gamma_eff: Option<f32>,
    /// Task 2.5 Bug #1 — last anti-LR multiplier applied by the anti-LR
    /// controller. 1.0 means no intervention; anything else fires the C1
    /// controller. Used by fire detection (`controller_fire_counts.anti_lr`)
    /// so detection observes the *intervention*, not scheduler drift.
    /// Initialized to 1.0; updated in the anti-LR block each epoch.
    pub(crate) last_anti_mult: f32,
    /// D4/N4 — counterfactual ratio
    pub(crate) last_cf_ratio_eff: Option<f32>,
    /// D1/N1 — distillation active flag
    pub(crate) last_distill_active: Option<bool>,
    /// D2/N2 — barrier loss value
    pub(crate) last_barrier_loss: Option<f32>,
    /// D3/N3 — plasticity-ready flag (true = ready to trigger, false = in cooldown)
    pub(crate) last_plasticity_ready: Option<bool>,
    /// D3/N3: Consecutive epoch count with health < 0.3. When reaches 3, triggers plasticity injection.
    pub(crate) unhealthy_epoch_count: u32,
    /// D5/N5 — information bottleneck penalty
    pub(crate) last_ib_penalty: Option<f32>,
    /// D6/N6 — ensemble collapse score
    pub(crate) last_ensemble_collapse_score: Option<f32>,
    /// D7/N7 — contrarian override active
    pub(crate) last_contrarian_active: Option<bool>,
    /// D7/N7: Consecutive epochs with WinRate < 0.40. Triggers contrarian override at 5.
    pub(crate) low_winrate_count: u32,
    /// D7/N7: Whether contrarian override (argmin) is currently active.
    pub(crate) contrarian_active: bool,
    /// D7/N7: Epochs remaining in current contrarian window (2 epochs per activation).
    pub(crate) contrarian_remaining_epochs: u32,
    /// D7/N7: Most recent epoch win_rate (0..1) from financials. Carried into next process_epoch_boundary.
    pub(crate) last_epoch_win_rate: f32,
    /// D8/N8 — meta-Q collapse prediction
    pub(crate) last_meta_q_pred: Option<f32>,
    /// D8/N8: Meta-Q network predicting collapse probability K epochs ahead.
    pub(crate) meta_q: crate::cuda_pipeline::meta_q_network::MetaQNetwork,
    /// D8/N8: Pending inputs awaiting label resolution (K epochs delayed).
    pub(crate) pending_meta_q_samples: std::collections::VecDeque<(crate::cuda_pipeline::meta_q_network::MetaQInput, u32)>,
    /// D8/N8: Rolling history of (epoch, health) for meta-Q label resolution.
    /// Capped at 2×META_Q_LOOKAHEAD.
    pub(crate) health_history: std::collections::VecDeque<(u32, f32)>,

    /// Plan 5 Task 2 — A.4 Regression Detection Hard-Stop.
    ///
    /// Per-metric warn/error bands loaded from `config/metric-bands.toml` at
    /// trainer construction. Updated once per epoch in
    /// `log_epoch_metrics_and_financials` (after the HEALTH_DIAG line is
    /// emitted) via `MetricBandsRegistry::update_and_check`. Crossing the
    /// `2N` consecutive-error-epochs threshold returns
    /// `CommonError::RegressionDetected` from the training loop.
    ///
    /// Defaults to an empty registry (no bands → no termination) when the
    /// toml file is absent — keeps unit tests / smoke tests that don't ship
    /// a metric-bands file functional. Production training must point at
    /// `config/metric-bands.toml`.
    pub(crate) metric_bands: monitoring::MetricBandsRegistry,
}

impl std::fmt::Debug for DQNTrainer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DQNTrainer")
            .field("hyperparams", &self.hyperparams)
            .finish_non_exhaustive()
    }
}

impl Drop for DQNTrainer {
    fn drop(&mut self) {
        // Drop GPU resources BEFORE the CudaStream and CudaContext.
        // Default field drop order follows declaration order, but GPU resources
        // (fused_ctx at line 285, gpu_experience_collector at line 228) are declared
        // AFTER cuda_stream (line 60) and device (line 55). Their Drop impls call
        // cuStreamSynchronize on the forked stream — which would be a use-after-free
        // if the stream dropped first.
        self.fused_ctx = None;
        self.gpu_experience_collector = None;
        self.experience_done_event = None;
        // Sync and drop the validation stream before the CudaContext drops.
        if let Some(ref stream) = self.validation_stream {
            #[allow(unsafe_code)]
            unsafe {
                cudarc::driver::sys::cuStreamSynchronize(stream.cu_stream());
            }
        }
        self.validation_stream = None;
        // Now sync and drop the forked stream before the CudaContext drops.
        if let Some(ref stream) = self.cuda_stream {
            #[allow(unsafe_code)]
            unsafe {
                cudarc::driver::sys::cuStreamSynchronize(stream.cu_stream());
            }
        }
        self.cuda_stream = None;
    }
}

impl DQNTrainer {
    /// Mutable access to fused CUDA context for QValueProvider.
    pub(crate) fn fused_ctx_mut(&mut self) -> Option<&mut super::fused_training::FusedTrainingCtx> {
        self.fused_ctx.as_mut()
    }

    /// Create new DQN trainer with hyperparameters and debug logging disabled
    pub fn new(hyperparams: DQNHyperparameters) -> Result<Self> {
        Self::new_with_debug(hyperparams, false)
    }

    /// Create new DQN trainer with a specific compute device.
    /// Used by hyperopt to share a single CUDA context across parallel trials.
    pub fn new_with_device(hyperparams: DQNHyperparameters, device: MlDevice) -> Result<Self> {
        Self::new_internal(hyperparams, false, Some(device))
    }

    /// Create new DQN trainer with hyperparameters and configurable debug logging
    ///
    /// # Arguments
    /// * `hyperparams` - DQN training hyperparameters
    /// * `debug_logging` - Enable debug logging (REWARD_DEBUG, gradient norms, etc.)
    pub fn new_with_debug(hyperparams: DQNHyperparameters, debug_logging: bool) -> Result<Self> {
        Self::new_internal(hyperparams, debug_logging, None)
    }


    /// Two-phase stress tester initialization.
    ///
    /// Call this after constructing `DQNTrainer` to resolve the circular dependency:
    /// `DQNStressTester::new()` requires a `DQNTrainer`, so the tester cannot be
    /// created *during* trainer construction.  This method builds a lightweight
    /// inner trainer (with stress testing itself disabled to avoid recursion) and
    /// hands it to `DQNStressTester::new()`.
    ///
    /// Initialize the stress tester (always active).
    pub fn init_stress_tester(&mut self) -> Result<()> {
        // Clone hyperparams with minimal buffer for the inner trainer.
        // The inner trainer is lightweight to avoid doubling VRAM usage.
        let mut inner_hp = self.hyperparams.clone();
        inner_hp.buffer_size = 1;

        let inner_trainer = Self::new_with_device(inner_hp, self.device.clone())
            .context("Failed to create inner DQNTrainer for stress tester")?;

        let tester = crate::dqn::stress_testing::DQNStressTester::new(inner_trainer)?;
        self.stress_tester = Some(Arc::new(tester));
        info!("Stress testing enabled (8 scenarios)");

        Ok(())
    }

    /// Set feature cache directory for faster hyperopt
    ///
    /// Enables loading pre-computed features from disk instead of recomputing them
    pub fn with_feature_cache(mut self, cache_dir: PathBuf) -> Self {
        self.feature_cache_dir = Some(cache_dir);
        self
    }

    /// Train DQN on market data from DBN files
    ///
    /// # Arguments
    ///
    /// * `dbn_data_dir` - Directory containing DBN files (e.g., "test_data/real/databento/ml_training/")
    /// * `checkpoint_callback` - Callback for saving checkpoints (epoch, model_data, is_final) -> `Result<String>`
    ///
    /// # Returns
    ///
    /// Training metrics (loss, accuracy, gradient norms, Q-values)
    pub async fn train<F>(
        &mut self,
        dbn_data_dir: &str,
        symbol: &str,
        checkpoint_callback: F,
    ) -> Result<TrainingMetrics>
    where
        F: FnMut(usize, Vec<u8>, bool) -> Result<String> + Send + 'static,
    {
        info!(
            "Starting DQN training for {} epochs with batch size {}",
            self.hyperparams.epochs, self.hyperparams.batch_size
        );

        // Load market data from DBN files (ALL data for walk-forward or single-pass)
        let (training_data, val_data) = self.load_training_data(dbn_data_dir, symbol).await?;

        info!(
            "Loaded {} training samples, {} validation samples",
            training_data.len(),
            val_data.len()
        );

        if self.cuda_stream.is_none() {
            return Err(anyhow::anyhow!("CUDA stream required — CPU training path removed"));
        }

        // Merge train+val into a single dataset for GPU walk-forward splitting
        let mut all_data = training_data;
        all_data.extend(val_data);
        info!(
            "GPU walk-forward: {} total bars, uploading to VRAM",
            all_data.len(),
        );
        self.train_walk_forward(&all_data, checkpoint_callback).await
    }

    /// Train one fold from contiguous feature/target slices.
    /// Zero Vec<f64> allocation — targets read as fixed-size &[[f64; 6]].
    ///
    /// Caller must call `set_val_data_from_slices` and `set_training_range` before
    /// this method. Val data and OFI offset are already set on `self`.
    pub async fn train_fold_from_slices<F>(
        &mut self,
        features: &[[f64; 42]],
        targets: &[[f64; 6]],
        checkpoint_callback: F,
    ) -> Result<TrainingMetrics>
    where
        F: FnMut(usize, Vec<u8>, bool) -> Result<String> + Send + 'static,
    {
        // Clear stale CUDA errors
        if let MlDevice::Cuda { ref context, .. } = self.device {
            let _ = context.check_err();
        }

        info!("train_fold_from_slices: {} bars", features.len());

        // Build lightweight training_data with fixed-size arrays — zero heap alloc per element.
        // Both [f64; 42] and [f64; 6] are Copy types that live entirely on the stack.
        let training_data: Vec<([f64; 42], [f64; 6])> = features
            .iter()
            .zip(targets.iter())
            .map(|(f, t)| (*f, *t))
            .collect();

        // Keep gpu_evaluator alive for deterministic cuBLAS state across folds.

        // Wrap the callback in Arc<Mutex<>> so the async-checkpoint worker
        // (spawn_blocking inside `train_with_data_full_loop_slices`) can
        // hold a clone alongside the synchronous epoch-loop callsites.
        // Single-fold call — wrap once; multi-fold callers go through
        // `train_walk_forward` which wraps once for the entire fold loop.
        let cb_handle: CheckpointCallbackHandle = Arc::new(std::sync::Mutex::new(
            Box::new(checkpoint_callback) as BoxedCheckpointCallback,
        ));
        self.train_with_data_full_loop_slices(&training_data, cb_handle)
            .await
    }

    /// Train with GPU-resident walk-forward cross-validation.
    ///
    /// Converts all_data to features/targets arrays once, uploads via
    /// `init_from_fxcache`, then runs expanding-window walk-forward with
    /// `set_training_range` + `set_val_data_from_slices` + `reset_for_fold`
    /// + `train_fold_from_slices`. Zero per-bar Vec<f64> heap allocation.
    ///
    /// Returns the metrics from the LAST fold (most data, most representative).
    pub async fn train_walk_forward<F>(
        &mut self,
        training_data: &[(FeatureVector, Vec<f64>)],
        checkpoint_callback: F,
    ) -> Result<TrainingMetrics>
    where
        F: FnMut(usize, Vec<u8>, bool) -> Result<String> + Send + 'static,
    {
        use crate::cuda_pipeline::gpu_walk_forward::GpuWalkForwardConfig;

        // SP15 Phase 1.6: Q1-Q7 train, Q8 dev val, Q9 sealed final-test split.
        //
        // The foxhunt training dataset is partitioned into 9 calendar quarters
        // (per train-dd4xl post-mortem). Walk-forward fold generation runs on
        // Q1..Q(9 - holdout - dev) only — the trailing dev + sealed slices are
        // peeled off BEFORE any fold logic touches the data.
        //
        // Defaults: holdout_quarters=1 (Q9 sealed), dev_quarters=1 (Q8 dev val).
        // Sealed holdout is consumed exclusively by Phase 4.3
        // `argo-eval-final.sh` (separate eval-only workflow) and is never
        // reachable from any training path; the `debug_assert` further down
        // guards against accidental re-entry.
        const TOTAL_QUARTERS: usize = 9;
        let holdout_quarters = self.hyperparams.holdout_quarters;
        let dev_quarters = self.hyperparams.dev_quarters;
        let bars_per_quarter = training_data.len() / TOTAL_QUARTERS;
        let train_quarters = TOTAL_QUARTERS
            .saturating_sub(holdout_quarters)
            .saturating_sub(dev_quarters);
        if train_quarters == 0 {
            return Err(anyhow::anyhow!(
                "SP15 split invalid: holdout ({}) + dev ({}) leaves 0 train quarters \
                 (have {} total)",
                holdout_quarters,
                dev_quarters,
                TOTAL_QUARTERS,
            ));
        }

        let train_end_bars = train_quarters * bars_per_quarter;
        let dev_start = train_end_bars;
        let dev_end = dev_start + dev_quarters * bars_per_quarter;
        let holdout_start = dev_end;
        let holdout_end = training_data.len();

        info!(
            "SP15 Phase 1.6 split: train Q1-Q{} ({} bars), dev Q{} ({} bars), \
             sealed holdout Q{}-Q{} ({} bars) [holdout={} dev={} per --holdout-quarters/--dev-quarters]",
            train_quarters,
            train_end_bars,
            train_quarters + 1,
            dev_end - dev_start,
            train_quarters + dev_quarters + 1,
            TOTAL_QUARTERS,
            holdout_end - holdout_start,
            holdout_quarters,
            dev_quarters,
        );

        // Stash dev + sealed holdout slices for end-of-training dev eval and
        // the Phase 4.3 separate eval-only workflow. Both peeled off BEFORE
        // walk-forward generation so neither slice can leak into folds.
        // Conversion from FeatureVector (Vec<f64>) to fixed-size [f64; 42] /
        // [f64; 6] mirrors the `features` / `targets` projection below
        // (truncates to first 42 / first 6 elements per existing trainer
        // contract on this path).
        if dev_end > dev_start {
            let dev_features_v: Vec<[f64; 42]> = training_data[dev_start..dev_end]
                .iter()
                .map(|(fv, _)| {
                    let mut f = [0.0_f64; 42];
                    f.copy_from_slice(&fv[..42]);
                    f
                })
                .collect();
            let dev_targets_v: Vec<[f64; 6]> = training_data[dev_start..dev_end]
                .iter()
                .map(|(_, tgt)| {
                    let mut t = [0.0_f64; 6];
                    for (i, &v) in tgt.iter().enumerate().take(6) {
                        t[i] = v;
                    }
                    t
                })
                .collect();
            self.dev_features = Some(dev_features_v);
            self.dev_targets = Some(dev_targets_v);
        }
        if holdout_end > holdout_start {
            let holdout_features_v: Vec<[f64; 42]> = training_data[holdout_start..holdout_end]
                .iter()
                .map(|(fv, _)| {
                    let mut f = [0.0_f64; 42];
                    f.copy_from_slice(&fv[..42]);
                    f
                })
                .collect();
            let holdout_targets_v: Vec<[f64; 6]> = training_data[holdout_start..holdout_end]
                .iter()
                .map(|(_, tgt)| {
                    let mut t = [0.0_f64; 6];
                    for (i, &v) in tgt.iter().enumerate().take(6) {
                        t[i] = v;
                    }
                    t
                })
                .collect();
            self.holdout_features = Some(holdout_features_v);
            self.holdout_targets = Some(holdout_targets_v);
        }

        // From here on, walk-forward MUST see only the training portion —
        // re-bind `training_data` to the sliced view so every downstream
        // consumer (features/targets projection, init_from_fxcache, fold
        // generation) sees the train-only window.
        let training_data: &[(FeatureVector, Vec<f64>)] = &training_data[..train_end_bars];

        // Wrap the callback once for the entire fold loop. Each fold passes
        // an Arc::clone into `train_with_data_full_loop_slices`, which
        // hands a clone to the async-checkpoint worker. The same Arc backs
        // the synchronous early-stop / periodic / plateau-exhausted
        // callsites within the fold. Without the shared handle, the F
        // generic would have to be moved by value into the first fold's
        // worker, leaving subsequent folds without a callback.
        let cb_handle: CheckpointCallbackHandle = Arc::new(std::sync::Mutex::new(
            Box::new(checkpoint_callback) as BoxedCheckpointCallback,
        ));

        // Convert all_data to fixed-size arrays ONCE.
        // NOTE: `training_data` here is already the SP15 train-only slice
        // (Q1..Q(9 - holdout - dev)); dev + holdout were peeled off above.
        let features: Vec<[f64; 42]> = training_data.iter().map(|(fv, _)| {
            let mut f = [0.0_f64; 42];
            f.copy_from_slice(&fv[..42]);
            f
        }).collect();
        let targets: Vec<[f64; 6]> = training_data.iter().map(|(_, tgt)| {
            let mut t = [0.0_f64; 6];
            for (i, &v) in tgt.iter().enumerate().take(6) { t[i] = v; }
            t
        }).collect();

        // SP15 Phase 1.6: sealed-slice guard. The trainer's own holdout buffer
        // must never share a heap region with the data that flows into
        // walk-forward fold generation. Catches accidental future refactors
        // that reintroduce holdout into the training path. Phase 4.3
        // `argo-eval-final.sh` is the only legitimate consumer — it loads
        // holdout via a separate eval-only entry point that does NOT call
        // this function.
        debug_assert!(
            self.holdout_features.as_ref().map_or(true, |hf| {
                hf.is_empty() || features.as_ptr() != hf.as_ptr()
            }),
            "SP15 Phase 1.6: holdout features reached training path — sealed slice violation"
        );

        let wf_config = GpuWalkForwardConfig {
            initial_train_fraction: f64::from(self.hyperparams.wf_initial_train_fraction),
            val_fraction: f64::from(self.hyperparams.wf_val_fraction),
            test_fraction: f64::from(self.hyperparams.wf_test_fraction),
            step_fraction: f64::from(self.hyperparams.wf_step_fraction),
        };

        // Upload FULL dataset to GPU FIRST so we can use GPU prefix sums for fold generation
        let ofi = self.ofi_features.as_ref()
            .ok_or_else(|| anyhow::anyhow!("OFI features required — model is worthless without order flow data. Check fxcache has_ofi flag and --mbp10-data-dir."))?;
        let ofi_slice: Vec<[f64; OFI_DIM]> = ofi.to_vec();
        self.init_from_fxcache(&features, &targets, &ofi_slice).await?;

        // Generate regime-stratified folds using GPU prefix sums (O(1) range queries)
        let folds = if let Some(ref stream) = self.cuda_stream {
            // Upload features as f32 for regime classification (temporary, freed after fold gen)
            let features_flat: Vec<f32> = features.iter()
                .flat_map(|f| f.iter().map(|&v| v as f32))
                .collect();
            let features_f32_gpu = stream.clone_htod(&features_flat)
                .map_err(|e| anyhow::anyhow!("regime features f32 upload: {e}"))?;
            wf_config.generate_folds_stratified_gpu(stream, &features_f32_gpu, features.len())?
        } else {
            wf_config.generate_folds_stratified(training_data)
        };
        let num_folds = folds.len();
        if num_folds == 0 {
            return Err(anyhow::anyhow!(
                "Insufficient data for walk-forward: {} bars, need at least {} for one fold",
                features.len(),
                ((wf_config.initial_train_fraction + wf_config.val_fraction + wf_config.test_fraction) * features.len() as f64) as usize,
            ));
        }

        info!(
            "GPU walk-forward: {} folds, {} total bars",
            num_folds, features.len(),
        );

        // Log stratification quality summary: compute max regime deviation across folds
        {
            let global_features_flat: Vec<f64> = features.iter()
                .flat_map(|f| f.iter().copied())
                .collect();
            let (g_t, g_r, g_v) =
                ml_dqn::experience::regime_distribution_flat_f64(&global_features_flat, 42);

            let mut max_dev_across_folds = 0.0_f64;
            for fold in &folds {
                let val_flat: Vec<f64> = features[fold.val_start..fold.val_end]
                    .iter()
                    .flat_map(|f| f.iter().copied())
                    .collect();
                let (v_t, v_r, v_v) =
                    ml_dqn::experience::regime_distribution_flat_f64(&val_flat, 42);
                let dev = (v_t - g_t).abs().max((v_r - g_r).abs()).max((v_v - g_v).abs());
                max_dev_across_folds = max_dev_across_folds.max(dev);
            }

            info!(
                "Regime stratification: global T={:.1}% R={:.1}% V={:.1}%, max fold deviation={:.1}pp",
                g_t, g_r, g_v, max_dev_across_folds,
            );
        }

        let mut last_metrics = TrainingMetrics::new();

        // Task 14: Collect per-fold data for post-loop R² gap analysis
        let mut fold_volatile_pcts: Vec<f64> = Vec::with_capacity(num_folds);
        let mut fold_sharpes: Vec<f64> = Vec::with_capacity(num_folds);

        for fold_idx in 0..num_folds {
            let fold = &folds[fold_idx];

            // Plan C T11 follow-up — F1 ep2 explosion diagnostic (2026-04-29).
            // Surface the 0-based fold index on `self` so the per-step
            // FOLD_EXPLOSION_DIAG block in `training_loop.rs` can gate emission
            // to fold >= 1. Diagnostic-only; remove with the emit block once
            // root cause is identified.
            self.current_fold = fold_idx;

            info!(
                "=== Walk-Forward Fold {}/{} === train: {} bars, val: {} bars, test: {} bars",
                fold_idx + 1, num_folds, fold.train_len(), fold.val_len(), fold.test_len(),
            );

            // Set fold ranges for the GPU-resident data
            self.set_training_range(fold.train_start, fold.train_end, fold.val_start, fold.val_end);
            self.set_val_data_from_slices(
                &features[fold.val_start..fold.val_end],
                &targets[fold.val_start..fold.val_end],
                fold.train_end,
            );

            // SP15 Phase 1.7 — stash this fold's walk-forward test slice
            // [fold.test_start..fold.test_end). Pre-SP15 the trainer only
            // consumed train+val and silently dropped the 12.5% test range
            // per spec §6.7. The stash fires `test_data_observer` so unit
            // tests can verify the right bars were captured. The actual
            // `evaluate_dqn_graphed` invocation against this slice is
            // deferred to a follow-up commit per `feedback_no_partial_refactor`
            // — wiring a parallel `GpuBacktestEvaluator` instance plus TLOB
            // weight-sync is its own atomic refactor (matches Phase 1.5
            // trunk-consumer / Phase 1.6 dev-eval deferrals).
            if fold.test_end > fold.test_start {
                self.set_test_data_from_slices(
                    &features[fold.test_start..fold.test_end],
                    &targets[fold.test_start..fold.test_end],
                    fold.test_start,
                    fold.test_end,
                );
            }

            // Reset training state for new fold (keeps GPU infrastructure)
            self.reset_for_fold().await?;

            // Compute regime distribution for this fold's validation data
            let val_features_flat: Vec<f64> = features[fold.val_start..fold.val_end]
                .iter()
                .flat_map(|f| f.iter().copied())
                .collect();
            let (trending_pct, ranging_pct, volatile_pct) =
                ml_dqn::experience::regime_distribution_flat_f64(&val_features_flat, 42);

            // Task 15: Per-fold adaptive regime replay decay
            let max_regime_pct = trending_pct.max(ranging_pct).max(volatile_pct);
            let adaptive_decay = self.hyperparams.regime_replay_decay
                * (1.0 - max_regime_pct as f32 / 100.0);
            let adaptive_decay = adaptive_decay.clamp(0.05, 0.95);

            let dominant_regime: u8 = if trending_pct >= ranging_pct && trending_pct >= volatile_pct {
                0
            } else if volatile_pct >= ranging_pct {
                2
            } else {
                1
            };

            self.regime_replay_decay_override = adaptive_decay;
            self.fold_dominant_regime = dominant_regime;

            info!(
                "Fold {}/{} adaptive replay decay: {:.3} (base={:.3}, max_regime={:.1}%, dominant={})",
                fold_idx + 1, num_folds, adaptive_decay, self.hyperparams.regime_replay_decay,
                max_regime_pct,
                match dominant_regime { 0 => "Trending", 2 => "Volatile", _ => "Ranging" },
            );

            // Run training loop on this fold — uses the _slices path
            let fold_features = &features[fold.train_start..fold.train_end];
            let fold_targets = &targets[fold.train_start..fold.train_end];
            // Build lightweight training_data slice — same format as the
            // public `train_fold_from_slices` would build, but bypassing
            // the per-fold callback re-wrap so the same Arc<Mutex<F>>
            // handle (created once at the top of `train_walk_forward`)
            // flows into every fold's async-checkpoint worker.
            let fold_training_data: Vec<([f64; 42], [f64; 6])> = fold_features
                .iter()
                .zip(fold_targets.iter())
                .map(|(f, t)| (*f, *t))
                .collect();
            last_metrics = self
                .train_with_data_full_loop_slices(&fold_training_data, Arc::clone(&cb_handle))
                .await?;

            // Store regime distribution in metrics
            last_metrics.add_metric("regime_trending_pct", trending_pct);
            last_metrics.add_metric("regime_ranging_pct", ranging_pct);
            last_metrics.add_metric("regime_volatile_pct", volatile_pct);

            // Task 14: Regime-normalized Sharpe ratio
            let raw_sharpe = self.best_sharpe;
            let regime_difficulty = 1.0 + 0.5 * (volatile_pct / 100.0) - 0.3 * (trending_pct / 100.0);
            let normalized_sharpe = raw_sharpe * regime_difficulty;

            last_metrics.add_metric("regime_normalized_sharpe", normalized_sharpe);
            last_metrics.add_metric("regime_difficulty", regime_difficulty);

            info!(
                "Fold {}/{} Sharpe: raw={:.4}, regime-normalized={:.4} (difficulty={:.3})",
                fold_idx + 1, num_folds, raw_sharpe, normalized_sharpe, regime_difficulty,
            );

            fold_volatile_pcts.push(volatile_pct);
            fold_sharpes.push(raw_sharpe);

            info!(
                "Fold {}/{} complete: loss={:.6}, epochs={}, best_sharpe={:.4}",
                fold_idx + 1, num_folds,
                last_metrics.loss,
                last_metrics.epochs_trained,
                self.best_sharpe,
            );
            info!(
                "Fold {}/{} regime distribution: Trending={:.1}% Ranging={:.1}% Volatile={:.1}%",
                fold_idx + 1, num_folds,
                trending_pct, ranging_pct, volatile_pct,
            );
        }

        // Reset regime replay decay override after walk-forward completes
        self.regime_replay_decay_override = 1.0;
        self.fold_dominant_regime = 1;

        // Task 14: IS->OOS gap analysis (R^2 between volatile% and Sharpe)
        let n = fold_volatile_pcts.len() as f64;
        if n >= 3.0 {
            let mean_v = fold_volatile_pcts.iter().sum::<f64>() / n;
            let mean_s = fold_sharpes.iter().sum::<f64>() / n;
            let mut cov = 0.0;
            let mut var_v = 0.0;
            let mut var_s = 0.0;
            for i in 0..fold_volatile_pcts.len() {
                let dv = fold_volatile_pcts[i] - mean_v;
                let ds = fold_sharpes[i] - mean_s;
                cov += dv * ds;
                var_v += dv * dv;
                var_s += ds * ds;
            }
            let r = if var_v > 1e-10 && var_s > 1e-10 {
                cov / (var_v.sqrt() * var_s.sqrt())
            } else {
                0.0
            };
            let r_squared = r * r;

            let verdict = if r_squared > 0.7 {
                "regime-driven (data problem \u{2014} regime distribution varies across folds)"
            } else if r_squared < 0.3 {
                "model-driven (architecture/hyperparams \u{2014} model generalizes poorly)"
            } else {
                "mixed (both regime and model contribute)"
            };

            info!(
                "IS\u{2192}OOS gap analysis: R\u{00B2}={:.3} between volatile% and Sharpe \u{2192} {}",
                r_squared, verdict,
            );

            last_metrics.add_metric("gap_r_squared", r_squared);
        }

        // Clean up GPU walk-forward buffers
        self.gpu_walk_forward = None;

        Ok(last_metrics)
    }


    
    /// Calculate adaptive bounds with margin
    ///
    /// # Arguments
    ///
    /// * `stats` - Q-value statistics from Phase 1
    /// * `margin` - Safety margin as fraction (e.g., 0.3 = 30%)
    ///
    /// # Returns
    ///
    /// Tuple of (v_min, v_max) with safety margin applied
    fn calculate_adaptive_bounds(stats: &QValueStats, margin: f64) -> (f64, f64) {
        let range = stats.max - stats.min;
        let v_min = stats.min - range * margin;
        let v_max = stats.max + range * margin;
        // Cap at ±10,000 to prevent explosion
        (v_min.max(-10000.0), v_max.min(10000.0))
    }
    
    /// Reinitialize categorical distribution with new bounds
    async fn reinit_categorical_distribution(&mut self, v_min: f64, v_max: f64) -> Result<(), crate::MLError> {
        let mut agent = self.agent.write().await;
        agent.agent.reinit_categorical_distribution(v_min, v_max)?;
        Ok(())
    }















    /// Calculate reward based on price movement
    ///
    /// # Arguments
    /// * `current_close` - Current bar's close price
    /// * `next_close` - Next bar's close price (target)
    ///
    /// # Returns
    /// Normalized reward in [-1.0, 1.0] based on price change
    fn calculate_reward(&self, current_close: f64, next_close: f64) -> f32 {
        let price_change = next_close - current_close;
        // Normalize by 10.0 for ES futures typical moves (±10 points)
        // Clamp to [-1.0, 1.0] to prevent extreme rewards
        (price_change / 10.0).clamp(-1.0, 1.0) as f32
    }

    /// Check if we can train (buffer has enough samples)
    async fn can_train(&self) -> Result<bool> {
        let agent = self.agent.read().await;
        Ok(agent.can_train())
    }




    /// Get current epsilon value
    async fn get_epsilon(&self) -> Result<f64> {
        let agent = self.agent.read().await;
        Ok(agent.get_epsilon() as f64)
    }

    /// Set epsilon value (used for deterministic evaluation)
    async fn set_epsilon(&self, epsilon: f64) -> Result<()> {
        let mut agent = self.agent.write().await;
        agent.set_epsilon(epsilon);
        Ok(())
    }

    /// Get best validation loss achieved during training
    ///
    /// Returns the lowest validation loss seen across all epochs.
    /// Used by hyperopt adapter to optimize for generalization.
    pub fn get_best_val_loss(&self) -> f64 {
        self.best_val_loss
    }

    /// Get epoch number where best validation loss was achieved
    ///
    /// Returns the 1-indexed epoch number with the best validation loss.
    pub fn get_best_epoch(&self) -> usize {
        self.best_epoch
    }

    /// Get validation data for backtest integration
    ///
    /// Returns a reference to the validation dataset for hyperopt backtest evaluation.
    /// Each entry contains a FeatureVector (42 market + 3 portfolio = 45 dims) and the corresponding target values.
    /// Used by hyperopt adapter to run backtests on unseen data after training.
    pub fn get_val_data(&self) -> &[(FeatureVector, Vec<f64>)] {
        &self.val_data
    }




    /// Update portfolio tracker to reflect current position from backtest engine.
    ///
    /// Called between chunks so the next chunk's portfolio features
    /// accurately reflect the current position (direction, value, exposure).
    pub fn set_portfolio_for_backtest(
        &mut self,
        position_size: f32,
        entry_price: f32,
        current_price: f32,
    ) {
        self.portfolio_tracker = PortfolioTracker::new(
            self.portfolio_tracker.initial_capital(),
            self.portfolio_tracker.spread(),
            0.0,
        );
        if position_size.abs() > f32::EPSILON {
            self.portfolio_tracker
                .set_position_direct(position_size, entry_price, current_price);
        }
    }

    /// Get the device used by this trainer
    pub fn device(&self) -> &MlDevice {
        &self.device
    }

    /// Get access to the DQN agent
    ///
    /// Returns a reference to the Arc<RwLock<DQNAgentType>> for checkpoint saving.
    /// Used by hyperopt adapter to save model weights after training.
    pub fn get_agent(&self) -> &Arc<RwLock<DQNAgentType>> {
        &self.agent
    }

    /// Get GPU-resident online weights from fused CUDA trainer.
    /// These are the ACTUAL trained weights (updated by Adam each step).
    /// Do NOT use agent.get_q_network_vars() — that reads the Candle VarMap
    /// which is NOT synced during fused CUDA training.
    pub fn fused_online_weights(&self) -> Option<(
        &crate::cuda_pipeline::gpu_weights::DuelingWeightSet,
        &crate::cuda_pipeline::gpu_weights::BranchingWeightSet,
    )> {
        self.fused_ctx.as_ref().map(|f| (f.online_dueling_ref(), f.online_branching_ref()))
    }

    /// Snapshot current GPU weights as best model. Pure async DtoD.
    pub fn save_best_gpu_params(&mut self) -> anyhow::Result<()> {
        if let Some(ref mut fused) = self.fused_ctx {
            fused.save_best_params()
        } else {
            Ok(())
        }
    }

    /// Restore best-epoch GPU weights for evaluation. Async DtoD + unflatten + event.
    pub fn restore_best_gpu_params(&mut self) -> anyhow::Result<()> {
        if let Some(ref mut fused) = self.fused_ctx {
            fused.restore_best_params()
        } else {
            Ok(())
        }
    }

    /// Wait for restore_best_gpu_params writes on a different stream (GPU-side, no CPU stall).
    pub fn wait_restore_on_stream(&self, stream: &std::sync::Arc<cudarc::driver::CudaStream>) -> anyhow::Result<()> {
        if let Some(ref fused) = self.fused_ctx {
            fused.wait_restore_on_stream(stream)
        } else {
            Ok(())
        }
    }

    /// Get reference to training hyperparameters
    ///
    /// Returns a reference to the DQN hyperparameters used for this trainer.
    /// Used by tests to validate configuration.
    pub fn hyperparams(&self) -> &DQNHyperparameters {
        &self.hyperparams
    }

    /// Drain in-flight async best-checkpoint workers. Each
    /// `tokio::task::spawn_blocking` JoinHandle is awaited; failures are
    /// logged but do not propagate (best-checkpoint is non-fatal —
    /// training has already advanced past the improvement epoch). Called:
    /// (a) at training end before returning final metrics,
    /// (b) immediately before any synchronous checkpoint write so that
    ///     a slower in-flight best ckpt doesn't race with the periodic /
    ///     early-stop write that follows.
    pub(crate) async fn await_pending_checkpoint_handles(&mut self) {
        if self.pending_checkpoint_handles.is_empty() {
            return;
        }
        let n = self.pending_checkpoint_handles.len();
        info!("Draining {} in-flight best-checkpoint worker(s)", n);
        let handles = std::mem::take(&mut self.pending_checkpoint_handles);
        for join in handles {
            match join.await {
                Ok(Ok(())) => {}
                Ok(Err(e)) => tracing::warn!(
                    "Async best-checkpoint worker returned error (non-fatal): {e:#}"
                ),
                Err(e) => tracing::warn!(
                    "Async best-checkpoint worker panicked or was cancelled: {e}"
                ),
            }
        }
    }

    /// Get current learning rate from scheduler
    ///
    /// Returns the current learning rate after applying warmup and decay.
    /// Used by tests and monitoring to track LR schedule.
    pub fn get_current_lr(&self) -> f64 {
        self.lr_scheduler.get_lr()
    }

    /// Serialize model to bytes with architecture metadata embedded in safetensors header.
    ///
    /// Serializes the single DQN (MoE + branching heads) into a safetensors file.
    /// The Phase 3 MoE gate and 8 expert weights are included via the branching
    /// network's `named_weight_slices`. Phase 4 removed the 3-head
    /// `RegimeConditionalDQN` wrapper; checkpoint format is now a single flat
    /// namespace (no `trending__` / `ranging__` / `volatile__` prefixes).
    ///
    /// Internally delegates to `snapshot_model_to_pinned` for the GPU readback
    /// (mapped-pinned DtoD-into-mapped, single stream sync — replaces the
    /// previous N-DtoH chain that violated `feedback_no_htod_htoh_only_mapped_pinned.md`)
    /// and to `serialize_snapshot_bytes` for the pure-CPU safetensors construction.
    pub async fn serialize_model(&self) -> Result<Vec<u8>> {
        let snap = self.snapshot_model_to_pinned().await?;
        Self::serialize_snapshot_bytes(&snap)
    }

    /// Snapshot the BranchingDuelingQNetwork's named weight tensors into a
    /// single mapped-pinned host buffer (`cuMemHostAlloc DEVICEMAP`). The
    /// kernel-resident slices are copied via `cuMemcpyDtoDAsync` into the
    /// buffer's device pointer (which aliases the host page), then a single
    /// stream sync makes the bytes visible to the CPU. This is the only
    /// allowed CPU↔GPU path per `feedback_no_htod_htoh_only_mapped_pinned.md`.
    ///
    /// Returns a fully self-contained `CheckpointSnapshot`: the host bytes
    /// are owned (copied out of the pinned buffer so the buffer can be
    /// dropped or reused), the per-tensor metadata is captured at snapshot
    /// time, and the architecture metadata is cloned. The result is `Send +
    /// 'static`, suitable for moving into `tokio::task::spawn_blocking` for
    /// off-thread safetensors construction + checkpoint_callback invocation.
    pub(crate) async fn snapshot_model_to_pinned(&self) -> Result<CheckpointSnapshot> {
        use crate::cuda_pipeline::mapped_pinned::MappedF32Buffer;
        let agent = self.agent.read().await;
        let stream = self.cuda_stream.as_ref()
            .ok_or_else(|| anyhow::anyhow!("CUDA stream required for snapshot_model_to_pinned"))?;

        // Build a flat layout for all named weight tensors. Each entry
        // records the f32 offset (not byte) so cuMemcpyDtoDAsync can target
        // (mapped.dev_ptr + offset_f32 * sizeof(f32)). One single
        // mapped-pinned buffer holds the concatenation of all tensors.
        let mut entries: Vec<TensorSnapshotEntry> = Vec::new();
        let mut total_f32 = 0_usize;
        if let Some(ref br) = agent.agent.branching_q_network {
            for (name, slice, shape) in br.named_weight_slices() {
                let n_elems: usize = shape.iter().product();
                if n_elems == 0 || slice.len() < n_elems {
                    return Err(anyhow::anyhow!(
                        "snapshot tensor '{name}': slice len {} < shape product {n_elems}",
                        slice.len(),
                    ));
                }
                entries.push(TensorSnapshotEntry {
                    name,
                    f32_offset: total_f32,
                    f32_len: n_elems,
                    shape,
                });
                total_f32 += n_elems;
            }
        }
        let arch_metadata = Some(agent.checkpoint_metadata());

        if total_f32 == 0 {
            // Pathological — no tensors to serialise. Return an empty snapshot.
            return Ok(CheckpointSnapshot {
                tensors: entries,
                host_bytes: Vec::new(),
                arch_metadata,
            });
        }

        // Allocate the mapped-pinned scratch. The dev_ptr aliases the host
        // page; cuMemcpyDtoDAsync into the dev_ptr is host-visible after
        // stream sync (mapped pinned coherence semantics).
        let pinned = unsafe { MappedF32Buffer::new(total_f32) }
            .map_err(|e| anyhow::anyhow!("snapshot mapped-pinned alloc ({total_f32} f32): {e}"))?;

        // Hold the read lock across the DtoD-async kernel submissions AND
        // the stream sync — the slice references must survive long enough
        // for the kernels to dispatch and complete. Releasing before the
        // sync would let a concurrent writer reallocate the underlying
        // CudaSlice while the kernel is still reading it. The lock is
        // released after `stream.synchronize()` returns, by which point
        // the bytes are committed to the mapped-pinned host page.
        if let Some(ref br) = agent.agent.branching_q_network {
            for ((_name, slice, _shape), entry) in
                br.named_weight_slices().into_iter().zip(entries.iter())
            {
                let n_bytes = entry.f32_len * std::mem::size_of::<f32>();
                let dst_ptr = pinned.dev_ptr
                    + (entry.f32_offset * std::mem::size_of::<f32>()) as u64;
                let src_ptr = cudarc::driver::DevicePtr::device_ptr(slice, stream).0;
                unsafe {
                    cudarc::driver::result::memcpy_dtod_async(
                        dst_ptr, src_ptr, n_bytes, stream.cu_stream(),
                    )
                }
                .map_err(|e| anyhow::anyhow!(
                    "snapshot DtoD '{}': {e}", entry.name,
                ))?;
            }
        }

        // Single sync — all per-tensor copies above batched onto the same
        // training stream. The async-checkpoint worker (when wired) will
        // wait on a recorded event instead, but the synchronous fallback
        // path used by periodic / plateau-exhausted checkpoints needs the
        // bytes immediately. The cost is bounded by the slowest pending
        // kernel on `cuda_stream`, not N round-trips as before.
        stream.synchronize()
            .map_err(|e| anyhow::anyhow!("snapshot stream sync: {e}"))?;
        drop(agent);

        // Copy the mapped-pinned f32s into a plain Vec<u8>. The pinned
        // buffer is dropped at end-of-scope; the bytes are now owned and
        // Send+'static — safe to move into spawn_blocking.
        let host_bytes: Vec<u8> = unsafe {
            let f32_slice = std::slice::from_raw_parts(pinned.host_ptr, total_f32);
            let byte_slice = std::slice::from_raw_parts(
                f32_slice.as_ptr() as *const u8,
                total_f32 * std::mem::size_of::<f32>(),
            );
            byte_slice.to_vec()
        };

        Ok(CheckpointSnapshot {
            tensors: entries,
            host_bytes,
            arch_metadata,
        })
    }

    /// Pure-CPU safetensors construction from a `CheckpointSnapshot`. Called
    /// either inline by `serialize_model` (synchronous cold paths: periodic
    /// + plateau-exhausted + early-stop) or off-thread by the async
    /// checkpoint worker (`tokio::task::spawn_blocking` on best-improvement
    /// epochs). Static so the worker can call it without holding `&self`.
    pub(crate) fn serialize_snapshot_bytes(snap: &CheckpointSnapshot) -> Result<Vec<u8>> {
        let mut st_views = std::collections::HashMap::new();
        for entry in &snap.tensors {
            let byte_offset = entry.f32_offset * std::mem::size_of::<f32>();
            let byte_len = entry.f32_len * std::mem::size_of::<f32>();
            let bytes: &[u8] = &snap.host_bytes[byte_offset..byte_offset + byte_len];
            let view = safetensors::tensor::TensorView::new(
                safetensors::Dtype::F32, entry.shape.clone(), bytes,
            ).map_err(|e| anyhow::anyhow!("TensorView '{}': {e}", entry.name))?;
            st_views.insert(entry.name.as_str(), view);
        }
        let data = safetensors::serialize(st_views, snap.arch_metadata.clone())
            .map_err(|e| anyhow::anyhow!("Failed to serialize safetensors: {}", e))?;
        Ok(data)
    }

    /// Upload full fxcache dataset to GPU ONCE. Call before the fold loop.
    /// Replaces per-fold init_gpu_data + init_gpu_raw_buffers.
    pub async fn init_from_fxcache(
        &mut self,
        features: &[[f64; 42]],
        targets: &[[f64; 6]],
        ofi: &[[f64; OFI_DIM]],
    ) -> Result<()> {
        let stream = self.cuda_stream.as_ref()
            .ok_or_else(|| anyhow::anyhow!("CUDA stream required for init_from_fxcache"))?;

        // Upload via DqnGpuData::upload_slices (contiguous, zero tuple unpacking)
        let mut gpu_data = crate::cuda_pipeline::DqnGpuData::upload_slices(
            features, targets, ofi, stream,
        ).map_err(|e| anyhow::anyhow!("DqnGpuData::upload_slices failed: {e}"))?;

        let raw_dim: usize = 42 + 3 + OFI_DIM; // market + portfolio (legacy 3-slot) + OFI
        let aligned_dim = (raw_dim + 7) & !7;
        gpu_data.set_aligned_state_dim(aligned_dim);

        tracing::info!(
            "init_from_fxcache: {} bars uploaded to GPU ({:.1} MB, OFI=true)",
            features.len(),
            (features.len() * (42 + 6 + OFI_DIM) * 4) as f64 / 1_048_576.0,
        );
        self.gpu_data = Some(gpu_data);

        // OFI is unconditional — empty OFI means the fxcache was built wrong.
        if ofi.is_empty() {
            return Err(anyhow::anyhow!(
                "init_from_fxcache called with empty OFI — rebuild fxcache with --mbp10-data-dir"
            ));
        }
        self.ofi_features = Some(std::sync::Arc::from(ofi));

        // Also upload raw buffers for experience collector
        self.init_gpu_raw_buffers_from_slices(features, targets).await?;

        Ok(())
    }

    /// Set validation data from contiguous fxcache slices.
    /// Converts to legacy Vec format internally — val set is small (~50K bars).
    pub fn set_val_data_from_slices(
        &mut self,
        features: &[[f64; 42]],
        targets: &[[f64; 6]],
        ofi_val_offset: usize,
    ) {
        self.val_data = features.iter().zip(targets.iter())
            .map(|(f, t)| (*f, t.to_vec()))
            .collect();
        self.ofi_val_offset = ofi_val_offset;
        // Keep gpu_evaluator for deterministic cuBLAS; validation data changes are handled
        // by lazy-reinit in evaluate_val_sharpe when the window size differs.
    }

    /// SP15 Phase 1.7 — stash the current fold's walk-forward test slice
    /// (`features[fold.test_start..fold.test_end]`) so the trainer can later
    /// evaluate the held-out test region. Pre-SP15 this slice was generated
    /// by `GpuWalkForwardConfig` but never read by the trainer (12.5% of the
    /// train range silently dropped per spec §6.7).
    ///
    /// Fires `test_data_observer` (if registered) with the stashed range so
    /// unit tests can assert the fold loop wires the right bars without a
    /// live GPU eval. The actual `evaluate_dqn_graphed` invocation on this
    /// slice is deferred — see the doc-comment on `test_features` for the
    /// rationale and the Phase 1.5 / 1.6 deferral precedent.
    pub fn set_test_data_from_slices(
        &mut self,
        test_features: &[[f64; 42]],
        test_targets: &[[f64; 6]],
        test_start_bar: usize,
        test_end_bar: usize,
    ) {
        self.test_features = Some(test_features.to_vec());
        self.test_targets = Some(test_targets.to_vec());
        self.test_start_bar = test_start_bar;
        self.test_end_bar = test_end_bar;
        if let Some(obs) = &self.test_data_observer {
            obs(test_start_bar, test_end_bar);
        }
    }

    /// SP15 Phase 1.7 — register a test-only observer that fires on every
    /// `set_test_data_from_slices` call. Production code leaves this unset;
    /// the SP15 oracle test `test_slice_consumed_per_fold` uses it to verify
    /// the fold loop stashes `[fold.test_start..fold.test_end)`.
    pub fn set_test_data_observer<F: Fn(usize, usize) + Send + Sync + 'static>(
        &mut self,
        observer: F,
    ) {
        self.test_data_observer = Some(Box::new(observer));
    }

    /// Inject pre-uploaded GPU data.
    pub fn set_gpu_data(&mut self, data: DqnGpuData) {
        info!(
            "DqnTrainer: injected pre-uploaded GPU data ({} bars, {:.1} MB)",
            data.num_bars,
            data.vram_bytes() as f64 / 1_048_576.0,
        );
        self.gpu_data = Some(data);
    }

    /// Drop cached GPU data, freeing VRAM for the next fold.
    pub fn clear_gpu_data(&mut self) {
        if self.gpu_data.is_some() {
            info!("DqnTrainer: cleared GPU data (VRAM freed)");
            self.gpu_data = None;
        }
    }

    /// BUG #38 FIX: Clear replay buffer of contaminated experiences
    pub async fn clear_replay_buffer(&mut self) -> Result<()> {
        let mut agent = self.agent.write().await;
        agent.clear_replay_buffer().map_err(|e| {
            anyhow::anyhow!("Failed to clear replay buffer: {}", e)
        })?;
        let buffer_size = agent.get_replay_buffer_size().unwrap_or(0);
        info!("Replay buffer cleared successfully. Current size: {}", buffer_size);
        Ok(())
    }

    /// BUG #38 FIX: Reset target network to match current network
    pub async fn reset_target_network(&mut self) -> Result<()> {
        let mut agent = self.agent.write().await;
        agent.reset_target_network().map_err(|e| {
            anyhow::anyhow!("Failed to reset target network: {}", e)
        })?;
        info!("Target network reset successfully");
        Ok(())
    }




    /// Reset per-fold training state while keeping GPU infrastructure.
    ///
    /// Clears: replay buffer, epoch counters, loss/Q-value/Sharpe history.
    /// Keeps: CUDA context, compiled kernels, CUDA graphs, cuBLAS handles,
    ///        GPU data buffers (features_raw_cuda, targets_raw_cuda),
    ///        experience collector, fused training context.
    pub async fn reset_for_fold(&mut self) -> Result<()> {
        // Clear replay buffer
        self.clear_replay_buffer().await?;

        // Reset epoch counters
        self.current_epoch = 0;
        self.gradient_logging_step = 0;
        // SP14 B.11 (2026-05-05): EGF warmup-gate counter resets at fold
        // boundary so the new fold cold-starts with α_grad force-closed
        // and ramps over WARMUP_STEPS_FALLBACK steps. Without this, fold N
        // SP14 L1+L2 follow-up: fold_step_counter reset removed (counter
        // deleted; variance-driven k_aux/k_q provide warmup intrinsically).

        // Reset loss/Q-value history
        self.loss_history.clear();
        self.q_value_history.clear();
        self.val_loss_history.clear();
        self.sharpe_history.clear();
        self.safety_loss_history.clear();

        // Reset Q-drift kill criterion's prev-baseline. Without this, the
        // kill ratio at fold-N epoch 0 is computed against fold-(N-1)
        // epoch 5's q_mean — a stale cross-fold baseline that fires
        // spuriously when fold N legitimately starts from a different
        // q-scale (different policy, different replay distribution).
        // Pairs with q_value_history clear above (the source of q_mean).
        // Plan C Phase 2 follow-up A.2 (2026-04-29) companion: the
        // `isv_q_drift_rate` registry entry below resets ISV[Q_DRIFT_RATE_
        // INDEX=129] to 0.0 — both must clear together so the new fold's
        // first epoch sees no inherited drift dampening (the launch_q_drift
        // _rate_ema gate `prev_epoch_q_mean.abs() > 1e-6` skips cold-start).
        self.prev_epoch_q_mean = 0.0;
        self.adaptive_tau = f64::from(self.hyperparams.tau);
        // Plan C Phase 2 follow-up H (2026-04-29): clear the rolling q_mean
        // window alongside `prev_epoch_q_mean` so the new fold's MAD-deviation
        // criterion starts fresh. Cross-fold q_mean histories carry policy and
        // replay-distribution shifts that legitimately move the median; mixing
        // them with the new fold's epochs would inflate MAD or shift the
        // median, masking real runaway in the new fold OR firing spuriously
        // on the new fold's first divergent epoch (same rationale that drove
        // the A.1 prev_epoch_q_mean reset).
        self.q_mean_window.clear();

        // Plan C T11 follow-up — F1 ep2 explosion diagnostic (2026-04-29).
        // Diagnostic-only fold-local state. `last_logged_grad_norm` is the
        // prev-step grad-norm baseline used for the 5x jump trigger; clearing
        // it ensures the new fold's first guard-step doesn't fire a spurious
        // ratio against the previous fold's last grad. Likewise zero out the
        // post-trigger trajectory counter so a mid-fold-N trigger doesn't
        // bleed into fold-(N+1)'s first steps.
        self.last_logged_grad_norm = None;
        self.explosion_diag_steps_remaining = 0;

        // Reset best-checkpoint tracking so each fold competes independently.
        self.best_sharpe = f64::NEG_INFINITY;
        self.best_epoch = 0;
        self.best_val_loss = f64::INFINITY;

        // Plan C T11 follow-up A.3 (2026-04-29): reset DQN agent state that's
        // keyed to fold lifecycle (gradient_collapse_counter, training_steps).
        // Pairs with the prev_epoch_q_mean reset above — see DQN::reset_for_fold
        // for the contract. Without this, smoke smoke-test-vh9bj fold 1 ep1
        // tripped the gradient-collapse patience instantly because fold 0's
        // late-epoch near-zero-grad steps had pushed the counter near patience,
        // and `past_warmup = training_steps > warmup_steps` was always-true in
        // fold 1+ (no per-fold warmup grace period for data distribution shifts).
        // Same fold-boundary state-reset gap pattern as A.1.
        {
            let mut agent = self.agent.write().await;
            agent.reset_for_fold();
        }

        // Plan 1 Task 3 + Plan 2 Task 4: registry-driven fold-boundary reset.
        // FoldReset entries are cleared/zeroed; SoftReset entries are written
        // to their bootstrap values (CPU-born input, not adaptive computation)
        // so the GPU kernel's adaptive-rate EMA converges from bootstrap toward
        // observed values over the next fold's epochs (D.5 Option A).
        // Adding new state of either category requires a registry entry AND a
        // dispatch arm in reset_named_state (Invariant 2 Wire-It-Up).
        let registry = StateResetRegistry::new();
        for entry in registry.fold_reset_entries() {
            self.reset_named_state(entry.name, 0u32)
                .map_err(|e| anyhow::anyhow!("fold-reset '{}': {}", entry.name, e))?;
        }
        for entry in registry.soft_reset_entries() {
            self.reset_named_state(entry.name, 0u32)
                .map_err(|e| anyhow::anyhow!("soft-reset '{}': {}", entry.name, e))?;
        }

        // Reset fused training context state (keep graphs, reset step counters).
        // This handles Adam state, IQN Adam, shrink-and-perturb, CUDA graph
        // invalidation, and PopArt — none of which are FoldReset registry entries
        // (Adam/network are TrainingPersist; Adam is reset explicitly here as a
        // special-case stabilisation, not a lifecycle category).
        if let Some(ref mut fused) = self.fused_ctx {
            fused.reset_for_fold()?;
        }

        // Reset training guard accumulators
        if let Some(ref mut guard) = self.training_guard {
            guard.reset_accumulators()
                .map_err(|e| anyhow::anyhow!("guard reset: {e}"))?;
        }

        // P5T2 fix: clear regression-detection consecutive-warn/error streaks
        // at fold boundary. Walk-forward folds are independent training runs;
        // without this reset a fold-2 epoch in the error band can trip
        // termination because earlier folds each contributed accumulated
        // streak counts. Found during P5T5 Phase D smoke (avg_grad_norm 3e7
        // tripped after 6 cumulative cross-fold epochs even though no single
        // fold individually crossed the 2N threshold).
        self.metric_bands.reset_streaks();

        // Task 2.5 Bug #5 — reset controller fire counters + total-epochs +
        // prev-controller snapshot at fold boundary. Without this, Track 3
        // C2/C5 fold-boundary artefacts (cosine-annealed tau jumps when
        // train_step resets, cql_alpha schedule drift across folds) would
        // accumulate into a multi-fold running count, misrepresenting the
        // per-fold fire rate for the controller_activity smoke gate.
        // `last_anti_mult` is within-epoch state (reset in reset_epoch_state)
        // and not reset here.
        self.controller_fire_counts = ControllerFireCounts::default();
        self.controller_total_epochs = 0;
        self.prev_controller_values = ControllerPrevValues::default();

        // Keep gpu_data — the full dataset is already on GPU via init_from_fxcache.
        // Clearing it would force a redundant re-upload on the next fold.

        info!("DQNTrainer: reset for new fold (GPU infrastructure preserved)");
        Ok(())
    }

    /// Set the training data range for the current fold.
    /// The experience collector and training loop will use this range
    /// to index into GPU-resident features/targets arrays.
    pub fn set_training_range(&mut self, train_start: usize, train_end: usize, val_start: usize, val_end: usize) {
        self.fold_train_start = train_start;
        self.fold_train_end = train_end;
        self.fold_val_start = val_start;
        self.fold_val_end = val_end;
    }

    /// Get per-epoch training loss history (for smoke test verification)
    pub fn loss_history(&self) -> &[f64] {
        &self.loss_history
    }

    /// Get per-epoch validation loss history
    pub fn val_loss_history(&self) -> &[f64] {
        &self.val_loss_history
    }

    /// Per-magnitude action distribution from the last completed epoch.
    /// Layout: [Quarter, Half, Full] fractions summing to ≤ 1.0. Used by
    /// magnitude_distribution smoke test to detect magnitude-branch collapse.
    pub fn magnitude_action_dist_final(&self) -> [f32; 3] {
        self.last_magnitude_dist
    }

    /// SP8 (Fix 36, 2026-05-03): GPU-resident `train_active_frac` canary.
    /// Reads from `ISV[TRAIN_ACTIVE_FRAC_INDEX]` (Pearls A+D smoothed),
    /// produced by `train_active_frac_compute_kernel` after the per-epoch
    /// monitoring reduce. Replaces the host-cached `last_train_active_frac`
    /// field per `feedback_no_cpu_compute_strict.md`. Returns 0.0 if no
    /// fused_ctx is initialised (CPU-only build path); 0.0 otherwise until
    /// the first per-epoch producer launch fires Pearl A's first-observation
    /// replacement.
    ///
    /// Used by `metrics.rs`'s HEALTH_DIAG `train_active_frac` line emitted
    /// adjacent to `val_active_frac` so operators can compare the train-side
    /// and eval-side directional commitment in lockstep. Required for Plan
    /// D's L3 verification gate (target > 0.40 = at least 40% of training-
    /// rollout actions are Long or Short).
    pub fn last_train_active_frac(&self) -> f32 {
        use crate::cuda_pipeline::sp5_isv_slots::TRAIN_ACTIVE_FRAC_INDEX;
        if let Some(ref fused) = self.fused_ctx {
            fused.trainer().read_isv_signal_at(TRAIN_ACTIVE_FRAC_INDEX)
        } else {
            0.0_f32
        }
    }

    /// Per-magnitude action distribution from the most recent VALIDATION
    /// backtest (eval-mode rollout, distinct from training-mode action_dist).
    /// Layout: [Quarter, Half, Full] in [0, 1]. Used by the magnitude_distribution
    /// smoke test for the H10 regression assertion (Task 2.2): eval-time
    /// argmax over near-tied Q-values must not collapse to bin 0.
    pub fn last_eval_magnitude_dist(&self) -> [f32; 3] {
        self.last_eval_magnitude_dist
    }

    /// Pure-policy magnitude-intent distribution from the most recent
    /// VALIDATION backtest — what the magnitude Q-head WOULD pick if
    /// direction gating (Hold/Flat → mag=0) and Kelly/margin caps didn't
    /// mask the realised `actual_mag`. Layout: [Quarter, Half, Full] in
    /// [0, 1]. Diagnostic-only — complements `last_eval_magnitude_dist`
    /// when Kelly cold-start pins realised magnitude to Quarter.
    pub fn last_eval_intent_magnitude_dist(&self) -> [f32; 3] {
        self.last_eval_intent_magnitude_dist
    }

    /// Task 2.X diagnostic — per-direction eval action distribution, readback
    /// from the most recent validation backtest. Layout: [Short, Hold, Long,
    /// Flat] in [0, 1]. When magnitude_distribution's `ef` collapses to zero
    /// this lets the test surface whether the root cause is direction-side
    /// (Hold + Flat > 0.95) or magnitude-side (direction healthy but mag
    /// collapsed). Returns `None` if no GPU evaluator is wired.
    pub fn last_eval_direction_dist(&self) -> Option<[f32; 4]> {
        self.gpu_evaluator
            .as_ref()
            .and_then(|ev| ev.read_eval_action_distribution_per_direction().ok())
    }

    /// Task 2.X diagnostic — ISV slots [13..16] (per-magnitude-bin Q-mean
    /// EMAs + absolute-scale reference). Returns
    /// `(q_mean_quarter, q_mean_half, q_mean_full, q_abs_ref)` or `None`
    /// if the fused trainer has not been initialised yet.
    pub fn last_isv_magnitude_bin_q_means(&self) -> Option<(f32, f32, f32, f32)> {
        self.fused_ctx
            .as_ref()
            .map(|ctx| ctx.trainer().read_isv_magnitude_bin_q_means())
    }

    /// Task 2.Y diagnostic — ISV slots [17..21] (per-direction-bin Q-mean
    /// EMAs + absolute-scale reference). Returns
    /// `(q_mean_short, q_mean_hold, q_mean_long, q_mean_flat, q_dir_abs_ref)`
    /// or `None` if the fused trainer has not been initialised yet. Used by
    /// the magnitude_distribution smoke test to log the direction-branch
    /// bin-weight engagement state.
    pub fn last_isv_direction_bin_q_means(&self) -> Option<(f32, f32, f32, f32, f32)> {
        self.fused_ctx
            .as_ref()
            .map(|ctx| ctx.trainer().read_isv_direction_bin_q_means())
    }

    /// Per-epoch magnitude-branch action entropy history.
    /// Each entry is (epoch_0_indexed, normalized_entropy ∈ [0, 1]).
    /// Used by exploration_coverage smoke test.
    pub fn exploration_entropy_history(&self) -> &[(u32, f32)] {
        &self.explore_entropy_mag_history
    }

    /// Per-term reward contribution summary (Track 2 audit).
    /// Layout: [popart, cf_flip, trail, micro].
    /// Each entry's interpretation depends on the term's measurement class
    /// per spec §4.1 (additive vs transform vs sample-selector). Used by
    /// reward_component_audit smoke test.
    ///
    /// Task 0.8: populated from HEALTH_DIAG emissions in the epoch-boundary
    /// block (training_loop.rs). Always finite + non-negative per the smoke
    /// test contract.
    ///
    /// History: `segment_patience` slot deleted (Task 0.8) — the kernel never
    /// wired a patience term so the slot was pure stub. `loss_aversion` slot
    /// deleted (Task 2.4) — R6 clamp relocated to c51_loss_kernel.cu::
    /// block_bellman_project_f (Q-target smoothing) so the reward-layer
    /// diagnostic no longer has a source signal.
    pub fn reward_component_audit_summary(&self) -> [f32; 4] {
        self.last_reward_contrib
    }

    /// Per-controller firing rates across all completed epochs.
    /// Layout: [anti_lr, tau, gamma, grad_clip, cql_alpha, cost_anneal].
    /// Each rate = epochs-where-value-changed / total-epochs. Used by the
    /// controller_activity smoke test to detect load-bearing controllers.
    pub fn controller_fire_rates_final(&self) -> [f32; 6] {
        let total = self.controller_total_epochs.max(1) as f32;
        [
            self.controller_fire_counts.anti_lr as f32 / total,
            self.controller_fire_counts.tau as f32 / total,
            self.controller_fire_counts.gamma as f32 / total,
            self.controller_fire_counts.grad_clip as f32 / total,
            self.controller_fire_counts.cql_alpha as f32 / total,
            self.controller_fire_counts.cost_anneal as f32 / total,
        ]
    }

    /// Get current epsilon from the DQN agent
    pub async fn get_agent_epsilon(&self) -> f32 {
        let agent_lock = self.agent.read().await;
        agent_lock.get_epsilon()
    }
}

// ---------------------------------------------------------------------------
// GPU Q-value diagnostics (Task 6)
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Test-only accessors: Plan 2 Task 2 (D.1 Mamba2 backward validation)
// ---------------------------------------------------------------------------
#[cfg(test)]
impl DQNTrainer {
    /// Frobenius norm of the Mamba2 weight gradient buffer after a training step.
    ///
    /// Delegates to `GpuDqnTrainer::mamba2_grad_norm_for_test()` via the
    /// active `fused_ctx`.  Returns 0.0 if the fused context is not yet
    /// initialised (no GPU data uploaded).
    pub(crate) fn mamba2_weight_grad_norm_for_test(&self) -> anyhow::Result<f32> {
        match &self.fused_ctx {
            None => Ok(0.0),
            Some(ctx) => ctx.trainer().mamba2_grad_norm_for_test()
                .map_err(|e| anyhow::anyhow!("mamba2_grad_norm_for_test: {e}")),
        }
    }
}
