pounce-minima 0.14.0

Multistart / find-minima global search for POUNCE: six strategies (multistart, MLSL, basin-hopping, flooding, deflation, tunneling) over a pluggable local solver.
Documentation
//! Options for the global search: which strategy to run and its knobs.
//!
//! Moved here from `pounce-cli`'s `cli.rs` (gh#966), where they were
//! `MinimaMethod` / `MinimaArgs`; the CLI still parses argv into them and
//! re-exports them under those names.

/// Global-search strategy for `--minima`. Mirrors the six methods of
/// `pounce.find_minima` (`python/pounce/_minima.py`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Method {
    /// Random / Sobol' box sampling (restart).
    Multistart,
    /// Multi-Level Single Linkage clustering (Rinnooy Kan & Timmer 1987).
    Mlsl,
    /// Metropolis chain over minima (Wales & Doye 1997).
    Basinhopping,
    /// Repulsive Gaussian bumps (filled-function; Ge 1990).
    Flooding,
    /// Softened `1/‖x−x*‖^p` poles (deflation; Farrell et al. 2015).
    Deflation,
    /// Equal-height tunnel between descents (Levy & Montalvo 1985).
    Tunneling,
}

impl Method {
    pub fn parse(s: &str) -> Result<Self, String> {
        Ok(match s {
            "multistart" => Self::Multistart,
            "mlsl" => Self::Mlsl,
            "basinhopping" => Self::Basinhopping,
            "flooding" => Self::Flooding,
            "deflation" => Self::Deflation,
            "tunneling" => Self::Tunneling,
            other => {
                return Err(format!(
                    "unknown --minima method '{other}'; choose from \
                     multistart, mlsl, basinhopping, flooding, deflation, tunneling"
                ));
            }
        })
    }

    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Multistart => "multistart",
            Self::Mlsl => "mlsl",
            Self::Basinhopping => "basinhopping",
            Self::Flooding => "flooding",
            Self::Deflation => "deflation",
            Self::Tunneling => "tunneling",
        }
    }
}

/// Parsed `--minima` configuration. Shared knobs have concrete defaults;
/// strategy-specific knobs are `Option`s resolved per-method in the driver
/// (so `"auto"` widths and curvature-based amplitudes match
/// `pounce.find_minima`). Field semantics mirror `_minima.py` exactly.
///
/// Equality and hashing compare every field, floats by bit pattern, so the
/// options can key a cache: `NaN == NaN`, and
/// `0.0 != -0.0` (a spurious miss, never a stale hit).
#[derive(Debug, Clone)]
pub struct MinimaOptions {
    pub method: Method,
    /// Target: stop once this many distinct minima are found (default 10).
    pub n_minima: usize,
    /// Budget: hard cap on solver calls (default `8 * n_minima`).
    pub max_solves: Option<usize>,
    /// Give-up: stop after this many solves in a row that find nothing new.
    pub patience: usize,
    /// Two minima within this scaled distance are the same (default 1e-4).
    pub dedup: f64,
    /// Smallest Hessian eigenvalue tolerated by saddle rejection (1e-6).
    pub psd_tol: f64,
    /// Reject a converged solve whose unscaled dual infeasibility exceeds
    /// `kkt_tol · max(1, max|∇f(x0)|, dual_scale)` (gh#983; default 1e-4).
    /// `None` (`--kkt-tol none`) disables the check.
    pub kkt_tol: Option<f64>,
    /// Seed for the sampler / Sobol' scramble (default 0; reproducible).
    pub seed: u64,
    /// Use a scrambled Sobol' sequence for box sampling (default true).
    pub sobol: bool,
    // ---- strategy-specific knobs (None ⇒ per-method default) ----
    pub sigma: Option<f64>,
    pub sigma_frac: Option<f64>,
    pub amplitude: Option<f64>,
    pub amp_margin: Option<f64>,
    pub eta: Option<f64>,
    pub power: Option<f64>,
    pub soft: Option<f64>,
    pub length: Option<f64>,
    pub length_frac: Option<f64>,
    pub gamma: Option<f64>,
    pub samples_per_round: Option<usize>,
    pub step: Option<f64>,
    pub temperature: Option<f64>,
    pub restart_jitter: Option<f64>,
    // ---- library-only knobs (no CLI flag; `pounce.find_minima` passes them) ----
    /// Per-dimension flooding widths; takes precedence over `sigma`.
    pub sigma_per_dim: Option<Vec<f64>>,
    /// Per-dimension deflation / tunneling lengths; takes precedence over
    /// `length`.
    pub length_per_dim: Option<Vec<f64>>,
    /// MLSL sample cap (default `max_solves * samples_per_round`).
    pub max_samples: Option<usize>,
    /// Flooding: factor a basin's bump grows by on a failed escape (3).
    pub amp_bump: Option<f64>,
    /// Flooding: cap on that growth (1e3).
    pub amp_bump_cap: Option<f64>,
    /// Flooding: bump height when no Hessian gives a curvature-based one (2).
    pub amp_fallback: Option<f64>,
}

impl MinimaOptions {
    /// Every field, floats as bits. The destructuring is exhaustive, so a
    /// new field is a compile error here until it joins the comparison.
    fn key(&self) -> OptionsKey {
        let bits = |v: &Option<f64>| v.map(f64::to_bits);
        let vec_bits =
            |v: &Option<Vec<f64>>| v.as_ref().map(|v| v.iter().map(|x| x.to_bits()).collect());
        let Self {
            method,
            n_minima,
            max_solves,
            patience,
            dedup,
            psd_tol,
            kkt_tol,
            seed,
            sobol,
            sigma,
            sigma_frac,
            amplitude,
            amp_margin,
            eta,
            power,
            soft,
            length,
            length_frac,
            gamma,
            samples_per_round,
            step,
            temperature,
            restart_jitter,
            sigma_per_dim,
            length_per_dim,
            max_samples,
            amp_bump,
            amp_bump_cap,
            amp_fallback,
        } = self;
        OptionsKey {
            method: *method,
            n_minima: *n_minima,
            max_solves: *max_solves,
            patience: *patience,
            dedup: dedup.to_bits(),
            psd_tol: psd_tol.to_bits(),
            kkt_tol: bits(kkt_tol),
            seed: *seed,
            sobol: *sobol,
            sigma: bits(sigma),
            sigma_frac: bits(sigma_frac),
            amplitude: bits(amplitude),
            amp_margin: bits(amp_margin),
            eta: bits(eta),
            power: bits(power),
            soft: bits(soft),
            length: bits(length),
            length_frac: bits(length_frac),
            gamma: bits(gamma),
            samples_per_round: *samples_per_round,
            step: bits(step),
            temperature: bits(temperature),
            restart_jitter: bits(restart_jitter),
            sigma_per_dim: vec_bits(sigma_per_dim),
            length_per_dim: vec_bits(length_per_dim),
            max_samples: *max_samples,
            amp_bump: bits(amp_bump),
            amp_bump_cap: bits(amp_bump_cap),
            amp_fallback: bits(amp_fallback),
        }
    }

    /// The solve budget: `max_solves`, or `8 * n_minima` when unset.
    pub fn max_solves_or_default(&self) -> usize {
        self.max_solves.unwrap_or(8 * self.n_minima)
    }
}

impl Default for MinimaOptions {
    fn default() -> Self {
        Self {
            // Matches `find_minima`'s default `method="deflation"`.
            method: Method::Deflation,
            n_minima: 10,
            max_solves: None,
            patience: 8,
            dedup: 1e-4,
            psd_tol: 1e-6,
            kkt_tol: Some(1e-4),
            seed: 0,
            sobol: true,
            sigma: None,
            sigma_frac: None,
            amplitude: None,
            amp_margin: None,
            eta: None,
            power: None,
            soft: None,
            length: None,
            length_frac: None,
            gamma: None,
            samples_per_round: None,
            step: None,
            temperature: None,
            restart_jitter: None,
            sigma_per_dim: None,
            length_per_dim: None,
            max_samples: None,
            amp_bump: None,
            amp_bump_cap: None,
            amp_fallback: None,
        }
    }
}

/// [`MinimaOptions`] with every `f64` replaced by its bit pattern, so that
/// equality is reflexive and `Eq` / `Hash` can be derived.
#[derive(PartialEq, Eq, Hash)]
struct OptionsKey {
    method: Method,
    n_minima: usize,
    max_solves: Option<usize>,
    patience: usize,
    dedup: u64,
    psd_tol: u64,
    kkt_tol: Option<u64>,
    seed: u64,
    sobol: bool,
    sigma: Option<u64>,
    sigma_frac: Option<u64>,
    amplitude: Option<u64>,
    amp_margin: Option<u64>,
    eta: Option<u64>,
    power: Option<u64>,
    soft: Option<u64>,
    length: Option<u64>,
    length_frac: Option<u64>,
    gamma: Option<u64>,
    samples_per_round: Option<usize>,
    step: Option<u64>,
    temperature: Option<u64>,
    restart_jitter: Option<u64>,
    sigma_per_dim: Option<Vec<u64>>,
    length_per_dim: Option<Vec<u64>>,
    max_samples: Option<usize>,
    amp_bump: Option<u64>,
    amp_bump_cap: Option<u64>,
    amp_fallback: Option<u64>,
}

impl PartialEq for MinimaOptions {
    fn eq(&self, other: &Self) -> bool {
        self.key() == other.key()
    }
}

impl Eq for MinimaOptions {}

impl std::hash::Hash for MinimaOptions {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.key().hash(state);
    }
}