1use std::path::PathBuf;
5
6#[derive(Debug, Clone)]
7pub enum ProblemSource {
8 Builtin(String),
9 NlFile(PathBuf),
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum OptionFileChoice {
17 Named(PathBuf),
22 Discover,
25 Suppressed,
27}
28
29#[derive(Debug, Clone)]
30pub struct Args {
31 pub problem: ProblemSource,
32 pub options_file: Option<PathBuf>,
33 pub no_options_file: bool,
39 pub set_options: Vec<(String, String)>,
44 pub json_output: Option<PathBuf>,
48 pub json_detail: crate::solve_report::ReportDetail,
53 pub sol_output: Option<PathBuf>,
59 pub no_sol: bool,
62 pub ampl: bool,
71 pub help: bool,
72 pub version: bool,
73 pub about: bool,
76 pub cite: bool,
83 pub cite_report: Option<PathBuf>,
86 pub cite_bibtex: bool,
89 pub dump_specs: Vec<(String, String)>,
95 pub dump_dir: Option<PathBuf>,
98 pub dump_format: Option<String>,
100 pub sens_boundcheck: bool,
111 pub sens_bound_eps: f64,
114 pub sens_bound_eps_explicit: bool,
122 pub compute_red_hessian: bool,
127 pub rh_eigendecomp: bool,
131 pub debug: Option<DebugMode>,
136 pub debug_on_error: bool,
141 pub debug_on_interrupt: bool,
145 pub debug_script: Option<PathBuf>,
149 pub minima: Option<MinimaArgs>,
154}
155
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub enum MinimaMethod {
160 Multistart,
162 Mlsl,
164 Basinhopping,
166 Flooding,
168 Deflation,
170 Tunneling,
172}
173
174impl MinimaMethod {
175 pub fn parse(s: &str) -> Result<Self, String> {
176 Ok(match s {
177 "multistart" => Self::Multistart,
178 "mlsl" => Self::Mlsl,
179 "basinhopping" => Self::Basinhopping,
180 "flooding" => Self::Flooding,
181 "deflation" => Self::Deflation,
182 "tunneling" => Self::Tunneling,
183 other => {
184 return Err(format!(
185 "unknown --minima method '{other}'; choose from \
186 multistart, mlsl, basinhopping, flooding, deflation, tunneling"
187 ));
188 }
189 })
190 }
191
192 pub fn as_str(&self) -> &'static str {
193 match self {
194 Self::Multistart => "multistart",
195 Self::Mlsl => "mlsl",
196 Self::Basinhopping => "basinhopping",
197 Self::Flooding => "flooding",
198 Self::Deflation => "deflation",
199 Self::Tunneling => "tunneling",
200 }
201 }
202}
203
204#[derive(Debug, Clone)]
209pub struct MinimaArgs {
210 pub method: MinimaMethod,
211 pub n_minima: usize,
213 pub max_solves: Option<usize>,
215 pub patience: usize,
217 pub dedup: f64,
219 pub psd_tol: f64,
221 pub seed: u64,
223 pub sobol: bool,
225 pub sigma: Option<f64>,
227 pub sigma_frac: Option<f64>,
228 pub amplitude: Option<f64>,
229 pub amp_margin: Option<f64>,
230 pub eta: Option<f64>,
231 pub power: Option<f64>,
232 pub soft: Option<f64>,
233 pub length: Option<f64>,
234 pub length_frac: Option<f64>,
235 pub gamma: Option<f64>,
236 pub samples_per_round: Option<usize>,
237 pub step: Option<f64>,
238 pub temperature: Option<f64>,
239 pub restart_jitter: Option<f64>,
240}
241
242impl Default for MinimaArgs {
243 fn default() -> Self {
244 Self {
245 method: MinimaMethod::Deflation,
247 n_minima: 10,
248 max_solves: None,
249 patience: 8,
250 dedup: 1e-4,
251 psd_tol: 1e-6,
252 seed: 0,
253 sobol: true,
254 sigma: None,
255 sigma_frac: None,
256 amplitude: None,
257 amp_margin: None,
258 eta: None,
259 power: None,
260 soft: None,
261 length: None,
262 length_frac: None,
263 gamma: None,
264 samples_per_round: None,
265 step: None,
266 temperature: None,
267 restart_jitter: None,
268 }
269 }
270}
271
272#[derive(Clone, Copy, Debug, PartialEq, Eq)]
274pub enum DebugMode {
275 Repl,
277 Json,
279}
280
281impl Args {
282 pub fn usage() -> &'static str {
283 "\
284Usage: pounce [OPTIONS] [PATH] [SOL] [KEY=VALUE ...]
285
286PATH is an AMPL .nl file (positional). Equivalent: --nl-file <path>.
287An extensionless AMPL stub is accepted: if PATH is missing but PATH.nl
288exists, PATH.nl is read (the `pounce mystub -AMPL` invocation convention).
289SOL is an optional second positional naming the .sol output file
290(equivalent to --sol-output <path>); the AMPL `solver in.nl out.sol`
291convention.
292
293Options may also be supplied via the `pounce_options` environment
294variable (AMPL's `<solver>_options` convention): a whitespace-separated
295list of KEY=VALUE tokens. Command-line KEY=VALUE options override it.
296
297Options are also read from an ipopt.opt-format options file: the one
298named by --options-file or by option_file_name=<path>, or — when
299neither names one — `pounce.opt` or `ipopt.opt` from the working
300directory, if present. A named file that does not exist is an error.
301KEY=VALUE options (command line or environment) override the file.
302--no-options-file reads none.
303
304Subcommands:
305 pounce verify <problem.nl> <claim.sol> [--feas-tol T] [--json-output P]
306 independently check that a .sol solution
307 satisfies the canonical .nl's constraints and
308 bounds, without trusting the solver/agent that
309 produced it. Exit 0 = feasible, 20 = violated.
310 Run `pounce verify --help` for details.
311 pounce check-x0 <problem.nl> [--json] [--json-output P]
312 starting-point preflight: evaluate the model
313 once at x0 and report NaN/inf, bound and
314 constraint violations, interior-clamp
315 displacement, and derivative scale spread
316 before any solve. Exit 0 = evaluates cleanly,
317 21 = NaN/inf at x0.
318 Run `pounce check-x0 --help` for details.
319
320When the .nl declares the sIPOPT suffixes (sens_state_1,
321sens_state_value_1, sens_init_constr), pounce additionally runs the
322post-optimal parametric sensitivity step and writes the perturbed
323primal back into the .sol as a `sens_sol_state_1` suffix.
324
325Trailing KEY=VALUE pairs are forwarded to the solver's OptionsList
326(same syntax/semantics as the ipopt CLI). They override values loaded
327from --options-file. Examples:
328
329 pounce problem.nl print_level=8
330 pounce problem.nl max_iter=500 tol=1e-10 linear_solver=ma57
331
332Required (one of):
333 PATH positional .nl file to solve
334 --nl-file <path> same, as a flag
335 --problem <name> solve a built-in test problem
336
337Options:
338 --options-file <path> read solver options from an ipopt.opt-format file
339 (same as option_file_name=<path>)
340 --no-options-file read no options file at all — skip the implicit
341 pounce.opt / ipopt.opt lookup in the working
342 directory
343 --json-output <path> write a JSON solve report to PATH after the solve
344 (pounce#8 — machine-readable, FAIR-aligned)
345 --json-detail LEVEL summary | full (default: summary). `full` adds
346 per-iteration history + suffix blocks.
347 --sol-output <path> write an AMPL .sol solution file to PATH.
348 A positional .nl input writes <stub>.sol
349 next to it by default (AMPL convention).
350 --no-sol suppress the default <stub>.sol write
351 --sens-boundcheck clamp the perturbed primal x* + Δx onto the
352 declared [x_l, x_u] box (sIPOPT sens_boundcheck)
353 --sens-bound-eps EPS tolerance for --sens-boundcheck (default 1e-3;
354 setting it also enables --sens-boundcheck)
355 --compute-red-hessian compute the reduced Hessian over the variables
356 tagged by the `red_hessian` integer var-suffix
357 --rh-eigendecomp also compute the reduced-Hessian eigendecomp;
358 implies --compute-red-hessian
359 --debug drop into the interactive solver debugger (a
360 pdb-for-the-IPM): pause each iteration to
361 inspect/mutate x, multipliers, mu, set
362 breakpoints, step/continue. Type `help` at
363 the pounce-dbg> prompt for commands.
364 --debug-json same loop, but speak newline-delimited JSON on
365 stdin/stdout so an LLM agent or program can drive
366 it. The first line is a self-describing `hello`
367 handshake (protocol version + every command,
368 event, checkpoint, metric, and capability), so a
369 client needs no out-of-band docs; each pause is one
370 JSON state object. Full spec: docs/src/debugger.md.
371 --debug-on-error don't pause every iteration; run freely and
372 drop into the debugger only if the solve fails,
373 for a post-mortem at the final iterate. Implies
374 --debug when no --debug* mode is given.
375 --debug-on-interrupt run normally but install a Ctrl-C handler that
376 drops into the debugger at the next iteration
377 (second Ctrl-C aborts). Implies --debug when no
378 --debug* mode is given.
379 --debug-script <file> run debugger commands from a file at the first
380 pause (e.g. set breakpoints then continue).
381 Implies --debug when no --debug* mode is given.
382 --list-problems print available built-in problems and exit
383 -AMPL AMPL solver-protocol mode (for Pyomo / AMPL
384 drivers): convey termination via the .sol
385 file and exit 0 for non-fatal outcomes
386 --help, -h print this message and exit
387 --version, -v, -V print version and exit
388 --about print version, build info, features,
389 linear solvers, and runtime paths
390 --cite [REPORT.json] print the papers to cite when publishing
391 pounce results, then exit. Always lists pounce
392 itself + Wächter-Biegler; pass a JSON solve
393 report (from --json-output) to also list papers
394 for features the run used (e.g. restoration).
395 --bibtex with --cite, emit BibTeX instead of a text list
396 --dump <cat>[:<spec>] dump diagnostic category to per-iter files.
397 Repeatable. Categories: kkt, iterate(s), step,
398 mu, ls, resto, convergence, timing.
399 Iter-spec grammar: all | N | N-M | N- | -M
400 (default: all). The `iterates` category also
401 accepts a `:summary` (default) or `:full`
402 variant suffix and streams one JSONL row
403 per iter to <dump-dir>/iterates.jsonl. The
404 `kkt` category accepts `+L` / `+L+Lvals`
405 suffixes that add the LDLᵀ factor's
406 strict-lower pattern (and optional values)
407 plus the fill-reducing permutation to each
408 kkt_solve_NNN.jsonl record (feral backend
409 only; MA57 silently omits the L fields).
410 Examples:
411 --dump kkt:5
412 --dump kkt:2-10 --dump iterate:all
413 --dump kkt:5-10+L
414 --dump kkt:5-10+L+Lvals
415 --dump iterates:summary
416 --dump iterates:5-:full
417 --dump-dir <path> override dump root (default ./pounce-dump-<ts>)
418 --dump-format <fmt> dump format (default: jsonl)
419
420Multistart / find-minima (search for several local minima, not one):
421 --minima <method> enable multistart with the given strategy:
422 multistart | mlsl | basinhopping |
423 flooding | deflation | tunneling
424 --multistart shorthand for --minima multistart
425 --n-minima <N> target number of distinct minima (default 10)
426 --max-solves <N> hard cap on solver calls (default 8*n_minima)
427 --patience <N> stop after N solves in a row that find nothing
428 new (default 8)
429 --dedup <d> minima within this per-dimension-scaled distance
430 are the same (default 1e-4)
431 --psd-tol <t> smallest Hessian eigenvalue tolerated by the
432 saddle-rejection check (default 1e-6)
433 --seed <S> seed for sampling / Sobol' scramble (default 0)
434 --sobol / --no-sobol use a scrambled Sobol' sequence for box
435 sampling (default: on)
436 Strategy knobs (used only by the relevant --minima method; all optional):
437 --sigma, --sigma-frac, --amplitude, --amp-margin (flooding)
438 --eta, --power, --soft, --length, --length-frac (deflation/tunneling)
439 --gamma, --samples-per-round (mlsl)
440 --step, --temperature (basinhopping)
441 --restart-jitter (all restart fallbacks)
442
443 When --minima is set, the global best minimum is written to <stub>.sol
444 (the usual AMPL output), and the remaining minima, ranked by objective,
445 to siblings <stub>.min001.sol, <stub>.min002.sol, …. The JSON report
446 (--json-output) gains a `minima` section listing every found minimum.
447"
448 }
449
450 pub fn parse_argv(argv: Vec<String>) -> Result<Self, String> {
451 let mut problem: Option<ProblemSource> = None;
452 let mut options_file: Option<PathBuf> = None;
453 let mut no_options_file = false;
454 let mut set_options: Vec<(String, String)> = Vec::new();
455 let mut json_output: Option<PathBuf> = None;
456 let mut json_detail = crate::solve_report::ReportDetail::Summary;
457 let mut sol_output: Option<PathBuf> = None;
458 let mut no_sol = false;
459 let mut ampl = false;
460 let mut help = false;
461 let mut version = false;
462 let mut about = false;
463 let mut cite = false;
464 let mut cite_report: Option<PathBuf> = None;
465 let mut cite_bibtex = false;
466 let mut list_problems = false;
467 let mut dump_specs: Vec<(String, String)> = Vec::new();
468 let mut dump_dir: Option<PathBuf> = None;
469 let mut dump_format: Option<String> = None;
470 let mut sens_boundcheck = false;
471 let mut sens_bound_eps: f64 = 1e-3;
472 let mut sens_bound_eps_explicit = false;
473 let mut compute_red_hessian = false;
474 let mut rh_eigendecomp = false;
475 let mut debug: Option<DebugMode> = None;
476 let mut debug_on_error = false;
477 let mut debug_on_interrupt = false;
478 let mut debug_script: Option<PathBuf> = None;
479 let mut minima: Option<MinimaArgs> = None;
480 let mut minima_method_explicit = false;
487 let mut minima_knob: Option<&'static str> = None;
488
489 let mut it = argv.into_iter().skip(1).peekable();
490 macro_rules! flag_val {
492 ($flag:expr_2021) => {
493 it.next()
494 .ok_or_else(|| format!("{} requires a value", $flag))?
495 };
496 }
497 macro_rules! minima_num {
500 ($flag:expr_2021, $ty:ty, $field:ident) => {{
501 let v = flag_val!($flag);
502 let parsed: $ty = v.parse().map_err(|e| format!("{}: {}", $flag, e))?;
503 minima.get_or_insert_with(MinimaArgs::default).$field = parsed;
504 if minima_knob.is_none() {
505 minima_knob = Some($flag);
506 }
507 }};
508 ($flag:expr_2021, $ty:ty, $field:ident, opt) => {{
509 let v = flag_val!($flag);
510 let parsed: $ty = v.parse().map_err(|e| format!("{}: {}", $flag, e))?;
511 minima.get_or_insert_with(MinimaArgs::default).$field = Some(parsed);
512 if minima_knob.is_none() {
513 minima_knob = Some($flag);
514 }
515 }};
516 }
517 while let Some(arg) = it.next() {
518 match arg.as_str() {
519 "-h" | "--help" => help = true,
520 "-v" | "-V" | "--version" => version = true,
521 "--about" => about = true,
522 "--cite" => {
523 cite = true;
524 if let Some(next) = it.peek() {
529 if !next.starts_with('-') {
530 cite_report = Some(PathBuf::from(it.next().unwrap()));
531 }
532 }
533 }
534 "--bibtex" => cite_bibtex = true,
535 "-AMPL" => ampl = true,
537 "--list-problems" => list_problems = true,
538 "--problem" => {
539 let v = it
540 .next()
541 .ok_or_else(|| "--problem requires a value".to_string())?;
542 problem = Some(ProblemSource::Builtin(v));
543 }
544 "--nl-file" => {
545 let v = it
546 .next()
547 .ok_or_else(|| "--nl-file requires a value".to_string())?;
548 problem = Some(ProblemSource::NlFile(PathBuf::from(v)));
549 }
550 "--options-file" => {
551 let v = it
552 .next()
553 .ok_or_else(|| "--options-file requires a value".to_string())?;
554 options_file = Some(PathBuf::from(v));
555 }
556 "--no-options-file" => no_options_file = true,
557 "--dump" => {
558 let v = it
559 .next()
560 .ok_or_else(|| "--dump requires a value (cat[:spec])".to_string())?;
561 let (cat, spec) = match v.split_once(':') {
562 Some((c, s)) => (c.to_string(), s.to_string()),
563 None => (v, "all".to_string()),
564 };
565 dump_specs.push((cat, spec));
566 }
567 "--dump-dir" => {
568 let v = it
569 .next()
570 .ok_or_else(|| "--dump-dir requires a value".to_string())?;
571 dump_dir = Some(PathBuf::from(v));
572 }
573 "--dump-format" => {
574 let v = it
575 .next()
576 .ok_or_else(|| "--dump-format requires a value".to_string())?;
577 dump_format = Some(v);
578 }
579 "--json-output" => {
580 let v = it
581 .next()
582 .ok_or_else(|| "--json-output requires a value".to_string())?;
583 json_output = Some(PathBuf::from(v));
584 }
585 "--json-detail" => {
586 let v = it
587 .next()
588 .ok_or_else(|| "--json-detail requires a value".to_string())?;
589 json_detail = crate::solve_report::ReportDetail::parse(&v)?;
590 }
591 "--sol-output" => {
592 let v = it
593 .next()
594 .ok_or_else(|| "--sol-output requires a value".to_string())?;
595 sol_output = Some(PathBuf::from(v));
596 }
597 "--no-sol" => no_sol = true,
598 "--sens-boundcheck" => sens_boundcheck = true,
599 "--sens-bound-eps" => {
600 let v = it
601 .next()
602 .ok_or_else(|| "--sens-bound-eps requires a value".to_string())?;
603 sens_bound_eps = v
604 .parse::<f64>()
605 .map_err(|e| format!("--sens-bound-eps: {e}"))?;
606 sens_bound_eps_explicit = true;
607 sens_boundcheck = true;
608 }
609 "--debug" => debug = Some(DebugMode::Repl),
610 "--debug-json" => debug = Some(DebugMode::Json),
611 "--debug-on-error" => debug_on_error = true,
612 "--debug-on-interrupt" => debug_on_interrupt = true,
613 "--debug-script" => {
614 let v = it
615 .next()
616 .ok_or_else(|| "--debug-script requires a value".to_string())?;
617 debug_script = Some(PathBuf::from(v));
618 }
619 "--compute-red-hessian" => compute_red_hessian = true,
620 "--rh-eigendecomp" => {
621 rh_eigendecomp = true;
622 compute_red_hessian = true;
623 }
624 "--minima" => {
626 let v = flag_val!("--minima");
627 let method = MinimaMethod::parse(&v)?;
628 minima.get_or_insert_with(MinimaArgs::default).method = method;
629 minima_method_explicit = true;
630 }
631 "--multistart" => {
632 minima.get_or_insert_with(MinimaArgs::default).method =
633 MinimaMethod::Multistart;
634 minima_method_explicit = true;
635 }
636 "--n-minima" => minima_num!("--n-minima", usize, n_minima),
637 "--max-solves" => minima_num!("--max-solves", usize, max_solves, opt),
638 "--patience" => minima_num!("--patience", usize, patience),
639 "--dedup" => minima_num!("--dedup", f64, dedup),
640 "--psd-tol" => minima_num!("--psd-tol", f64, psd_tol),
641 "--seed" => minima_num!("--seed", u64, seed),
642 "--sobol" => {
643 minima.get_or_insert_with(MinimaArgs::default).sobol = true;
644 if minima_knob.is_none() {
645 minima_knob = Some("--sobol");
646 }
647 }
648 "--no-sobol" => {
649 minima.get_or_insert_with(MinimaArgs::default).sobol = false;
650 if minima_knob.is_none() {
651 minima_knob = Some("--no-sobol");
652 }
653 }
654 "--sigma" => minima_num!("--sigma", f64, sigma, opt),
655 "--sigma-frac" => minima_num!("--sigma-frac", f64, sigma_frac, opt),
656 "--amplitude" => minima_num!("--amplitude", f64, amplitude, opt),
657 "--amp-margin" => minima_num!("--amp-margin", f64, amp_margin, opt),
658 "--eta" => minima_num!("--eta", f64, eta, opt),
659 "--power" => minima_num!("--power", f64, power, opt),
660 "--soft" => minima_num!("--soft", f64, soft, opt),
661 "--length" => minima_num!("--length", f64, length, opt),
662 "--length-frac" => minima_num!("--length-frac", f64, length_frac, opt),
663 "--gamma" => minima_num!("--gamma", f64, gamma, opt),
664 "--samples-per-round" => {
665 minima_num!("--samples-per-round", usize, samples_per_round, opt)
666 }
667 "--step" => minima_num!("--step", f64, step, opt),
668 "--temperature" => minima_num!("--temperature", f64, temperature, opt),
669 "--restart-jitter" => minima_num!("--restart-jitter", f64, restart_jitter, opt),
670 other if !other.starts_with('-') => {
671 if let Some((k, v)) = parse_kv(other) {
676 set_options.push((k, v));
677 } else if problem.is_none() {
678 problem = Some(ProblemSource::NlFile(PathBuf::from(other)));
679 } else if sol_output.is_none() {
680 sol_output = Some(PathBuf::from(other));
681 } else {
682 return Err(format!(
683 "unexpected positional argument '{other}' (expected KEY=VALUE)"
684 ));
685 }
686 }
687 other => return Err(format!("unrecognized argument '{other}'")),
688 }
689 }
690
691 if list_problems {
692 println!("{}", crate::builtin::list().join("\n"));
693 std::process::exit(0);
694 }
695
696 if (debug_on_error || debug_on_interrupt || debug_script.is_some()) && debug.is_none() {
699 debug = Some(DebugMode::Repl);
700 }
701
702 if !help && !version && !about && !cite {
703 if let Some(knob) = minima_knob {
709 if !minima_method_explicit {
710 return Err(format!(
711 "{knob} is a --minima tuning knob and has no effect on its own; \
712 enable global search with --minima <method> or --multistart"
713 ));
714 }
715 }
716 let problem = problem.ok_or_else(|| {
717 "missing problem: pass a positional .nl path, --nl-file, or --problem".to_string()
718 })?;
719 return Ok(Self {
720 problem,
721 options_file,
722 no_options_file,
723 set_options,
724 json_output,
725 json_detail,
726 sol_output,
727 no_sol,
728 ampl,
729 help,
730 version,
731 about,
732 cite,
733 cite_report,
734 cite_bibtex,
735 dump_specs,
736 dump_dir,
737 dump_format,
738 sens_boundcheck,
739 sens_bound_eps,
740 sens_bound_eps_explicit,
741 compute_red_hessian,
742 rh_eigendecomp,
743 debug,
744 debug_on_error,
745 debug_on_interrupt,
746 debug_script,
747 minima,
748 });
749 }
750
751 Ok(Self {
752 problem: ProblemSource::Builtin(String::new()),
753 options_file,
754 no_options_file,
755 set_options,
756 json_output,
757 json_detail,
758 sol_output,
759 no_sol,
760 ampl,
761 help,
762 version,
763 about,
764 cite,
765 cite_report,
766 cite_bibtex,
767 dump_specs,
768 dump_dir,
769 dump_format,
770 sens_boundcheck,
771 sens_bound_eps,
772 sens_bound_eps_explicit,
773 compute_red_hessian,
774 rh_eigendecomp,
775 debug,
776 debug_on_error,
777 debug_on_interrupt,
778 debug_script,
779 minima,
780 })
781 }
782}
783
784impl Args {
785 pub fn option_file_choice(&self) -> Result<OptionFileChoice, String> {
806 let named = match (&self.options_file, self.option_file_name_override()) {
807 (Some(flag), Some(kv)) if *flag != kv => {
808 return Err(format!(
809 "--options-file '{}' and option_file_name={} name different \
810 options files; only one is read, so pass one or the other",
811 flag.display(),
812 kv.display()
813 ));
814 }
815 (Some(flag), _) => Some(flag.clone()),
816 (None, kv) => kv,
817 };
818 match (self.no_options_file, named) {
819 (true, Some(p)) => Err(format!(
820 "--no-options-file conflicts with the options file '{}' named on \
821 the command line; pass one or the other",
822 p.display()
823 )),
824 (true, None) => Ok(OptionFileChoice::Suppressed),
825 (false, Some(p)) => Ok(OptionFileChoice::Named(p)),
826 (false, None) => Ok(OptionFileChoice::Discover),
827 }
828 }
829
830 fn option_file_name_override(&self) -> Option<PathBuf> {
833 self.set_options
834 .iter()
835 .rev()
836 .find(|(k, _)| k.eq_ignore_ascii_case("option_file_name"))
837 .map(|(_, v)| PathBuf::from(v))
838 }
839}
840
841fn parse_kv(s: &str) -> Option<(String, String)> {
857 let (k, v) = s.split_once('=')?;
858 let k = k.trim().trim_end_matches(':');
859 let v = unquote(v.trim());
860 if k.is_empty() || v.is_empty() {
861 return None;
862 }
863 Some((k.to_string(), v.to_string()))
864}
865
866fn unquote(v: &str) -> &str {
870 let b = v.as_bytes();
871 if b.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] {
872 &v[1..v.len() - 1]
873 } else {
874 v
875 }
876}
877
878pub fn options_from_env(value: &str) -> Vec<(String, String)> {
889 value.split_whitespace().filter_map(parse_kv).collect()
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895
896 fn argv(args: &[&str]) -> Vec<String> {
897 std::iter::once("pounce")
898 .chain(args.iter().copied())
899 .map(String::from)
900 .collect()
901 }
902
903 #[test]
904 fn help_short_and_long() {
905 assert!(Args::parse_argv(argv(&["-h"])).unwrap().help);
906 assert!(Args::parse_argv(argv(&["--help"])).unwrap().help);
907 }
908
909 #[test]
910 fn version_short_and_long() {
911 assert!(Args::parse_argv(argv(&["-v"])).unwrap().version);
912 assert!(Args::parse_argv(argv(&["-V"])).unwrap().version);
913 assert!(Args::parse_argv(argv(&["--version"])).unwrap().version);
914 }
915
916 #[test]
917 fn ampl_flag_sets_mode_and_keeps_positional() {
918 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL"])).unwrap();
919 assert!(a.ampl);
920 match a.problem {
921 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
922 _ => panic!("expected positional .nl"),
923 }
924 }
925
926 #[test]
927 fn ampl_flag_defaults_off() {
928 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
929 assert!(!a.ampl);
930 }
931
932 #[test]
933 fn ampl_flag_with_options() {
934 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL", "max_iter=500"])).unwrap();
935 assert!(a.ampl);
936 assert_eq!(a.set_options, vec![("max_iter".into(), "500".into())]);
937 }
938
939 #[test]
940 fn about_flag_does_not_require_problem() {
941 let a = Args::parse_argv(argv(&["--about"])).unwrap();
942 assert!(a.about);
943 }
944
945 #[test]
946 fn cite_flag_alone_needs_no_problem_or_report() {
947 let a = Args::parse_argv(argv(&["--cite"])).unwrap();
948 assert!(a.cite);
949 assert!(a.cite_report.is_none());
950 assert!(!a.cite_bibtex);
951 }
952
953 #[test]
954 fn cite_consumes_following_report_path() {
955 let a = Args::parse_argv(argv(&["--cite", "run.json"])).unwrap();
956 assert!(a.cite);
957 assert_eq!(a.cite_report.unwrap().to_str(), Some("run.json"));
958 }
959
960 #[test]
961 fn cite_does_not_swallow_a_following_flag() {
962 let a = Args::parse_argv(argv(&["--cite", "--bibtex"])).unwrap();
963 assert!(a.cite);
964 assert!(a.cite_report.is_none());
965 assert!(a.cite_bibtex);
966 }
967
968 #[test]
969 fn cite_with_report_and_bibtex() {
970 let a = Args::parse_argv(argv(&["--cite", "run.json", "--bibtex"])).unwrap();
971 assert!(a.cite);
972 assert_eq!(a.cite_report.unwrap().to_str(), Some("run.json"));
973 assert!(a.cite_bibtex);
974 }
975
976 #[test]
977 fn problem_flag_captures_name() {
978 let a = Args::parse_argv(argv(&["--problem", "rosenbrock"])).unwrap();
979 match a.problem {
980 ProblemSource::Builtin(s) => assert_eq!(s, "rosenbrock"),
981 _ => panic!("expected builtin"),
982 }
983 }
984
985 #[test]
986 fn nl_file_captured() {
987 let a = Args::parse_argv(argv(&["--nl-file", "/tmp/foo.nl"])).unwrap();
988 match a.problem {
989 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
990 _ => panic!("expected nl file"),
991 }
992 }
993
994 #[test]
995 fn positional_nl_path() {
996 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
997 match a.problem {
998 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
999 _ => panic!("expected positional .nl"),
1000 }
1001 }
1002
1003 #[test]
1004 fn positional_with_options_file() {
1005 let a = Args::parse_argv(argv(&["--options-file", "ipopt.opt", "/tmp/foo.nl"])).unwrap();
1006 match a.problem {
1007 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
1008 _ => panic!("expected positional .nl"),
1009 }
1010 assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
1011 }
1012
1013 #[test]
1014 fn options_file_captured() {
1015 let a = Args::parse_argv(argv(&["--problem", "x", "--options-file", "ipopt.opt"])).unwrap();
1016 assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
1017 }
1018
1019 #[test]
1023 fn option_file_choice_defaults_to_discovery() {
1024 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1025 assert_eq!(a.option_file_choice(), Ok(OptionFileChoice::Discover));
1026 }
1027
1028 #[test]
1029 fn option_file_choice_from_option_file_name() {
1030 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "option_file_name=tiny.opt"])).unwrap();
1031 assert_eq!(
1032 a.option_file_choice(),
1033 Ok(OptionFileChoice::Named(PathBuf::from("tiny.opt")))
1034 );
1035 }
1036
1037 #[test]
1041 fn option_file_choice_takes_the_last_option_file_name() {
1042 let a = Args::parse_argv(argv(&[
1043 "/tmp/foo.nl",
1044 "option_file_name=first.opt",
1045 "option_file_name=second.opt",
1046 ]))
1047 .unwrap();
1048 assert_eq!(
1049 a.option_file_choice(),
1050 Ok(OptionFileChoice::Named(PathBuf::from("second.opt")))
1051 );
1052 }
1053
1054 #[test]
1058 fn the_two_spellings_may_agree() {
1059 let a = Args::parse_argv(argv(&[
1060 "/tmp/foo.nl",
1061 "--options-file",
1062 "same.opt",
1063 "option_file_name=same.opt",
1064 ]))
1065 .unwrap();
1066 assert_eq!(
1067 a.option_file_choice(),
1068 Ok(OptionFileChoice::Named(PathBuf::from("same.opt")))
1069 );
1070 }
1071
1072 #[test]
1073 fn the_two_spellings_disagreeing_is_rejected() {
1074 let a = Args::parse_argv(argv(&[
1075 "/tmp/foo.nl",
1076 "--options-file",
1077 "flag.opt",
1078 "option_file_name=kv.opt",
1079 ]))
1080 .unwrap();
1081 let err = a.option_file_choice().unwrap_err();
1082 assert!(err.contains("flag.opt") && err.contains("kv.opt"), "{err}");
1083 }
1084
1085 #[test]
1086 fn no_options_file_suppresses_discovery() {
1087 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-options-file"])).unwrap();
1088 assert!(a.no_options_file);
1089 assert_eq!(a.option_file_choice(), Ok(OptionFileChoice::Suppressed));
1090 }
1091
1092 #[test]
1093 fn no_options_file_plus_a_named_file_is_rejected() {
1094 let a = Args::parse_argv(argv(&[
1095 "/tmp/foo.nl",
1096 "--no-options-file",
1097 "--options-file",
1098 "tiny.opt",
1099 ]))
1100 .unwrap();
1101 assert!(a.option_file_choice().is_err());
1102 }
1103
1104 #[test]
1105 fn missing_value_for_flag() {
1106 assert!(Args::parse_argv(argv(&["--problem"])).is_err());
1107 }
1108
1109 #[test]
1110 fn missing_problem() {
1111 assert!(Args::parse_argv(argv(&[])).is_err());
1112 }
1113
1114 #[test]
1115 fn unknown_arg() {
1116 assert!(Args::parse_argv(argv(&["--bogus"])).is_err());
1117 }
1118
1119 #[test]
1120 fn key_value_options_collected() {
1121 let a = Args::parse_argv(argv(&[
1122 "/tmp/foo.nl",
1123 "print_level=8",
1124 "max_iter=500",
1125 "tol=1e-10",
1126 ]))
1127 .unwrap();
1128 assert_eq!(
1129 a.set_options,
1130 vec![
1131 ("print_level".into(), "8".into()),
1132 ("max_iter".into(), "500".into()),
1133 ("tol".into(), "1e-10".into()),
1134 ]
1135 );
1136 }
1137
1138 #[test]
1139 fn key_value_before_path() {
1140 let a = Args::parse_argv(argv(&["print_level=8", "/tmp/foo.nl"])).unwrap();
1141 match a.problem {
1142 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
1143 _ => panic!("expected positional .nl"),
1144 }
1145 assert_eq!(a.set_options, vec![("print_level".into(), "8".into())]);
1146 }
1147
1148 #[test]
1149 fn dump_flag_captures_cat_and_spec() {
1150 let a = Args::parse_argv(argv(&[
1151 "--problem",
1152 "x",
1153 "--dump",
1154 "kkt:2-10",
1155 "--dump",
1156 "iterate",
1157 ]))
1158 .unwrap();
1159 assert_eq!(
1160 a.dump_specs,
1161 vec![
1162 ("kkt".into(), "2-10".into()),
1163 ("iterate".into(), "all".into()),
1164 ]
1165 );
1166 }
1167
1168 #[test]
1169 fn dump_dir_and_format_captured() {
1170 let a = Args::parse_argv(argv(&[
1171 "--problem",
1172 "x",
1173 "--dump",
1174 "kkt",
1175 "--dump-dir",
1176 "/tmp/d",
1177 "--dump-format",
1178 "jsonl",
1179 ]))
1180 .unwrap();
1181 assert_eq!(a.dump_dir.unwrap().to_str(), Some("/tmp/d"));
1182 assert_eq!(a.dump_format.as_deref(), Some("jsonl"));
1183 }
1184
1185 #[test]
1186 fn sol_output_captured() {
1187 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output", "/tmp/out.sol"])).unwrap();
1188 assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
1189 assert!(!a.no_sol);
1190 }
1191
1192 #[test]
1193 fn no_sol_flag() {
1194 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sol"])).unwrap();
1195 assert!(a.no_sol);
1196 assert!(a.sol_output.is_none());
1197 }
1198
1199 #[test]
1200 fn sol_output_defaults_unset() {
1201 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1202 assert!(a.sol_output.is_none());
1203 assert!(!a.no_sol);
1204 }
1205
1206 #[test]
1207 fn sol_output_missing_value() {
1208 assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output"])).is_err());
1209 }
1210
1211 #[test]
1212 fn second_positional_is_sol_output() {
1213 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "/tmp/out.sol"])).unwrap();
1214 match a.problem {
1215 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
1216 _ => panic!("expected positional .nl"),
1217 }
1218 assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
1219 }
1220
1221 #[test]
1222 fn third_positional_is_an_error() {
1223 assert!(Args::parse_argv(argv(&["/tmp/a.nl", "/tmp/b.sol", "/tmp/c"])).is_err());
1224 }
1225
1226 #[test]
1227 fn sens_flags_default_off() {
1228 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1229 assert!(!a.sens_boundcheck);
1230 assert!(!a.compute_red_hessian);
1231 assert!(!a.rh_eigendecomp);
1232 assert_eq!(a.sens_bound_eps, 1e-3);
1233 }
1234
1235 #[test]
1236 fn sens_boundcheck_flag() {
1237 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-boundcheck"])).unwrap();
1238 assert!(a.sens_boundcheck);
1239 }
1240
1241 #[test]
1242 fn sens_bound_eps_sets_value_and_enables_boundcheck() {
1243 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-bound-eps", "1e-6"])).unwrap();
1244 assert_eq!(a.sens_bound_eps, 1e-6);
1245 assert!(a.sens_boundcheck);
1246 assert!(a.sens_bound_eps_explicit);
1247 }
1248
1249 #[test]
1255 fn sens_bound_eps_at_the_default_still_counts_as_given() {
1256 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-bound-eps", "1e-3"])).unwrap();
1257 assert_eq!(a.sens_bound_eps, 1e-3);
1258 assert!(
1259 a.sens_bound_eps_explicit,
1260 "typing the default is still typing it",
1261 );
1262
1263 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1264 assert_eq!(a.sens_bound_eps, 1e-3);
1265 assert!(!a.sens_bound_eps_explicit);
1266 }
1267
1268 #[test]
1269 fn rh_eigendecomp_implies_compute_red_hessian() {
1270 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--rh-eigendecomp"])).unwrap();
1271 assert!(a.rh_eigendecomp);
1272 assert!(a.compute_red_hessian);
1273 }
1274
1275 #[test]
1276 fn minima_absent_by_default() {
1277 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1278 assert!(a.minima.is_none());
1279 }
1280
1281 #[test]
1282 fn minima_method_and_shared_knobs() {
1283 let a = Args::parse_argv(argv(&[
1284 "/tmp/foo.nl",
1285 "--minima",
1286 "flooding",
1287 "--n-minima",
1288 "5",
1289 "--max-solves",
1290 "42",
1291 "--patience",
1292 "3",
1293 "--dedup",
1294 "1e-2",
1295 "--psd-tol",
1296 "1e-8",
1297 "--seed",
1298 "7",
1299 "--no-sobol",
1300 ]))
1301 .unwrap();
1302 let m = a.minima.expect("minima parsed");
1303 assert_eq!(m.method, MinimaMethod::Flooding);
1304 assert_eq!(m.n_minima, 5);
1305 assert_eq!(m.max_solves, Some(42));
1306 assert_eq!(m.patience, 3);
1307 assert_eq!(m.dedup, 1e-2);
1308 assert_eq!(m.psd_tol, 1e-8);
1309 assert_eq!(m.seed, 7);
1310 assert!(!m.sobol);
1311 }
1312
1313 #[test]
1314 fn multistart_shorthand_selects_multistart() {
1315 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--multistart"])).unwrap();
1316 assert_eq!(a.minima.unwrap().method, MinimaMethod::Multistart);
1317 }
1318
1319 #[test]
1320 fn minima_strategy_knobs_are_optional_and_parsed() {
1321 let a = Args::parse_argv(argv(&[
1322 "/tmp/foo.nl",
1323 "--minima",
1324 "deflation",
1325 "--eta",
1326 "2.5",
1327 "--power",
1328 "3",
1329 "--soft",
1330 "1e-4",
1331 "--length",
1332 "0.2",
1333 "--restart-jitter",
1334 "0.9",
1335 ]))
1336 .unwrap();
1337 let m = a.minima.unwrap();
1338 assert_eq!(m.method, MinimaMethod::Deflation);
1339 assert_eq!(m.eta, Some(2.5));
1340 assert_eq!(m.power, Some(3.0));
1341 assert_eq!(m.soft, Some(1e-4));
1342 assert_eq!(m.length, Some(0.2));
1343 assert_eq!(m.restart_jitter, Some(0.9));
1344 assert_eq!(m.sigma, None);
1346 assert_eq!(m.gamma, None);
1347 }
1348
1349 #[test]
1350 fn minima_unknown_method_errors() {
1351 assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--minima", "nope"])).is_err());
1352 }
1353
1354 #[test]
1360 fn lone_minima_knob_without_method_is_rejected() {
1361 let err = Args::parse_argv(argv(&["/tmp/foo.nl", "--seed", "42"]))
1362 .expect_err("lone --seed should be rejected");
1363 assert!(
1364 err.contains("--seed") && err.contains("--minima"),
1365 "error should name the knob and the method selectors; got: {err}"
1366 );
1367 let err2 = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sobol"]))
1369 .expect_err("lone --no-sobol should be rejected");
1370 assert!(err2.contains("--no-sobol"), "got: {err2}");
1371 assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--seed", "42"])).is_err());
1373 }
1374
1375 #[test]
1378 fn minima_knob_with_explicit_method_is_accepted() {
1379 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--seed", "7", "--multistart"])).unwrap();
1380 let m = a.minima.expect("minima parsed");
1381 assert_eq!(m.method, MinimaMethod::Multistart);
1382 assert_eq!(m.seed, 7);
1383 }
1384
1385 #[test]
1386 fn parse_kv_basic() {
1387 assert_eq!(
1388 parse_kv("print_level=8"),
1389 Some(("print_level".into(), "8".into()))
1390 );
1391 assert_eq!(
1392 parse_kv("tol = 1e-10"),
1393 Some(("tol".into(), "1e-10".into()))
1394 );
1395 assert_eq!(parse_kv("plain_path.nl"), None);
1396 assert_eq!(parse_kv("=value"), None);
1397 assert_eq!(parse_kv("key="), None);
1398 }
1399
1400 #[test]
1406 fn parse_kv_strips_the_quotes_a_driver_added() {
1407 assert_eq!(
1408 parse_kv(r#"option_file_name="/tmp/pyomo/x.opt""#),
1409 Some(("option_file_name".into(), "/tmp/pyomo/x.opt".into()))
1410 );
1411 assert_eq!(
1412 parse_kv("option_file_name='/tmp/has space/x.opt'"),
1413 Some(("option_file_name".into(), "/tmp/has space/x.opt".into()))
1414 );
1415 assert_eq!(
1417 parse_kv(r#"msg="unbalanced"#),
1418 Some(("msg".into(), r#""unbalanced"#.into()))
1419 );
1420 assert_eq!(parse_kv(r#"key="""#), None);
1422 }
1423
1424 #[test]
1425 fn options_from_env_parses_whitespace_separated_pairs() {
1426 assert_eq!(
1429 options_from_env("max_iter=100 tol=1e-8"),
1430 vec![
1431 ("max_iter".into(), "100".into()),
1432 ("tol".into(), "1e-8".into()),
1433 ]
1434 );
1435 assert_eq!(
1437 options_from_env("a=1\tb=2\n c=3"),
1438 vec![
1439 ("a".into(), "1".into()),
1440 ("b".into(), "2".into()),
1441 ("c".into(), "3".into()),
1442 ]
1443 );
1444 }
1445
1446 #[test]
1447 fn options_from_env_skips_non_kv_tokens_and_empty() {
1448 assert_eq!(
1451 options_from_env("max_iter=100 bareword tol=1e-8"),
1452 vec![
1453 ("max_iter".into(), "100".into()),
1454 ("tol".into(), "1e-8".into()),
1455 ]
1456 );
1457 assert!(options_from_env("").is_empty());
1458 assert!(options_from_env(" \t\n ").is_empty());
1459 assert!(options_from_env("just some words").is_empty());
1460 }
1461}