Skip to main content

pounce_minima/
options.rs

1//! Options for the global search: which strategy to run and its knobs.
2//!
3//! Moved here from `pounce-cli`'s `cli.rs` (gh#966), where they were
4//! `MinimaMethod` / `MinimaArgs`; the CLI still parses argv into them and
5//! re-exports them under those names.
6
7/// Global-search strategy for `--minima`. Mirrors the six methods of
8/// `pounce.find_minima` (`python/pounce/_minima.py`).
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum Method {
11    /// Random / Sobol' box sampling (restart).
12    Multistart,
13    /// Multi-Level Single Linkage clustering (Rinnooy Kan & Timmer 1987).
14    Mlsl,
15    /// Metropolis chain over minima (Wales & Doye 1997).
16    Basinhopping,
17    /// Repulsive Gaussian bumps (filled-function; Ge 1990).
18    Flooding,
19    /// Softened `1/‖x−x*‖^p` poles (deflation; Farrell et al. 2015).
20    Deflation,
21    /// Equal-height tunnel between descents (Levy & Montalvo 1985).
22    Tunneling,
23}
24
25impl Method {
26    pub fn parse(s: &str) -> Result<Self, String> {
27        Ok(match s {
28            "multistart" => Self::Multistart,
29            "mlsl" => Self::Mlsl,
30            "basinhopping" => Self::Basinhopping,
31            "flooding" => Self::Flooding,
32            "deflation" => Self::Deflation,
33            "tunneling" => Self::Tunneling,
34            other => {
35                return Err(format!(
36                    "unknown --minima method '{other}'; choose from \
37                     multistart, mlsl, basinhopping, flooding, deflation, tunneling"
38                ));
39            }
40        })
41    }
42
43    pub fn as_str(&self) -> &'static str {
44        match self {
45            Self::Multistart => "multistart",
46            Self::Mlsl => "mlsl",
47            Self::Basinhopping => "basinhopping",
48            Self::Flooding => "flooding",
49            Self::Deflation => "deflation",
50            Self::Tunneling => "tunneling",
51        }
52    }
53}
54
55/// Parsed `--minima` configuration. Shared knobs have concrete defaults;
56/// strategy-specific knobs are `Option`s resolved per-method in the driver
57/// (so `"auto"` widths and curvature-based amplitudes match
58/// `pounce.find_minima`). Field semantics mirror `_minima.py` exactly.
59///
60/// Equality and hashing compare every field, floats by bit pattern, so the
61/// options can key a cache: `NaN == NaN`, and
62/// `0.0 != -0.0` (a spurious miss, never a stale hit).
63#[derive(Debug, Clone)]
64pub struct MinimaOptions {
65    pub method: Method,
66    /// Target: stop once this many distinct minima are found (default 10).
67    pub n_minima: usize,
68    /// Budget: hard cap on solver calls (default `8 * n_minima`).
69    pub max_solves: Option<usize>,
70    /// Give-up: stop after this many solves in a row that find nothing new.
71    pub patience: usize,
72    /// Two minima within this scaled distance are the same (default 1e-4).
73    pub dedup: f64,
74    /// Smallest Hessian eigenvalue tolerated by saddle rejection (1e-6).
75    pub psd_tol: f64,
76    /// Reject a converged solve whose unscaled dual infeasibility exceeds
77    /// `kkt_tol · max(1, max|∇f(x0)|, dual_scale)` (gh#983; default 1e-4).
78    /// `None` (`--kkt-tol none`) disables the check.
79    pub kkt_tol: Option<f64>,
80    /// Seed for the sampler / Sobol' scramble (default 0; reproducible).
81    pub seed: u64,
82    /// Use a scrambled Sobol' sequence for box sampling (default true).
83    pub sobol: bool,
84    // ---- strategy-specific knobs (None ⇒ per-method default) ----
85    pub sigma: Option<f64>,
86    pub sigma_frac: Option<f64>,
87    pub amplitude: Option<f64>,
88    pub amp_margin: Option<f64>,
89    pub eta: Option<f64>,
90    pub power: Option<f64>,
91    pub soft: Option<f64>,
92    pub length: Option<f64>,
93    pub length_frac: Option<f64>,
94    pub gamma: Option<f64>,
95    pub samples_per_round: Option<usize>,
96    pub step: Option<f64>,
97    pub temperature: Option<f64>,
98    pub restart_jitter: Option<f64>,
99    // ---- library-only knobs (no CLI flag; `pounce.find_minima` passes them) ----
100    /// Per-dimension flooding widths; takes precedence over `sigma`.
101    pub sigma_per_dim: Option<Vec<f64>>,
102    /// Per-dimension deflation / tunneling lengths; takes precedence over
103    /// `length`.
104    pub length_per_dim: Option<Vec<f64>>,
105    /// MLSL sample cap (default `max_solves * samples_per_round`).
106    pub max_samples: Option<usize>,
107    /// Flooding: factor a basin's bump grows by on a failed escape (3).
108    pub amp_bump: Option<f64>,
109    /// Flooding: cap on that growth (1e3).
110    pub amp_bump_cap: Option<f64>,
111    /// Flooding: bump height when no Hessian gives a curvature-based one (2).
112    pub amp_fallback: Option<f64>,
113}
114
115impl MinimaOptions {
116    /// Every field, floats as bits. The destructuring is exhaustive, so a
117    /// new field is a compile error here until it joins the comparison.
118    fn key(&self) -> OptionsKey {
119        let bits = |v: &Option<f64>| v.map(f64::to_bits);
120        let vec_bits =
121            |v: &Option<Vec<f64>>| v.as_ref().map(|v| v.iter().map(|x| x.to_bits()).collect());
122        let Self {
123            method,
124            n_minima,
125            max_solves,
126            patience,
127            dedup,
128            psd_tol,
129            kkt_tol,
130            seed,
131            sobol,
132            sigma,
133            sigma_frac,
134            amplitude,
135            amp_margin,
136            eta,
137            power,
138            soft,
139            length,
140            length_frac,
141            gamma,
142            samples_per_round,
143            step,
144            temperature,
145            restart_jitter,
146            sigma_per_dim,
147            length_per_dim,
148            max_samples,
149            amp_bump,
150            amp_bump_cap,
151            amp_fallback,
152        } = self;
153        OptionsKey {
154            method: *method,
155            n_minima: *n_minima,
156            max_solves: *max_solves,
157            patience: *patience,
158            dedup: dedup.to_bits(),
159            psd_tol: psd_tol.to_bits(),
160            kkt_tol: bits(kkt_tol),
161            seed: *seed,
162            sobol: *sobol,
163            sigma: bits(sigma),
164            sigma_frac: bits(sigma_frac),
165            amplitude: bits(amplitude),
166            amp_margin: bits(amp_margin),
167            eta: bits(eta),
168            power: bits(power),
169            soft: bits(soft),
170            length: bits(length),
171            length_frac: bits(length_frac),
172            gamma: bits(gamma),
173            samples_per_round: *samples_per_round,
174            step: bits(step),
175            temperature: bits(temperature),
176            restart_jitter: bits(restart_jitter),
177            sigma_per_dim: vec_bits(sigma_per_dim),
178            length_per_dim: vec_bits(length_per_dim),
179            max_samples: *max_samples,
180            amp_bump: bits(amp_bump),
181            amp_bump_cap: bits(amp_bump_cap),
182            amp_fallback: bits(amp_fallback),
183        }
184    }
185
186    /// The solve budget: `max_solves`, or `8 * n_minima` when unset.
187    pub fn max_solves_or_default(&self) -> usize {
188        self.max_solves.unwrap_or(8 * self.n_minima)
189    }
190}
191
192impl Default for MinimaOptions {
193    fn default() -> Self {
194        Self {
195            // Matches `find_minima`'s default `method="deflation"`.
196            method: Method::Deflation,
197            n_minima: 10,
198            max_solves: None,
199            patience: 8,
200            dedup: 1e-4,
201            psd_tol: 1e-6,
202            kkt_tol: Some(1e-4),
203            seed: 0,
204            sobol: true,
205            sigma: None,
206            sigma_frac: None,
207            amplitude: None,
208            amp_margin: None,
209            eta: None,
210            power: None,
211            soft: None,
212            length: None,
213            length_frac: None,
214            gamma: None,
215            samples_per_round: None,
216            step: None,
217            temperature: None,
218            restart_jitter: None,
219            sigma_per_dim: None,
220            length_per_dim: None,
221            max_samples: None,
222            amp_bump: None,
223            amp_bump_cap: None,
224            amp_fallback: None,
225        }
226    }
227}
228
229/// [`MinimaOptions`] with every `f64` replaced by its bit pattern, so that
230/// equality is reflexive and `Eq` / `Hash` can be derived.
231#[derive(PartialEq, Eq, Hash)]
232struct OptionsKey {
233    method: Method,
234    n_minima: usize,
235    max_solves: Option<usize>,
236    patience: usize,
237    dedup: u64,
238    psd_tol: u64,
239    kkt_tol: Option<u64>,
240    seed: u64,
241    sobol: bool,
242    sigma: Option<u64>,
243    sigma_frac: Option<u64>,
244    amplitude: Option<u64>,
245    amp_margin: Option<u64>,
246    eta: Option<u64>,
247    power: Option<u64>,
248    soft: Option<u64>,
249    length: Option<u64>,
250    length_frac: Option<u64>,
251    gamma: Option<u64>,
252    samples_per_round: Option<usize>,
253    step: Option<u64>,
254    temperature: Option<u64>,
255    restart_jitter: Option<u64>,
256    sigma_per_dim: Option<Vec<u64>>,
257    length_per_dim: Option<Vec<u64>>,
258    max_samples: Option<usize>,
259    amp_bump: Option<u64>,
260    amp_bump_cap: Option<u64>,
261    amp_fallback: Option<u64>,
262}
263
264impl PartialEq for MinimaOptions {
265    fn eq(&self, other: &Self) -> bool {
266        self.key() == other.key()
267    }
268}
269
270impl Eq for MinimaOptions {}
271
272impl std::hash::Hash for MinimaOptions {
273    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
274        self.key().hash(state);
275    }
276}