//! 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>,
    pub weight_decay: Option<f64>,
    /// Maps to `DQNHyperparameters::max_training_steps_per_epoch`.
    pub max_steps_per_epoch: Option<usize>,
    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>,
}

/// 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>,
}

/// 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>,
}

/// 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: exposure head size (default 5).
    pub branch_0_size: Option<usize>,
    /// Branch 1: order-type head size (default 3).
    pub branch_1_size: Option<usize>,
    /// Branch 2: urgency head size (default 3).
    pub branch_2_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>,
    /// 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>,
}

/// Risk management and position-control parameters.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RiskSection {
    pub enable_kelly_sizing: Option<bool>,
    pub kelly_fractional: Option<f64>,
    pub kelly_max_fraction: Option<f64>,
    pub enable_action_masking: Option<bool>,
    pub max_position_absolute: Option<f64>,
    /// Alias for `max_position_absolute` — used when TOML key is `max_position`.
    pub max_position: Option<f64>,
    pub enable_circuit_breaker: Option<bool>,
    /// 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>,
}

/// GPU experience collection parameters.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ExperienceSection {
    pub gpu_n_episodes: Option<usize>,
    pub gpu_timesteps_per_episode: Option<usize>,
    pub initial_capital: Option<f64>,
    /// Maps to `DQNHyperparameters::transaction_cost_multiplier`.
    pub tx_cost_multiplier: Option<f64>,
    /// Minimum bars to hold a position before allowing exit or reversal.
    pub min_hold_bars: Option<usize>,
    /// 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 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 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]>,
    pub weight_decay: Option<[f64; 2]>,
    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]>,
    /// 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,
            "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,
            "weight_decay" => ss.weight_decay,
            "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,
            "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;
                // Recompute v_min/v_max when gamma changes (v_range depends on gamma)
                hp.v_min = hp.computed_v_min();
                hp.v_max = hp.computed_v_max();
            }
            if let Some(v) = t.tau {
                hp.tau = v;
            }
            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);
            }
            if let Some(v) = t.weight_decay {
                hp.weight_decay = v;
            }
            // TOML: max_steps_per_epoch → hp: max_training_steps_per_epoch
            if let Some(v) = t.max_steps_per_epoch {
                hp.max_training_steps_per_epoch = v;
            }
            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;
                // Recompute v_min/v_max from the new reward_scale + current gamma
                hp.v_min = hp.computed_v_min();
                hp.v_max = hp.computed_v_max();
            }
        }

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

        // [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;
            }
            if let Some(v) = rb.per_beta_start {
                hp.per_beta_start = 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;
            }
            if let Some(v) = d.v_max {
                hp.v_max = v;
            }
        }

        // [branching] — informational only (use_branching always enabled).
        // 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;
            }
            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;
            }
            if let Some(v) = a.c51_warmup_epochs {
                hp.c51_warmup_epochs = v;
            }
            if let Some(v) = a.her_ratio {
                hp.her_ratio = v;
            }
            if let Some(v) = a.iqn_lambda {
                hp.iqn_lambda = v;
            }
            if let Some(v) = a.spectral_norm_sigma_max {
                hp.spectral_norm_sigma_max = v;
            }
            if let Some(v) = a.gradient_clip_norm {
                hp.gradient_clip_norm = Some(v);
            }
        }

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

        // [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_n_episodes {
                hp.gpu_n_episodes = v;
            }
            if let Some(v) = ex.gpu_timesteps_per_episode {
                hp.gpu_timesteps_per_episode = 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;
            }
            if let Some(v) = ex.min_hold_bars {
                hp.min_hold_bars = v;
            }
            // [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;
                }
                if let Some(v) = fs.limit_fill_min {
                    hp.fill_limit_fill_min = v;
                }
                if let Some(v) = fs.limit_fill_max {
                    hp.fill_limit_fill_max = v;
                }
                if let Some(v) = fs.spread_cost_frac {
                    hp.fill_spread_cost_frac = v;
                }
                if let Some(v) = fs.spread_capture_frac {
                    hp.fill_spread_capture_frac = v;
                }
            }
        }

        // [reward]
        // GPU composite reward weights — 7 fields map directly to DQNHyperparameters
        if let Some(ref rw) = self.reward {
            if let Some(v) = rw.w_dsr {
                hp.w_dsr = v;
            }
            if let Some(v) = rw.w_pnl {
                hp.w_pnl = v;
            }
            if let Some(v) = rw.w_dd {
                hp.w_dd = v;
            }
            if let Some(v) = rw.w_idle {
                hp.w_idle = v;
            }
            if let Some(v) = rw.dd_threshold {
                hp.dd_threshold = v;
            }
            if let Some(v) = rw.loss_aversion {
                hp.loss_aversion = v;
            }
            if let Some(v) = rw.time_decay_rate {
                hp.time_decay_rate = v;
            }
            if let Some(v) = rw.q_gap_threshold {
                hp.q_gap_threshold = v;
            }
            if let Some(v) = rw.reward_scale {
                hp.reward_scale = v;
                hp.v_min = hp.computed_v_min();
                hp.v_max = hp.computed_v_max();
            }
        }

        // [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(3));
        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
        let p = DqnTrainingProfile::load("dqn-smoketest");
        let d = p.distributional.expect("smoketest profile should have [distributional]");
        assert_eq!(d.num_atoms, Some(51));
        // v_min/v_max are not set — they are computed from reward_scale + gamma
        assert!(d.v_min.is_none(), "v_min should not be hardcoded in smoketest");
        assert!(d.v_max.is_none(), "v_max should not be hardcoded in smoketest");
    }

    #[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, 100);
        assert_eq!(hp.batch_size, 1024);
        assert!((hp.learning_rate - 0.0001).abs() < 1e-10);
    }

    #[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, 3);
        assert_eq!(hp.batch_size, 64);
        // smoketest enables early stopping with high min_epochs (won't trigger in 3 epochs)
        assert!(hp.early_stopping_enabled);
        // smoketest caps max steps per epoch
        assert_eq!(hp.max_training_steps_per_epoch, 200);
    }

    #[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_bounds_fix_architecture() {
        // When phase=fast, architecture dims must become single-point bounds.
        set_hyperopt_phase(HyperoptPhase::Fast);
        use crate::hyperopt::ParameterSpace;
        let bounds = crate::hyperopt::adapters::dqn::DQNParams::continuous_bounds();
        // Index 21: hidden_dim_base, 15: num_atoms, 28: branch_hidden_dim,
        // 13: dueling_hidden_dim, 10: v_range
        assert_eq!(bounds[21].0, bounds[21].1, "hidden_dim_base must be fixed");
        assert_eq!(bounds[15].0, bounds[15].1, "num_atoms must be fixed");
        assert_eq!(bounds[28].0, bounds[28].1, "branch_hidden_dim must be fixed");
        assert_eq!(bounds[13].0, bounds[13].1, "dueling_hidden_dim must be fixed");
        assert_eq!(bounds[10].0, bounds[10].1, "v_range must be fixed");
        // Learning rate (index 0) must still be a range.
        assert_ne!(bounds[0].0, bounds[0].1, "learning_rate must still be searchable");
    }

    #[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, not hardcoded
        let expected_v_range = (hp.reward_scale / (1.0 - hp.gamma) * 1.2).clamp(20.0, 300.0);
        assert!(
            (hp.v_min - (-expected_v_range)).abs() < 0.01,
            "v_min should be computed from reward_scale+gamma, 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+gamma, 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 3
        assert_eq!(hp.epochs, 3, "epochs should be 3 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 - 10.0).abs() < 0.01,
            "reward_scale should be 10.0, got {}",
            hp.reward_scale
        );
        // v_min/v_max should be recomputed from reward_scale + gamma
        assert!(
            (hp.computed_v_min() - hp.v_min).abs() < 0.01,
            "v_min should be recomputed after reward_scale override"
        );
        // Advanced section fields should be applied
        assert_eq!(hp.n_steps, 3, "n_steps should be 3 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, 5,
            "c51_warmup_epochs should be 5 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 - 3.0).abs() < 0.01,
            "spectral_norm_sigma_max should be 3.0 from smoketest [advanced], got {}",
            hp.spectral_norm_sigma_max
        );
        // Exploration section new fields
        assert!(
            (hp.noisy_sigma_init - 0.5).abs() < 0.01,
            "noisy_sigma_init should be 0.5 from smoketest [exploration], got {}",
            hp.noisy_sigma_init
        );
        assert_eq!(
            hp.entropy_coefficient,
            Some(0.001),
            "entropy_coefficient should be Some(0.001) from smoketest [exploration]"
        );
        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.5).abs() < 0.01,
            "loss_aversion should be 1.5 from smoketest [risk], 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
        );
        // Distributional section
        assert_eq!(hp.num_atoms, 51, "num_atoms should be 51 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, 100);
        assert_eq!(hp.batch_size, 1024);
        assert!((hp.learning_rate - 0.0001).abs() < 1e-10);
        assert!((hp.gamma - 0.99).abs() < 0.001);
        assert!((hp.reward_scale - 10.0).abs() < 0.01);

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

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

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

        // [reward] section — all composite weights
        assert!((hp.w_dsr - 1.0).abs() < 0.01);
        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.01).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();
        let original_v_max = hp.v_max;

        // Override with a different gamma via profile to trigger v_range recomputation
        let profile = DqnTrainingProfile::load("dqn-production");
        profile.apply_to(&mut hp);

        // production gamma=0.99 vs conservative gamma=0.95 → different v_range
        let expected_v_range = (hp.reward_scale / (1.0 - hp.gamma) * 1.2).clamp(20.0, 300.0);
        assert!(
            (hp.v_max - expected_v_range).abs() < 0.01,
            "v_max should be recomputed from gamma override: got {} expected {}",
            hp.v_max, expected_v_range
        );
        // With gamma=0.99: v_range = 10/(1-0.99)*1.2 = 1200 → clamp to 300
        // With gamma=0.95: v_range = 10/(1-0.95)*1.2 = 240
        // So v_max should differ from original
        assert!(
            (hp.v_max - original_v_max).abs() > 1.0,
            "v_max should have changed after gamma override (was {}, now {})",
            original_v_max, hp.v_max
        );
    }
}
