Skip to main content

pounce_cli/minima/
mod.rs

1//! Multistart / find-minima driver for the `pounce` CLI (`--minima`).
2//!
3//! A pure-Rust port of `pounce.find_minima` (`python/pounce/_minima.py`):
4//! drive the same local IPM solver in a loop, escaping already-found minima
5//! by one of six strategies, and collect the distinct local minima into a
6//! deduplicated archive. The strategies and their references:
7//!
8//! * `multistart` — random / Sobol' box sampling.
9//! * `mlsl` — Multi-Level Single Linkage clustering (Rinnooy Kan & Timmer 1987).
10//! * `basinhopping` — Metropolis chain over minima (Wales & Doye 1997).
11//! * `flooding` — repulsive Gaussian bumps (filled-function; Ge 1990).
12//! * `deflation` — softened `1/‖x−x*‖^p` poles (Farrell et al. 2015).
13//! * `tunneling` — equal-height tunnel between descents (Levy & Montalvo 1985).
14//!
15//! The local solver is reused across starts on a single `IpoptApplication`
16//! (no rebuild per start): each start wraps the base TNLP in a
17//! [`SeededTnlp`] (and, for the repulsion strategies, a penalty wrapper).
18//! Acceptance mirrors `_minima.py`: solve succeeded ∧ finite ∧ in-bounds ∧
19//! (Hessian PSD within `psd_tol`) ∧ not already in the archive.
20
21pub mod archive;
22pub mod penalty_tnlp;
23pub mod sampling;
24
25use crate::cli::{Args, MinimaArgs, MinimaMethod, ProblemSource};
26use crate::seeded_tnlp::SeededTnlp;
27use crate::solve_report::{InputDescriptor, ReportBuilder, status_to_solve_result_num};
28use archive::{Archive, scaled_distance};
29use penalty_tnlp::{Kernel, PenaltyTnlp, TunnelTnlp};
30use pounce_algorithm::application::IpoptApplication;
31use pounce_common::types::{Index, Number};
32use pounce_nlp::return_codes::ApplicationReturnStatus;
33use pounce_nlp::tnlp::{BoundsInfo, IndexStyle, SparsityRequest, StartingPoint, TNLP};
34use sampling::{Sampler, clip};
35use std::cell::RefCell;
36use std::path::{Path, PathBuf};
37use std::process::ExitCode;
38use std::rc::Rc;
39
40/// AMPL bound sentinel: a bound beyond this magnitude counts as ±∞.
41const BOUND_INF: Number = 1e19;
42/// Above this dimension a dense symmetric eigendecomposition (cyclic
43/// Jacobi, O(n³)) is too slow for the per-acceptance saddle-rejection
44/// check, so we skip it and accept (matching `find_minima`'s `hess=None`).
45const PSD_MAX_N: usize = 256;
46
47/// Why the search stopped (mirrors `MinimaResult.status`).
48#[derive(Clone, Copy, Debug)]
49enum Stop {
50    TargetReached,
51    Converged,
52    BudgetExhausted,
53}
54
55impl Stop {
56    fn as_str(self) -> &'static str {
57        match self {
58            Stop::TargetReached => "target_reached",
59            Stop::Converged => "converged",
60            Stop::BudgetExhausted => "budget_exhausted",
61        }
62    }
63}
64
65/// One local solve's outcome (the captured minimizer + whether it converged).
66struct SolveOut {
67    success: bool,
68    x: Vec<Number>,
69}
70
71/// On-converged capture slot: the lifted full-length primal `x` plus the
72/// base-problem constraint duals `lambda` of the most recent solve.
73type SolveCapture = Rc<RefCell<Option<(Vec<Number>, Vec<Number>)>>>;
74
75/// The find-minima driver. Holds the single application and base problem and
76/// runs the chosen strategy until a [`Stop`].
77struct Driver<'a> {
78    app: &'a mut IpoptApplication,
79    base: Rc<RefCell<dyn TNLP>>,
80    /// Filled by the `on_converged` hook with the converged primal (full
81    /// length) plus the base-problem constraint duals; cleared before each
82    /// solve, taken after.
83    capture: SolveCapture,
84    cfg: &'a MinimaArgs,
85    n: usize,
86    m: usize,
87    nnz_h: usize,
88    /// 1 when the TNLP emits Fortran (1-based) triplet indices, else 0.
89    index_offset: usize,
90    x0: Vec<Number>,
91    x_l: Vec<Number>,
92    x_u: Vec<Number>,
93    has_box: bool,
94    /// Per-dimension scale `L` (box width, 1.0 for unbounded dims).
95    l_scale: Vec<Number>,
96    sampler: Sampler,
97    archive: Archive,
98    stagnant: usize,
99    n_solves: usize,
100    max_solves: usize,
101    /// Sampled points drawn so far (only MLSL counts against this).
102    n_samples: usize,
103    /// Hard ceiling on sampled points for solve-gated strategies (MLSL),
104    /// so `--max-solves` bounds wall-clock even when the clustering filter
105    /// rejects every sample (pounce#103).
106    max_samples: usize,
107    psd_skipped_logged: bool,
108}
109
110impl<'a> Driver<'a> {
111    // ---- shared local-solver ops -------------------------------------
112
113    /// Run one solve of `solve_tnlp`, toggling the Hessian mode, and return
114    /// the converged minimizer (captured via the `on_converged` hook).
115    /// Returns `Err(Stop::BudgetExhausted)` once the solve budget is spent.
116    fn run_solve(
117        &mut self,
118        solve_tnlp: Rc<RefCell<dyn TNLP>>,
119        exact_hessian: bool,
120    ) -> Result<SolveOut, Stop> {
121        if self.n_solves >= self.max_solves {
122            return Err(Stop::BudgetExhausted);
123        }
124        self.n_solves += 1;
125        // Penalty (repulsion) solves go quasi-Newton; clean / polish solves
126        // keep the exact Hessian. The IPM rereads this option each solve.
127        let line = if exact_hessian {
128            "hessian_approximation exact\n"
129        } else {
130            "hessian_approximation limited-memory\n"
131        };
132        let _ = self.app.options_mut().read_from_str(line, true);
133        *self.capture.borrow_mut() = None;
134        let status = self.app.optimize_tnlp(solve_tnlp);
135        let success = matches!(
136            status,
137            ApplicationReturnStatus::SolveSucceeded
138                | ApplicationReturnStatus::SolvedToAcceptableLevel
139        );
140        match self.capture.borrow_mut().take() {
141            // Only the primal is needed for acceptance; the duals are recovered
142            // later by `recover_duals` (a clean base re-solve) so a point
143            // accepted from an augmented penalty/tunnel solve still gets the
144            // base problem's multipliers.
145            Some((x, _lambda)) if success => Ok(SolveOut { success: true, x }),
146            // A failed solve has no usable captured point; acceptance needs
147            // success anyway, so the empty x is never read.
148            _ => Ok(SolveOut {
149                success: false,
150                x: Vec::new(),
151            }),
152        }
153    }
154
155    /// Recover the base-problem constraint duals at an accepted minimum `x`.
156    /// The accepting solve may have run on an augmented (penalty / tunnel)
157    /// objective, whose multipliers are not the base problem's, so re-solve the
158    /// clean base objective once from `x` — it is already optimal, so this
159    /// converges immediately — and take the captured `lambda`. Budget-exempt:
160    /// the point is already kept, so this does not consume a `--max-solves`
161    /// slot. Falls back to zeros if the recovery solve does not converge or the
162    /// nlp exposes no user-facing duals (length ≠ `m`).
163    fn recover_duals(&mut self, x: &[Number]) -> Vec<Number> {
164        let t: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SeededTnlp::new(
165            Rc::clone(&self.base),
166            x.to_vec(),
167        )));
168        let _ = self
169            .app
170            .options_mut()
171            .read_from_str("hessian_approximation exact\n", true);
172        *self.capture.borrow_mut() = None;
173        let status = self.app.optimize_tnlp(t);
174        let ok = matches!(
175            status,
176            ApplicationReturnStatus::SolveSucceeded
177                | ApplicationReturnStatus::SolvedToAcceptableLevel
178        );
179        match self.capture.borrow_mut().take() {
180            Some((_x, lambda)) if ok && lambda.len() == self.m => lambda,
181            _ => vec![0.0; self.m],
182        }
183    }
184
185    fn solve_seeded(&mut self, seed_x: &[Number], exact: bool) -> Result<SolveOut, Stop> {
186        let t: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SeededTnlp::new(
187            Rc::clone(&self.base),
188            seed_x.to_vec(),
189        )));
190        self.run_solve(t, exact)
191    }
192
193    fn solve_penalty(&mut self, seed_x: &[Number], kernel: Kernel) -> Result<SolveOut, Stop> {
194        let pen: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(PenaltyTnlp::new(
195            Rc::clone(&self.base),
196            kernel,
197        )));
198        let t: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SeededTnlp::new(pen, seed_x.to_vec())));
199        self.run_solve(t, false)
200    }
201
202    fn solve_tunnel(
203        &mut self,
204        seed_x: &[Number],
205        f_ref: Number,
206        pole: Kernel,
207    ) -> Result<SolveOut, Stop> {
208        let tun: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TunnelTnlp::new(
209            Rc::clone(&self.base),
210            f_ref,
211            pole,
212        )));
213        let t: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SeededTnlp::new(tun, seed_x.to_vec())));
214        self.run_solve(t, false)
215    }
216
217    /// Clean objective value at `x` (the un-augmented problem).
218    fn clean_f(&mut self, x: &[Number]) -> Option<Number> {
219        self.base.borrow_mut().eval_f(x, true)
220    }
221
222    /// Is `x` inside the box, allowing for the solver's bound relaxation?
223    ///
224    /// The IPM lets a converged primal sit slightly *outside* a bound — by up
225    /// to `bound_relax_factor · max(1, |bound|)` (the Ipopt default factor is
226    /// `1e-8`). On problems whose optimum binds a large-magnitude limit (e.g.
227    /// ACOPF generator/flow limits in the hundreds), that legal slack exceeds
228    /// any fixed absolute tolerance, so a purely absolute test wrongly rejects
229    /// every minimum (pounce#101). Use a bound-magnitude-relative tolerance
230    /// comfortably above the relaxation but far below any real basin spacing.
231    fn in_bounds(&self, x: &[Number]) -> bool {
232        x.iter()
233            .zip(&self.x_l)
234            .zip(&self.x_u)
235            .all(|((&xi, &lo), &hi)| coord_in_bounds(xi, lo, hi))
236    }
237
238    /// Dense objective Hessian at `x` (row-major n×n), or `None` when no
239    /// exact Hessian is available / the problem is too large.
240    fn obj_hessian_dense(&mut self, x: &[Number]) -> Option<Vec<Number>> {
241        if self.n > PSD_MAX_N || self.nnz_h == 0 {
242            return None;
243        }
244        let nnz = self.nnz_h;
245        let mut irow = vec![0 as Index; nnz];
246        let mut jcol = vec![0 as Index; nnz];
247        {
248            let mut b = self.base.borrow_mut();
249            if !b.eval_h(
250                None,
251                false,
252                1.0,
253                None,
254                false,
255                SparsityRequest::Structure {
256                    irow: &mut irow,
257                    jcol: &mut jcol,
258                },
259            ) {
260                return None;
261            }
262        }
263        let lam = vec![0.0; self.m];
264        let mut vals = vec![0.0; nnz];
265        {
266            let mut b = self.base.borrow_mut();
267            if !b.eval_h(
268                Some(x),
269                true,
270                1.0,
271                Some(&lam),
272                true,
273                SparsityRequest::Values { values: &mut vals },
274            ) {
275                return None;
276            }
277        }
278        let n = self.n;
279        let mut dense = vec![0.0; n * n];
280        for k in 0..nnz {
281            let i = irow[k] as usize - self.index_offset;
282            let j = jcol[k] as usize - self.index_offset;
283            dense[i * n + j] += vals[k];
284            if i != j {
285                dense[j * n + i] += vals[k];
286            }
287        }
288        Some(dense)
289    }
290
291    /// Reject saddles/maxima via the clean Hessian's smallest eigenvalue
292    /// (accept when no Hessian is available — matching `find_minima`).
293    fn is_minimum(&mut self, x: &[Number]) -> bool {
294        if self.n > PSD_MAX_N {
295            if !self.psd_skipped_logged {
296                eprintln!(
297                    "pounce: --minima saddle-rejection (PSD) check skipped — n={} exceeds the \
298                     dense-eigendecomposition cap ({PSD_MAX_N}); accepting converged points as minima.",
299                    self.n
300                );
301                self.psd_skipped_logged = true;
302            }
303            return true;
304        }
305        let dense = match self.obj_hessian_dense(x) {
306            Some(d) => d,
307            None => return true,
308        };
309        let n = self.n;
310        let mut w = vec![0.0; n];
311        let mut v = vec![0.0; n * n];
312        if !pounce_sensitivity::symmetric_eigen(&dense, n, &mut w, &mut v) {
313            return true;
314        }
315        let min_eig = w.iter().cloned().fold(f64::INFINITY, f64::min);
316        min_eig >= -self.cfg.psd_tol
317    }
318
319    /// Per-dimension width vector from a knob spec, mirroring
320    /// `_resolve_lengths`: a scalar ⇒ isotropic; `None` ("auto") ⇒
321    /// `frac · L` when a box is known, else `fallback`.
322    fn resolve_lengths(
323        &self,
324        spec: Option<f64>,
325        frac_default: f64,
326        frac_override: Option<f64>,
327        fallback: f64,
328    ) -> Vec<Number> {
329        match spec {
330            Some(s) => vec![s; self.n],
331            None => {
332                let frac = frac_override.unwrap_or(frac_default);
333                if self.has_box {
334                    self.l_scale.iter().map(|&l| frac * l).collect()
335                } else {
336                    vec![fallback; self.n]
337                }
338            }
339        }
340    }
341
342    /// Curvature-based escape height for a flooding bump at `center`
343    /// (`margin · μ_min` of `diag(σ)·H·diag(σ)`); `None` when no Hessian.
344    fn auto_amplitude(&mut self, center: &[Number], sigma: &[Number], margin: f64) -> Option<f64> {
345        let h = self.obj_hessian_dense(center)?;
346        let n = self.n;
347        let mut s_mat = vec![0.0; n * n];
348        for i in 0..n {
349            for j in 0..n {
350                s_mat[i * n + j] = sigma[i] * sigma[j] * h[i * n + j];
351            }
352        }
353        let mut w = vec![0.0; n];
354        let mut v = vec![0.0; n * n];
355        if !pounce_sensitivity::symmetric_eigen(&s_mat, n, &mut w, &mut v) {
356            return None;
357        }
358        let mu_min = w.iter().cloned().fold(f64::INFINITY, f64::min);
359        Some(margin * mu_min.max(1e-12))
360    }
361
362    /// Draw a fresh start from the box (Sobol'/uniform) or jitter around x0.
363    fn sample(&mut self, jitter: f64) -> Vec<Number> {
364        let x0 = self.x0.clone();
365        let lo = self.x_l.clone();
366        let hi = self.x_u.clone();
367        self.sampler.sample(&x0, &lo, &hi, self.has_box, jitter)
368    }
369
370    // ---- acceptance --------------------------------------------------
371
372    /// Consider a candidate for the archive. With `polish`, first re-solve
373    /// the clean objective from the candidate (exact Hessian) — the
374    /// repulsion strategies escape on the augmented objective, then polish
375    /// back onto the true one. Returns whether it was accepted.
376    fn consider(
377        &mut self,
378        mut x: Vec<Number>,
379        mut success: bool,
380        polish: bool,
381    ) -> Result<bool, Stop> {
382        if success && polish {
383            let r = self.solve_seeded(&x, true)?;
384            success = r.success;
385            if success {
386                x = r.x;
387            }
388        }
389        if !success {
390            return self.reject();
391        }
392        let fval = match self.clean_f(&x) {
393            Some(f) => f,
394            None => return self.reject(),
395        };
396        let finite = x.iter().all(|v| v.is_finite()) && fval.is_finite();
397        let accepted =
398            finite && self.in_bounds(&x) && self.is_minimum(&x) && !self.archive.is_known(&x);
399        if accepted {
400            // Recover the base-problem duals at the accepted point before
401            // archiving (issue #196, related): the search may have accepted a
402            // point from an augmented penalty/tunnel solve whose multipliers
403            // are not the base problem's.
404            let lambda = self.recover_duals(&x);
405            self.archive.add(x, lambda, fval);
406            self.stagnant = 0;
407            if self.archive.len() >= self.cfg.n_minima {
408                return Err(Stop::TargetReached);
409            }
410            Ok(true)
411        } else {
412            self.reject()
413        }
414    }
415
416    fn reject(&mut self) -> Result<bool, Stop> {
417        self.stagnant += 1;
418        if self.stagnant >= self.cfg.patience {
419            return Err(Stop::Converged);
420        }
421        Ok(false)
422    }
423
424    /// Count one drawn sample against the sampling budget. MLSL's expensive
425    /// work is *sampling* (an O(N²) single-linkage scan over a growing pool),
426    /// not solving, so on a problem where the clustering filter rejects
427    /// almost every sample no solve ever fires and `max_solves` cannot bound
428    /// the loop (pounce#103). The sample budget gives it a hard ceiling.
429    fn note_sample(&mut self) -> Result<(), Stop> {
430        if self.n_samples >= self.max_samples {
431            return Err(Stop::BudgetExhausted);
432        }
433        self.n_samples += 1;
434        Ok(())
435    }
436
437    // ---- strategy loops (each runs until a Stop) ---------------------
438
439    fn run(&mut self) -> Stop {
440        let res = match self.cfg.method {
441            MinimaMethod::Multistart => self.run_multistart(),
442            MinimaMethod::Mlsl => self.run_mlsl(),
443            MinimaMethod::Basinhopping => self.run_basinhopping(),
444            MinimaMethod::Flooding => self.run_flooding(),
445            MinimaMethod::Deflation => self.run_deflation(),
446            MinimaMethod::Tunneling => self.run_tunneling(),
447        };
448        // The loops only exit by propagating a `Stop`; `Ok` is unreachable.
449        res.err().unwrap_or(Stop::BudgetExhausted)
450    }
451
452    fn run_multistart(&mut self) -> Result<(), Stop> {
453        let jitter = self.cfg.restart_jitter.unwrap_or(1.0);
454        let x0 = self.x0.clone();
455        let r = self.solve_seeded(&x0, true)?;
456        self.consider(r.x, r.success, false)?;
457        loop {
458            let s = self.sample(jitter);
459            let r = self.solve_seeded(&s, true)?;
460            self.consider(r.x, r.success, false)?;
461        }
462    }
463
464    fn run_mlsl(&mut self) -> Result<(), Stop> {
465        let batch = self.cfg.samples_per_round.unwrap_or(20);
466        let gamma = self.cfg.gamma.unwrap_or(2.0);
467        let jitter = self.cfg.restart_jitter.unwrap_or(1.0);
468        let n = self.n;
469        let diag = (n as f64).sqrt();
470        let mut pool_x: Vec<Vec<Number>> = Vec::new();
471        let mut pool_f: Vec<Number> = Vec::new();
472        let x0 = self.x0.clone();
473        let r = self.solve_seeded(&x0, true)?;
474        self.consider(r.x, r.success, false)?;
475        loop {
476            // Grow the pool; each draw counts against the sample budget, so a
477            // round that solves nothing still drives the loop to terminate.
478            for _ in 0..batch {
479                self.note_sample()?;
480                let s = self.sample(jitter);
481                let f = self.clean_f(&s).unwrap_or(f64::INFINITY);
482                pool_x.push(s);
483                pool_f.push(f);
484            }
485            let bign = pool_x.len();
486            let ne = bign.max(2) as f64;
487            let radius = gamma * diag * (ne.ln() / ne).powf(1.0 / n as f64);
488            let mut order: Vec<usize> = (0..bign).collect();
489            order.sort_by(|&a, &b| {
490                pool_f[a]
491                    .partial_cmp(&pool_f[b])
492                    .unwrap_or(std::cmp::Ordering::Equal)
493            });
494            for i in order {
495                let si = pool_x[i].clone();
496                let fi = pool_f[i];
497                // Single-linkage: skip if a *better* sample is within radius.
498                let better_near = (0..bign).any(|j| {
499                    j != i
500                        && pool_f[j] < fi
501                        && scaled_distance(&si, &pool_x[j], &self.l_scale) < radius
502                });
503                if better_near || self.archive.near_any(&si, radius) {
504                    continue;
505                }
506                let r = self.solve_seeded(&si, true)?;
507                self.consider(r.x, r.success, false)?;
508            }
509        }
510    }
511
512    fn run_basinhopping(&mut self) -> Result<(), Stop> {
513        let step = self.cfg.step.unwrap_or(0.5);
514        let temperature = self.cfg.temperature.unwrap_or(1.0);
515        let x0 = self.x0.clone();
516        let r = self.solve_seeded(&x0, true)?;
517        let mut cur = if r.success { r.x.clone() } else { x0.clone() };
518        let mut cur_f = self.clean_f(&cur).unwrap_or(f64::INFINITY);
519        self.consider(r.x, r.success, false)?;
520        loop {
521            let mut trial = self.sampler.perturb(&cur, &[step]);
522            clip(&mut trial, &self.x_l, &self.x_u, self.has_box);
523            let r = self.solve_seeded(&trial, true)?;
524            if !r.success {
525                self.consider(r.x, false, false)?;
526                continue;
527            }
528            let new_x = r.x.clone();
529            self.consider(r.x, true, false)?;
530            let new_f = self.clean_f(&new_x).unwrap_or(f64::INFINITY);
531            let accept_uphill = self.sampler.uniform() < (-(new_f - cur_f) / temperature).exp();
532            if new_f < cur_f || accept_uphill {
533                cur = new_x;
534                cur_f = new_f;
535            }
536        }
537    }
538
539    fn run_flooding(&mut self) -> Result<(), Stop> {
540        let sigma = self.resolve_lengths(self.cfg.sigma, 0.1, self.cfg.sigma_frac, 0.5);
541        let inv_sigma2: Vec<f64> = sigma.iter().map(|&s| 1.0 / (s * s)).collect();
542        let amp_spec = self.cfg.amplitude;
543        let margin = self.cfg.amp_margin.unwrap_or(2.0);
544        let bump_factor = 3.0;
545        let bump_cap = 1e3;
546        let fallback_amp = 2.0;
547        let jitter = self.cfg.restart_jitter.unwrap_or(0.5);
548        let x0 = self.x0.clone();
549        let mut base_amp: Vec<f64> = Vec::new();
550        let mut mult: Vec<f64> = Vec::new();
551        let mut start = x0.clone();
552        let mut last_center: Option<usize> = None;
553        let mut fails = 0usize;
554        loop {
555            let centers = self.archive.xs.clone();
556            while base_amp.len() < centers.len() {
557                let k = base_amp.len();
558                let a = match amp_spec {
559                    Some(a) => a,
560                    None => self
561                        .auto_amplitude(&centers[k], &sigma, margin)
562                        .unwrap_or(fallback_amp),
563                };
564                base_amp.push(a);
565                mult.push(1.0);
566            }
567            let eff: Vec<f64> = (0..centers.len()).map(|k| base_amp[k] * mult[k]).collect();
568            let polish = !centers.is_empty();
569            let solve_out = if centers.is_empty() {
570                self.solve_seeded(&start, true)?
571            } else {
572                let kernel = Kernel::Gauss {
573                    centers: centers.clone(),
574                    amps: eff,
575                    inv_sigma2: inv_sigma2.clone(),
576                };
577                self.solve_penalty(&start, kernel)?
578            };
579            let accepted = self.consider(solve_out.x, solve_out.success, polish)?;
580            if accepted {
581                if let Some(last) = self.archive.xs.last() {
582                    start = last.clone();
583                }
584                last_center = Some(self.archive.xs.len() - 1);
585                fails = 0;
586            } else if let Some(lc) = last_center {
587                if mult[lc] < bump_cap && fails < 8 {
588                    // Under-flooded the basin we started from: bump and retry.
589                    mult[lc] *= bump_factor;
590                    let scale: Vec<f64> = sigma.iter().map(|&s| 0.05 * s).collect();
591                    start = self.sampler.perturb(&centers[lc], &scale);
592                    fails += 1;
593                    continue;
594                }
595                start = self.sample(jitter);
596                last_center = None;
597                fails = 0;
598            } else {
599                start = self.sample(jitter);
600                last_center = None;
601                fails = 0;
602            }
603        }
604    }
605
606    fn run_deflation(&mut self) -> Result<(), Stop> {
607        let eta = self.cfg.eta.unwrap_or(1.0);
608        let power = self.cfg.power.unwrap_or(2.0);
609        let soft = self.cfg.soft.unwrap_or(1e-3);
610        let length = self.resolve_lengths(self.cfg.length, 0.1, self.cfg.length_frac, 0.5);
611        let inv_len2: Vec<f64> = length.iter().map(|&l| 1.0 / (l * l)).collect();
612        let jitter = self.cfg.restart_jitter.unwrap_or(0.5);
613        let q = power / 2.0;
614        let mut start = self.x0.clone();
615        loop {
616            let centers = self.archive.xs.clone();
617            let polish = !centers.is_empty();
618            // Step a little off the pole so the first gradient is finite.
619            let mut s = start.clone();
620            if !centers.is_empty() && self.archive.is_known(&s) {
621                let scale: Vec<f64> = length.iter().map(|&l| 0.1 * l).collect();
622                s = self.sampler.perturb(&s, &scale);
623            }
624            let solve_out = if centers.is_empty() {
625                self.solve_seeded(&s, true)?
626            } else {
627                let kernel = Kernel::Pole {
628                    centers: centers.clone(),
629                    eta,
630                    q,
631                    soft,
632                    inv_len2: inv_len2.clone(),
633                };
634                self.solve_penalty(&s, kernel)?
635            };
636            let accepted = self.consider(solve_out.x, solve_out.success, polish)?;
637            if accepted {
638                if let Some(last) = self.archive.xs.last() {
639                    start = last.clone();
640                }
641            } else {
642                start = self.sample(jitter);
643            }
644        }
645    }
646
647    fn run_tunneling(&mut self) -> Result<(), Stop> {
648        let eta = self.cfg.eta.unwrap_or(1.0);
649        let power = self.cfg.power.unwrap_or(2.0);
650        let soft = self.cfg.soft.unwrap_or(1e-3);
651        let length = self.resolve_lengths(self.cfg.length, 0.1, self.cfg.length_frac, 0.5);
652        let inv_len2: Vec<f64> = length.iter().map(|&l| 1.0 / (l * l)).collect();
653        let jitter = self.cfg.restart_jitter.unwrap_or(0.75);
654        let q = power / 2.0;
655        let x0 = self.x0.clone();
656        // Seed: one clean descent.
657        let r = self.solve_seeded(&x0, true)?;
658        self.consider(r.x, r.success, false)?;
659        loop {
660            let centers = self.archive.xs.clone();
661            // Tunnel at the height of the most-recent minimum, away from all
662            // known minima — the classic monotone-descending tunnel.
663            let f_ref = match self.archive.fs.last() {
664                Some(&f) => f,
665                None => self.clean_f(&x0).unwrap_or(0.0),
666            };
667            let anchor = self
668                .archive
669                .xs
670                .last()
671                .cloned()
672                .unwrap_or_else(|| x0.clone());
673            let jit = vec![jitter; self.n];
674            let mut start = self.sampler.perturb(&anchor, &jit);
675            clip(&mut start, &self.x_l, &self.x_u, self.has_box);
676            let kernel = Kernel::Pole {
677                centers: centers.clone(),
678                eta,
679                q,
680                soft,
681                inv_len2: inv_len2.clone(),
682            };
683            let r = self.solve_tunnel(&start, f_ref, kernel)?;
684            self.consider(r.x, r.success, true)?;
685        }
686    }
687}
688
689/// A single found minimum (used for output / JSON).
690struct Minimum {
691    x: Vec<Number>,
692    objective: Number,
693    /// Base-problem constraint duals at this minimum (length `m`), recovered by
694    /// a clean re-solve (issue #196, related). Zeros if unavailable.
695    lambda: Vec<Number>,
696}
697
698/// Entry point: run the `--minima` search on `base` (the raw problem TNLP —
699/// presolve / counting wrappers are intentionally bypassed so coordinates
700/// match the original problem and the clean objective is evaluated directly).
701/// Returns the process exit code.
702pub fn run(
703    app: &mut IpoptApplication,
704    base: &Rc<RefCell<dyn TNLP>>,
705    cfg: &MinimaArgs,
706    args: &Args,
707    sol_path: Option<&Path>,
708) -> ExitCode {
709    let info = match base.borrow_mut().get_nlp_info() {
710        Some(i) => i,
711        None => {
712            eprintln!("pounce: --minima could not read problem dimensions");
713            return ExitCode::from(2);
714        }
715    };
716    let n = info.n as usize;
717    let m = info.m as usize;
718    let nnz_h = info.nnz_h_lag as usize;
719    let index_offset = match info.index_style {
720        IndexStyle::Fortran => 1,
721        IndexStyle::C => 0,
722    };
723
724    // Bounds + starting point straight from the TNLP (works uniformly for
725    // built-ins and `.nl` files).
726    let mut x_l = vec![0.0; n];
727    let mut x_u = vec![0.0; n];
728    let mut g_l = vec![0.0; m];
729    let mut g_u = vec![0.0; m];
730    base.borrow_mut().get_bounds_info(BoundsInfo {
731        x_l: &mut x_l,
732        x_u: &mut x_u,
733        g_l: &mut g_l,
734        g_u: &mut g_u,
735    });
736    let mut x0 = vec![0.0; n];
737    {
738        let mut z_l = vec![0.0; n];
739        let mut z_u = vec![0.0; n];
740        let mut lambda = vec![0.0; m];
741        base.borrow_mut().get_starting_point(StartingPoint {
742            init_x: true,
743            x: &mut x0,
744            init_z: false,
745            z_l: &mut z_l,
746            z_u: &mut z_u,
747            init_lambda: false,
748            lambda: &mut lambda,
749        });
750    }
751
752    // Per-dimension scale and box availability (mirror `_scale_from_bounds`).
753    let has_box = (0..n).all(|i| x_l[i] > -BOUND_INF && x_u[i] < BOUND_INF);
754    let l_scale: Vec<Number> = (0..n)
755        .map(|i| {
756            let w = x_u[i] - x_l[i];
757            if has_box && w > 0.0 { w } else { 1.0 }
758        })
759        .collect();
760
761    // Capture the converged primal AND the base-problem constraint duals of
762    // each solve via the on-converged hook. `lambda` uses the same
763    // `finalize_solution_lambda` convention as the main NLP `.sol` path (c/d
764    // split inversion + unscaling), so the `.sol` duals match a plain solve.
765    // An nlp that does not expose user-facing duals returns an empty vec; that
766    // (or any length mismatch) falls back to zeros where the duals are stored.
767    let capture: SolveCapture = Rc::new(RefCell::new(None));
768    {
769        let cap = Rc::clone(&capture);
770        app.set_on_converged(Box::new(move |data, _cq, nlp, _pd| {
771            if let Some(curr) = data.borrow().curr.clone() {
772                let nlp_ref = nlp.borrow();
773                let x = nlp_ref.lift_x_to_full(&*curr.x);
774                let lambda = nlp_ref.finalize_solution_lambda(&*curr.y_c, &*curr.y_d);
775                *cap.borrow_mut() = Some((x, lambda));
776            }
777        }));
778    }
779
780    let max_solves = cfg.max_solves.unwrap_or(8 * cfg.n_minima);
781    // Sample ceiling for solve-gated strategies (MLSL): one round of samples
782    // per unit of solve budget. The patience-on-stall rule normally
783    // terminates first; this guarantees `--max-solves` bounds wall-clock even
784    // when the clustering filter rejects everything (pounce#103).
785    let batch = cfg.samples_per_round.unwrap_or(20).max(1);
786    let max_samples = max_solves.saturating_mul(batch);
787
788    println!(
789        "Searching for up to {} minima via `{}` (max {} solves, seed {})...",
790        cfg.n_minima,
791        cfg.method.as_str(),
792        max_solves,
793        cfg.seed
794    );
795
796    let mut driver = Driver {
797        app,
798        base: Rc::clone(base),
799        capture,
800        cfg,
801        n,
802        m,
803        nnz_h,
804        index_offset,
805        x0,
806        x_l: x_l.clone(),
807        x_u: x_u.clone(),
808        has_box,
809        l_scale: l_scale.clone(),
810        sampler: Sampler::new(cfg.seed, cfg.sobol),
811        archive: Archive::new(cfg.dedup, l_scale.clone()),
812        stagnant: 0,
813        n_solves: 0,
814        max_solves,
815        n_samples: 0,
816        max_samples,
817        psd_skipped_logged: false,
818    };
819
820    let stop = driver.run();
821    let n_solves = driver.n_solves;
822
823    // Rank the found minima by objective (best first).
824    let order = driver.archive.order_by_objective();
825    let minima: Vec<Minimum> = order
826        .iter()
827        .map(|&i| Minimum {
828            x: driver.archive.xs[i].clone(),
829            objective: driver.archive.fs[i],
830            lambda: driver.archive.ls[i].clone(),
831        })
832        .collect();
833    let best_obj = order.first().map(|&i| driver.archive.fs[i]);
834
835    print_table(&minima, &l_scale, stop, n_solves);
836
837    // Write the per-minimum `.sol` files: best → <stub>.sol, the rest →
838    // ranked siblings <stub>.minNNN.sol.
839    if let Some(sp) = sol_path {
840        write_sol_files(sp, &minima, m);
841    }
842
843    // JSON report: the standard single-solve report for the best minimum,
844    // plus a backward-compatible `minima` section listing all of them.
845    if let Some(json_path) = &args.json_output {
846        write_json_report(json_path, args, cfg, stop, n_solves, &minima, n, m, &info);
847    }
848
849    if best_obj.is_some() {
850        ExitCode::SUCCESS
851    } else {
852        ExitCode::from(1)
853    }
854}
855
856/// Print a ranked console table of the distinct minima found.
857fn print_table(minima: &[Minimum], l_scale: &[Number], stop: Stop, n_solves: usize) {
858    println!();
859    println!(
860        "find-minima: {} distinct minim{} in {} solves ({})",
861        minima.len(),
862        if minima.len() == 1 { "um" } else { "a" },
863        n_solves,
864        stop.as_str()
865    );
866    if minima.is_empty() {
867        println!("  (no accepted minima — try raising --max-solves or --patience)");
868        return;
869    }
870    println!("  rank        objective     dist-to-best");
871    let best = &minima[0].x;
872    for (rank, mn) in minima.iter().enumerate() {
873        let d = scaled_distance(&mn.x, best, l_scale);
874        println!("  {rank:>4}   {:>16.8e}   {:>14.6e}", mn.objective, d);
875    }
876}
877
878/// Write `.sol` files: best to `sol_path`, ranked siblings alongside.
879fn write_sol_files(sol_path: &Path, minima: &[Minimum], m: usize) {
880    let zeros = vec![0.0; m];
881    for (rank, mn) in minima.iter().enumerate() {
882        let path = if rank == 0 {
883            sol_path.to_path_buf()
884        } else {
885            sibling_sol_path(sol_path, rank)
886        };
887        let message = format!(
888            "POUNCE {} find-minima rank {rank}: Solve_Succeeded",
889            env!("CARGO_PKG_VERSION")
890        );
891        // Real base-problem duals recovered per minimum (issue #196, related);
892        // `recover_duals` guarantees length `m`, but guard defensively.
893        let lambda = if mn.lambda.len() == m {
894            &mn.lambda
895        } else {
896            &zeros
897        };
898        let payload = crate::nl_writer::SolutionFile {
899            message: &message,
900            x: &mn.x,
901            mult_g: lambda,
902            solve_result_num: status_to_solve_result_num(ApplicationReturnStatus::SolveSucceeded),
903            suffixes: &[],
904        };
905        match crate::nl_writer::write_sol_file(&path, &payload) {
906            Ok(_) => eprintln!("pounce: wrote {}", path.display()),
907            Err(e) => eprintln!("pounce: failed to write {}: {e}", path.display()),
908        }
909    }
910}
911
912/// `<stub>.sol` → `<stub>.minNNN.sol` for rank ≥ 1.
913fn sibling_sol_path(sol_path: &Path, rank: usize) -> PathBuf {
914    let mut stub = sol_path.to_path_buf();
915    stub.set_extension(""); // drop `.sol`
916    let base = stub.to_string_lossy().into_owned();
917    PathBuf::from(format!("{base}.min{rank:03}.sol"))
918}
919
920/// Build the JSON report (standard best-solution report + `minima` section)
921/// and write it.
922#[allow(clippy::too_many_arguments)]
923fn write_json_report(
924    json_path: &Path,
925    args: &Args,
926    cfg: &MinimaArgs,
927    stop: Stop,
928    n_solves: usize,
929    minima: &[Minimum],
930    n: usize,
931    m: usize,
932    info: &pounce_nlp::tnlp::NlpInfo,
933) {
934    let input = match &args.problem {
935        ProblemSource::Builtin(name) => InputDescriptor::Builtin { name: name.clone() },
936        ProblemSource::NlFile(p) => InputDescriptor::NlFile {
937            path: p.clone(),
938            size_bytes: std::fs::metadata(p).ok().map(|md| md.len()),
939        },
940    };
941    let mut builder = ReportBuilder::new(args.json_detail, input);
942    builder.problem.n_variables = n as Index;
943    builder.problem.n_constraints = m as Index;
944    builder.problem.n_objectives = 1;
945    builder.problem.nnz_jac_g = Some(info.nnz_jac_g);
946    builder.problem.nnz_h_lag = Some(info.nnz_h_lag);
947    if let Some(best) = minima.first() {
948        builder.solution.status = ApplicationReturnStatus::SolveSucceeded;
949        builder.solution.solve_result_num =
950            status_to_solve_result_num(ApplicationReturnStatus::SolveSucceeded);
951        builder.solution.objective = best.objective;
952        builder.solution.x = best.x.clone();
953        // Real base-problem duals for the best minimum (issue #196, related);
954        // `recover_duals` guarantees length `m`, guard defensively.
955        builder.solution.lambda = if best.lambda.len() == m {
956            best.lambda.clone()
957        } else {
958            vec![0.0; m]
959        };
960    }
961    let report = builder.finish();
962
963    // Inject the `minima` section without a schema change: serialize, then
964    // splice it into the top-level object.
965    let mut value = match serde_json::to_value(&report) {
966        Ok(v) => v,
967        Err(e) => {
968            eprintln!("pounce: failed to serialize minima report: {e}");
969            return;
970        }
971    };
972    let minima_json: Vec<serde_json::Value> = minima
973        .iter()
974        .map(|mn| {
975            serde_json::json!({
976                "x": mn.x,
977                "objective": mn.objective,
978            })
979        })
980        .collect();
981    let values: Vec<Number> = minima.iter().map(|mn| mn.objective).collect();
982    if let serde_json::Value::Object(map) = &mut value {
983        map.insert(
984            "minima".to_string(),
985            serde_json::json!({
986                "method": cfg.method.as_str(),
987                "status": stop.as_str(),
988                "n_solves": n_solves,
989                "n_minima": minima.len(),
990                "minima": minima_json,
991                "values": values,
992            }),
993        );
994    }
995    match serde_json::to_string_pretty(&value) {
996        Ok(s) => match std::fs::write(json_path, s) {
997            Ok(_) => eprintln!("pounce: wrote {}", json_path.display()),
998            Err(e) => eprintln!(
999                "pounce: failed to write JSON report to {}: {e}",
1000                json_path.display()
1001            ),
1002        },
1003        Err(e) => eprintln!("pounce: failed to render minima report: {e}"),
1004    }
1005}
1006
1007/// Per-coordinate box test with a bound-magnitude-relative tolerance that
1008/// absorbs the IPM's bound relaxation (`bound_relax_factor·max(1,|bound|)`,
1009/// Ipopt default factor `1e-8`). A purely absolute tolerance rejects minima
1010/// that legally bind large-magnitude limits (pounce#101); the relative term
1011/// tracks the relaxation while staying far below any real basin spacing.
1012fn coord_in_bounds(xi: Number, lo: Number, hi: Number) -> bool {
1013    const ATOL: Number = 1e-9;
1014    const RTOL: Number = 1e-6;
1015    let tol_lo = ATOL + RTOL * lo.abs().max(1.0);
1016    let tol_hi = ATOL + RTOL * hi.abs().max(1.0);
1017    xi >= lo - tol_lo && xi <= hi + tol_hi
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::coord_in_bounds;
1023
1024    #[test]
1025    fn accepts_interior_point() {
1026        assert!(coord_in_bounds(0.0, -1.0, 1.0));
1027        assert!(coord_in_bounds(250.0, 0.0, 500.0));
1028    }
1029
1030    #[test]
1031    fn accepts_bound_relaxed_point_at_large_magnitude() {
1032        // A converged primal may sit ~bound_relax_factor·|bound| (≈5e-6 at a
1033        // 500-unit limit) past the bound; that point is a legal minimum.
1034        assert!(coord_in_bounds(500.000005, 0.0, 500.0));
1035        assert!(coord_in_bounds(-500.000005, -500.0, 0.0));
1036    }
1037
1038    #[test]
1039    fn rejects_genuinely_outside_point() {
1040        assert!(!coord_in_bounds(500.1, 0.0, 500.0));
1041        assert!(!coord_in_bounds(-1.1, -1.0, 1.0));
1042    }
1043}