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