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