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