Skip to main content

pounce_cli/minima/
mod.rs

1//! `pounce --minima`: the CLI face of the multistart / find-minima search.
2//!
3//! The search itself is [`pounce_minima::find_minima`] (gh#966). This module
4//! runs it on the CLI's application through [`IpoptLocalSolve`] and turns the
5//! result into the console table, the ranked `.sol` files and the JSON
6//! report.
7
8// Moved to `pounce-minima` (gh#966); re-exported so `minima::archive` etc.
9// keep resolving.
10pub use pounce_minima::{archive, penalty_tnlp, sampling};
11
12use crate::cli::{Args, MinimaArgs, ProblemSource};
13use crate::solve_report::{InputDescriptor, ReportBuilder, status_to_solve_result_num};
14use archive::scaled_distance;
15use pounce_algorithm::application::IpoptApplication;
16use pounce_common::types::{Index, Number};
17use pounce_minima::{IpoptLocalSolve, Minimum, Stop, find_minima};
18use pounce_nlp::return_codes::ApplicationReturnStatus;
19use pounce_nlp::tnlp::TNLP;
20use std::cell::RefCell;
21use std::path::{Path, PathBuf};
22use std::process::ExitCode;
23use std::rc::Rc;
24
25/// Entry point: run the `--minima` search on `base` (the raw problem TNLP —
26/// presolve / counting wrappers are intentionally bypassed so coordinates
27/// match the original problem and the clean objective is evaluated directly).
28/// Returns the process exit code.
29pub fn run(
30    app: &mut IpoptApplication,
31    base: &Rc<RefCell<dyn TNLP>>,
32    cfg: &MinimaArgs,
33    args: &Args,
34    sol_path: Option<&Path>,
35    dual_sign: Number,
36) -> ExitCode {
37    let Some(info) = base.borrow_mut().get_nlp_info() else {
38        eprintln!("pounce: --minima could not read problem dimensions");
39        return ExitCode::from(2);
40    };
41    let n = info.n as usize;
42    let m = info.m as usize;
43
44    println!(
45        "Searching for up to {} minima via `{}` (max {} solves, seed {})...",
46        cfg.n_minima,
47        cfg.method.as_str(),
48        cfg.max_solves_or_default(),
49        cfg.seed
50    );
51
52    let result = {
53        let mut solver = IpoptLocalSolve::new(app);
54        match find_minima(base, &mut solver, cfg) {
55            Ok(r) => r,
56            Err(e) => {
57                eprintln!("pounce: --minima {e}");
58                return ExitCode::from(2);
59            }
60        }
61    };
62    for w in &result.warnings {
63        eprintln!("pounce: --minima {w}");
64    }
65    // An empty result caused by the filter is never silent (gh#983 review).
66    if result.minima.is_empty() && result.n_kkt_rejected > 0 {
67        eprintln!(
68            "pounce: --minima: every converged candidate ({}) was rejected by \
69             --kkt-tol {} (unscaled dual infeasibility above kkt_tol * max(1, gradient \
70             scale)); no minima are returned. Loosen --kkt-tol, or pass --kkt-tol none.",
71            result.n_kkt_rejected,
72            cfg.kkt_tol.unwrap_or(f64::NAN)
73        );
74    }
75
76    let minima = &result.minima;
77    print_table(
78        minima,
79        &result.scale,
80        result.stop,
81        result.n_solves,
82        result.n_kkt_rejected,
83        dual_sign,
84    );
85
86    // Write the per-minimum `.sol` files: best → <stub>.sol, the rest →
87    // ranked siblings <stub>.minNNN.sol.
88    if let Some(sp) = sol_path {
89        write_sol_files(sp, minima, n, m, dual_sign);
90    }
91
92    // JSON report: the standard single-solve report for the best minimum,
93    // plus a backward-compatible `minima` section listing all of them.
94    if let Some(json_path) = &args.json_output {
95        write_json_report(
96            json_path,
97            args,
98            cfg,
99            MinimaCounts {
100                stop: result.stop,
101                n_solves: result.n_solves,
102                n_kkt_rejected: result.n_kkt_rejected,
103            },
104            minima,
105            n,
106            m,
107            &info,
108            dual_sign,
109        );
110    }
111
112    if minima.is_empty() {
113        ExitCode::from(1)
114    } else {
115        ExitCode::SUCCESS
116    }
117}
118
119/// Print a ranked console table of the distinct optima found.
120///
121/// `obj_sign` is `-1.0` for a `maximize` `.nl` and `+1.0` otherwise, and it is
122/// applied to the printed objective for the same reason the `.sol` duals carry
123/// it (gh#959): the search runs on the internal `min -f`, so the number the
124/// archive holds is the negated one. The RANKING is unaffected — rank 0 is the
125/// best point either way — but for a maximize model the column now reads as
126/// the maxima it actually found, in the model's own units, matching what a
127/// plain solve of the same file reports.
128fn print_table(
129    minima: &[Minimum],
130    l_scale: &[Number],
131    stop: Stop,
132    n_solves: usize,
133    n_kkt_rejected: usize,
134    obj_sign: Number,
135) {
136    println!();
137    println!(
138        "find-minima: {} distinct minim{} in {} solves ({})",
139        minima.len(),
140        if minima.len() == 1 { "um" } else { "a" },
141        n_solves,
142        stop.as_str()
143    );
144    if n_kkt_rejected > 0 {
145        println!("  {n_kkt_rejected} converged solve(s) rejected by --kkt-tol");
146    }
147    if minima.is_empty() {
148        println!("  (no accepted minima — try raising --max-solves or --patience)");
149        return;
150    }
151    println!("  rank        objective     dist-to-best");
152    let best = &minima[0].x;
153    for (rank, mn) in minima.iter().enumerate() {
154        let d = scaled_distance(&mn.x, best, l_scale);
155        println!(
156            "  {rank:>4}   {:>16.8e}   {:>14.6e}",
157            obj_sign * mn.objective,
158            d
159        );
160    }
161}
162
163/// Write `.sol` files: best to `sol_path`, ranked siblings alongside.
164///
165/// `dual_sign` is `-1.0` for a `maximize` `.nl` and `+1.0` otherwise. The
166/// captured `lambda` is a multiplier of the problem the engine solved, which
167/// for a maximize model is the negated one, so the sense has to be carried
168/// back out here exactly as the single-solve path does (gh#959). Without it a
169/// `--minima` run on a maximize model writes marginals opposite in sign to the
170/// ones a plain solve of the same file writes. The same factor is applied to
171/// the reported objective, in `print_table` and `write_json_report`.
172///
173/// Each file also carries the `ipopt_zL_out` / `ipopt_zU_out` suffix blocks a
174/// plain solve's `.sol` carries (gh #296), built from the bound multipliers
175/// recovered at that minimum with the plain solve's signs:
176/// `ipopt_zL_out = σ·z_l`, `ipopt_zU_out = −σ·z_u` (`σ = dual_sign`). Without
177/// them a Pyomo `model.ipopt_zL_out` / AMPL `.rc` read after `--minima` came
178/// back empty while the same read after a plain solve did not.
179fn write_sol_files(sol_path: &Path, minima: &[Minimum], n: usize, m: usize, dual_sign: Number) {
180    let zeros = vec![0.0; m];
181    for (rank, mn) in minima.iter().enumerate() {
182        let path = if rank == 0 {
183            sol_path.to_path_buf()
184        } else {
185            sibling_sol_path(sol_path, rank)
186        };
187        let message = format!(
188            "POUNCE {} find-minima rank {rank}: Solve_Succeeded",
189            env!("CARGO_PKG_VERSION")
190        );
191        // Real base-problem duals recovered per minimum (issue #196, related);
192        // `recover_duals` guarantees length `m`, but guard defensively.
193        let lambda: Vec<Number> = if mn.lambda.len() == m {
194            mn.lambda.iter().map(|&v| dual_sign * v).collect()
195        } else {
196            zeros.clone()
197        };
198        let suffixes = bound_multiplier_suffixes(mn, n, dual_sign);
199        let payload = crate::nl_writer::SolutionFile {
200            message: &message,
201            x: &mn.x,
202            mult_g: &lambda,
203            solve_result_num: status_to_solve_result_num(ApplicationReturnStatus::SolveSucceeded),
204            suffixes: &suffixes,
205        };
206        match crate::nl_writer::write_sol_file(&path, &payload) {
207            Ok(_) => eprintln!("pounce: wrote {}", path.display()),
208            Err(e) => eprintln!("pounce: failed to write {}: {e}", path.display()),
209        }
210    }
211}
212
213/// The `ipopt_zL_out` / `ipopt_zU_out` blocks for one minimum, in the sign a
214/// plain solve writes them (`main.rs`, "Bound-multiplier suffixes"): the
215/// engine-frame `z_l, z_u ≥ 0` scaled by the sense `σ`, with the upper block
216/// negated — `σ·z_l` and `−σ·z_u`. Empty when the minimum carries no
217/// full-length pair, so a missing recovery writes no block rather than a
218/// block of zeros.
219fn bound_multiplier_suffixes(
220    mn: &Minimum,
221    n: usize,
222    sigma: Number,
223) -> Vec<crate::nl_writer::SolSuffix> {
224    if mn.z_l.len() != n || mn.z_u.len() != n {
225        return Vec::new();
226    }
227    let block = |name: &str, values: Vec<Number>| crate::nl_writer::SolSuffix {
228        name: name.to_string(),
229        target: crate::nl_writer::SolSuffixTarget::Var,
230        values: crate::nl_writer::SolSuffixValues::Real(values),
231    };
232    vec![
233        block("ipopt_zL_out", mn.z_l.iter().map(|&z| sigma * z).collect()),
234        block("ipopt_zU_out", mn.z_u.iter().map(|&z| -sigma * z).collect()),
235    ]
236}
237
238/// `<stub>.sol` → `<stub>.minNNN.sol` for rank ≥ 1.
239fn sibling_sol_path(sol_path: &Path, rank: usize) -> PathBuf {
240    let mut stub = sol_path.to_path_buf();
241    stub.set_extension(""); // drop `.sol`
242    let base = stub.to_string_lossy().into_owned();
243    PathBuf::from(format!("{base}.min{rank:03}.sol"))
244}
245
246/// The search's stop reason and counters, as reported in the JSON `minima`
247/// block.
248struct MinimaCounts {
249    stop: Stop,
250    n_solves: usize,
251    n_kkt_rejected: usize,
252}
253
254/// Build the JSON report (standard best-solution report + `minima` section)
255/// and write it.
256#[allow(clippy::too_many_arguments)]
257fn write_json_report(
258    json_path: &Path,
259    args: &Args,
260    cfg: &MinimaArgs,
261    counts: MinimaCounts,
262    minima: &[Minimum],
263    n: usize,
264    m: usize,
265    info: &pounce_nlp::tnlp::NlpInfo,
266    obj_sign: Number,
267) {
268    let input = match &args.problem {
269        ProblemSource::Builtin(name) => InputDescriptor::Builtin { name: name.clone() },
270        ProblemSource::NlFile(p) => InputDescriptor::NlFile {
271            path: p.clone(),
272            size_bytes: std::fs::metadata(p).ok().map(|md| md.len()),
273        },
274    };
275    let mut builder = ReportBuilder::new(args.json_detail, input);
276    builder.problem.n_variables = n as Index;
277    builder.problem.n_constraints = m as Index;
278    builder.problem.n_objectives = 1;
279    builder.problem.nnz_jac_g = Some(info.nnz_jac_g);
280    builder.problem.nnz_h_lag = Some(info.nnz_h_lag);
281    builder.problem.minimize = obj_sign > 0.0;
282    if let Some(best) = minima.first() {
283        builder.solution.status = ApplicationReturnStatus::SolveSucceeded;
284        builder.solution.solve_result_num =
285            status_to_solve_result_num(ApplicationReturnStatus::SolveSucceeded);
286        // The model's own sense, as every other POUNCE report carries it
287        // (gh#959). Without it `--minima` on a `maximize` model reports an
288        // objective opposite in sign to a plain solve of the same file.
289        builder.solution.objective = obj_sign * best.objective;
290        builder.solution.x = best.x.clone();
291        // Real base-problem duals for the best minimum (issue #196, related);
292        // `recover_duals` guarantees length `m`, guard defensively. Scaled
293        // by the sense like a plain solve's report (gh#959) and like this
294        // run's own `.sol` (`write_sol_files`), or a maximize model's JSON
295        // `lambda` disagrees with both.
296        builder.solution.lambda = if best.lambda.len() == m {
297            best.lambda.iter().map(|&v| obj_sign * v).collect()
298        } else {
299            vec![0.0; m]
300        };
301    }
302    let report = builder.finish();
303
304    // Inject the `minima` section without a schema change: serialize, then
305    // splice it into the top-level object.
306    let mut value = match serde_json::to_value(&report) {
307        Ok(v) => v,
308        Err(e) => {
309            eprintln!("pounce: failed to serialize minima report: {e}");
310            return;
311        }
312    };
313    let minima_json: Vec<serde_json::Value> = minima
314        .iter()
315        .map(|mn| {
316            serde_json::json!({
317                "x": mn.x,
318                "objective": obj_sign * mn.objective,
319            })
320        })
321        .collect();
322    let values: Vec<Number> = minima.iter().map(|mn| obj_sign * mn.objective).collect();
323    if let serde_json::Value::Object(map) = &mut value {
324        map.insert(
325            "minima".to_string(),
326            serde_json::json!({
327                "method": cfg.method.as_str(),
328                "status": counts.stop.as_str(),
329                "n_solves": counts.n_solves,
330                "n_kkt_rejected": counts.n_kkt_rejected,
331                "n_minima": minima.len(),
332                "minima": minima_json,
333                "values": values,
334            }),
335        );
336    }
337    match serde_json::to_string_pretty(&value) {
338        Ok(s) => match std::fs::write(json_path, s) {
339            Ok(_) => eprintln!("pounce: wrote {}", json_path.display()),
340            Err(e) => eprintln!(
341                "pounce: failed to write JSON report to {}: {e}",
342                json_path.display()
343            ),
344        },
345        Err(e) => eprintln!("pounce: failed to render minima report: {e}"),
346    }
347}