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 dump_specs: Vec<(String, String)>,
59 pub dump_dir: Option<PathBuf>,
62 pub dump_format: Option<String>,
64 pub sens_boundcheck: bool,
69 pub sens_bound_eps: f64,
72 pub compute_red_hessian: bool,
77 pub rh_eigendecomp: bool,
81}
82
83impl Args {
84 pub fn usage() -> &'static str {
85 "\
86Usage: pounce [OPTIONS] [PATH] [SOL] [KEY=VALUE ...]
87
88PATH is an AMPL .nl file (positional). Equivalent: --nl-file <path>.
89SOL is an optional second positional naming the .sol output file
90(equivalent to --sol-output <path>); the AMPL `solver in.nl out.sol`
91convention.
92
93When the .nl declares the sIPOPT suffixes (sens_state_1,
94sens_state_value_1, sens_init_constr), pounce additionally runs the
95post-optimal parametric sensitivity step and writes the perturbed
96primal back into the .sol as a `sens_sol_state_1` suffix.
97
98Trailing KEY=VALUE pairs are forwarded to the solver's OptionsList
99(same syntax/semantics as the ipopt CLI). They override values loaded
100from --options-file. Examples:
101
102 pounce problem.nl print_level=8
103 pounce problem.nl max_iter=500 tol=1e-10 linear_solver=ma57
104
105Required (one of):
106 PATH positional .nl file to solve
107 --nl-file <path> same, as a flag
108 --problem <name> solve a built-in test problem
109
110Options:
111 --options-file <path> read solver options from an ipopt.opt-format file
112 --json-output <path> write a JSON solve report to PATH after the solve
113 (pounce#8 — machine-readable, FAIR-aligned)
114 --json-detail LEVEL summary | full (default: summary). `full` adds
115 per-iteration history + suffix blocks.
116 --sol-output <path> write an AMPL .sol solution file to PATH.
117 A positional .nl input writes <stub>.sol
118 next to it by default (AMPL convention).
119 --no-sol suppress the default <stub>.sol write
120 --sens-boundcheck clamp the perturbed primal x* + Δx onto the
121 declared [x_l, x_u] box (sIPOPT sens_boundcheck)
122 --sens-bound-eps EPS tolerance for --sens-boundcheck (default 1e-3;
123 setting it also enables --sens-boundcheck)
124 --compute-red-hessian compute the reduced Hessian over the variables
125 tagged by the `red_hessian` integer var-suffix
126 --rh-eigendecomp also compute the reduced-Hessian eigendecomp;
127 implies --compute-red-hessian
128 --list-problems print available built-in problems and exit
129 -AMPL AMPL solver-protocol mode (for Pyomo / AMPL
130 drivers): convey termination via the .sol
131 file and exit 0 for non-fatal outcomes
132 --help, -h print this message and exit
133 --version, -v, -V print version and exit
134 --about print version, build info, features,
135 linear solvers, and runtime paths
136 --dump <cat>[:<spec>] dump diagnostic category to per-iter files.
137 Repeatable. Categories: kkt, iterate, step,
138 mu, ls, resto, convergence, timing.
139 Iter-spec grammar: all | N | N-M | N- | -M
140 (default: all). Examples:
141 --dump kkt:5
142 --dump kkt:2-10 --dump iterate:all
143 --dump-dir <path> override dump root (default ./pounce-dump-<ts>)
144 --dump-format <fmt> dump format (default: jsonl)
145"
146 }
147
148 pub fn parse_argv(argv: Vec<String>) -> Result<Self, String> {
149 let mut problem: Option<ProblemSource> = None;
150 let mut options_file: Option<PathBuf> = None;
151 let mut set_options: Vec<(String, String)> = Vec::new();
152 let mut json_output: Option<PathBuf> = None;
153 let mut json_detail = crate::solve_report::ReportDetail::Summary;
154 let mut sol_output: Option<PathBuf> = None;
155 let mut no_sol = false;
156 let mut ampl = false;
157 let mut help = false;
158 let mut version = false;
159 let mut about = false;
160 let mut list_problems = false;
161 let mut dump_specs: Vec<(String, String)> = Vec::new();
162 let mut dump_dir: Option<PathBuf> = None;
163 let mut dump_format: Option<String> = None;
164 let mut sens_boundcheck = false;
165 let mut sens_bound_eps: f64 = 1e-3;
166 let mut compute_red_hessian = false;
167 let mut rh_eigendecomp = false;
168
169 let mut it = argv.into_iter().skip(1);
170 while let Some(arg) = it.next() {
171 match arg.as_str() {
172 "-h" | "--help" => help = true,
173 "-v" | "-V" | "--version" => version = true,
174 "--about" => about = true,
175 "-AMPL" => ampl = true,
177 "--list-problems" => list_problems = true,
178 "--problem" => {
179 let v = it
180 .next()
181 .ok_or_else(|| "--problem requires a value".to_string())?;
182 problem = Some(ProblemSource::Builtin(v));
183 }
184 "--nl-file" => {
185 let v = it
186 .next()
187 .ok_or_else(|| "--nl-file requires a value".to_string())?;
188 problem = Some(ProblemSource::NlFile(PathBuf::from(v)));
189 }
190 "--options-file" => {
191 let v = it
192 .next()
193 .ok_or_else(|| "--options-file requires a value".to_string())?;
194 options_file = Some(PathBuf::from(v));
195 }
196 "--dump" => {
197 let v = it
198 .next()
199 .ok_or_else(|| "--dump requires a value (cat[:spec])".to_string())?;
200 let (cat, spec) = match v.split_once(':') {
201 Some((c, s)) => (c.to_string(), s.to_string()),
202 None => (v, "all".to_string()),
203 };
204 dump_specs.push((cat, spec));
205 }
206 "--dump-dir" => {
207 let v = it
208 .next()
209 .ok_or_else(|| "--dump-dir requires a value".to_string())?;
210 dump_dir = Some(PathBuf::from(v));
211 }
212 "--dump-format" => {
213 let v = it
214 .next()
215 .ok_or_else(|| "--dump-format requires a value".to_string())?;
216 dump_format = Some(v);
217 }
218 "--json-output" => {
219 let v = it
220 .next()
221 .ok_or_else(|| "--json-output requires a value".to_string())?;
222 json_output = Some(PathBuf::from(v));
223 }
224 "--json-detail" => {
225 let v = it
226 .next()
227 .ok_or_else(|| "--json-detail requires a value".to_string())?;
228 json_detail = crate::solve_report::ReportDetail::parse(&v)?;
229 }
230 "--sol-output" => {
231 let v = it
232 .next()
233 .ok_or_else(|| "--sol-output requires a value".to_string())?;
234 sol_output = Some(PathBuf::from(v));
235 }
236 "--no-sol" => no_sol = true,
237 "--sens-boundcheck" => sens_boundcheck = true,
238 "--sens-bound-eps" => {
239 let v = it
240 .next()
241 .ok_or_else(|| "--sens-bound-eps requires a value".to_string())?;
242 sens_bound_eps = v
243 .parse::<f64>()
244 .map_err(|e| format!("--sens-bound-eps: {e}"))?;
245 sens_boundcheck = true;
246 }
247 "--compute-red-hessian" => compute_red_hessian = true,
248 "--rh-eigendecomp" => {
249 rh_eigendecomp = true;
250 compute_red_hessian = true;
251 }
252 other if !other.starts_with('-') => {
253 if let Some((k, v)) = parse_kv(other) {
258 set_options.push((k, v));
259 } else if problem.is_none() {
260 problem = Some(ProblemSource::NlFile(PathBuf::from(other)));
261 } else if sol_output.is_none() {
262 sol_output = Some(PathBuf::from(other));
263 } else {
264 return Err(format!(
265 "unexpected positional argument '{other}' (expected KEY=VALUE)"
266 ));
267 }
268 }
269 other => return Err(format!("unrecognized argument '{other}'")),
270 }
271 }
272
273 if list_problems {
274 println!("{}", crate::builtin::list().join("\n"));
275 std::process::exit(0);
276 }
277
278 if !help && !version && !about {
279 let problem = problem.ok_or_else(|| {
280 "missing problem: pass a positional .nl path, --nl-file, or --problem".to_string()
281 })?;
282 return Ok(Self {
283 problem,
284 options_file,
285 set_options,
286 json_output,
287 json_detail,
288 sol_output,
289 no_sol,
290 ampl,
291 help,
292 version,
293 about,
294 dump_specs,
295 dump_dir,
296 dump_format,
297 sens_boundcheck,
298 sens_bound_eps,
299 compute_red_hessian,
300 rh_eigendecomp,
301 });
302 }
303
304 Ok(Self {
305 problem: ProblemSource::Builtin(String::new()),
306 options_file,
307 set_options,
308 json_output,
309 json_detail,
310 sol_output,
311 no_sol,
312 ampl,
313 help,
314 version,
315 about,
316 dump_specs,
317 dump_dir,
318 dump_format,
319 sens_boundcheck,
320 sens_bound_eps,
321 compute_red_hessian,
322 rh_eigendecomp,
323 })
324 }
325}
326
327fn parse_kv(s: &str) -> Option<(String, String)> {
331 let (k, v) = s.split_once('=')?;
332 let k = k.trim().trim_end_matches(':');
333 let v = v.trim();
334 if k.is_empty() || v.is_empty() {
335 return None;
336 }
337 Some((k.to_string(), v.to_string()))
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 fn argv(args: &[&str]) -> Vec<String> {
345 std::iter::once("pounce")
346 .chain(args.iter().copied())
347 .map(String::from)
348 .collect()
349 }
350
351 #[test]
352 fn help_short_and_long() {
353 assert!(Args::parse_argv(argv(&["-h"])).unwrap().help);
354 assert!(Args::parse_argv(argv(&["--help"])).unwrap().help);
355 }
356
357 #[test]
358 fn version_short_and_long() {
359 assert!(Args::parse_argv(argv(&["-v"])).unwrap().version);
360 assert!(Args::parse_argv(argv(&["-V"])).unwrap().version);
361 assert!(Args::parse_argv(argv(&["--version"])).unwrap().version);
362 }
363
364 #[test]
365 fn ampl_flag_sets_mode_and_keeps_positional() {
366 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL"])).unwrap();
367 assert!(a.ampl);
368 match a.problem {
369 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
370 _ => panic!("expected positional .nl"),
371 }
372 }
373
374 #[test]
375 fn ampl_flag_defaults_off() {
376 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
377 assert!(!a.ampl);
378 }
379
380 #[test]
381 fn ampl_flag_with_options() {
382 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL", "max_iter=500"])).unwrap();
383 assert!(a.ampl);
384 assert_eq!(a.set_options, vec![("max_iter".into(), "500".into())]);
385 }
386
387 #[test]
388 fn about_flag_does_not_require_problem() {
389 let a = Args::parse_argv(argv(&["--about"])).unwrap();
390 assert!(a.about);
391 }
392
393 #[test]
394 fn problem_flag_captures_name() {
395 let a = Args::parse_argv(argv(&["--problem", "rosenbrock"])).unwrap();
396 match a.problem {
397 ProblemSource::Builtin(s) => assert_eq!(s, "rosenbrock"),
398 _ => panic!("expected builtin"),
399 }
400 }
401
402 #[test]
403 fn nl_file_captured() {
404 let a = Args::parse_argv(argv(&["--nl-file", "/tmp/foo.nl"])).unwrap();
405 match a.problem {
406 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
407 _ => panic!("expected nl file"),
408 }
409 }
410
411 #[test]
412 fn positional_nl_path() {
413 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
414 match a.problem {
415 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
416 _ => panic!("expected positional .nl"),
417 }
418 }
419
420 #[test]
421 fn positional_with_options_file() {
422 let a = Args::parse_argv(argv(&["--options-file", "ipopt.opt", "/tmp/foo.nl"])).unwrap();
423 match a.problem {
424 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
425 _ => panic!("expected positional .nl"),
426 }
427 assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
428 }
429
430 #[test]
431 fn options_file_captured() {
432 let a = Args::parse_argv(argv(&["--problem", "x", "--options-file", "ipopt.opt"])).unwrap();
433 assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
434 }
435
436 #[test]
437 fn missing_value_for_flag() {
438 assert!(Args::parse_argv(argv(&["--problem"])).is_err());
439 }
440
441 #[test]
442 fn missing_problem() {
443 assert!(Args::parse_argv(argv(&[])).is_err());
444 }
445
446 #[test]
447 fn unknown_arg() {
448 assert!(Args::parse_argv(argv(&["--bogus"])).is_err());
449 }
450
451 #[test]
452 fn key_value_options_collected() {
453 let a = Args::parse_argv(argv(&[
454 "/tmp/foo.nl",
455 "print_level=8",
456 "max_iter=500",
457 "tol=1e-10",
458 ]))
459 .unwrap();
460 assert_eq!(
461 a.set_options,
462 vec![
463 ("print_level".into(), "8".into()),
464 ("max_iter".into(), "500".into()),
465 ("tol".into(), "1e-10".into()),
466 ]
467 );
468 }
469
470 #[test]
471 fn key_value_before_path() {
472 let a = Args::parse_argv(argv(&["print_level=8", "/tmp/foo.nl"])).unwrap();
473 match a.problem {
474 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
475 _ => panic!("expected positional .nl"),
476 }
477 assert_eq!(a.set_options, vec![("print_level".into(), "8".into())]);
478 }
479
480 #[test]
481 fn dump_flag_captures_cat_and_spec() {
482 let a = Args::parse_argv(argv(&[
483 "--problem",
484 "x",
485 "--dump",
486 "kkt:2-10",
487 "--dump",
488 "iterate",
489 ]))
490 .unwrap();
491 assert_eq!(
492 a.dump_specs,
493 vec![
494 ("kkt".into(), "2-10".into()),
495 ("iterate".into(), "all".into()),
496 ]
497 );
498 }
499
500 #[test]
501 fn dump_dir_and_format_captured() {
502 let a = Args::parse_argv(argv(&[
503 "--problem",
504 "x",
505 "--dump",
506 "kkt",
507 "--dump-dir",
508 "/tmp/d",
509 "--dump-format",
510 "jsonl",
511 ]))
512 .unwrap();
513 assert_eq!(a.dump_dir.unwrap().to_str(), Some("/tmp/d"));
514 assert_eq!(a.dump_format.as_deref(), Some("jsonl"));
515 }
516
517 #[test]
518 fn sol_output_captured() {
519 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output", "/tmp/out.sol"])).unwrap();
520 assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
521 assert!(!a.no_sol);
522 }
523
524 #[test]
525 fn no_sol_flag() {
526 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sol"])).unwrap();
527 assert!(a.no_sol);
528 assert!(a.sol_output.is_none());
529 }
530
531 #[test]
532 fn sol_output_defaults_unset() {
533 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
534 assert!(a.sol_output.is_none());
535 assert!(!a.no_sol);
536 }
537
538 #[test]
539 fn sol_output_missing_value() {
540 assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output"])).is_err());
541 }
542
543 #[test]
544 fn second_positional_is_sol_output() {
545 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "/tmp/out.sol"])).unwrap();
546 match a.problem {
547 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
548 _ => panic!("expected positional .nl"),
549 }
550 assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
551 }
552
553 #[test]
554 fn third_positional_is_an_error() {
555 assert!(Args::parse_argv(argv(&["/tmp/a.nl", "/tmp/b.sol", "/tmp/c"])).is_err());
556 }
557
558 #[test]
559 fn sens_flags_default_off() {
560 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
561 assert!(!a.sens_boundcheck);
562 assert!(!a.compute_red_hessian);
563 assert!(!a.rh_eigendecomp);
564 assert_eq!(a.sens_bound_eps, 1e-3);
565 }
566
567 #[test]
568 fn sens_boundcheck_flag() {
569 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-boundcheck"])).unwrap();
570 assert!(a.sens_boundcheck);
571 }
572
573 #[test]
574 fn sens_bound_eps_sets_value_and_enables_boundcheck() {
575 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-bound-eps", "1e-6"])).unwrap();
576 assert_eq!(a.sens_bound_eps, 1e-6);
577 assert!(a.sens_boundcheck);
578 }
579
580 #[test]
581 fn rh_eigendecomp_implies_compute_red_hessian() {
582 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--rh-eigendecomp"])).unwrap();
583 assert!(a.rh_eigendecomp);
584 assert!(a.compute_red_hessian);
585 }
586
587 #[test]
588 fn parse_kv_basic() {
589 assert_eq!(
590 parse_kv("print_level=8"),
591 Some(("print_level".into(), "8".into()))
592 );
593 assert_eq!(
594 parse_kv("tol = 1e-10"),
595 Some(("tol".into(), "1e-10".into()))
596 );
597 assert_eq!(parse_kv("plain_path.nl"), None);
598 assert_eq!(parse_kv("=value"), None);
599 assert_eq!(parse_kv("key="), None);
600 }
601}