//! Training profile loader for ML model hyperparameters.
//!
//! Loads TOML training profiles with 3-tier resolution:
//! 1. `$FOXHUNT_TRAINING_PROFILE` env var (exact file path, highest priority)
//! 2. `config/training/<profile>.toml` relative to workspace root
//! 3. Embedded compile-time defaults (always available, zero filesystem dependency)
//!
//! All section structs use `Option<T>` fields so sparse profiles work correctly —
//! only the fields present in the TOML override the caller's existing hyperparameters.
//!
//! # Example
//!
//! ```no_run
//! use ml::training_profile::DqnTrainingProfile;
//! use ml::trainers::dqn::DQNHyperparameters;
//!
//! let mut hp = DQNHyperparameters::conservative();
//! let profile = DqnTrainingProfile::load("dqn-production");
//! profile.apply_to(&mut hp);
//! ```

use serde::Deserialize;

// ---------------------------------------------------------------------------
// Embedded compile-time TOML defaults
// ---------------------------------------------------------------------------
// Paths are relative to this source file: crates/ml/src/training_profile.rs
// The TOML files live at the workspace root: config/training/
// ---------------------------------------------------------------------------

const DQN_PRODUCTION_TOML: &str =
    include_str!("../../../config/training/dqn-production.toml");
const DQN_SMOKETEST_TOML: &str =
    include_str!("../../../config/training/dqn-smoketest.toml");
const DQN_HYPEROPT_TOML: &str =
    include_str!("../../../config/training/dqn-hyperopt.toml");
const PPO_PRODUCTION_TOML: &str =
    include_str!("../../../config/training/ppo-production.toml");
const PPO_SMOKETEST_TOML: &str =
    include_str!("../../../config/training/ppo-smoketest.toml");
const SUPERVISED_PRODUCTION_TOML: &str =
    include_str!("../../../config/training/supervised-production.toml");
const SUPERVISED_SMOKETEST_TOML: &str =
    include_str!("../../../config/training/supervised-smoketest.toml");
const WALK_FORWARD_TOML: &str =
    include_str!("../../../config/training/walk-forward.toml");

// ---------------------------------------------------------------------------
// Section structs — all fields Option<T> for sparse-profile support
// ---------------------------------------------------------------------------

/// Core training loop parameters.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct TrainingSection {
    pub epochs: Option<usize>,
    pub batch_size: Option<usize>,
    pub learning_rate: Option<f64>,
    pub gamma: Option<f64>,
    pub tau: Option<f64>,
    pub warmup_steps: Option<usize>,
    pub gradient_clip_norm: Option<f64>,
    // SP4 Layer B: `weight_decay` removed from trial overrides. Per-group
    // AdamW weight_decay rates are now sourced from ISV[WD_RATE[group]];
    // PSO no longer searches over a static config field.
    pub hidden_dim_base: Option<usize>,
    /// Reward scale factor for v_range computation.
    /// v_range = (reward_scale / (1 - gamma) * 1.2).clamp(20.0, 300.0)
    pub reward_scale: Option<f64>,
    /// Huber loss delta — controls transition from quadratic to linear loss.
    /// BF16: use 1.0 (default 10.0 amplifies gradient noise in f32).
    pub huber_delta: Option<f64>,
    /// Adam epsilon — BF16 requires ≥1e-4 (1e-8 rounds to 0 → div-by-zero).
    pub adam_epsilon: Option<f64>,
    /// LR decay type: 0=constant, 1=linear, 2=cosine (default: 0).
    pub lr_decay_type: Option<u32>,
    /// Minimum LR for cosine/linear decay (default: 1e-6).
    pub lr_min: Option<f64>,
    /// Directory containing MBP-10 .dbn.zst files for OFI feature extraction.
    /// 8 OFI features are always computed and appended to the state (state_dim += 8).
    pub mbp10_data_dir: Option<String>,
    /// Directory containing trade .dbn.zst files for real VPIN/Kyle's Lambda.
    /// Trades are always loaded and fed to `OFICalculator::feed_trade()`.
    pub trades_data_dir: Option<String>,

    /// Data source for training bars: "ohlcv" (1-minute candles) or "mbp10"
    /// (imbalance bars from MBP-10 order book). No fallback. Default: "mbp10".
    pub data_source: Option<String>,

    /// Trading symbol subdirectory (e.g. "ES.FUT"). Filters DBN loading to one instrument.
    pub symbol: Option<String>,

    /// Imbalance bar threshold for MBP-10 to bar conversion. Default: 100.0.
    pub imbalance_bar_threshold: Option<f64>,

    /// EWMA alpha for adaptive imbalance threshold. Default: 0.1.
    pub imbalance_bar_ewma_alpha: Option<f64>,

    /// Maximum bars to load for training. 0 = unlimited (load all data).
    /// Useful for fast smoketests on large datasets.
    pub max_bars: Option<usize>,
}

/// Epsilon-greedy exploration parameters (DQN-specific).
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ExplorationSection {
    pub epsilon_start: Option<f64>,
    pub epsilon_end: Option<f64>,
    pub epsilon_decay: Option<f64>,
    /// Initial noise std dev for NoisyNet exploration (Rainbow DQN standard: 0.5).
    pub noisy_sigma_init: Option<f64>,
    /// SAC-style entropy coefficient for Q-value softmax regularization.
    pub entropy_coefficient: Option<f64>,
    /// Count-based exploration bonus coefficient (UCB exploration).
    pub count_bonus_coefficient: Option<f64>,
    /// Q-value gap threshold for trade conviction filter (0.0 = disabled).
    pub q_gap_threshold: Option<f64>,
    /// Advantage noise std for dueling symmetry breaking (0.0 = disabled, default: 0.1).
    pub noise_sigma: Option<f64>,
}

/// Replay buffer configuration.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ReplayBufferSection {
    pub buffer_size: Option<usize>,
    pub min_replay_size: Option<usize>,
    pub per_alpha: Option<f64>,
    pub per_beta_start: Option<f64>,
    pub regime_replay_decay: Option<f64>,
    /// Plan 3 Task 8 B.3: number of GPU scripted-policy seed-phase samples
    /// before action selection switches to the network. Default 100_000;
    /// smoke configs override smaller (e.g. 1_000) so the seed→network
    /// transition is observable inside the smoke run.
    pub replay_seed_steps: Option<u32>,
}

/// C51 distributional RL parameters.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct DistributionalSection {
    pub num_atoms: Option<usize>,
    pub v_min: Option<f64>,
    pub v_max: Option<f64>,
}

/// Branching DQN per-head action-space sizes.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct BranchingSection {
    /// Branch 0: direction head size (default 3 — Short/Flat/Long).
    pub branch_0_size: Option<usize>,
    /// Branch 1: magnitude head size (default 3 — 25%/50%/100%).
    pub branch_1_size: Option<usize>,
    /// Branch 2: order-type head size (default 3).
    pub branch_2_size: Option<usize>,
    /// Branch 3: urgency head size (default 3 — Patient/Normal/Aggressive).
    pub branch_3_size: Option<usize>,
}

/// Advanced Rainbow DQN features.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AdvancedSection {
    pub noisy_sigma_init: Option<f64>,
    pub cql_alpha: Option<f64>,
    pub gradient_accumulation_steps: Option<usize>,
    /// N-step returns (1-10, default: 3 for Rainbow DQN).
    pub n_steps: Option<usize>,
    /// Polyak averaging coefficient for soft target updates (overrides [training].tau if set).
    pub tau: Option<f64>,
    /// C51 distributional loss warmup epochs (MSE before cross-entropy).
    pub c51_warmup_epochs: Option<usize>,
    /// Maximum C51 alpha blend (caps warmup ramp so MSE always contributes).
    pub c51_alpha_max: Option<f32>,
    /// Hindsight Experience Replay ratio (0.0-1.0, 0.0 = disabled).
    pub her_ratio: Option<f64>,
    /// IQN dual-head lambda weight relative to C51 loss.
    pub iqn_lambda: Option<f64>,
    /// Spectral norm sigma max — constrains ||W||_sigma <= sigma_max.
    pub spectral_norm_sigma_max: Option<f64>,
    /// Gradient clipping max norm (overrides [training].gradient_clip_norm if set).
    pub gradient_clip_norm: Option<f64>,
    /// Curiosity-driven exploration weight (0.0 = disabled).
    pub curiosity_weight: Option<f64>,
}

/// Gems & Pearls: generalization techniques to close IS/OOS gap.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct GeneralizationSection {
    pub shrink_perturb_interval: Option<usize>,
    pub shrink_perturb_alpha: Option<f64>,
    pub shrink_perturb_sigma: Option<f64>,
    pub adversarial_dd_threshold: Option<f64>,
    pub adversarial_epochs_trigger: Option<usize>,
    pub anti_lr_good_mult: Option<f64>,
    pub anti_lr_bad_mult: Option<f64>,
    pub anti_lr_sharpe_threshold: Option<f64>,
    pub feature_mask_fraction: Option<f64>,
    pub feature_noise_scale: Option<f64>,
    pub pruning_epoch: Option<usize>,
    pub pruning_fraction: Option<f64>,
    pub causal_weight: Option<f64>,
    pub causal_intervention_interval: Option<usize>,
    pub adversarial_warmup_epochs: Option<usize>,
    pub adversarial_checkpoint_interval: Option<usize>,
    pub bottleneck_dim: Option<usize>,
    pub stochastic_depth_prob: Option<f64>,
    pub asymmetric_dd_weight: Option<f64>,
    pub time_reversal_mod: Option<usize>,
    pub regret_blend: Option<f64>,
    pub position_entropy_weight: Option<f64>,
    pub trade_clustering_penalty: Option<f64>,
    pub ensemble_disagreement_penalty: Option<f64>,
    pub mixup_alpha: Option<f64>,
    // Core family intensity scalars for hyperopt (1.0 = defaults, range [0.0, 2.0])
    pub learning_intensity: Option<f64>,
    pub exploration_intensity: Option<f64>,
    pub replay_intensity: Option<f64>,
    pub architecture_intensity: Option<f64>,
    pub risk_intensity: Option<f64>,
    // Generalization family intensity scalars for hyperopt (1.0 = defaults, range [0.0, 2.0])
    pub adversarial_intensity: Option<f64>,
    pub regularization_intensity: Option<f64>,
    pub augmentation_intensity: Option<f64>,
    pub loss_shaping_intensity: Option<f64>,
    pub ensemble_intensity: Option<f64>,
    pub causal_intensity: Option<f64>,
}

/// Risk management and position-control parameters.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RiskSection {
    pub kelly_fractional: Option<f64>,
    pub kelly_max_fraction: Option<f64>,
    pub max_position_absolute: Option<f64>,
    /// Alias for `max_position_absolute` — used when TOML key is `max_position`.
    pub max_position: Option<f64>,
    /// Maximum leverage ratio relative to initial capital.
    /// Position cap = floor(initial_capital * max_leverage / (price * contract_multiplier)).
    pub max_leverage: Option<f64>,
    /// Minimum Q-value clamp in GPU kernel (default -200.0)
    pub q_clip_min: Option<f64>,
    /// Maximum Q-value clamp in GPU kernel (default 200.0)
    pub q_clip_max: Option<f64>,
    /// Asymmetric loss scaling factor (prospect theory). Also settable in [reward].
    pub loss_aversion: Option<f64>,
}

/// Early stopping configuration.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct EarlyStoppingSection {
    /// Maps to `DQNHyperparameters::early_stopping_enabled`.
    pub enabled: Option<bool>,
    /// Maps to `DQNHyperparameters::gradient_collapse_patience`.
    pub patience: Option<usize>,
    pub min_epochs_before_stopping: Option<usize>,
    pub min_loss_improvement_pct: Option<f64>,
}

/// GPU composite reward weights.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RewardSection {
    /// DSR (Sharpe) weight
    pub w_dsr: Option<f64>,
    /// Normalized PnL weight
    pub w_pnl: Option<f64>,
    /// Drawdown penalty weight
    pub w_dd: Option<f64>,
    /// Idle penalty weight
    pub w_idle: Option<f64>,
    /// Drawdown tolerance before penalty (fraction)
    pub dd_threshold: Option<f64>,
    /// Asymmetric loss scaling factor (prospect theory)
    pub loss_aversion: Option<f64>,
    /// Position staleness rent per step
    pub time_decay_rate: Option<f64>,
    /// Q-value gap threshold for trade conviction (0.0 = disabled)
    pub q_gap_threshold: Option<f64>,
    /// Reward scale factor for v_range computation (alias for [training].reward_scale).
    pub reward_scale: Option<f64>,
    /// v8: PopArt reward normalization enabled.
    pub popart_enabled: Option<bool>,
    /// v8: Robust PopArt using median/IQR instead of Welford mean/var.
    pub popart_robust: Option<bool>,
    /// v8: Dense directional micro-reward scale.
    pub micro_reward_scale: Option<f64>,
    /// v8: TD(λ) trace parameter.
    pub td_lambda: Option<f64>,
    /// v8: Maximum trace length for truncated TD(λ).
    pub max_trace_length: Option<usize>,
    /// v8: Fraction of replay batch relabeled with hindsight optimal exit.
    pub hindsight_fraction: Option<f64>,
    /// v8: Lookahead bars for hindsight optimal exit.
    pub hindsight_lookahead: Option<usize>,
    /// Dense micro-reward: price confirmation weight (default 0.5).
    pub price_confirm_weight: Option<f64>,
    /// Dense micro-reward: book aggression weight (default 0.3).
    pub book_aggression_weight: Option<f64>,
    /// Dense micro-reward: retrospective hold quality weight (default 0.2).
    pub hold_quality_weight: Option<f64>,
    /// Dense micro-reward: tanh temperature (default 3.0).
    pub micro_reward_temp: Option<f64>,
    /// Fallback holding cost rate when micro_reward_scale=0 (default 0.0001).
    /// Overrides [experience].holding_cost_rate when set.
    pub holding_cost_rate: Option<f64>,
}

/// GPU experience collection parameters.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ExperienceSection {
    pub gpu_timesteps_per_episode: Option<usize>,
    pub gpu_n_episodes: Option<usize>,
    pub initial_capital: Option<f64>,
    /// Maps to `DQNHyperparameters::transaction_cost_multiplier`.
    pub tx_cost_multiplier: Option<f64>,
    /// Holding cost rate per bar per unit position. Continuous inventory penalty.
    pub holding_cost_rate: Option<f64>,
    /// Bars threshold for churn penalty.
    pub churn_threshold_bars: Option<usize>,
    /// Scale factor for churn penalty.
    pub churn_penalty_scale: Option<f64>,
    /// Fill simulation sub-section for order routing realism.
    pub fill_simulation: Option<FillSimulationSection>,
}

/// Fill simulation parameters for realistic order routing in GPU experience collection.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FillSimulationSection {
    /// IoC base fill probability (default 0.85)
    pub ioc_fill_prob: Option<f64>,
    /// Minimum limit fill probability at wide spread (default 0.30)
    pub limit_fill_min: Option<f64>,
    /// Maximum limit fill probability at tight spread (default 0.80)
    pub limit_fill_max: Option<f64>,
    /// Fraction of spread paid by market orders (default 0.50)
    pub spread_cost_frac: Option<f64>,
    /// Fraction of spread captured by limit orders (default 0.50)
    pub spread_capture_frac: Option<f64>,
}

/// Walk-forward cross-validation window configuration.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct WalkForwardSection {
    pub train_months: Option<usize>,
    pub val_months: Option<usize>,
    pub test_months: Option<usize>,
    pub step_months: Option<usize>,
}

// ---------------------------------------------------------------------------
// Top-level profile struct
// ---------------------------------------------------------------------------

/// Complete training profile loaded from TOML.
///
/// All sections are `Option` so a sparse profile (e.g. only `[training]`) still
/// works — missing sections leave the caller's existing hyperparameters unchanged.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct DqnTrainingProfile {
    pub training: Option<TrainingSection>,
    pub exploration: Option<ExplorationSection>,
    pub replay_buffer: Option<ReplayBufferSection>,
    pub distributional: Option<DistributionalSection>,
    pub branching: Option<BranchingSection>,
    pub advanced: Option<AdvancedSection>,
    pub generalization: Option<GeneralizationSection>,
    pub risk: Option<RiskSection>,
    pub early_stopping: Option<EarlyStoppingSection>,
    pub experience: Option<ExperienceSection>,
    pub reward: Option<RewardSection>,
    pub walk_forward: Option<WalkForwardSection>,
}

// ---------------------------------------------------------------------------
// Hyperopt search space profile
// ---------------------------------------------------------------------------

/// Search space bounds for PSO/TPE hyperopt. Each field is [min, max].
/// Loaded from `config/training/dqn-hyperopt.toml` [search_space] section.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SearchSpaceSection {
    pub learning_rate: Option<[f64; 2]>,
    pub batch_size: Option<[f64; 2]>,
    pub gamma: Option<[f64; 2]>,
    pub buffer_size: Option<[f64; 2]>,
    pub max_position_absolute: Option<[f64; 2]>,
    pub max_leverage: Option<[f64; 2]>,
    pub huber_delta: Option<[f64; 2]>,
    pub entropy_coefficient: Option<[f64; 2]>,
    pub transaction_cost_multiplier: Option<[f64; 2]>,
    pub per_alpha: Option<[f64; 2]>,
    pub per_beta_start: Option<[f64; 2]>,
    pub v_range: Option<[f64; 2]>,
    pub noisy_sigma_init: Option<[f64; 2]>,
    pub dueling_hidden_dim: Option<[f64; 2]>,
    pub n_steps: Option<[f64; 2]>,
    pub num_atoms: Option<[f64; 2]>,
    // SP4 Layer B: `weight_decay` removed from PSO search bounds. Per-group
    // weight_decay rates are ISV-driven at runtime — no static knob.
    pub kelly_fractional: Option<[f64; 2]>,
    pub kelly_max_fraction: Option<[f64; 2]>,
    pub volatility_window: Option<[f64; 2]>,
    pub tau: Option<[f64; 2]>,
    pub hidden_dim_base: Option<[f64; 2]>,
    pub cql_alpha: Option<[f64; 2]>,
    pub lr_decay_type: Option<[f64; 2]>,
    pub dsr_eta: Option<[f64; 2]>,
    pub minimum_profit_factor: Option<[f64; 2]>,
    pub count_bonus_coefficient: Option<[f64; 2]>,
    pub sharpe_weight: Option<[f64; 2]>,
    pub branch_hidden_dim: Option<[f64; 2]>,
    pub gradient_accumulation_steps: Option<[f64; 2]>,
    pub iqn_lambda: Option<[f64; 2]>,
    /// Spectral norm sigma max bounds.
    pub spectral_norm_sigma_max: Option<[f64; 2]>,
    /// C51 warmup epochs bounds.
    pub c51_warmup_epochs: Option<[f64; 2]>,
    /// C51 alpha max bounds.
    pub c51_alpha_max: Option<[f64; 2]>,
    /// HER ratio bounds.
    pub her_ratio: Option<[f64; 2]>,
    // Composite reward weights
    pub w_dsr: Option<[f64; 2]>,
    pub w_pnl: Option<[f64; 2]>,
    pub w_dd: Option<[f64; 2]>,
    pub w_idle: Option<[f64; 2]>,
    pub dd_threshold: Option<[f64; 2]>,
    pub loss_aversion: Option<[f64; 2]>,
    pub time_decay_rate: Option<[f64; 2]>,
    /// Q-value gap threshold for trade conviction filter
    pub q_gap_threshold: Option<[f64; 2]>,
}

/// PSO optimizer configuration.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PsoSection {
    pub swarm_size: Option<usize>,
    pub max_iterations: Option<usize>,
    pub inertia: Option<f64>,
    pub cognitive: Option<f64>,
    pub social: Option<f64>,
}

/// Phase 1 ("fast") fixed architecture and reward values.
/// When phase=fast, architecture params and reward weights are fixed to single-point
/// bounds so PSO searches only learning dynamics (~17D instead of 38D).
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PhaseFastSection {
    pub hidden_dim_base: Option<f64>,
    pub num_atoms: Option<f64>,
    pub branch_hidden_dim: Option<f64>,
    pub dueling_hidden_dim: Option<f64>,
    pub v_range: Option<f64>,
    // Composite reward weights (fixed in phase fast)
    pub w_dsr: Option<f64>,
    pub w_pnl: Option<f64>,
    pub w_dd: Option<f64>,
    pub w_idle: Option<f64>,
    pub dd_threshold: Option<f64>,
    pub loss_aversion: Option<f64>,
    pub time_decay_rate: Option<f64>,
    pub q_gap_threshold: Option<f64>,
}

/// Three-phase hyperopt configuration.
/// Set via `HYPEROPT_PHASE` static before running optimization.
#[derive(Debug, Clone, PartialEq)]
pub enum HyperoptPhase {
    /// Phase 1 (default): fix architecture + reward, search learning dynamics.
    Fast,
    /// Phase 2: fix dynamics from Phase 1 JSON, search architecture.
    /// Contains the best continuous parameter vector from Phase 1.
    Full(Vec<f64>),
    /// Phase 3: fix dynamics + architecture from Phase 2 JSON, search only reward weights (7D).
    /// Fastest phase (~10s/trial) — only experience collection changes, not network or training.
    Reward(Vec<f64>),
    /// Phase 4: fix dynamics + architecture from Phase 2, search risk params (8D).
    /// Risk dims: kelly_fractional, dd_threshold, loss_aversion, time_decay_rate,
    /// q_gap_threshold, w_dsr, w_pnl, w_dd.
    Risk(Vec<f64>),
}

impl Default for HyperoptPhase {
    fn default() -> Self {
        Self::Fast
    }
}

/// Global hyperopt phase configuration.
/// Set by the CLI before optimization starts; read by `continuous_bounds()`.
/// Uses Mutex (not OnceLock) to allow resetting in tests.
static HYPEROPT_PHASE: std::sync::Mutex<Option<HyperoptPhase>> = std::sync::Mutex::new(None);

/// Set the global hyperopt phase. Must be called before optimization starts.
pub fn set_hyperopt_phase(phase: HyperoptPhase) {
    if let Ok(mut guard) = HYPEROPT_PHASE.lock() {
        *guard = Some(phase);
    }
}

/// Get the current hyperopt phase (defaults to Fast if not set).
pub fn get_hyperopt_phase() -> HyperoptPhase {
    HYPEROPT_PHASE
        .lock()
        .ok()
        .and_then(|guard| guard.clone())
        .unwrap_or(HyperoptPhase::Fast)
}

/// Hyperopt profile with search space bounds + PSO config.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct HyperoptProfile {
    pub search_space: Option<SearchSpaceSection>,
    pub fixed: Option<std::collections::HashMap<String, toml::Value>>,
    pub pso: Option<PsoSection>,
    pub phase_fast: Option<PhaseFastSection>,
}

impl HyperoptProfile {
    /// Load a hyperopt profile by name (same 3-tier resolution as DqnTrainingProfile).
    pub fn load(profile_name: &str) -> Self {
        if let Ok(path) = std::env::var("FOXHUNT_TRAINING_PROFILE") {
            if let Ok(contents) = std::fs::read_to_string(&path) {
                if let Ok(profile) = toml::from_str(&contents) {
                    return profile;
                }
            }
        }
        for base in &["config/training", "../config/training", "../../config/training", "../../../config/training"] {
            let path = format!("{}/{}.toml", base, profile_name);
            if let Ok(contents) = std::fs::read_to_string(&path) {
                if let Ok(profile) = toml::from_str(&contents) {
                    tracing::info!("Hyperopt profile loaded: {}", path);
                    return profile;
                }
            }
        }
        let embedded = match profile_name {
            "dqn-hyperopt" => DQN_HYPEROPT_TOML,
            _ => return Self::default(),
        };
        toml::from_str(embedded).unwrap_or_default()
    }

    /// Get a search space bound, falling back to a hardcoded default.
    pub fn bound(&self, field: &str, default: (f64, f64)) -> (f64, f64) {
        let ss = match &self.search_space {
            Some(s) => s,
            None => return default,
        };
        let val = match field {
            "learning_rate" => ss.learning_rate,
            "batch_size" => ss.batch_size,
            "gamma" => ss.gamma,
            "buffer_size" => ss.buffer_size,
            "max_position_absolute" => ss.max_position_absolute,
            "max_leverage" => ss.max_leverage,
            "huber_delta" => ss.huber_delta,
            "entropy_coefficient" => ss.entropy_coefficient,
            "transaction_cost_multiplier" => ss.transaction_cost_multiplier,
            "per_alpha" => ss.per_alpha,
            "per_beta_start" => ss.per_beta_start,
            "v_range" => ss.v_range,
            "noisy_sigma_init" => ss.noisy_sigma_init,
            "dueling_hidden_dim" => ss.dueling_hidden_dim,
            "n_steps" => ss.n_steps,
            "num_atoms" => ss.num_atoms,
            // SP4 Layer B: `weight_decay` removed from PSO search-space.
            "kelly_fractional" => ss.kelly_fractional,
            "kelly_max_fraction" => ss.kelly_max_fraction,
            "volatility_window" => ss.volatility_window,
            "tau" => ss.tau,
            "hidden_dim_base" => ss.hidden_dim_base,
            "cql_alpha" => ss.cql_alpha,
            "lr_decay_type" => ss.lr_decay_type,
            "dsr_eta" => ss.dsr_eta,
            "minimum_profit_factor" => ss.minimum_profit_factor,
            "count_bonus_coefficient" => ss.count_bonus_coefficient,
            "sharpe_weight" => ss.sharpe_weight,
            "branch_hidden_dim" => ss.branch_hidden_dim,
            "gradient_accumulation_steps" => ss.gradient_accumulation_steps,
            "iqn_lambda" => ss.iqn_lambda,
            "spectral_norm_sigma_max" => ss.spectral_norm_sigma_max,
            "c51_warmup_epochs" => ss.c51_warmup_epochs,
            "c51_alpha_max" => ss.c51_alpha_max,
            "her_ratio" => ss.her_ratio,
            "w_dsr" => ss.w_dsr,
            "w_pnl" => ss.w_pnl,
            "w_dd" => ss.w_dd,
            "w_idle" => ss.w_idle,
            "dd_threshold" => ss.dd_threshold,
            "loss_aversion" => ss.loss_aversion,
            "time_decay_rate" => ss.time_decay_rate,
            "q_gap_threshold" => ss.q_gap_threshold,
            _ => None,
        };
        match val {
            Some([min, max]) => (min, max),
            None => default,
        }
    }
}

// ---------------------------------------------------------------------------
// Loader implementation
// ---------------------------------------------------------------------------

impl DqnTrainingProfile {
    /// Load a training profile by name using 3-tier resolution.
    ///
    /// Resolution order:
    /// 1. `$FOXHUNT_TRAINING_PROFILE` env var (exact file path, overrides everything)
    /// 2. `config/training/<profile_name>.toml` relative to workspace root
    /// 3. Embedded compile-time defaults (always available)
    ///
    /// Known profile names: `dqn-production`, `dqn-smoketest`, `dqn-hyperopt`,
    /// `ppo-production`, `ppo-smoketest`, `supervised-production`,
    /// `supervised-smoketest`, `walk-forward`.
    ///
    /// An unknown profile name logs a warning and returns [`Self::default()`]
    /// (all `None` fields — no-op when passed to [`apply_to`][Self::apply_to]).
    pub fn load(profile_name: &str) -> Self {
        // Priority 1: $FOXHUNT_TRAINING_PROFILE env var (exact path)
        if let Ok(path) = std::env::var("FOXHUNT_TRAINING_PROFILE") {
            match std::fs::read_to_string(&path) {
                Ok(contents) => match toml::from_str::<Self>(&contents) {
                    Ok(profile) => {
                        tracing::info!(
                            "Training profile loaded from $FOXHUNT_TRAINING_PROFILE: {}",
                            path
                        );
                        return profile;
                    }
                    Err(e) => {
                        tracing::warn!(
                            "Failed to parse training profile from $FOXHUNT_TRAINING_PROFILE={}: {} -- falling back",
                            path, e
                        );
                    }
                },
                Err(e) => {
                    tracing::warn!(
                        "Could not read $FOXHUNT_TRAINING_PROFILE={}: {} -- falling back",
                        path, e
                    );
                }
            }
        }

        // Priority 2: config/training/<profile>.toml on the filesystem
        if let Some(workspace_root) = Self::find_workspace_root() {
            let profile_path = workspace_root
                .join("config")
                .join("training")
                .join(format!("{}.toml", profile_name));
            if let Ok(contents) = std::fs::read_to_string(&profile_path) {
                match toml::from_str::<Self>(&contents) {
                    Ok(profile) => {
                        tracing::info!(
                            "Training profile loaded from filesystem: {}",
                            profile_path.display()
                        );
                        return profile;
                    }
                    Err(e) => {
                        tracing::warn!(
                            "Failed to parse training profile {}: {} -- using embedded default",
                            profile_path.display(),
                            e
                        );
                    }
                }
            }
        }

        // Priority 3: embedded compile-time defaults
        let embedded = match profile_name {
            "dqn-production" => DQN_PRODUCTION_TOML,
            "dqn-smoketest" => DQN_SMOKETEST_TOML,
            "dqn-hyperopt" => DQN_HYPEROPT_TOML,
            "ppo-production" => PPO_PRODUCTION_TOML,
            "ppo-smoketest" => PPO_SMOKETEST_TOML,
            "supervised-production" => SUPERVISED_PRODUCTION_TOML,
            "supervised-smoketest" => SUPERVISED_SMOKETEST_TOML,
            "walk-forward" => WALK_FORWARD_TOML,
            _ => {
                tracing::warn!(
                    "Unknown training profile '{}', returning empty defaults (no-op apply_to)",
                    profile_name
                );
                return Self::default();
            }
        };

        match toml::from_str::<Self>(embedded) {
            Ok(profile) => {
                tracing::debug!(
                    "Training profile loaded from embedded defaults: {}",
                    profile_name
                );
                profile
            }
            Err(e) => {
                tracing::error!(
                    "Failed to parse embedded training profile '{}': {} -- returning empty defaults",
                    profile_name, e
                );
                Self::default()
            }
        }
    }

    /// Walk parent directories from cwd looking for `config/training/`.
    fn find_workspace_root() -> Option<std::path::PathBuf> {
        let cwd = std::env::current_dir().ok()?;
        let mut dir = cwd.as_path();
        loop {
            if dir.join("config").join("training").is_dir() {
                return Some(dir.to_path_buf());
            }
            dir = dir.parent()?;
        }
    }

    /// Apply all `Some` fields from this profile to a [`DQNHyperparameters`] struct.
    ///
    /// Fields that are `None` in the profile are left unchanged in `hp`.
    /// This allows sparse profiles that only override what they need.
    pub fn apply_to(&self, hp: &mut crate::trainers::dqn::DQNHyperparameters) {
        // [training]
        if let Some(ref t) = self.training {
            if let Some(v) = t.epochs {
                hp.epochs = v;
            }
            if let Some(v) = t.batch_size {
                hp.batch_size = v;
            }
            if let Some(v) = t.learning_rate {
                hp.learning_rate = v;
            }
            if let Some(v) = t.gamma {
                hp.gamma = v as f32;
                // Recompute v_min/v_max (reward_scale-derived, gamma-independent)
                hp.v_min = hp.computed_v_min();
                hp.v_max = hp.computed_v_max();
            }
            if let Some(v) = t.tau {
                hp.tau = v as f32;
            }
            if let Some(v) = t.warmup_steps {
                hp.warmup_steps = v;
            }
            if let Some(v) = t.gradient_clip_norm {
                hp.gradient_clip_norm = Some(v);
            }
            // SP4 Layer B: `weight_decay` removed from trial overrides; per-group
            // rates flow through ISV[WD_RATE[group]] at runtime.
            if let Some(v) = t.hidden_dim_base {
                hp.hidden_dim_base = Some(v);
            }
            if let Some(v) = t.reward_scale {
                hp.reward_scale = v as f32;
                // Recompute v_min/v_max from the new reward_scale
                hp.v_min = hp.computed_v_min();
                hp.v_max = hp.computed_v_max();
            }
            if let Some(v) = t.huber_delta {
                hp.huber_delta = v as f32;
            }
            if let Some(v) = t.adam_epsilon {
                hp.adam_epsilon = v;
            }
            if let Some(decay) = t.lr_decay_type {
                let total_steps = t.epochs.unwrap_or(hp.epochs);
                let min_lr = t.lr_min.unwrap_or(1e-6);
                hp.lr_decay_type = match decay {
                    1 => crate::trainers::dqn::lr_scheduler::LRDecayType::Linear {
                        end_lr: min_lr,
                        total_steps,
                    },
                    2 => crate::trainers::dqn::lr_scheduler::LRDecayType::Cosine {
                        min_lr,
                        total_steps,
                    },
                    _ => crate::trainers::dqn::lr_scheduler::LRDecayType::Constant,
                };
            }
            // OFI feature enrichment from MBP-10 order book data
            if let Some(ref v) = t.mbp10_data_dir {
                hp.mbp10_data_dir = v.clone();
            }
            if let Some(ref v) = t.trades_data_dir {
                hp.trades_data_dir = v.clone();
            }
            // Data source selection: "mbp10" or "mbp10"
            if let Some(ref v) = t.data_source {
                hp.data_source = v.clone();
            }
            if let Some(ref v) = t.symbol {
                hp.symbol = v.clone();
            }
            if let Some(v) = t.max_bars {
                hp.max_bars = v;
            }
            if let Some(v) = t.imbalance_bar_threshold {
                hp.imbalance_bar_threshold = v as f32;
            }
            if let Some(v) = t.imbalance_bar_ewma_alpha {
                hp.imbalance_bar_ewma_alpha = v as f32;
            }
        }

        // [exploration]
        if let Some(ref e) = self.exploration {
            if let Some(v) = e.epsilon_start {
                hp.epsilon_start = v as f32;
            }
            if let Some(v) = e.epsilon_end {
                hp.epsilon_end = v as f32;
            }
            if let Some(v) = e.epsilon_decay {
                hp.epsilon_decay = v as f32;
            }
            if let Some(v) = e.noisy_sigma_init {
                hp.noisy_sigma_init = v as f32;
            }
            if let Some(v) = e.entropy_coefficient {
                hp.entropy_coefficient = v;
            }
            if let Some(v) = e.count_bonus_coefficient {
                hp.count_bonus_coefficient = Some(v as f32);
            }
            if let Some(v) = e.q_gap_threshold {
                hp.q_gap_threshold = v as f32;
            }
            if let Some(v) = e.noise_sigma {
                hp.noise_sigma = v as f32;
            }
        }

        // [replay_buffer]
        if let Some(ref rb) = self.replay_buffer {
            if let Some(v) = rb.buffer_size {
                hp.buffer_size = v;
            }
            if let Some(v) = rb.min_replay_size {
                hp.min_replay_size = v;
            }
            if let Some(v) = rb.per_alpha {
                hp.per_alpha = v as f32;
            }
            if let Some(v) = rb.per_beta_start {
                hp.per_beta_start = v as f32;
            }
            if let Some(v) = rb.regime_replay_decay {
                hp.regime_replay_decay = v as f32;
            }
            if let Some(v) = rb.replay_seed_steps {
                hp.replay_seed_steps = v;
            }
        }

        // [distributional]
        if let Some(ref d) = self.distributional {
            if let Some(v) = d.num_atoms {
                hp.num_atoms = v;
            }
            if let Some(v) = d.v_min {
                hp.v_min = v as f32;
            }
            if let Some(v) = d.v_max {
                hp.v_max = v as f32;
            }
        }

        // [branching] — always enabled, unconditionally.
        // branch_0_size / branch_1_size / branch_2_size are documentation fields;
        // action space is fixed by DQN architecture constants in CUDA header.

        // [advanced]
        if let Some(ref a) = self.advanced {
            if let Some(v) = a.noisy_sigma_init {
                hp.noisy_sigma_init = v as f32;
            }
            if let Some(v) = a.cql_alpha {
                hp.cql_alpha = v;
            }
            if let Some(v) = a.gradient_accumulation_steps {
                hp.gradient_accumulation_steps = v;
            }
            if let Some(v) = a.n_steps {
                hp.n_steps = v;
            }
            if let Some(v) = a.tau {
                hp.tau = v as f32;
            }
            if let Some(v) = a.c51_warmup_epochs {
                hp.c51_warmup_epochs = v;
            }
            if let Some(v) = a.c51_alpha_max {
                hp.c51_alpha_max = v;
            }
            if let Some(v) = a.her_ratio {
                hp.her_ratio = v as f32;
            }
            if let Some(v) = a.iqn_lambda {
                hp.iqn_lambda = v as f32;
            }
            if let Some(v) = a.spectral_norm_sigma_max {
                hp.spectral_norm_sigma_max = v as f32;
            }
            if let Some(v) = a.gradient_clip_norm {
                hp.gradient_clip_norm = Some(v);
            }
            if let Some(v) = a.curiosity_weight {
                hp.curiosity_weight = v as f32;
            }
        }

        // [generalization]
        if let Some(ref g) = self.generalization {
            if let Some(v) = g.shrink_perturb_interval { hp.shrink_perturb_interval = v; }
            if let Some(v) = g.shrink_perturb_alpha { hp.shrink_perturb_alpha = v as f32; }
            if let Some(v) = g.shrink_perturb_sigma { hp.shrink_perturb_sigma = v as f32; }
            if let Some(v) = g.adversarial_dd_threshold { hp.adversarial_dd_threshold = v as f32; }
            if let Some(v) = g.adversarial_epochs_trigger { hp.adversarial_epochs_trigger = v; }
            if let Some(v) = g.anti_lr_good_mult { hp.anti_lr_good_mult = v as f32; }
            if let Some(v) = g.anti_lr_bad_mult { hp.anti_lr_bad_mult = v as f32; }
            if let Some(v) = g.anti_lr_sharpe_threshold { hp.anti_lr_sharpe_threshold = v as f32; }
            if let Some(v) = g.feature_mask_fraction { hp.feature_mask_fraction = v as f32; }
            if let Some(v) = g.feature_noise_scale { hp.feature_noise_scale = v as f32; }
            if let Some(v) = g.pruning_epoch { hp.pruning_epoch = v; }
            if let Some(v) = g.pruning_fraction { hp.pruning_fraction = v as f32; }
            if let Some(v) = g.causal_weight { hp.causal_weight = v as f32; }
            if let Some(v) = g.causal_intervention_interval { hp.causal_intervention_interval = v; }
            if let Some(v) = g.adversarial_warmup_epochs { hp.adversarial_warmup_epochs = v; }
            if let Some(v) = g.adversarial_checkpoint_interval { hp.adversarial_checkpoint_interval = v; }
            if let Some(v) = g.bottleneck_dim { hp.bottleneck_dim = v; }
            if let Some(v) = g.stochastic_depth_prob { hp.stochastic_depth_prob = v as f32; }
            if let Some(v) = g.asymmetric_dd_weight { hp.asymmetric_dd_weight = v as f32; }
            if let Some(v) = g.time_reversal_mod { hp.time_reversal_mod = v; }
            if let Some(v) = g.regret_blend { hp.regret_blend = v as f32; }
            if let Some(v) = g.trade_clustering_penalty { hp.trade_clustering_penalty = v as f32; }
            if let Some(v) = g.ensemble_disagreement_penalty { hp.ensemble_disagreement_penalty = v as f32; }
            if let Some(v) = g.mixup_alpha { hp.mixup_alpha = v as f32; }
            // Core family intensity scalars
            if let Some(v) = g.learning_intensity { hp.learning_intensity = v; }
            if let Some(v) = g.exploration_intensity { hp.exploration_intensity = v; }
            if let Some(v) = g.replay_intensity { hp.replay_intensity = v; }
            if let Some(v) = g.architecture_intensity { hp.architecture_intensity = v; }
            if let Some(v) = g.risk_intensity { hp.risk_intensity = v; }
            // Generalization family intensity scalars
            if let Some(v) = g.adversarial_intensity { hp.adversarial_intensity = v; }
            if let Some(v) = g.regularization_intensity { hp.regularization_intensity = v; }
            if let Some(v) = g.augmentation_intensity { hp.augmentation_intensity = v; }
            if let Some(v) = g.loss_shaping_intensity { hp.loss_shaping_intensity = v; }
            if let Some(v) = g.ensemble_intensity { hp.ensemble_intensity = v; }
            if let Some(v) = g.causal_intensity { hp.causal_intensity = v; }
        }

        // [risk]
        if let Some(ref r) = self.risk {
            if let Some(v) = r.kelly_fractional {
                hp.kelly_fractional = v as f32;
            }
            if let Some(v) = r.kelly_max_fraction {
                hp.kelly_max_fraction = v as f32;
            }
            if let Some(v) = r.max_position_absolute {
                hp.max_position_absolute = v as f32;
            }
            // max_position is an alias for max_position_absolute
            if let Some(v) = r.max_position {
                hp.max_position_absolute = v as f32;
            }
            if let Some(v) = r.max_leverage {
                hp.max_leverage = v as f32;
            }
            if let Some(v) = r.q_clip_min {
                hp.q_clip_min = v as f32;
            }
            if let Some(v) = r.q_clip_max {
                hp.q_clip_max = v as f32;
            }
            if let Some(v) = r.loss_aversion {
                hp.loss_aversion = v as f32;
            }
        }

        // [early_stopping]
        // TOML: enabled → hp: early_stopping_enabled
        // TOML: patience → hp: gradient_collapse_patience
        if let Some(ref es) = self.early_stopping {
            if let Some(v) = es.enabled {
                hp.early_stopping_enabled = v;
            }
            if let Some(v) = es.patience {
                hp.gradient_collapse_patience = v;
            }
            if let Some(v) = es.min_epochs_before_stopping {
                hp.min_epochs_before_stopping = v;
            }
            if let Some(v) = es.min_loss_improvement_pct {
                hp.min_loss_improvement_pct = v;
            }
        }

        // [experience]
        // TOML: tx_cost_multiplier → hp: transaction_cost_multiplier
        // TOML: initial_capital    → hp: initial_capital (f32 in hp, f64 in TOML)
        if let Some(ref ex) = self.experience {
            if let Some(v) = ex.gpu_timesteps_per_episode {
                hp.gpu_timesteps_per_episode = v;
            }
            if let Some(v) = ex.gpu_n_episodes {
                hp.gpu_n_episodes = v;
            }
            if let Some(v) = ex.initial_capital {
                hp.initial_capital = v as f32;
            }
            if let Some(v) = ex.tx_cost_multiplier {
                hp.transaction_cost_multiplier = v as f32;
            }
            if let Some(v) = ex.holding_cost_rate {
                hp.holding_cost_rate = v as f32;
            }
            if let Some(v) = ex.churn_threshold_bars {
                hp.churn_threshold_bars = v;
            }
            if let Some(v) = ex.churn_penalty_scale {
                hp.churn_penalty_scale = v as f32;
            }
            // [experience.fill_simulation]
            if let Some(ref fs) = ex.fill_simulation {
                if let Some(v) = fs.ioc_fill_prob {
                    hp.fill_ioc_fill_prob = v as f32;
                }
                if let Some(v) = fs.limit_fill_min {
                    hp.fill_limit_fill_min = v as f32;
                }
                if let Some(v) = fs.limit_fill_max {
                    hp.fill_limit_fill_max = v as f32;
                }
                if let Some(v) = fs.spread_cost_frac {
                    hp.fill_spread_cost_frac = v as f32;
                }
                if let Some(v) = fs.spread_capture_frac {
                    hp.fill_spread_capture_frac = v as f32;
                }
            }
        }

        // [reward]
        // GPU composite reward weights — map directly to DQNHyperparameters
        if let Some(ref rw) = self.reward {
            if let Some(v) = rw.w_pnl {
                hp.w_pnl = v as f32;
            }
            if let Some(v) = rw.w_dd {
                hp.w_dd = v as f32;
            }
            if let Some(v) = rw.w_idle {
                hp.w_idle = v as f32;
            }
            if let Some(v) = rw.dd_threshold {
                hp.dd_threshold = v as f32;
            }
            if let Some(v) = rw.loss_aversion {
                hp.loss_aversion = v as f32;
            }
            if let Some(v) = rw.time_decay_rate {
                hp.time_decay_rate = v as f32;
            }
            if let Some(v) = rw.q_gap_threshold {
                hp.q_gap_threshold = v as f32;
            }
            if let Some(v) = rw.reward_scale {
                hp.reward_scale = v as f32;
                hp.v_min = hp.computed_v_min();
                hp.v_max = hp.computed_v_max();
            }
            if let Some(v) = rw.popart_enabled {
                hp.popart_enabled = v;
            }
            if let Some(v) = rw.popart_robust {
                hp.popart_robust = v;
            }
            if let Some(v) = rw.micro_reward_scale {
                hp.micro_reward_scale = v as f32;
            }
            if let Some(v) = rw.td_lambda {
                hp.td_lambda = v as f32;
            }
            if let Some(v) = rw.max_trace_length {
                hp.max_trace_length = v;
            }
            if let Some(v) = rw.hindsight_fraction {
                hp.hindsight_fraction = v as f32;
            }
            if let Some(v) = rw.hindsight_lookahead {
                hp.hindsight_lookahead = v;
            }
            if let Some(v) = rw.price_confirm_weight {
                hp.price_confirm_weight = v as f32;
            }
            if let Some(v) = rw.book_aggression_weight {
                hp.book_aggression_weight = v as f32;
            }
            if let Some(v) = rw.hold_quality_weight {
                hp.hold_quality_weight = v as f32;
            }
            if let Some(v) = rw.micro_reward_temp {
                hp.micro_reward_temp = v as f32;
            }
            if let Some(v) = rw.holding_cost_rate {
                hp.holding_cost_rate = v as f32;
            }
        }

        // [walk_forward] — no direct mapping to DQNHyperparameters;
        // walk-forward parameters are consumed by the training harness separately
        // via WalkForwardSection accessors on the profile itself.
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_load_embedded_dqn_production() {
        let p = DqnTrainingProfile::load("dqn-production");
        assert!(p.training.is_some(), "dqn-production must have [training] section");
    }

    #[test]
    fn test_load_embedded_dqn_smoketest() {
        let p = DqnTrainingProfile::load("dqn-smoketest");
        assert!(p.training.is_some(), "dqn-smoketest must have [training] section");
        let t = p.training.unwrap();
        assert_eq!(t.epochs, Some(10));
        assert_eq!(t.batch_size, Some(64));
    }

    #[test]
    fn test_sparse_profile_preserves_defaults() {
        // dqn-smoketest has [distributional] with num_atoms but no v_min/v_max.
        // Section-side TOML values stay f64 (ingest boundary); conversion to f32 happens in apply_to.
        let p = DqnTrainingProfile::load("dqn-smoketest");
        let d = p.distributional.expect("smoketest profile should have [distributional]");
        assert_eq!(d.num_atoms, Some(52));
        // v_min/v_max are set explicitly for adaptive C51 z-support
        assert_eq!(d.v_min, Some(-15.0));
        assert_eq!(d.v_max, Some(15.0));
    }

    #[test]
    fn test_unknown_profile_returns_default() {
        let p = DqnTrainingProfile::load("nonexistent-profile");
        assert!(
            p.training.is_none(),
            "unknown profile should return all-None default"
        );
    }

    #[test]
    fn test_all_embedded_profiles_parse() {
        for name in &[
            "dqn-production",
            "dqn-smoketest",
            "dqn-hyperopt",
            "ppo-production",
            "ppo-smoketest",
            "supervised-production",
            "supervised-smoketest",
            "walk-forward",
        ] {
            let p = DqnTrainingProfile::load(name);
            // Trigger Clone + Debug to exercise derived impls — must not panic
            let _ = format!("{:?}", p.clone());
        }
    }

    #[test]
    fn test_apply_to_dqn_production() {
        let mut hp = crate::trainers::dqn::DQNHyperparameters::conservative();
        let profile = DqnTrainingProfile::load("dqn-production");
        profile.apply_to(&mut hp);

        // [training] values must have been applied
        assert_eq!(hp.epochs, 200);
        assert_eq!(hp.batch_size, 16384);
        assert!((hp.learning_rate - 1e-5).abs() < 1e-10);

        // OFI MBP-10 data dir should be set in production profile
        assert_eq!(
            hp.mbp10_data_dir.as_str(),
            "/data/futures-baseline-mbp10",
            "dqn-production profile must wire mbp10_data_dir"
        );
    }

    #[test]
    fn test_apply_to_dqn_smoketest() {
        let mut hp = crate::trainers::dqn::DQNHyperparameters::conservative();
        let profile = DqnTrainingProfile::load("dqn-smoketest");
        profile.apply_to(&mut hp);

        assert_eq!(hp.epochs, 10);
        assert_eq!(hp.batch_size, 64);
        // smoketest enables early stopping with min_epochs=10
        assert!(hp.early_stopping_enabled);
        // smoketest uses OHLCV — mbp10_data_dir retains conservative() default (non-empty)
        assert!(!hp.mbp10_data_dir.is_empty(), "mbp10_data_dir should have default value");
    }

    #[test]
    fn test_apply_to_noop_on_empty_profile() {
        let mut hp = crate::trainers::dqn::DQNHyperparameters::conservative();
        let original_epochs = hp.epochs;
        let original_batch = hp.batch_size;

        // An unknown profile returns all-None, so apply_to must not change anything
        let profile = DqnTrainingProfile::load("nonexistent-profile");
        profile.apply_to(&mut hp);

        assert_eq!(hp.epochs, original_epochs);
        assert_eq!(hp.batch_size, original_batch);
    }

    #[test]
    fn test_walk_forward_section_accessible() {
        let p = DqnTrainingProfile::load("walk-forward");
        let wf = p.walk_forward.expect("walk-forward profile must have [walk_forward]");
        assert_eq!(wf.train_months, Some(12));
        assert_eq!(wf.val_months, Some(3));
        assert_eq!(wf.test_months, Some(3));
        assert_eq!(wf.step_months, Some(3));
    }

    #[test]
    fn test_dqn_hyperopt_parses_without_panic() {
        // dqn-hyperopt uses [search_space]/[pso]/[fixed] sections for PSO search;
        // these are unknown to DqnTrainingProfile and must be silently ignored.
        let p = DqnTrainingProfile::load("dqn-hyperopt");
        // The profile parses successfully (unknown sections ignored by serde).
        // It may or may not have a [training] section — just check no panic.
        let _ = p;
    }

    #[test]
    fn test_hyperopt_search_space_loads() {
        let p = HyperoptProfile::load("dqn-hyperopt");
        let ss = p.search_space.expect("search_space section");
        assert!(ss.learning_rate.is_some());
        assert!(ss.hidden_dim_base.is_some());
        let hd = ss.hidden_dim_base.unwrap();
        assert_eq!(hd, [128.0, 256.0]);
    }

    #[test]
    fn test_hyperopt_phase_fast_section_loads() {
        let p = HyperoptProfile::load("dqn-hyperopt");
        let pf = p.phase_fast.expect("[phase_fast] section must exist");
        assert_eq!(pf.hidden_dim_base, Some(128.0));
        assert_eq!(pf.num_atoms, Some(101.0));
        assert_eq!(pf.branch_hidden_dim, Some(64.0));
        assert_eq!(pf.dueling_hidden_dim, Some(128.0));
        assert_eq!(pf.v_range, Some(1.0)); // v_range now dynamic from gamma, TOML value ignored
    }

    #[test]
    fn test_phase_fast_searches_all_14d() {
        // Fast (default): all 14 dims searchable — 14D is within PSO's efficient range
        set_hyperopt_phase(HyperoptPhase::Fast);
        use crate::hyperopt::ParameterSpace;
        let bounds = crate::hyperopt::adapters::dqn::DQNParams::continuous_bounds();
        assert_eq!(bounds.len(), 15, "15D search space");
        // ALL dims must be searchable in Fast (single-pass)
        for (i, (lo, hi)) in bounds.iter().enumerate() {
            assert_ne!(lo, hi, "dim {} must be searchable in Fast phase", i);
        }
    }

    #[test]
    fn test_ppo_production_has_training_section() {
        let p = DqnTrainingProfile::load("ppo-production");
        assert!(p.training.is_some(), "ppo-production must have [training]");
    }

    // ═══════════════════════════════════════════════════════════════════════
    // Task 8: Config consistency integration tests
    // ═══════════════════════════════════════════════════════════════════════

    #[test]
    fn test_config_consistency_across_structs() {
        let hp = crate::trainers::dqn::DQNHyperparameters::conservative();

        // Verify v_min/v_max are computed from reward_scale
        let expected_v_range: f32 = (hp.reward_scale * 1.5).clamp(10.0, 50.0);
        assert!(
            (hp.v_min - (-expected_v_range)).abs() < 0.01,
            "v_min should be computed from reward_scale, got {} expected {}",
            hp.v_min, -expected_v_range
        );
        assert!(
            (hp.v_max - expected_v_range).abs() < 0.01,
            "v_max should be computed from reward_scale, got {} expected {}",
            hp.v_max, expected_v_range
        );

        // Verify computed methods match stored values
        assert!(
            (hp.computed_v_min() - hp.v_min).abs() < 0.01,
            "computed_v_min() {} != v_min {}",
            hp.computed_v_min(), hp.v_min
        );
        assert!(
            (hp.computed_v_max() - hp.v_max).abs() < 0.01,
            "computed_v_max() {} != v_max {}",
            hp.computed_v_max(), hp.v_max
        );

        // Verify reward_scale is set
        assert!(hp.reward_scale > 0.0, "reward_scale must be positive, got {}", hp.reward_scale);

        // Verify fill simulation params are reasonable
        assert!(
            hp.fill_ioc_fill_prob > 0.0 && hp.fill_ioc_fill_prob <= 1.0,
            "fill_ioc_fill_prob out of range: {}",
            hp.fill_ioc_fill_prob
        );
        assert!(
            hp.fill_limit_fill_min >= 0.0 && hp.fill_limit_fill_min <= 1.0,
            "fill_limit_fill_min out of range: {}",
            hp.fill_limit_fill_min
        );
        assert!(
            hp.fill_limit_fill_max >= hp.fill_limit_fill_min,
            "fill_limit_fill_max {} < fill_limit_fill_min {}",
            hp.fill_limit_fill_max, hp.fill_limit_fill_min
        );
        assert!(
            hp.q_clip_min < 0.0 && hp.q_clip_max > 0.0,
            "q_clip range invalid: [{}, {}]",
            hp.q_clip_min, hp.q_clip_max
        );
        assert!(
            hp.q_clip_max > -hp.q_clip_min * 0.5,
            "q_clip should be roughly symmetric: [{}, {}]",
            hp.q_clip_min, hp.q_clip_max
        );
    }

    #[test]
    fn test_toml_profile_applies_all_fields() {
        let profile = DqnTrainingProfile::load("dqn-smoketest");
        let mut hp = crate::trainers::dqn::DQNHyperparameters::conservative();
        profile.apply_to(&mut hp);

        // Smoketest should override batch_size to 64
        assert_eq!(hp.batch_size, 64, "batch_size should be 64 from smoketest profile");
        // Smoketest should override epochs to 10
        assert_eq!(hp.epochs, 10, "epochs should be 10 from smoketest profile");
        // Fill simulation should be applied
        assert!(
            (hp.fill_ioc_fill_prob - 0.85).abs() < 0.01,
            "fill_ioc_fill_prob should be 0.85, got {}",
            hp.fill_ioc_fill_prob
        );
        // Gamma should be applied
        assert!(
            (hp.gamma - 0.95).abs() < 0.001,
            "gamma should be 0.95, got {}",
            hp.gamma
        );
        // reward_scale should be applied
        assert!(
            (hp.reward_scale - 1.0).abs() < 0.01,
            "reward_scale should be 1.0, got {}",
            hp.reward_scale
        );
        // v_min/v_max are explicitly set in the smoketest TOML (adaptive C51)
        assert!(
            (hp.v_min - (-15.0)).abs() < 0.01,
            "v_min should be -15.0 from smoketest profile, got {}",
            hp.v_min,
        );
        // Advanced section fields should be applied
        assert_eq!(hp.n_steps, 5, "n_steps should be 5 from smoketest [advanced]");
        assert!(
            (hp.tau - 0.005).abs() < 0.0001,
            "tau should be 0.005 from smoketest [advanced], got {}",
            hp.tau
        );
        assert_eq!(
            hp.c51_warmup_epochs, 0,
            "c51_warmup_epochs should be 0 from smoketest [advanced]"
        );
        assert!(
            (hp.her_ratio - 0.2).abs() < 0.01,
            "her_ratio should be 0.2 from smoketest [advanced], got {}",
            hp.her_ratio
        );
        assert!(
            (hp.iqn_lambda - 0.25).abs() < 0.01,
            "iqn_lambda should be 0.25 from smoketest [advanced], got {}",
            hp.iqn_lambda
        );
        assert!(
            (hp.spectral_norm_sigma_max - 1.5).abs() < 0.01,
            "spectral_norm_sigma_max should be 1.5 from smoketest [advanced], got {}",
            hp.spectral_norm_sigma_max
        );
        // Exploration section new fields
        assert!(
            (hp.noisy_sigma_init - 0.3).abs() < 0.01,
            "noisy_sigma_init should be 0.3 from smoketest [exploration], got {}",
            hp.noisy_sigma_init
        );
        assert!(
            (hp.entropy_coefficient - 0.001).abs() < 1e-9,
            "entropy_coefficient should be 0.001 from smoketest [exploration], got {}",
            hp.entropy_coefficient
        );
        assert_eq!(
            hp.count_bonus_coefficient,
            Some(0.1),
            "count_bonus_coefficient should be Some(0.1) from smoketest [exploration]"
        );
        // Risk section loss_aversion
        assert!(
            (hp.loss_aversion - 1.0).abs() < 0.01,
            "loss_aversion should be 1.0 from smoketest [risk] (v7: no-op), got {}",
            hp.loss_aversion
        );
        // Risk section max_position (alias for max_position_absolute)
        assert!(
            (hp.max_position_absolute - 2.0).abs() < 0.01,
            "max_position_absolute should be 2.0 from smoketest [risk], got {}",
            hp.max_position_absolute
        );
        // Risk section max_leverage
        assert!(
            (hp.max_leverage - 5.0).abs() < 0.01,
            "max_leverage should be 5.0 from smoketest [risk], got {}",
            hp.max_leverage
        );
        // Distributional section
        assert_eq!(hp.num_atoms, 52, "num_atoms should be 52 from smoketest [distributional]");
    }

    #[test]
    fn test_production_profile_applies_all_sections() {
        let profile = DqnTrainingProfile::load("dqn-production");
        let mut hp = crate::trainers::dqn::DQNHyperparameters::conservative();
        profile.apply_to(&mut hp);

        // [training] section
        assert_eq!(hp.epochs, 200);
        assert_eq!(hp.batch_size, 16384);
        assert!((hp.learning_rate - 1e-5).abs() < 1e-10);
        assert!((hp.gamma - 0.99).abs() < 0.001);
        assert!((hp.reward_scale - 1.0).abs() < 0.01);

        // [advanced] section — new fields
        assert_eq!(hp.n_steps, 5);
        assert!((hp.tau - 0.005).abs() < 0.0001);
        assert_eq!(hp.c51_warmup_epochs, 0);
        assert!((hp.her_ratio - 0.2).abs() < 0.01);
        assert!((hp.iqn_lambda - 0.25).abs() < 0.01);
        assert!((hp.spectral_norm_sigma_max - 1.5).abs() < 0.01);

        // [exploration] section — new fields
        assert!((hp.noisy_sigma_init - 0.3).abs() < 0.01);
        assert!((hp.entropy_coefficient - 0.001).abs() < 1e-9);
        assert_eq!(hp.count_bonus_coefficient, Some(0.1));

        // [risk] section — new loss_aversion field
        assert!((hp.loss_aversion - 1.0).abs() < 0.01);

        // [reward] section — all composite weights
        assert!((hp.w_pnl - 0.3).abs() < 0.01);
        assert!((hp.w_dd - 1.0).abs() < 0.01);
        assert!((hp.w_idle - 0.01).abs() < 0.001);
        assert!((hp.dd_threshold - 0.02).abs() < 0.001);
        assert!((hp.time_decay_rate - 0.0005).abs() < 0.0001);
    }

    #[test]
    fn test_hyperopt_search_space_has_new_bounds() {
        let p = HyperoptProfile::load("dqn-hyperopt");
        let ss = p.search_space.expect("search_space section");

        // Verify new search space fields parse correctly
        assert!(ss.spectral_norm_sigma_max.is_some(), "spectral_norm_sigma_max bounds missing");
        assert_eq!(ss.spectral_norm_sigma_max.unwrap(), [1.0, 10.0]);

        assert!(ss.c51_warmup_epochs.is_some(), "c51_warmup_epochs bounds missing");
        assert_eq!(ss.c51_warmup_epochs.unwrap(), [0.0, 10.0]);

        assert!(ss.her_ratio.is_some(), "her_ratio bounds missing");
        assert_eq!(ss.her_ratio.unwrap(), [0.0, 0.8]);
    }

    #[test]
    fn test_reward_scale_recomputes_v_range() {
        let mut hp = crate::trainers::dqn::DQNHyperparameters::conservative();

        // Changing reward_scale should recompute v_min/v_max
        hp.reward_scale = 20.0;
        hp.v_min = hp.computed_v_min();
        hp.v_max = hp.computed_v_max();

        // reward_scale=20 × 1.5 = 30, clamped to [10, 50] → ±30
        let expected_v_range: f32 = (20.0_f32 * 1.5).clamp(10.0, 50.0);
        assert!(
            (hp.v_max - expected_v_range).abs() < 0.01,
            "v_max should be recomputed from reward_scale: got {} expected {}",
            hp.v_max, expected_v_range
        );
        assert!(
            (hp.v_min - (-expected_v_range)).abs() < 0.01,
            "v_min should be recomputed from reward_scale: got {} expected {}",
            hp.v_min, -expected_v_range
        );
    }
}
