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>.
250An extensionless AMPL stub is accepted: if PATH is missing but PATH.nl
251exists, PATH.nl is read (the `pounce mystub -AMPL` invocation convention).
252SOL is an optional second positional naming the .sol output file
253(equivalent to --sol-output <path>); the AMPL `solver in.nl out.sol`
254convention.
255
256Options may also be supplied via the `pounce_options` environment
257variable (AMPL's `<solver>_options` convention): a whitespace-separated
258list of KEY=VALUE tokens. Command-line KEY=VALUE options override it.
259
260Subcommand:
261 pounce verify <problem.nl> <claim.sol> [--feas-tol T] [--json-output P]
262 independently check that a .sol solution
263 satisfies the canonical .nl's constraints and
264 bounds, without trusting the solver/agent that
265 produced it. Exit 0 = feasible, 20 = violated.
266 Run `pounce verify --help` for details.
267
268When the .nl declares the sIPOPT suffixes (sens_state_1,
269sens_state_value_1, sens_init_constr), pounce additionally runs the
270post-optimal parametric sensitivity step and writes the perturbed
271primal back into the .sol as a `sens_sol_state_1` suffix.
272
273Trailing KEY=VALUE pairs are forwarded to the solver's OptionsList
274(same syntax/semantics as the ipopt CLI). They override values loaded
275from --options-file. Examples:
276
277 pounce problem.nl print_level=8
278 pounce problem.nl max_iter=500 tol=1e-10 linear_solver=ma57
279
280Required (one of):
281 PATH positional .nl file to solve
282 --nl-file <path> same, as a flag
283 --problem <name> solve a built-in test problem
284
285Options:
286 --options-file <path> read solver options from an ipopt.opt-format file
287 --json-output <path> write a JSON solve report to PATH after the solve
288 (pounce#8 — machine-readable, FAIR-aligned)
289 --json-detail LEVEL summary | full (default: summary). `full` adds
290 per-iteration history + suffix blocks.
291 --sol-output <path> write an AMPL .sol solution file to PATH.
292 A positional .nl input writes <stub>.sol
293 next to it by default (AMPL convention).
294 --no-sol suppress the default <stub>.sol write
295 --sens-boundcheck clamp the perturbed primal x* + Δx onto the
296 declared [x_l, x_u] box (sIPOPT sens_boundcheck)
297 --sens-bound-eps EPS tolerance for --sens-boundcheck (default 1e-3;
298 setting it also enables --sens-boundcheck)
299 --compute-red-hessian compute the reduced Hessian over the variables
300 tagged by the `red_hessian` integer var-suffix
301 --rh-eigendecomp also compute the reduced-Hessian eigendecomp;
302 implies --compute-red-hessian
303 --debug drop into the interactive solver debugger (a
304 pdb-for-the-IPM): pause each iteration to
305 inspect/mutate x, multipliers, mu, set
306 breakpoints, step/continue. Type `help` at
307 the pounce-dbg> prompt for commands.
308 --debug-json same loop, but speak newline-delimited JSON on
309 stdin/stdout so an LLM agent or program can drive
310 it. The first line is a self-describing `hello`
311 handshake (protocol version + every command,
312 event, checkpoint, metric, and capability), so a
313 client needs no out-of-band docs; each pause is one
314 JSON state object. Full spec: docs/src/debugger.md.
315 --debug-on-error don't pause every iteration; run freely and
316 drop into the debugger only if the solve fails,
317 for a post-mortem at the final iterate. Implies
318 --debug when no --debug* mode is given.
319 --debug-on-interrupt run normally but install a Ctrl-C handler that
320 drops into the debugger at the next iteration
321 (second Ctrl-C aborts). Implies --debug when no
322 --debug* mode is given.
323 --debug-script <file> run debugger commands from a file at the first
324 pause (e.g. set breakpoints then continue).
325 Implies --debug when no --debug* mode is given.
326 --list-problems print available built-in problems and exit
327 -AMPL AMPL solver-protocol mode (for Pyomo / AMPL
328 drivers): convey termination via the .sol
329 file and exit 0 for non-fatal outcomes
330 --help, -h print this message and exit
331 --version, -v, -V print version and exit
332 --about print version, build info, features,
333 linear solvers, and runtime paths
334 --cite [REPORT.json] print the papers to cite when publishing
335 pounce results, then exit. Always lists pounce
336 itself + Wächter-Biegler; pass a JSON solve
337 report (from --json-output) to also list papers
338 for features the run used (e.g. restoration).
339 --bibtex with --cite, emit BibTeX instead of a text list
340 --dump <cat>[:<spec>] dump diagnostic category to per-iter files.
341 Repeatable. Categories: kkt, iterate(s), step,
342 mu, ls, resto, convergence, timing.
343 Iter-spec grammar: all | N | N-M | N- | -M
344 (default: all). The `iterates` category also
345 accepts a `:summary` (default) or `:full`
346 variant suffix and streams one JSONL row
347 per iter to <dump-dir>/iterates.jsonl. The
348 `kkt` category accepts `+L` / `+L+Lvals`
349 suffixes that add the LDLᵀ factor's
350 strict-lower pattern (and optional values)
351 plus the fill-reducing permutation to each
352 kkt_solve_NNN.jsonl record (feral backend
353 only; MA57 silently omits the L fields).
354 Examples:
355 --dump kkt:5
356 --dump kkt:2-10 --dump iterate:all
357 --dump kkt:5-10+L
358 --dump kkt:5-10+L+Lvals
359 --dump iterates:summary
360 --dump iterates:5-:full
361 --dump-dir <path> override dump root (default ./pounce-dump-<ts>)
362 --dump-format <fmt> dump format (default: jsonl)
363
364Multistart / find-minima (search for several local minima, not one):
365 --minima <method> enable multistart with the given strategy:
366 multistart | mlsl | basinhopping |
367 flooding | deflation | tunneling
368 --multistart shorthand for --minima multistart
369 --n-minima <N> target number of distinct minima (default 10)
370 --max-solves <N> hard cap on solver calls (default 8*n_minima)
371 --patience <N> stop after N solves in a row that find nothing
372 new (default 8)
373 --dedup <d> minima within this per-dimension-scaled distance
374 are the same (default 1e-4)
375 --psd-tol <t> smallest Hessian eigenvalue tolerated by the
376 saddle-rejection check (default 1e-6)
377 --seed <S> seed for sampling / Sobol' scramble (default 0)
378 --sobol / --no-sobol use a scrambled Sobol' sequence for box
379 sampling (default: on)
380 Strategy knobs (used only by the relevant --minima method; all optional):
381 --sigma, --sigma-frac, --amplitude, --amp-margin (flooding)
382 --eta, --power, --soft, --length, --length-frac (deflation/tunneling)
383 --gamma, --samples-per-round (mlsl)
384 --step, --temperature (basinhopping)
385 --restart-jitter (all restart fallbacks)
386
387 When --minima is set, the global best minimum is written to <stub>.sol
388 (the usual AMPL output), and the remaining minima, ranked by objective,
389 to siblings <stub>.min001.sol, <stub>.min002.sol, …. The JSON report
390 (--json-output) gains a `minima` section listing every found minimum.
391"
392 }
393
394 pub fn parse_argv(argv: Vec<String>) -> Result<Self, String> {
395 let mut problem: Option<ProblemSource> = None;
396 let mut options_file: Option<PathBuf> = None;
397 let mut set_options: Vec<(String, String)> = Vec::new();
398 let mut json_output: Option<PathBuf> = None;
399 let mut json_detail = crate::solve_report::ReportDetail::Summary;
400 let mut sol_output: Option<PathBuf> = None;
401 let mut no_sol = false;
402 let mut ampl = false;
403 let mut help = false;
404 let mut version = false;
405 let mut about = false;
406 let mut cite = false;
407 let mut cite_report: Option<PathBuf> = None;
408 let mut cite_bibtex = false;
409 let mut list_problems = false;
410 let mut dump_specs: Vec<(String, String)> = Vec::new();
411 let mut dump_dir: Option<PathBuf> = None;
412 let mut dump_format: Option<String> = None;
413 let mut sens_boundcheck = false;
414 let mut sens_bound_eps: f64 = 1e-3;
415 let mut compute_red_hessian = false;
416 let mut rh_eigendecomp = false;
417 let mut debug: Option<DebugMode> = None;
418 let mut debug_on_error = false;
419 let mut debug_on_interrupt = false;
420 let mut debug_script: Option<PathBuf> = None;
421 let mut minima: Option<MinimaArgs> = None;
422 let mut minima_method_explicit = false;
429 let mut minima_knob: Option<&'static str> = None;
430
431 let mut it = argv.into_iter().skip(1).peekable();
432 macro_rules! flag_val {
434 ($flag:expr) => {
435 it.next()
436 .ok_or_else(|| format!("{} requires a value", $flag))?
437 };
438 }
439 macro_rules! minima_num {
442 ($flag:expr, $ty:ty, $field:ident) => {{
443 let v = flag_val!($flag);
444 let parsed: $ty = v.parse().map_err(|e| format!("{}: {}", $flag, e))?;
445 minima.get_or_insert_with(MinimaArgs::default).$field = parsed;
446 if minima_knob.is_none() {
447 minima_knob = Some($flag);
448 }
449 }};
450 ($flag:expr, $ty:ty, $field:ident, opt) => {{
451 let v = flag_val!($flag);
452 let parsed: $ty = v.parse().map_err(|e| format!("{}: {}", $flag, e))?;
453 minima.get_or_insert_with(MinimaArgs::default).$field = Some(parsed);
454 if minima_knob.is_none() {
455 minima_knob = Some($flag);
456 }
457 }};
458 }
459 while let Some(arg) = it.next() {
460 match arg.as_str() {
461 "-h" | "--help" => help = true,
462 "-v" | "-V" | "--version" => version = true,
463 "--about" => about = true,
464 "--cite" => {
465 cite = true;
466 if let Some(next) = it.peek() {
471 if !next.starts_with('-') {
472 cite_report = Some(PathBuf::from(it.next().unwrap()));
473 }
474 }
475 }
476 "--bibtex" => cite_bibtex = true,
477 "-AMPL" => ampl = true,
479 "--list-problems" => list_problems = true,
480 "--problem" => {
481 let v = it
482 .next()
483 .ok_or_else(|| "--problem requires a value".to_string())?;
484 problem = Some(ProblemSource::Builtin(v));
485 }
486 "--nl-file" => {
487 let v = it
488 .next()
489 .ok_or_else(|| "--nl-file requires a value".to_string())?;
490 problem = Some(ProblemSource::NlFile(PathBuf::from(v)));
491 }
492 "--options-file" => {
493 let v = it
494 .next()
495 .ok_or_else(|| "--options-file requires a value".to_string())?;
496 options_file = Some(PathBuf::from(v));
497 }
498 "--dump" => {
499 let v = it
500 .next()
501 .ok_or_else(|| "--dump requires a value (cat[:spec])".to_string())?;
502 let (cat, spec) = match v.split_once(':') {
503 Some((c, s)) => (c.to_string(), s.to_string()),
504 None => (v, "all".to_string()),
505 };
506 dump_specs.push((cat, spec));
507 }
508 "--dump-dir" => {
509 let v = it
510 .next()
511 .ok_or_else(|| "--dump-dir requires a value".to_string())?;
512 dump_dir = Some(PathBuf::from(v));
513 }
514 "--dump-format" => {
515 let v = it
516 .next()
517 .ok_or_else(|| "--dump-format requires a value".to_string())?;
518 dump_format = Some(v);
519 }
520 "--json-output" => {
521 let v = it
522 .next()
523 .ok_or_else(|| "--json-output requires a value".to_string())?;
524 json_output = Some(PathBuf::from(v));
525 }
526 "--json-detail" => {
527 let v = it
528 .next()
529 .ok_or_else(|| "--json-detail requires a value".to_string())?;
530 json_detail = crate::solve_report::ReportDetail::parse(&v)?;
531 }
532 "--sol-output" => {
533 let v = it
534 .next()
535 .ok_or_else(|| "--sol-output requires a value".to_string())?;
536 sol_output = Some(PathBuf::from(v));
537 }
538 "--no-sol" => no_sol = true,
539 "--sens-boundcheck" => sens_boundcheck = true,
540 "--sens-bound-eps" => {
541 let v = it
542 .next()
543 .ok_or_else(|| "--sens-bound-eps requires a value".to_string())?;
544 sens_bound_eps = v
545 .parse::<f64>()
546 .map_err(|e| format!("--sens-bound-eps: {e}"))?;
547 sens_boundcheck = true;
548 }
549 "--debug" => debug = Some(DebugMode::Repl),
550 "--debug-json" => debug = Some(DebugMode::Json),
551 "--debug-on-error" => debug_on_error = true,
552 "--debug-on-interrupt" => debug_on_interrupt = true,
553 "--debug-script" => {
554 let v = it
555 .next()
556 .ok_or_else(|| "--debug-script requires a value".to_string())?;
557 debug_script = Some(PathBuf::from(v));
558 }
559 "--compute-red-hessian" => compute_red_hessian = true,
560 "--rh-eigendecomp" => {
561 rh_eigendecomp = true;
562 compute_red_hessian = true;
563 }
564 "--minima" => {
566 let v = flag_val!("--minima");
567 let method = MinimaMethod::parse(&v)?;
568 minima.get_or_insert_with(MinimaArgs::default).method = method;
569 minima_method_explicit = true;
570 }
571 "--multistart" => {
572 minima.get_or_insert_with(MinimaArgs::default).method =
573 MinimaMethod::Multistart;
574 minima_method_explicit = true;
575 }
576 "--n-minima" => minima_num!("--n-minima", usize, n_minima),
577 "--max-solves" => minima_num!("--max-solves", usize, max_solves, opt),
578 "--patience" => minima_num!("--patience", usize, patience),
579 "--dedup" => minima_num!("--dedup", f64, dedup),
580 "--psd-tol" => minima_num!("--psd-tol", f64, psd_tol),
581 "--seed" => minima_num!("--seed", u64, seed),
582 "--sobol" => {
583 minima.get_or_insert_with(MinimaArgs::default).sobol = true;
584 if minima_knob.is_none() {
585 minima_knob = Some("--sobol");
586 }
587 }
588 "--no-sobol" => {
589 minima.get_or_insert_with(MinimaArgs::default).sobol = false;
590 if minima_knob.is_none() {
591 minima_knob = Some("--no-sobol");
592 }
593 }
594 "--sigma" => minima_num!("--sigma", f64, sigma, opt),
595 "--sigma-frac" => minima_num!("--sigma-frac", f64, sigma_frac, opt),
596 "--amplitude" => minima_num!("--amplitude", f64, amplitude, opt),
597 "--amp-margin" => minima_num!("--amp-margin", f64, amp_margin, opt),
598 "--eta" => minima_num!("--eta", f64, eta, opt),
599 "--power" => minima_num!("--power", f64, power, opt),
600 "--soft" => minima_num!("--soft", f64, soft, opt),
601 "--length" => minima_num!("--length", f64, length, opt),
602 "--length-frac" => minima_num!("--length-frac", f64, length_frac, opt),
603 "--gamma" => minima_num!("--gamma", f64, gamma, opt),
604 "--samples-per-round" => {
605 minima_num!("--samples-per-round", usize, samples_per_round, opt)
606 }
607 "--step" => minima_num!("--step", f64, step, opt),
608 "--temperature" => minima_num!("--temperature", f64, temperature, opt),
609 "--restart-jitter" => minima_num!("--restart-jitter", f64, restart_jitter, opt),
610 other if !other.starts_with('-') => {
611 if let Some((k, v)) = parse_kv(other) {
616 set_options.push((k, v));
617 } else if problem.is_none() {
618 problem = Some(ProblemSource::NlFile(PathBuf::from(other)));
619 } else if sol_output.is_none() {
620 sol_output = Some(PathBuf::from(other));
621 } else {
622 return Err(format!(
623 "unexpected positional argument '{other}' (expected KEY=VALUE)"
624 ));
625 }
626 }
627 other => return Err(format!("unrecognized argument '{other}'")),
628 }
629 }
630
631 if list_problems {
632 println!("{}", crate::builtin::list().join("\n"));
633 std::process::exit(0);
634 }
635
636 if (debug_on_error || debug_on_interrupt || debug_script.is_some()) && debug.is_none() {
639 debug = Some(DebugMode::Repl);
640 }
641
642 if !help && !version && !about && !cite {
643 if let Some(knob) = minima_knob {
649 if !minima_method_explicit {
650 return Err(format!(
651 "{knob} is a --minima tuning knob and has no effect on its own; \
652 enable global search with --minima <method> or --multistart"
653 ));
654 }
655 }
656 let problem = problem.ok_or_else(|| {
657 "missing problem: pass a positional .nl path, --nl-file, or --problem".to_string()
658 })?;
659 return Ok(Self {
660 problem,
661 options_file,
662 set_options,
663 json_output,
664 json_detail,
665 sol_output,
666 no_sol,
667 ampl,
668 help,
669 version,
670 about,
671 cite,
672 cite_report,
673 cite_bibtex,
674 dump_specs,
675 dump_dir,
676 dump_format,
677 sens_boundcheck,
678 sens_bound_eps,
679 compute_red_hessian,
680 rh_eigendecomp,
681 debug,
682 debug_on_error,
683 debug_on_interrupt,
684 debug_script,
685 minima,
686 });
687 }
688
689 Ok(Self {
690 problem: ProblemSource::Builtin(String::new()),
691 options_file,
692 set_options,
693 json_output,
694 json_detail,
695 sol_output,
696 no_sol,
697 ampl,
698 help,
699 version,
700 about,
701 cite,
702 cite_report,
703 cite_bibtex,
704 dump_specs,
705 dump_dir,
706 dump_format,
707 sens_boundcheck,
708 sens_bound_eps,
709 compute_red_hessian,
710 rh_eigendecomp,
711 debug,
712 debug_on_error,
713 debug_on_interrupt,
714 debug_script,
715 minima,
716 })
717 }
718}
719
720fn parse_kv(s: &str) -> Option<(String, String)> {
724 let (k, v) = s.split_once('=')?;
725 let k = k.trim().trim_end_matches(':');
726 let v = v.trim();
727 if k.is_empty() || v.is_empty() {
728 return None;
729 }
730 Some((k.to_string(), v.to_string()))
731}
732
733pub fn options_from_env(value: &str) -> Vec<(String, String)> {
744 value.split_whitespace().filter_map(parse_kv).collect()
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 fn argv(args: &[&str]) -> Vec<String> {
752 std::iter::once("pounce")
753 .chain(args.iter().copied())
754 .map(String::from)
755 .collect()
756 }
757
758 #[test]
759 fn help_short_and_long() {
760 assert!(Args::parse_argv(argv(&["-h"])).unwrap().help);
761 assert!(Args::parse_argv(argv(&["--help"])).unwrap().help);
762 }
763
764 #[test]
765 fn version_short_and_long() {
766 assert!(Args::parse_argv(argv(&["-v"])).unwrap().version);
767 assert!(Args::parse_argv(argv(&["-V"])).unwrap().version);
768 assert!(Args::parse_argv(argv(&["--version"])).unwrap().version);
769 }
770
771 #[test]
772 fn ampl_flag_sets_mode_and_keeps_positional() {
773 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL"])).unwrap();
774 assert!(a.ampl);
775 match a.problem {
776 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
777 _ => panic!("expected positional .nl"),
778 }
779 }
780
781 #[test]
782 fn ampl_flag_defaults_off() {
783 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
784 assert!(!a.ampl);
785 }
786
787 #[test]
788 fn ampl_flag_with_options() {
789 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL", "max_iter=500"])).unwrap();
790 assert!(a.ampl);
791 assert_eq!(a.set_options, vec![("max_iter".into(), "500".into())]);
792 }
793
794 #[test]
795 fn about_flag_does_not_require_problem() {
796 let a = Args::parse_argv(argv(&["--about"])).unwrap();
797 assert!(a.about);
798 }
799
800 #[test]
801 fn cite_flag_alone_needs_no_problem_or_report() {
802 let a = Args::parse_argv(argv(&["--cite"])).unwrap();
803 assert!(a.cite);
804 assert!(a.cite_report.is_none());
805 assert!(!a.cite_bibtex);
806 }
807
808 #[test]
809 fn cite_consumes_following_report_path() {
810 let a = Args::parse_argv(argv(&["--cite", "run.json"])).unwrap();
811 assert!(a.cite);
812 assert_eq!(a.cite_report.unwrap().to_str(), Some("run.json"));
813 }
814
815 #[test]
816 fn cite_does_not_swallow_a_following_flag() {
817 let a = Args::parse_argv(argv(&["--cite", "--bibtex"])).unwrap();
818 assert!(a.cite);
819 assert!(a.cite_report.is_none());
820 assert!(a.cite_bibtex);
821 }
822
823 #[test]
824 fn cite_with_report_and_bibtex() {
825 let a = Args::parse_argv(argv(&["--cite", "run.json", "--bibtex"])).unwrap();
826 assert!(a.cite);
827 assert_eq!(a.cite_report.unwrap().to_str(), Some("run.json"));
828 assert!(a.cite_bibtex);
829 }
830
831 #[test]
832 fn problem_flag_captures_name() {
833 let a = Args::parse_argv(argv(&["--problem", "rosenbrock"])).unwrap();
834 match a.problem {
835 ProblemSource::Builtin(s) => assert_eq!(s, "rosenbrock"),
836 _ => panic!("expected builtin"),
837 }
838 }
839
840 #[test]
841 fn nl_file_captured() {
842 let a = Args::parse_argv(argv(&["--nl-file", "/tmp/foo.nl"])).unwrap();
843 match a.problem {
844 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
845 _ => panic!("expected nl file"),
846 }
847 }
848
849 #[test]
850 fn positional_nl_path() {
851 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
852 match a.problem {
853 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
854 _ => panic!("expected positional .nl"),
855 }
856 }
857
858 #[test]
859 fn positional_with_options_file() {
860 let a = Args::parse_argv(argv(&["--options-file", "ipopt.opt", "/tmp/foo.nl"])).unwrap();
861 match a.problem {
862 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
863 _ => panic!("expected positional .nl"),
864 }
865 assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
866 }
867
868 #[test]
869 fn options_file_captured() {
870 let a = Args::parse_argv(argv(&["--problem", "x", "--options-file", "ipopt.opt"])).unwrap();
871 assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
872 }
873
874 #[test]
875 fn missing_value_for_flag() {
876 assert!(Args::parse_argv(argv(&["--problem"])).is_err());
877 }
878
879 #[test]
880 fn missing_problem() {
881 assert!(Args::parse_argv(argv(&[])).is_err());
882 }
883
884 #[test]
885 fn unknown_arg() {
886 assert!(Args::parse_argv(argv(&["--bogus"])).is_err());
887 }
888
889 #[test]
890 fn key_value_options_collected() {
891 let a = Args::parse_argv(argv(&[
892 "/tmp/foo.nl",
893 "print_level=8",
894 "max_iter=500",
895 "tol=1e-10",
896 ]))
897 .unwrap();
898 assert_eq!(
899 a.set_options,
900 vec![
901 ("print_level".into(), "8".into()),
902 ("max_iter".into(), "500".into()),
903 ("tol".into(), "1e-10".into()),
904 ]
905 );
906 }
907
908 #[test]
909 fn key_value_before_path() {
910 let a = Args::parse_argv(argv(&["print_level=8", "/tmp/foo.nl"])).unwrap();
911 match a.problem {
912 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
913 _ => panic!("expected positional .nl"),
914 }
915 assert_eq!(a.set_options, vec![("print_level".into(), "8".into())]);
916 }
917
918 #[test]
919 fn dump_flag_captures_cat_and_spec() {
920 let a = Args::parse_argv(argv(&[
921 "--problem",
922 "x",
923 "--dump",
924 "kkt:2-10",
925 "--dump",
926 "iterate",
927 ]))
928 .unwrap();
929 assert_eq!(
930 a.dump_specs,
931 vec![
932 ("kkt".into(), "2-10".into()),
933 ("iterate".into(), "all".into()),
934 ]
935 );
936 }
937
938 #[test]
939 fn dump_dir_and_format_captured() {
940 let a = Args::parse_argv(argv(&[
941 "--problem",
942 "x",
943 "--dump",
944 "kkt",
945 "--dump-dir",
946 "/tmp/d",
947 "--dump-format",
948 "jsonl",
949 ]))
950 .unwrap();
951 assert_eq!(a.dump_dir.unwrap().to_str(), Some("/tmp/d"));
952 assert_eq!(a.dump_format.as_deref(), Some("jsonl"));
953 }
954
955 #[test]
956 fn sol_output_captured() {
957 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output", "/tmp/out.sol"])).unwrap();
958 assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
959 assert!(!a.no_sol);
960 }
961
962 #[test]
963 fn no_sol_flag() {
964 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sol"])).unwrap();
965 assert!(a.no_sol);
966 assert!(a.sol_output.is_none());
967 }
968
969 #[test]
970 fn sol_output_defaults_unset() {
971 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
972 assert!(a.sol_output.is_none());
973 assert!(!a.no_sol);
974 }
975
976 #[test]
977 fn sol_output_missing_value() {
978 assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output"])).is_err());
979 }
980
981 #[test]
982 fn second_positional_is_sol_output() {
983 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "/tmp/out.sol"])).unwrap();
984 match a.problem {
985 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
986 _ => panic!("expected positional .nl"),
987 }
988 assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
989 }
990
991 #[test]
992 fn third_positional_is_an_error() {
993 assert!(Args::parse_argv(argv(&["/tmp/a.nl", "/tmp/b.sol", "/tmp/c"])).is_err());
994 }
995
996 #[test]
997 fn sens_flags_default_off() {
998 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
999 assert!(!a.sens_boundcheck);
1000 assert!(!a.compute_red_hessian);
1001 assert!(!a.rh_eigendecomp);
1002 assert_eq!(a.sens_bound_eps, 1e-3);
1003 }
1004
1005 #[test]
1006 fn sens_boundcheck_flag() {
1007 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-boundcheck"])).unwrap();
1008 assert!(a.sens_boundcheck);
1009 }
1010
1011 #[test]
1012 fn sens_bound_eps_sets_value_and_enables_boundcheck() {
1013 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-bound-eps", "1e-6"])).unwrap();
1014 assert_eq!(a.sens_bound_eps, 1e-6);
1015 assert!(a.sens_boundcheck);
1016 }
1017
1018 #[test]
1019 fn rh_eigendecomp_implies_compute_red_hessian() {
1020 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--rh-eigendecomp"])).unwrap();
1021 assert!(a.rh_eigendecomp);
1022 assert!(a.compute_red_hessian);
1023 }
1024
1025 #[test]
1026 fn minima_absent_by_default() {
1027 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1028 assert!(a.minima.is_none());
1029 }
1030
1031 #[test]
1032 fn minima_method_and_shared_knobs() {
1033 let a = Args::parse_argv(argv(&[
1034 "/tmp/foo.nl",
1035 "--minima",
1036 "flooding",
1037 "--n-minima",
1038 "5",
1039 "--max-solves",
1040 "42",
1041 "--patience",
1042 "3",
1043 "--dedup",
1044 "1e-2",
1045 "--psd-tol",
1046 "1e-8",
1047 "--seed",
1048 "7",
1049 "--no-sobol",
1050 ]))
1051 .unwrap();
1052 let m = a.minima.expect("minima parsed");
1053 assert_eq!(m.method, MinimaMethod::Flooding);
1054 assert_eq!(m.n_minima, 5);
1055 assert_eq!(m.max_solves, Some(42));
1056 assert_eq!(m.patience, 3);
1057 assert_eq!(m.dedup, 1e-2);
1058 assert_eq!(m.psd_tol, 1e-8);
1059 assert_eq!(m.seed, 7);
1060 assert!(!m.sobol);
1061 }
1062
1063 #[test]
1064 fn multistart_shorthand_selects_multistart() {
1065 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--multistart"])).unwrap();
1066 assert_eq!(a.minima.unwrap().method, MinimaMethod::Multistart);
1067 }
1068
1069 #[test]
1070 fn minima_strategy_knobs_are_optional_and_parsed() {
1071 let a = Args::parse_argv(argv(&[
1072 "/tmp/foo.nl",
1073 "--minima",
1074 "deflation",
1075 "--eta",
1076 "2.5",
1077 "--power",
1078 "3",
1079 "--soft",
1080 "1e-4",
1081 "--length",
1082 "0.2",
1083 "--restart-jitter",
1084 "0.9",
1085 ]))
1086 .unwrap();
1087 let m = a.minima.unwrap();
1088 assert_eq!(m.method, MinimaMethod::Deflation);
1089 assert_eq!(m.eta, Some(2.5));
1090 assert_eq!(m.power, Some(3.0));
1091 assert_eq!(m.soft, Some(1e-4));
1092 assert_eq!(m.length, Some(0.2));
1093 assert_eq!(m.restart_jitter, Some(0.9));
1094 assert_eq!(m.sigma, None);
1096 assert_eq!(m.gamma, None);
1097 }
1098
1099 #[test]
1100 fn minima_unknown_method_errors() {
1101 assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--minima", "nope"])).is_err());
1102 }
1103
1104 #[test]
1110 fn lone_minima_knob_without_method_is_rejected() {
1111 let err = Args::parse_argv(argv(&["/tmp/foo.nl", "--seed", "42"]))
1112 .expect_err("lone --seed should be rejected");
1113 assert!(
1114 err.contains("--seed") && err.contains("--minima"),
1115 "error should name the knob and the method selectors; got: {err}"
1116 );
1117 let err2 = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sobol"]))
1119 .expect_err("lone --no-sobol should be rejected");
1120 assert!(err2.contains("--no-sobol"), "got: {err2}");
1121 assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--seed", "42"])).is_err());
1123 }
1124
1125 #[test]
1128 fn minima_knob_with_explicit_method_is_accepted() {
1129 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--seed", "7", "--multistart"])).unwrap();
1130 let m = a.minima.expect("minima parsed");
1131 assert_eq!(m.method, MinimaMethod::Multistart);
1132 assert_eq!(m.seed, 7);
1133 }
1134
1135 #[test]
1136 fn parse_kv_basic() {
1137 assert_eq!(
1138 parse_kv("print_level=8"),
1139 Some(("print_level".into(), "8".into()))
1140 );
1141 assert_eq!(
1142 parse_kv("tol = 1e-10"),
1143 Some(("tol".into(), "1e-10".into()))
1144 );
1145 assert_eq!(parse_kv("plain_path.nl"), None);
1146 assert_eq!(parse_kv("=value"), None);
1147 assert_eq!(parse_kv("key="), None);
1148 }
1149
1150 #[test]
1151 fn options_from_env_parses_whitespace_separated_pairs() {
1152 assert_eq!(
1155 options_from_env("max_iter=100 tol=1e-8"),
1156 vec![
1157 ("max_iter".into(), "100".into()),
1158 ("tol".into(), "1e-8".into()),
1159 ]
1160 );
1161 assert_eq!(
1163 options_from_env("a=1\tb=2\n c=3"),
1164 vec![
1165 ("a".into(), "1".into()),
1166 ("b".into(), "2".into()),
1167 ("c".into(), "3".into()),
1168 ]
1169 );
1170 }
1171
1172 #[test]
1173 fn options_from_env_skips_non_kv_tokens_and_empty() {
1174 assert_eq!(
1177 options_from_env("max_iter=100 bareword tol=1e-8"),
1178 vec![
1179 ("max_iter".into(), "100".into()),
1180 ("tol".into(), "1e-8".into()),
1181 ]
1182 );
1183 assert!(options_from_env("").is_empty());
1184 assert!(options_from_env(" \t\n ").is_empty());
1185 assert!(options_from_env("just some words").is_empty());
1186 }
1187}