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(s), step,
138 mu, ls, resto, convergence, timing.
139 Iter-spec grammar: all | N | N-M | N- | -M
140 (default: all). The `iterates` category also
141 accepts a `:summary` (default) or `:full`
142 variant suffix and streams one JSONL row
143 per iter to <dump-dir>/iterates.jsonl. The
144 `kkt` category accepts `+L` / `+L+Lvals`
145 suffixes that add the LDLᵀ factor's
146 strict-lower pattern (and optional values)
147 plus the fill-reducing permutation to each
148 kkt_solve_NNN.jsonl record (feral backend
149 only; MA57 silently omits the L fields).
150 Examples:
151 --dump kkt:5
152 --dump kkt:2-10 --dump iterate:all
153 --dump kkt:5-10+L
154 --dump kkt:5-10+L+Lvals
155 --dump iterates:summary
156 --dump iterates:5-:full
157 --dump-dir <path> override dump root (default ./pounce-dump-<ts>)
158 --dump-format <fmt> dump format (default: jsonl)
159"
160 }
161
162 pub fn parse_argv(argv: Vec<String>) -> Result<Self, String> {
163 let mut problem: Option<ProblemSource> = None;
164 let mut options_file: Option<PathBuf> = None;
165 let mut set_options: Vec<(String, String)> = Vec::new();
166 let mut json_output: Option<PathBuf> = None;
167 let mut json_detail = crate::solve_report::ReportDetail::Summary;
168 let mut sol_output: Option<PathBuf> = None;
169 let mut no_sol = false;
170 let mut ampl = false;
171 let mut help = false;
172 let mut version = false;
173 let mut about = false;
174 let mut list_problems = false;
175 let mut dump_specs: Vec<(String, String)> = Vec::new();
176 let mut dump_dir: Option<PathBuf> = None;
177 let mut dump_format: Option<String> = None;
178 let mut sens_boundcheck = false;
179 let mut sens_bound_eps: f64 = 1e-3;
180 let mut compute_red_hessian = false;
181 let mut rh_eigendecomp = false;
182
183 let mut it = argv.into_iter().skip(1);
184 while let Some(arg) = it.next() {
185 match arg.as_str() {
186 "-h" | "--help" => help = true,
187 "-v" | "-V" | "--version" => version = true,
188 "--about" => about = true,
189 "-AMPL" => ampl = true,
191 "--list-problems" => list_problems = true,
192 "--problem" => {
193 let v = it
194 .next()
195 .ok_or_else(|| "--problem requires a value".to_string())?;
196 problem = Some(ProblemSource::Builtin(v));
197 }
198 "--nl-file" => {
199 let v = it
200 .next()
201 .ok_or_else(|| "--nl-file requires a value".to_string())?;
202 problem = Some(ProblemSource::NlFile(PathBuf::from(v)));
203 }
204 "--options-file" => {
205 let v = it
206 .next()
207 .ok_or_else(|| "--options-file requires a value".to_string())?;
208 options_file = Some(PathBuf::from(v));
209 }
210 "--dump" => {
211 let v = it
212 .next()
213 .ok_or_else(|| "--dump requires a value (cat[:spec])".to_string())?;
214 let (cat, spec) = match v.split_once(':') {
215 Some((c, s)) => (c.to_string(), s.to_string()),
216 None => (v, "all".to_string()),
217 };
218 dump_specs.push((cat, spec));
219 }
220 "--dump-dir" => {
221 let v = it
222 .next()
223 .ok_or_else(|| "--dump-dir requires a value".to_string())?;
224 dump_dir = Some(PathBuf::from(v));
225 }
226 "--dump-format" => {
227 let v = it
228 .next()
229 .ok_or_else(|| "--dump-format requires a value".to_string())?;
230 dump_format = Some(v);
231 }
232 "--json-output" => {
233 let v = it
234 .next()
235 .ok_or_else(|| "--json-output requires a value".to_string())?;
236 json_output = Some(PathBuf::from(v));
237 }
238 "--json-detail" => {
239 let v = it
240 .next()
241 .ok_or_else(|| "--json-detail requires a value".to_string())?;
242 json_detail = crate::solve_report::ReportDetail::parse(&v)?;
243 }
244 "--sol-output" => {
245 let v = it
246 .next()
247 .ok_or_else(|| "--sol-output requires a value".to_string())?;
248 sol_output = Some(PathBuf::from(v));
249 }
250 "--no-sol" => no_sol = true,
251 "--sens-boundcheck" => sens_boundcheck = true,
252 "--sens-bound-eps" => {
253 let v = it
254 .next()
255 .ok_or_else(|| "--sens-bound-eps requires a value".to_string())?;
256 sens_bound_eps = v
257 .parse::<f64>()
258 .map_err(|e| format!("--sens-bound-eps: {e}"))?;
259 sens_boundcheck = true;
260 }
261 "--compute-red-hessian" => compute_red_hessian = true,
262 "--rh-eigendecomp" => {
263 rh_eigendecomp = true;
264 compute_red_hessian = true;
265 }
266 other if !other.starts_with('-') => {
267 if let Some((k, v)) = parse_kv(other) {
272 set_options.push((k, v));
273 } else if problem.is_none() {
274 problem = Some(ProblemSource::NlFile(PathBuf::from(other)));
275 } else if sol_output.is_none() {
276 sol_output = Some(PathBuf::from(other));
277 } else {
278 return Err(format!(
279 "unexpected positional argument '{other}' (expected KEY=VALUE)"
280 ));
281 }
282 }
283 other => return Err(format!("unrecognized argument '{other}'")),
284 }
285 }
286
287 if list_problems {
288 println!("{}", crate::builtin::list().join("\n"));
289 std::process::exit(0);
290 }
291
292 if !help && !version && !about {
293 let problem = problem.ok_or_else(|| {
294 "missing problem: pass a positional .nl path, --nl-file, or --problem".to_string()
295 })?;
296 return Ok(Self {
297 problem,
298 options_file,
299 set_options,
300 json_output,
301 json_detail,
302 sol_output,
303 no_sol,
304 ampl,
305 help,
306 version,
307 about,
308 dump_specs,
309 dump_dir,
310 dump_format,
311 sens_boundcheck,
312 sens_bound_eps,
313 compute_red_hessian,
314 rh_eigendecomp,
315 });
316 }
317
318 Ok(Self {
319 problem: ProblemSource::Builtin(String::new()),
320 options_file,
321 set_options,
322 json_output,
323 json_detail,
324 sol_output,
325 no_sol,
326 ampl,
327 help,
328 version,
329 about,
330 dump_specs,
331 dump_dir,
332 dump_format,
333 sens_boundcheck,
334 sens_bound_eps,
335 compute_red_hessian,
336 rh_eigendecomp,
337 })
338 }
339}
340
341fn parse_kv(s: &str) -> Option<(String, String)> {
345 let (k, v) = s.split_once('=')?;
346 let k = k.trim().trim_end_matches(':');
347 let v = v.trim();
348 if k.is_empty() || v.is_empty() {
349 return None;
350 }
351 Some((k.to_string(), v.to_string()))
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 fn argv(args: &[&str]) -> Vec<String> {
359 std::iter::once("pounce")
360 .chain(args.iter().copied())
361 .map(String::from)
362 .collect()
363 }
364
365 #[test]
366 fn help_short_and_long() {
367 assert!(Args::parse_argv(argv(&["-h"])).unwrap().help);
368 assert!(Args::parse_argv(argv(&["--help"])).unwrap().help);
369 }
370
371 #[test]
372 fn version_short_and_long() {
373 assert!(Args::parse_argv(argv(&["-v"])).unwrap().version);
374 assert!(Args::parse_argv(argv(&["-V"])).unwrap().version);
375 assert!(Args::parse_argv(argv(&["--version"])).unwrap().version);
376 }
377
378 #[test]
379 fn ampl_flag_sets_mode_and_keeps_positional() {
380 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL"])).unwrap();
381 assert!(a.ampl);
382 match a.problem {
383 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
384 _ => panic!("expected positional .nl"),
385 }
386 }
387
388 #[test]
389 fn ampl_flag_defaults_off() {
390 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
391 assert!(!a.ampl);
392 }
393
394 #[test]
395 fn ampl_flag_with_options() {
396 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL", "max_iter=500"])).unwrap();
397 assert!(a.ampl);
398 assert_eq!(a.set_options, vec![("max_iter".into(), "500".into())]);
399 }
400
401 #[test]
402 fn about_flag_does_not_require_problem() {
403 let a = Args::parse_argv(argv(&["--about"])).unwrap();
404 assert!(a.about);
405 }
406
407 #[test]
408 fn problem_flag_captures_name() {
409 let a = Args::parse_argv(argv(&["--problem", "rosenbrock"])).unwrap();
410 match a.problem {
411 ProblemSource::Builtin(s) => assert_eq!(s, "rosenbrock"),
412 _ => panic!("expected builtin"),
413 }
414 }
415
416 #[test]
417 fn nl_file_captured() {
418 let a = Args::parse_argv(argv(&["--nl-file", "/tmp/foo.nl"])).unwrap();
419 match a.problem {
420 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
421 _ => panic!("expected nl file"),
422 }
423 }
424
425 #[test]
426 fn positional_nl_path() {
427 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
428 match a.problem {
429 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
430 _ => panic!("expected positional .nl"),
431 }
432 }
433
434 #[test]
435 fn positional_with_options_file() {
436 let a = Args::parse_argv(argv(&["--options-file", "ipopt.opt", "/tmp/foo.nl"])).unwrap();
437 match a.problem {
438 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
439 _ => panic!("expected positional .nl"),
440 }
441 assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
442 }
443
444 #[test]
445 fn options_file_captured() {
446 let a = Args::parse_argv(argv(&["--problem", "x", "--options-file", "ipopt.opt"])).unwrap();
447 assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
448 }
449
450 #[test]
451 fn missing_value_for_flag() {
452 assert!(Args::parse_argv(argv(&["--problem"])).is_err());
453 }
454
455 #[test]
456 fn missing_problem() {
457 assert!(Args::parse_argv(argv(&[])).is_err());
458 }
459
460 #[test]
461 fn unknown_arg() {
462 assert!(Args::parse_argv(argv(&["--bogus"])).is_err());
463 }
464
465 #[test]
466 fn key_value_options_collected() {
467 let a = Args::parse_argv(argv(&[
468 "/tmp/foo.nl",
469 "print_level=8",
470 "max_iter=500",
471 "tol=1e-10",
472 ]))
473 .unwrap();
474 assert_eq!(
475 a.set_options,
476 vec![
477 ("print_level".into(), "8".into()),
478 ("max_iter".into(), "500".into()),
479 ("tol".into(), "1e-10".into()),
480 ]
481 );
482 }
483
484 #[test]
485 fn key_value_before_path() {
486 let a = Args::parse_argv(argv(&["print_level=8", "/tmp/foo.nl"])).unwrap();
487 match a.problem {
488 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
489 _ => panic!("expected positional .nl"),
490 }
491 assert_eq!(a.set_options, vec![("print_level".into(), "8".into())]);
492 }
493
494 #[test]
495 fn dump_flag_captures_cat_and_spec() {
496 let a = Args::parse_argv(argv(&[
497 "--problem",
498 "x",
499 "--dump",
500 "kkt:2-10",
501 "--dump",
502 "iterate",
503 ]))
504 .unwrap();
505 assert_eq!(
506 a.dump_specs,
507 vec![
508 ("kkt".into(), "2-10".into()),
509 ("iterate".into(), "all".into()),
510 ]
511 );
512 }
513
514 #[test]
515 fn dump_dir_and_format_captured() {
516 let a = Args::parse_argv(argv(&[
517 "--problem",
518 "x",
519 "--dump",
520 "kkt",
521 "--dump-dir",
522 "/tmp/d",
523 "--dump-format",
524 "jsonl",
525 ]))
526 .unwrap();
527 assert_eq!(a.dump_dir.unwrap().to_str(), Some("/tmp/d"));
528 assert_eq!(a.dump_format.as_deref(), Some("jsonl"));
529 }
530
531 #[test]
532 fn sol_output_captured() {
533 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output", "/tmp/out.sol"])).unwrap();
534 assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
535 assert!(!a.no_sol);
536 }
537
538 #[test]
539 fn no_sol_flag() {
540 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sol"])).unwrap();
541 assert!(a.no_sol);
542 assert!(a.sol_output.is_none());
543 }
544
545 #[test]
546 fn sol_output_defaults_unset() {
547 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
548 assert!(a.sol_output.is_none());
549 assert!(!a.no_sol);
550 }
551
552 #[test]
553 fn sol_output_missing_value() {
554 assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output"])).is_err());
555 }
556
557 #[test]
558 fn second_positional_is_sol_output() {
559 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "/tmp/out.sol"])).unwrap();
560 match a.problem {
561 ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
562 _ => panic!("expected positional .nl"),
563 }
564 assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
565 }
566
567 #[test]
568 fn third_positional_is_an_error() {
569 assert!(Args::parse_argv(argv(&["/tmp/a.nl", "/tmp/b.sol", "/tmp/c"])).is_err());
570 }
571
572 #[test]
573 fn sens_flags_default_off() {
574 let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
575 assert!(!a.sens_boundcheck);
576 assert!(!a.compute_red_hessian);
577 assert!(!a.rh_eigendecomp);
578 assert_eq!(a.sens_bound_eps, 1e-3);
579 }
580
581 #[test]
582 fn sens_boundcheck_flag() {
583 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-boundcheck"])).unwrap();
584 assert!(a.sens_boundcheck);
585 }
586
587 #[test]
588 fn sens_bound_eps_sets_value_and_enables_boundcheck() {
589 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-bound-eps", "1e-6"])).unwrap();
590 assert_eq!(a.sens_bound_eps, 1e-6);
591 assert!(a.sens_boundcheck);
592 }
593
594 #[test]
595 fn rh_eigendecomp_implies_compute_red_hessian() {
596 let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--rh-eigendecomp"])).unwrap();
597 assert!(a.rh_eigendecomp);
598 assert!(a.compute_red_hessian);
599 }
600
601 #[test]
602 fn parse_kv_basic() {
603 assert_eq!(
604 parse_kv("print_level=8"),
605 Some(("print_level".into(), "8".into()))
606 );
607 assert_eq!(
608 parse_kv("tol = 1e-10"),
609 Some(("tol".into(), "1e-10".into()))
610 );
611 assert_eq!(parse_kv("plain_path.nl"), None);
612 assert_eq!(parse_kv("=value"), None);
613 assert_eq!(parse_kv("key="), None);
614 }
615}