Skip to main content

pounce_cli/
cli.rs

1//! Argv parser for the `pounce` binary. Tiny hand-rolled parser so we
2//! avoid pulling in `clap` (and its 100k LOC dependency tree).
3
4use std::path::PathBuf;
5
6#[derive(Debug, Clone)]
7pub enum ProblemSource {
8    Builtin(String),
9    NlFile(PathBuf),
10}
11
12#[derive(Debug, Clone)]
13pub struct Args {
14    pub problem: ProblemSource,
15    pub options_file: Option<PathBuf>,
16    /// `key=value` options collected from the command line. Forwarded to
17    /// the application's `OptionsList` after the options-file load (so
18    /// CLI args override file values), mirroring upstream ipopt's
19    /// `ipopt problem.nl print_level=8 ...` convention.
20    pub set_options: Vec<(String, String)>,
21    /// `--json-output PATH` — when set, the binary writes a
22    /// machine-readable JSON solve report to PATH after the solve
23    /// completes. See [`crate::solve_report`] (pounce#8).
24    pub json_output: Option<PathBuf>,
25    /// `--json-detail summary|full` — controls how much detail the
26    /// JSON report carries. Defaults to `Summary`. `Full` adds
27    /// per-iteration history and suffix blocks; same scale as
28    /// upstream's `print_level` but on the JSON side.
29    pub json_detail: crate::solve_report::ReportDetail,
30    /// `--sol-output PATH` — write an AMPL `.sol` solution file to
31    /// PATH. When unset, a positional `.nl` input still gets a sibling
32    /// `<stub>.sol` (the AMPL solver convention); `--no-sol` opts out
33    /// of that default. Builtin problems have no stub, so they only
34    /// produce a `.sol` when this flag is given explicitly.
35    pub sol_output: Option<PathBuf>,
36    /// `--no-sol` — suppress the default `<stub>.sol` write for `.nl`
37    /// inputs.
38    pub no_sol: bool,
39    /// `-AMPL` — the AMPL solver-protocol flag. AMPL and Pyomo's ASL
40    /// interface invoke a solver as `solver problem.nl -AMPL`. It needs
41    /// no positional behavior (pounce already reads the `.nl` and
42    /// writes `<stub>.sol`), but it does switch the process exit-code
43    /// contract: in AMPL mode the termination is conveyed through the
44    /// `.sol` file's `solve_result_num`, so the process exits 0 for any
45    /// non-fatal solve outcome (limit reached, infeasible, etc.) rather
46    /// than the non-zero code the plain CLI uses.
47    pub ampl: bool,
48    pub help: bool,
49    pub version: bool,
50    /// `--about`: print build metadata, compiled-in features, available
51    /// linear solvers, and runtime paths. Used for bug reports.
52    pub about: bool,
53    /// `--cite [REPORT.json]`: print the citations a user should include
54    /// when publishing pounce results, then exit. Always lists the static
55    /// core (pounce itself + Wächter-Biegler). When a solve-report JSON
56    /// path follows, adds solve-aware extras for features the run actually
57    /// used (v1: the restoration phase). A terminal mode like `--about` —
58    /// requires no problem.
59    pub cite: bool,
60    /// Optional solve-report path consumed by `--cite` (the immediately
61    /// following argument, iff present and not another flag).
62    pub cite_report: Option<PathBuf>,
63    /// `--bibtex`: render `--cite` output as BibTeX instead of the human
64    /// list. No effect without `--cite`.
65    pub cite_bibtex: bool,
66    /// `--dump <cat>[:<iter-spec>]`, repeatable. Each entry asks the
67    /// solver to dump one diagnostic category at the specified iter
68    /// range (`all`, `N`, `N-M`, `N-`, `-M`); omitting the spec is
69    /// equivalent to `:all`. Forwarded to
70    /// [`pounce_common::diagnostics::DiagnosticsConfig`].
71    pub dump_specs: Vec<(String, String)>,
72    /// `--dump-dir <path>`: override the dump root. Defaults to
73    /// `./pounce-dump-<unix-secs>`, picked at solve-start time.
74    pub dump_dir: Option<PathBuf>,
75    /// `--dump-format <fmt>`: dump file format. Currently only `jsonl`.
76    pub dump_format: Option<String>,
77    /// `--sens-boundcheck` — clamp the perturbed primal `x* + Δx` onto
78    /// the declared `[x_l, x_u]` box after the sensitivity step. Only
79    /// has effect when the `.nl` declares the sIPOPT suffixes. Mirrors
80    /// upstream sIPOPT's `sens_boundcheck`.
81    pub sens_boundcheck: bool,
82    /// `--sens-bound-eps <eps>` — tolerance for `--sens-boundcheck`
83    /// (default `1e-3`). Setting it also enables `--sens-boundcheck`.
84    pub sens_bound_eps: f64,
85    /// `--compute-red-hessian` — after the solve, compute the reduced
86    /// Hessian over the variables tagged by the `red_hessian` integer
87    /// var-suffix in the input `.nl`. Mirrors upstream sIPOPT's
88    /// `compute_red_hessian`.
89    pub compute_red_hessian: bool,
90    /// `--rh-eigendecomp` — also compute the eigendecomposition of the
91    /// reduced Hessian. Implies `--compute-red-hessian`. Mirrors
92    /// upstream `rh_eigendecomp`.
93    pub rh_eigendecomp: bool,
94    /// `--debug` / `--debug-json` — drop into the interactive solver
95    /// debugger at each iteration. `Repl` is the human line-oriented
96    /// front end; `Json` speaks newline-delimited JSON so an LLM agent
97    /// (or any program) can drive the loop. `None` disables it.
98    pub debug: Option<DebugMode>,
99    /// `--debug-on-error` — don't pause every iteration; instead run
100    /// freely and only drop into the debugger at the terminal checkpoint
101    /// *if the solve did not succeed*, for a post-mortem at the failing
102    /// iterate. Implies `--debug` (REPL) when no `--debug*` mode is given.
103    pub debug_on_error: bool,
104    /// `--debug-on-interrupt` — run normally but install a Ctrl-C handler
105    /// that drops into the debugger at the next iteration. No automatic
106    /// pauses. Implies `--debug` (REPL) when no `--debug*` mode is given.
107    pub debug_on_interrupt: bool,
108    /// `--debug-script <file>` — run debugger commands from a file at the
109    /// first pause (e.g. set breakpoints then `continue`). Implies
110    /// `--debug` when no `--debug*` mode is given.
111    pub debug_script: Option<PathBuf>,
112    /// `--minima <method>` (or `--multistart`) — search for multiple local
113    /// minima instead of a single solve. `None` keeps the default
114    /// single-solve behaviour. See [`MinimaArgs`] for the strategy knobs.
115    /// Mirrors `pounce.find_minima` (`python/pounce/_minima.py`).
116    pub minima: Option<MinimaArgs>,
117}
118
119/// Global-search strategy for `--minima`. Mirrors the six methods of
120/// `pounce.find_minima` (`python/pounce/_minima.py`).
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum MinimaMethod {
123    /// Random / Sobol' box sampling (restart).
124    Multistart,
125    /// Multi-Level Single Linkage clustering (Rinnooy Kan & Timmer 1987).
126    Mlsl,
127    /// Metropolis chain over minima (Wales & Doye 1997).
128    Basinhopping,
129    /// Repulsive Gaussian bumps (filled-function; Ge 1990).
130    Flooding,
131    /// Softened `1/‖x−x*‖^p` poles (deflation; Farrell et al. 2015).
132    Deflation,
133    /// Equal-height tunnel between descents (Levy & Montalvo 1985).
134    Tunneling,
135}
136
137impl MinimaMethod {
138    pub fn parse(s: &str) -> Result<Self, String> {
139        Ok(match s {
140            "multistart" => Self::Multistart,
141            "mlsl" => Self::Mlsl,
142            "basinhopping" => Self::Basinhopping,
143            "flooding" => Self::Flooding,
144            "deflation" => Self::Deflation,
145            "tunneling" => Self::Tunneling,
146            other => {
147                return Err(format!(
148                    "unknown --minima method '{other}'; choose from \
149                     multistart, mlsl, basinhopping, flooding, deflation, tunneling"
150                ))
151            }
152        })
153    }
154
155    pub fn as_str(&self) -> &'static str {
156        match self {
157            Self::Multistart => "multistart",
158            Self::Mlsl => "mlsl",
159            Self::Basinhopping => "basinhopping",
160            Self::Flooding => "flooding",
161            Self::Deflation => "deflation",
162            Self::Tunneling => "tunneling",
163        }
164    }
165}
166
167/// Parsed `--minima` configuration. Shared knobs have concrete defaults;
168/// strategy-specific knobs are `Option`s resolved per-method in the driver
169/// (so `"auto"` widths and curvature-based amplitudes match
170/// `pounce.find_minima`). Field semantics mirror `_minima.py` exactly.
171#[derive(Debug, Clone)]
172pub struct MinimaArgs {
173    pub method: MinimaMethod,
174    /// Target: stop once this many distinct minima are found (default 10).
175    pub n_minima: usize,
176    /// Budget: hard cap on solver calls (default `8 * n_minima`).
177    pub max_solves: Option<usize>,
178    /// Give-up: stop after this many solves in a row that find nothing new.
179    pub patience: usize,
180    /// Two minima within this scaled distance are the same (default 1e-4).
181    pub dedup: f64,
182    /// Smallest Hessian eigenvalue tolerated by saddle rejection (1e-6).
183    pub psd_tol: f64,
184    /// Seed for the sampler / Sobol' scramble (default 0; reproducible).
185    pub seed: u64,
186    /// Use a scrambled Sobol' sequence for box sampling (default true).
187    pub sobol: bool,
188    // ---- strategy-specific knobs (None ⇒ per-method default) ----
189    pub sigma: Option<f64>,
190    pub sigma_frac: Option<f64>,
191    pub amplitude: Option<f64>,
192    pub amp_margin: Option<f64>,
193    pub eta: Option<f64>,
194    pub power: Option<f64>,
195    pub soft: Option<f64>,
196    pub length: Option<f64>,
197    pub length_frac: Option<f64>,
198    pub gamma: Option<f64>,
199    pub samples_per_round: Option<usize>,
200    pub step: Option<f64>,
201    pub temperature: Option<f64>,
202    pub restart_jitter: Option<f64>,
203}
204
205impl Default for MinimaArgs {
206    fn default() -> Self {
207        Self {
208            // Matches `find_minima`'s default `method="deflation"`.
209            method: MinimaMethod::Deflation,
210            n_minima: 10,
211            max_solves: None,
212            patience: 8,
213            dedup: 1e-4,
214            psd_tol: 1e-6,
215            seed: 0,
216            sobol: true,
217            sigma: None,
218            sigma_frac: None,
219            amplitude: None,
220            amp_margin: None,
221            eta: None,
222            power: None,
223            soft: None,
224            length: None,
225            length_frac: None,
226            gamma: None,
227            samples_per_round: None,
228            step: None,
229            temperature: None,
230            restart_jitter: None,
231        }
232    }
233}
234
235/// Front end for the interactive solver debugger (`--debug*`).
236#[derive(Clone, Copy, Debug, PartialEq, Eq)]
237pub enum DebugMode {
238    /// Human-facing line REPL on stdin/stdout.
239    Repl,
240    /// Newline-delimited JSON protocol for an agent / program.
241    Json,
242}
243
244impl Args {
245    pub fn usage() -> &'static str {
246        "\
247Usage: pounce [OPTIONS] [PATH] [SOL] [KEY=VALUE ...]
248
249PATH is an AMPL .nl file (positional). Equivalent: --nl-file <path>.
250SOL is an optional second positional naming the .sol output file
251(equivalent to --sol-output <path>); the AMPL `solver in.nl out.sol`
252convention.
253
254Subcommand:
255  pounce verify <problem.nl> <claim.sol> [--feas-tol T] [--json-output P]
256                            independently check that a .sol solution
257                            satisfies the canonical .nl's constraints and
258                            bounds, without trusting the solver/agent that
259                            produced it. Exit 0 = feasible, 20 = violated.
260                            Run `pounce verify --help` for details.
261
262When the .nl declares the sIPOPT suffixes (sens_state_1,
263sens_state_value_1, sens_init_constr), pounce additionally runs the
264post-optimal parametric sensitivity step and writes the perturbed
265primal back into the .sol as a `sens_sol_state_1` suffix.
266
267Trailing KEY=VALUE pairs are forwarded to the solver's OptionsList
268(same syntax/semantics as the ipopt CLI). They override values loaded
269from --options-file. Examples:
270
271  pounce problem.nl print_level=8
272  pounce problem.nl max_iter=500 tol=1e-10 linear_solver=ma57
273
274Required (one of):
275  PATH                      positional .nl file to solve
276  --nl-file <path>          same, as a flag
277  --problem <name>          solve a built-in test problem
278
279Options:
280  --options-file <path>     read solver options from an ipopt.opt-format file
281  --json-output <path>      write a JSON solve report to PATH after the solve
282                            (pounce#8 — machine-readable, FAIR-aligned)
283  --json-detail LEVEL       summary | full (default: summary). `full` adds
284                            per-iteration history + suffix blocks.
285  --sol-output <path>       write an AMPL .sol solution file to PATH.
286                            A positional .nl input writes <stub>.sol
287                            next to it by default (AMPL convention).
288  --no-sol                  suppress the default <stub>.sol write
289  --sens-boundcheck         clamp the perturbed primal x* + Δx onto the
290                            declared [x_l, x_u] box (sIPOPT sens_boundcheck)
291  --sens-bound-eps EPS      tolerance for --sens-boundcheck (default 1e-3;
292                            setting it also enables --sens-boundcheck)
293  --compute-red-hessian     compute the reduced Hessian over the variables
294                            tagged by the `red_hessian` integer var-suffix
295  --rh-eigendecomp          also compute the reduced-Hessian eigendecomp;
296                            implies --compute-red-hessian
297  --debug                   drop into the interactive solver debugger (a
298                            pdb-for-the-IPM): pause each iteration to
299                            inspect/mutate x, multipliers, mu, set
300                            breakpoints, step/continue. Type `help` at
301                            the pounce-dbg> prompt for commands.
302  --debug-json              same loop, but speak newline-delimited JSON on
303                            stdin/stdout so an LLM agent or program can drive
304                            it. The first line is a self-describing `hello`
305                            handshake (protocol version + every command,
306                            event, checkpoint, metric, and capability), so a
307                            client needs no out-of-band docs; each pause is one
308                            JSON state object. Full spec: docs/src/debugger.md.
309  --debug-on-error          don't pause every iteration; run freely and
310                            drop into the debugger only if the solve fails,
311                            for a post-mortem at the final iterate. Implies
312                            --debug when no --debug* mode is given.
313  --debug-on-interrupt      run normally but install a Ctrl-C handler that
314                            drops into the debugger at the next iteration
315                            (second Ctrl-C aborts). Implies --debug when no
316                            --debug* mode is given.
317  --debug-script <file>     run debugger commands from a file at the first
318                            pause (e.g. set breakpoints then continue).
319                            Implies --debug when no --debug* mode is given.
320  --list-problems           print available built-in problems and exit
321  -AMPL                     AMPL solver-protocol mode (for Pyomo / AMPL
322                            drivers): convey termination via the .sol
323                            file and exit 0 for non-fatal outcomes
324  --help, -h                print this message and exit
325  --version, -v, -V         print version and exit
326  --about                   print version, build info, features,
327                            linear solvers, and runtime paths
328  --cite [REPORT.json]      print the papers to cite when publishing
329                            pounce results, then exit. Always lists pounce
330                            itself + Wächter-Biegler; pass a JSON solve
331                            report (from --json-output) to also list papers
332                            for features the run used (e.g. restoration).
333  --bibtex                  with --cite, emit BibTeX instead of a text list
334  --dump <cat>[:<spec>]     dump diagnostic category to per-iter files.
335                            Repeatable. Categories: kkt, iterate(s), step,
336                            mu, ls, resto, convergence, timing.
337                            Iter-spec grammar: all | N | N-M | N- | -M
338                            (default: all). The `iterates` category also
339                            accepts a `:summary` (default) or `:full`
340                            variant suffix and streams one JSONL row
341                            per iter to <dump-dir>/iterates.jsonl. The
342                            `kkt` category accepts `+L` / `+L+Lvals`
343                            suffixes that add the LDLᵀ factor's
344                            strict-lower pattern (and optional values)
345                            plus the fill-reducing permutation to each
346                            kkt_solve_NNN.jsonl record (feral backend
347                            only; MA57 silently omits the L fields).
348                            Examples:
349                              --dump kkt:5
350                              --dump kkt:2-10 --dump iterate:all
351                              --dump kkt:5-10+L
352                              --dump kkt:5-10+L+Lvals
353                              --dump iterates:summary
354                              --dump iterates:5-:full
355  --dump-dir <path>         override dump root (default ./pounce-dump-<ts>)
356  --dump-format <fmt>       dump format (default: jsonl)
357
358Multistart / find-minima (search for several local minima, not one):
359  --minima <method>         enable multistart with the given strategy:
360                            multistart | mlsl | basinhopping |
361                            flooding | deflation | tunneling
362  --multistart              shorthand for --minima multistart
363  --n-minima <N>            target number of distinct minima (default 10)
364  --max-solves <N>          hard cap on solver calls (default 8*n_minima)
365  --patience <N>            stop after N solves in a row that find nothing
366                            new (default 8)
367  --dedup <d>               minima within this per-dimension-scaled distance
368                            are the same (default 1e-4)
369  --psd-tol <t>             smallest Hessian eigenvalue tolerated by the
370                            saddle-rejection check (default 1e-6)
371  --seed <S>                seed for sampling / Sobol' scramble (default 0)
372  --sobol / --no-sobol      use a scrambled Sobol' sequence for box
373                            sampling (default: on)
374  Strategy knobs (used only by the relevant --minima method; all optional):
375    --sigma, --sigma-frac, --amplitude, --amp-margin   (flooding)
376    --eta, --power, --soft, --length, --length-frac    (deflation/tunneling)
377    --gamma, --samples-per-round                       (mlsl)
378    --step, --temperature                              (basinhopping)
379    --restart-jitter                                   (all restart fallbacks)
380
381  When --minima is set, the global best minimum is written to <stub>.sol
382  (the usual AMPL output), and the remaining minima, ranked by objective,
383  to siblings <stub>.min001.sol, <stub>.min002.sol, ….  The JSON report
384  (--json-output) gains a `minima` section listing every found minimum.
385"
386    }
387
388    pub fn parse_argv(argv: Vec<String>) -> Result<Self, String> {
389        let mut problem: Option<ProblemSource> = None;
390        let mut options_file: Option<PathBuf> = None;
391        let mut set_options: Vec<(String, String)> = Vec::new();
392        let mut json_output: Option<PathBuf> = None;
393        let mut json_detail = crate::solve_report::ReportDetail::Summary;
394        let mut sol_output: Option<PathBuf> = None;
395        let mut no_sol = false;
396        let mut ampl = false;
397        let mut help = false;
398        let mut version = false;
399        let mut about = false;
400        let mut cite = false;
401        let mut cite_report: Option<PathBuf> = None;
402        let mut cite_bibtex = false;
403        let mut list_problems = false;
404        let mut dump_specs: Vec<(String, String)> = Vec::new();
405        let mut dump_dir: Option<PathBuf> = None;
406        let mut dump_format: Option<String> = None;
407        let mut sens_boundcheck = false;
408        let mut sens_bound_eps: f64 = 1e-3;
409        let mut compute_red_hessian = false;
410        let mut rh_eigendecomp = false;
411        let mut debug: Option<DebugMode> = None;
412        let mut debug_on_error = false;
413        let mut debug_on_interrupt = false;
414        let mut debug_script: Option<PathBuf> = None;
415        let mut minima: Option<MinimaArgs> = None;
416
417        let mut it = argv.into_iter().skip(1).peekable();
418        // Shorthand: fetch the value for a flag that requires one.
419        macro_rules! flag_val {
420            ($flag:expr) => {
421                it.next()
422                    .ok_or_else(|| format!("{} requires a value", $flag))?
423            };
424        }
425        // Parse a numeric value for a `--minima` knob, lazily creating the
426        // config (default method = deflation, overridden by `--minima <m>`).
427        macro_rules! minima_num {
428            ($flag:expr, $ty:ty, $field:ident) => {{
429                let v = flag_val!($flag);
430                let parsed: $ty = v.parse().map_err(|e| format!("{}: {}", $flag, e))?;
431                minima.get_or_insert_with(MinimaArgs::default).$field = parsed;
432            }};
433            ($flag:expr, $ty:ty, $field:ident, opt) => {{
434                let v = flag_val!($flag);
435                let parsed: $ty = v.parse().map_err(|e| format!("{}: {}", $flag, e))?;
436                minima.get_or_insert_with(MinimaArgs::default).$field = Some(parsed);
437            }};
438        }
439        while let Some(arg) = it.next() {
440            match arg.as_str() {
441                "-h" | "--help" => help = true,
442                "-v" | "-V" | "--version" => version = true,
443                "--about" => about = true,
444                "--cite" => {
445                    cite = true;
446                    // Optional value: consume the next argument as the
447                    // solve-report path only if it's present and is not
448                    // itself a flag (so `--cite --bibtex` doesn't swallow
449                    // the modifier, and bare `--cite` stays report-less).
450                    if let Some(next) = it.peek() {
451                        if !next.starts_with('-') {
452                            cite_report = Some(PathBuf::from(it.next().unwrap()));
453                        }
454                    }
455                }
456                "--bibtex" => cite_bibtex = true,
457                // AMPL solver-protocol flag — see `Args::ampl`.
458                "-AMPL" => ampl = true,
459                "--list-problems" => list_problems = true,
460                "--problem" => {
461                    let v = it
462                        .next()
463                        .ok_or_else(|| "--problem requires a value".to_string())?;
464                    problem = Some(ProblemSource::Builtin(v));
465                }
466                "--nl-file" => {
467                    let v = it
468                        .next()
469                        .ok_or_else(|| "--nl-file requires a value".to_string())?;
470                    problem = Some(ProblemSource::NlFile(PathBuf::from(v)));
471                }
472                "--options-file" => {
473                    let v = it
474                        .next()
475                        .ok_or_else(|| "--options-file requires a value".to_string())?;
476                    options_file = Some(PathBuf::from(v));
477                }
478                "--dump" => {
479                    let v = it
480                        .next()
481                        .ok_or_else(|| "--dump requires a value (cat[:spec])".to_string())?;
482                    let (cat, spec) = match v.split_once(':') {
483                        Some((c, s)) => (c.to_string(), s.to_string()),
484                        None => (v, "all".to_string()),
485                    };
486                    dump_specs.push((cat, spec));
487                }
488                "--dump-dir" => {
489                    let v = it
490                        .next()
491                        .ok_or_else(|| "--dump-dir requires a value".to_string())?;
492                    dump_dir = Some(PathBuf::from(v));
493                }
494                "--dump-format" => {
495                    let v = it
496                        .next()
497                        .ok_or_else(|| "--dump-format requires a value".to_string())?;
498                    dump_format = Some(v);
499                }
500                "--json-output" => {
501                    let v = it
502                        .next()
503                        .ok_or_else(|| "--json-output requires a value".to_string())?;
504                    json_output = Some(PathBuf::from(v));
505                }
506                "--json-detail" => {
507                    let v = it
508                        .next()
509                        .ok_or_else(|| "--json-detail requires a value".to_string())?;
510                    json_detail = crate::solve_report::ReportDetail::parse(&v)?;
511                }
512                "--sol-output" => {
513                    let v = it
514                        .next()
515                        .ok_or_else(|| "--sol-output requires a value".to_string())?;
516                    sol_output = Some(PathBuf::from(v));
517                }
518                "--no-sol" => no_sol = true,
519                "--sens-boundcheck" => sens_boundcheck = true,
520                "--sens-bound-eps" => {
521                    let v = it
522                        .next()
523                        .ok_or_else(|| "--sens-bound-eps requires a value".to_string())?;
524                    sens_bound_eps = v
525                        .parse::<f64>()
526                        .map_err(|e| format!("--sens-bound-eps: {e}"))?;
527                    sens_boundcheck = true;
528                }
529                "--debug" => debug = Some(DebugMode::Repl),
530                "--debug-json" => debug = Some(DebugMode::Json),
531                "--debug-on-error" => debug_on_error = true,
532                "--debug-on-interrupt" => debug_on_interrupt = true,
533                "--debug-script" => {
534                    let v = it
535                        .next()
536                        .ok_or_else(|| "--debug-script requires a value".to_string())?;
537                    debug_script = Some(PathBuf::from(v));
538                }
539                "--compute-red-hessian" => compute_red_hessian = true,
540                "--rh-eigendecomp" => {
541                    rh_eigendecomp = true;
542                    compute_red_hessian = true;
543                }
544                // ---- multistart / find-minima (`--minima`) ----
545                "--minima" => {
546                    let v = flag_val!("--minima");
547                    let method = MinimaMethod::parse(&v)?;
548                    minima.get_or_insert_with(MinimaArgs::default).method = method;
549                }
550                "--multistart" => {
551                    minima.get_or_insert_with(MinimaArgs::default).method =
552                        MinimaMethod::Multistart;
553                }
554                "--n-minima" => minima_num!("--n-minima", usize, n_minima),
555                "--max-solves" => minima_num!("--max-solves", usize, max_solves, opt),
556                "--patience" => minima_num!("--patience", usize, patience),
557                "--dedup" => minima_num!("--dedup", f64, dedup),
558                "--psd-tol" => minima_num!("--psd-tol", f64, psd_tol),
559                "--seed" => minima_num!("--seed", u64, seed),
560                "--sobol" => {
561                    minima.get_or_insert_with(MinimaArgs::default).sobol = true;
562                }
563                "--no-sobol" => {
564                    minima.get_or_insert_with(MinimaArgs::default).sobol = false;
565                }
566                "--sigma" => minima_num!("--sigma", f64, sigma, opt),
567                "--sigma-frac" => minima_num!("--sigma-frac", f64, sigma_frac, opt),
568                "--amplitude" => minima_num!("--amplitude", f64, amplitude, opt),
569                "--amp-margin" => minima_num!("--amp-margin", f64, amp_margin, opt),
570                "--eta" => minima_num!("--eta", f64, eta, opt),
571                "--power" => minima_num!("--power", f64, power, opt),
572                "--soft" => minima_num!("--soft", f64, soft, opt),
573                "--length" => minima_num!("--length", f64, length, opt),
574                "--length-frac" => minima_num!("--length-frac", f64, length_frac, opt),
575                "--gamma" => minima_num!("--gamma", f64, gamma, opt),
576                "--samples-per-round" => {
577                    minima_num!("--samples-per-round", usize, samples_per_round, opt)
578                }
579                "--step" => minima_num!("--step", f64, step, opt),
580                "--temperature" => minima_num!("--temperature", f64, temperature, opt),
581                "--restart-jitter" => minima_num!("--restart-jitter", f64, restart_jitter, opt),
582                other if !other.starts_with('-') => {
583                    // `key=value` forms an option pair (matches upstream
584                    // ipopt CLI). Otherwise the first bare arg is the
585                    // positional .nl path, and a second bare arg is the
586                    // .sol output (AMPL `solver in.nl out.sol`).
587                    if let Some((k, v)) = parse_kv(other) {
588                        set_options.push((k, v));
589                    } else if problem.is_none() {
590                        problem = Some(ProblemSource::NlFile(PathBuf::from(other)));
591                    } else if sol_output.is_none() {
592                        sol_output = Some(PathBuf::from(other));
593                    } else {
594                        return Err(format!(
595                            "unexpected positional argument '{other}' (expected KEY=VALUE)"
596                        ));
597                    }
598                }
599                other => return Err(format!("unrecognized argument '{other}'")),
600            }
601        }
602
603        if list_problems {
604            println!("{}", crate::builtin::list().join("\n"));
605            std::process::exit(0);
606        }
607
608        // `--debug-on-error` / `--debug-on-interrupt` / `--debug-script`
609        // without an explicit mode imply the REPL.
610        if (debug_on_error || debug_on_interrupt || debug_script.is_some()) && debug.is_none() {
611            debug = Some(DebugMode::Repl);
612        }
613
614        if !help && !version && !about && !cite {
615            let problem = problem.ok_or_else(|| {
616                "missing problem: pass a positional .nl path, --nl-file, or --problem".to_string()
617            })?;
618            return Ok(Self {
619                problem,
620                options_file,
621                set_options,
622                json_output,
623                json_detail,
624                sol_output,
625                no_sol,
626                ampl,
627                help,
628                version,
629                about,
630                cite,
631                cite_report,
632                cite_bibtex,
633                dump_specs,
634                dump_dir,
635                dump_format,
636                sens_boundcheck,
637                sens_bound_eps,
638                compute_red_hessian,
639                rh_eigendecomp,
640                debug,
641                debug_on_error,
642                debug_on_interrupt,
643                debug_script,
644                minima,
645            });
646        }
647
648        Ok(Self {
649            problem: ProblemSource::Builtin(String::new()),
650            options_file,
651            set_options,
652            json_output,
653            json_detail,
654            sol_output,
655            no_sol,
656            ampl,
657            help,
658            version,
659            about,
660            cite,
661            cite_report,
662            cite_bibtex,
663            dump_specs,
664            dump_dir,
665            dump_format,
666            sens_boundcheck,
667            sens_bound_eps,
668            compute_red_hessian,
669            rh_eigendecomp,
670            debug,
671            debug_on_error,
672            debug_on_interrupt,
673            debug_script,
674            minima,
675        })
676    }
677}
678
679/// Parse `key=value` (or `key:=value`, ipopt-compatible). Returns
680/// `None` if the token does not contain `=`. Whitespace around the
681/// separator is trimmed; empty key or value yields `None`.
682fn parse_kv(s: &str) -> Option<(String, String)> {
683    let (k, v) = s.split_once('=')?;
684    let k = k.trim().trim_end_matches(':');
685    let v = v.trim();
686    if k.is_empty() || v.is_empty() {
687        return None;
688    }
689    Some((k.to_string(), v.to_string()))
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    fn argv(args: &[&str]) -> Vec<String> {
697        std::iter::once("pounce")
698            .chain(args.iter().copied())
699            .map(String::from)
700            .collect()
701    }
702
703    #[test]
704    fn help_short_and_long() {
705        assert!(Args::parse_argv(argv(&["-h"])).unwrap().help);
706        assert!(Args::parse_argv(argv(&["--help"])).unwrap().help);
707    }
708
709    #[test]
710    fn version_short_and_long() {
711        assert!(Args::parse_argv(argv(&["-v"])).unwrap().version);
712        assert!(Args::parse_argv(argv(&["-V"])).unwrap().version);
713        assert!(Args::parse_argv(argv(&["--version"])).unwrap().version);
714    }
715
716    #[test]
717    fn ampl_flag_sets_mode_and_keeps_positional() {
718        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL"])).unwrap();
719        assert!(a.ampl);
720        match a.problem {
721            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
722            _ => panic!("expected positional .nl"),
723        }
724    }
725
726    #[test]
727    fn ampl_flag_defaults_off() {
728        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
729        assert!(!a.ampl);
730    }
731
732    #[test]
733    fn ampl_flag_with_options() {
734        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL", "max_iter=500"])).unwrap();
735        assert!(a.ampl);
736        assert_eq!(a.set_options, vec![("max_iter".into(), "500".into())]);
737    }
738
739    #[test]
740    fn about_flag_does_not_require_problem() {
741        let a = Args::parse_argv(argv(&["--about"])).unwrap();
742        assert!(a.about);
743    }
744
745    #[test]
746    fn cite_flag_alone_needs_no_problem_or_report() {
747        let a = Args::parse_argv(argv(&["--cite"])).unwrap();
748        assert!(a.cite);
749        assert!(a.cite_report.is_none());
750        assert!(!a.cite_bibtex);
751    }
752
753    #[test]
754    fn cite_consumes_following_report_path() {
755        let a = Args::parse_argv(argv(&["--cite", "run.json"])).unwrap();
756        assert!(a.cite);
757        assert_eq!(a.cite_report.unwrap().to_str(), Some("run.json"));
758    }
759
760    #[test]
761    fn cite_does_not_swallow_a_following_flag() {
762        let a = Args::parse_argv(argv(&["--cite", "--bibtex"])).unwrap();
763        assert!(a.cite);
764        assert!(a.cite_report.is_none());
765        assert!(a.cite_bibtex);
766    }
767
768    #[test]
769    fn cite_with_report_and_bibtex() {
770        let a = Args::parse_argv(argv(&["--cite", "run.json", "--bibtex"])).unwrap();
771        assert!(a.cite);
772        assert_eq!(a.cite_report.unwrap().to_str(), Some("run.json"));
773        assert!(a.cite_bibtex);
774    }
775
776    #[test]
777    fn problem_flag_captures_name() {
778        let a = Args::parse_argv(argv(&["--problem", "rosenbrock"])).unwrap();
779        match a.problem {
780            ProblemSource::Builtin(s) => assert_eq!(s, "rosenbrock"),
781            _ => panic!("expected builtin"),
782        }
783    }
784
785    #[test]
786    fn nl_file_captured() {
787        let a = Args::parse_argv(argv(&["--nl-file", "/tmp/foo.nl"])).unwrap();
788        match a.problem {
789            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
790            _ => panic!("expected nl file"),
791        }
792    }
793
794    #[test]
795    fn positional_nl_path() {
796        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
797        match a.problem {
798            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
799            _ => panic!("expected positional .nl"),
800        }
801    }
802
803    #[test]
804    fn positional_with_options_file() {
805        let a = Args::parse_argv(argv(&["--options-file", "ipopt.opt", "/tmp/foo.nl"])).unwrap();
806        match a.problem {
807            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
808            _ => panic!("expected positional .nl"),
809        }
810        assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
811    }
812
813    #[test]
814    fn options_file_captured() {
815        let a = Args::parse_argv(argv(&["--problem", "x", "--options-file", "ipopt.opt"])).unwrap();
816        assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
817    }
818
819    #[test]
820    fn missing_value_for_flag() {
821        assert!(Args::parse_argv(argv(&["--problem"])).is_err());
822    }
823
824    #[test]
825    fn missing_problem() {
826        assert!(Args::parse_argv(argv(&[])).is_err());
827    }
828
829    #[test]
830    fn unknown_arg() {
831        assert!(Args::parse_argv(argv(&["--bogus"])).is_err());
832    }
833
834    #[test]
835    fn key_value_options_collected() {
836        let a = Args::parse_argv(argv(&[
837            "/tmp/foo.nl",
838            "print_level=8",
839            "max_iter=500",
840            "tol=1e-10",
841        ]))
842        .unwrap();
843        assert_eq!(
844            a.set_options,
845            vec![
846                ("print_level".into(), "8".into()),
847                ("max_iter".into(), "500".into()),
848                ("tol".into(), "1e-10".into()),
849            ]
850        );
851    }
852
853    #[test]
854    fn key_value_before_path() {
855        let a = Args::parse_argv(argv(&["print_level=8", "/tmp/foo.nl"])).unwrap();
856        match a.problem {
857            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
858            _ => panic!("expected positional .nl"),
859        }
860        assert_eq!(a.set_options, vec![("print_level".into(), "8".into())]);
861    }
862
863    #[test]
864    fn dump_flag_captures_cat_and_spec() {
865        let a = Args::parse_argv(argv(&[
866            "--problem",
867            "x",
868            "--dump",
869            "kkt:2-10",
870            "--dump",
871            "iterate",
872        ]))
873        .unwrap();
874        assert_eq!(
875            a.dump_specs,
876            vec![
877                ("kkt".into(), "2-10".into()),
878                ("iterate".into(), "all".into()),
879            ]
880        );
881    }
882
883    #[test]
884    fn dump_dir_and_format_captured() {
885        let a = Args::parse_argv(argv(&[
886            "--problem",
887            "x",
888            "--dump",
889            "kkt",
890            "--dump-dir",
891            "/tmp/d",
892            "--dump-format",
893            "jsonl",
894        ]))
895        .unwrap();
896        assert_eq!(a.dump_dir.unwrap().to_str(), Some("/tmp/d"));
897        assert_eq!(a.dump_format.as_deref(), Some("jsonl"));
898    }
899
900    #[test]
901    fn sol_output_captured() {
902        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output", "/tmp/out.sol"])).unwrap();
903        assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
904        assert!(!a.no_sol);
905    }
906
907    #[test]
908    fn no_sol_flag() {
909        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sol"])).unwrap();
910        assert!(a.no_sol);
911        assert!(a.sol_output.is_none());
912    }
913
914    #[test]
915    fn sol_output_defaults_unset() {
916        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
917        assert!(a.sol_output.is_none());
918        assert!(!a.no_sol);
919    }
920
921    #[test]
922    fn sol_output_missing_value() {
923        assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output"])).is_err());
924    }
925
926    #[test]
927    fn second_positional_is_sol_output() {
928        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "/tmp/out.sol"])).unwrap();
929        match a.problem {
930            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
931            _ => panic!("expected positional .nl"),
932        }
933        assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
934    }
935
936    #[test]
937    fn third_positional_is_an_error() {
938        assert!(Args::parse_argv(argv(&["/tmp/a.nl", "/tmp/b.sol", "/tmp/c"])).is_err());
939    }
940
941    #[test]
942    fn sens_flags_default_off() {
943        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
944        assert!(!a.sens_boundcheck);
945        assert!(!a.compute_red_hessian);
946        assert!(!a.rh_eigendecomp);
947        assert_eq!(a.sens_bound_eps, 1e-3);
948    }
949
950    #[test]
951    fn sens_boundcheck_flag() {
952        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-boundcheck"])).unwrap();
953        assert!(a.sens_boundcheck);
954    }
955
956    #[test]
957    fn sens_bound_eps_sets_value_and_enables_boundcheck() {
958        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-bound-eps", "1e-6"])).unwrap();
959        assert_eq!(a.sens_bound_eps, 1e-6);
960        assert!(a.sens_boundcheck);
961    }
962
963    #[test]
964    fn rh_eigendecomp_implies_compute_red_hessian() {
965        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--rh-eigendecomp"])).unwrap();
966        assert!(a.rh_eigendecomp);
967        assert!(a.compute_red_hessian);
968    }
969
970    #[test]
971    fn minima_absent_by_default() {
972        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
973        assert!(a.minima.is_none());
974    }
975
976    #[test]
977    fn minima_method_and_shared_knobs() {
978        let a = Args::parse_argv(argv(&[
979            "/tmp/foo.nl",
980            "--minima",
981            "flooding",
982            "--n-minima",
983            "5",
984            "--max-solves",
985            "42",
986            "--patience",
987            "3",
988            "--dedup",
989            "1e-2",
990            "--psd-tol",
991            "1e-8",
992            "--seed",
993            "7",
994            "--no-sobol",
995        ]))
996        .unwrap();
997        let m = a.minima.expect("minima parsed");
998        assert_eq!(m.method, MinimaMethod::Flooding);
999        assert_eq!(m.n_minima, 5);
1000        assert_eq!(m.max_solves, Some(42));
1001        assert_eq!(m.patience, 3);
1002        assert_eq!(m.dedup, 1e-2);
1003        assert_eq!(m.psd_tol, 1e-8);
1004        assert_eq!(m.seed, 7);
1005        assert!(!m.sobol);
1006    }
1007
1008    #[test]
1009    fn multistart_shorthand_selects_multistart() {
1010        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--multistart"])).unwrap();
1011        assert_eq!(a.minima.unwrap().method, MinimaMethod::Multistart);
1012    }
1013
1014    #[test]
1015    fn minima_strategy_knobs_are_optional_and_parsed() {
1016        let a = Args::parse_argv(argv(&[
1017            "/tmp/foo.nl",
1018            "--minima",
1019            "deflation",
1020            "--eta",
1021            "2.5",
1022            "--power",
1023            "3",
1024            "--soft",
1025            "1e-4",
1026            "--length",
1027            "0.2",
1028            "--restart-jitter",
1029            "0.9",
1030        ]))
1031        .unwrap();
1032        let m = a.minima.unwrap();
1033        assert_eq!(m.method, MinimaMethod::Deflation);
1034        assert_eq!(m.eta, Some(2.5));
1035        assert_eq!(m.power, Some(3.0));
1036        assert_eq!(m.soft, Some(1e-4));
1037        assert_eq!(m.length, Some(0.2));
1038        assert_eq!(m.restart_jitter, Some(0.9));
1039        // Untouched knobs stay None.
1040        assert_eq!(m.sigma, None);
1041        assert_eq!(m.gamma, None);
1042    }
1043
1044    #[test]
1045    fn minima_unknown_method_errors() {
1046        assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--minima", "nope"])).is_err());
1047    }
1048
1049    #[test]
1050    fn parse_kv_basic() {
1051        assert_eq!(
1052            parse_kv("print_level=8"),
1053            Some(("print_level".into(), "8".into()))
1054        );
1055        assert_eq!(
1056            parse_kv("tol = 1e-10"),
1057            Some(("tol".into(), "1e-10".into()))
1058        );
1059        assert_eq!(parse_kv("plain_path.nl"), None);
1060        assert_eq!(parse_kv("=value"), None);
1061        assert_eq!(parse_kv("key="), None);
1062    }
1063}