1pub mod convexity;
42pub mod domain;
43pub mod kkt;
44pub mod report;
45pub mod robustness;
46pub mod scaling;
47pub mod statics;
48pub mod structure;
49
50#[cfg(test)]
51mod phase2_tests;
52
53use crate::check_x0::{
54 CheckX0Args, CheckX0Outcome, check_tnlp_with_quadratics, clamp_to_interior, quad_row_coefs,
55};
56use crate::nl_reader::{self, NlProblem, NlTnlp};
57use crate::verify::{name_at, sha256};
58use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
59use pounce_nlp::tnlp::{SparsityRequest, StartingPoint, TNLP};
60use report::{Basis, Confidence, Finding, Report, Severity};
61use serde_json::json;
62use std::path::PathBuf;
63use std::process::ExitCode;
64
65const BOUND_PUSH: Number = 1e-2;
67const BOUND_FRAC: Number = 1e-2;
68
69#[derive(Debug, Clone)]
71pub struct DiagnoseOptions {
72 pub feas_tol: Number,
75 pub domain_tol: Number,
78 pub rank_rel_tol: Option<Number>,
81 pub max_dense_entries: usize,
83 pub max_dense_dim: usize,
84 pub max_dependent_sets: usize,
86 pub max_dependent_search: usize,
88 pub wide_range: Number,
90 pub max_per_code: usize,
92 pub opt_tol: Number,
95 pub weak_band: Number,
98 pub seed: u64,
100 pub convexity_samples: usize,
102 pub sample_radius: Number,
104 pub convexity_tol: Number,
106}
107
108impl Default for DiagnoseOptions {
109 fn default() -> Self {
110 DiagnoseOptions {
111 feas_tol: 1e-6,
112 domain_tol: 1e-12,
113 rank_rel_tol: None,
114 max_dense_entries: 4_000_000,
115 max_dense_dim: 3_000,
116 max_dependent_sets: 5,
117 max_dependent_search: 200,
118 wide_range: 1e8,
119 max_per_code: 25,
120 opt_tol: 1e-6,
121 weak_band: 1e-4,
122 seed: 0,
123 convexity_samples: 64,
124 sample_radius: 1.0,
125 convexity_tol: 1e-9,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Default)]
132pub struct DiagnoseArgs {
133 pub nl: Option<PathBuf>,
134 pub x0_file: Option<PathBuf>,
135 pub sol: Option<PathBuf>,
136 pub point_label: Option<String>,
138 pub extra_points: Vec<PathBuf>,
140 pub json: bool,
141 pub json_output: Option<PathBuf>,
142 pub opts: DiagnoseOptions,
143}
144
145const USAGE: &str = "\
146Usage: pounce diagnose <problem.nl> [OPTIONS]
147
148Check a model without solving it. Reports, with the evidence for each:
149contradictory bounds and rows (proved from the algebra), duplicate and
150parallel rows, single-variable rows that are really bounds, variables that
151appear nowhere, unbounded objective directions, operators whose arguments
152the bounds do not keep inside their domain, rows that can never hold,
153coefficient and scaling ranges, and - at one point - which operator made a
154value or derivative NaN/Inf, structural and numerical rank of the equality
155Jacobian, the smallest sets of dependent equations, and Jacobian scale
156spread. The starting-point checks of `pounce check-x0` are included.
157
158With --sol it also checks the solution: stationarity, multiplier signs and
159complementarity, LICQ (with the dependent active constraints and how far
160the multipliers can move), MFCQ, near-active and weakly active
161constraints, and the reduced Hessian. At every point: whether a rank loss
162survives other tolerances, scalings, a QR, and other points; the Hessian's
163inertia; and sampled counterexamples to convexity.
164
165Arguments:
166 <problem.nl> AMPL .nl problem
167
168Point for the numerical checks (default: the model's starting point):
169 --x0-file <path> n whitespace-separated values
170 --sol <path> the primal values in an AMPL .sol file
171 --point-label <text> name the point in the report
172 --extra-point <path> a further point (n values) for rank_robustness;
173 repeatable
174
175Options:
176 --json print the JSON report (pounce.diagnose/v1)
177 --json-output <path> also write the JSON report to <path>
178 --feas-tol <t> slack for 'violated' / 'empty' (default 1e-6)
179 --domain-tol <t> slack for operator domains (default 1e-12)
180 --rank-tol <t> singular-value threshold relative to sigma_max
181 (default max(m,n)*eps)
182 --max-dense-entries <k> dense SVD limit on m*n (default 4000000)
183 --max-dense-dim <k> dense SVD limit on min(m,n) (default 3000)
184 --max-dependent-sets <k> minimal dependent sets to localize (default 5)
185 --max-dependent-search <k>
186 largest set the minimal-set search shrinks
187 (default 200)
188 --wide-range <r> magnitude ratio reported as wide (default 1e8)
189 --max-per-code <k> findings kept per code (default 25)
190 --opt-tol <t> stationarity / zero-multiplier tolerance,
191 relative to max(1, |grad f|) (default 1e-6)
192 --weak-band <t> near-active band, relative (default 1e-4)
193 --seed <k> convexity sampler seed (default 0)
194 --convexity-samples <k> sample pairs per function (default 64)
195 --sample-radius <r> sampling box half-width, relative (default 1)
196 --convexity-tol <t> relative Jensen gap that counts as a
197 counterexample (default 1e-9)
198 -h, --help print this message
199
200Exit code: 0 = no error findings (warnings allowed), 22 = at least one
201error finding, 2 = usage/IO error.";
202
203pub fn run_from_argv(rest: &[String]) -> ExitCode {
205 let args = match parse_argv(rest) {
206 Ok(Some(a)) => a,
207 Ok(None) => {
208 println!("{USAGE}");
209 return ExitCode::SUCCESS;
210 }
211 Err(msg) => {
212 eprintln!("pounce diagnose: {msg}");
213 eprintln!("{USAGE}");
214 return ExitCode::from(2);
215 }
216 };
217 run(&args)
218}
219
220fn parse_argv(rest: &[String]) -> Result<Option<DiagnoseArgs>, String> {
221 let mut a = DiagnoseArgs::default();
222 let mut i = 0;
223 fn value<'a>(rest: &'a [String], i: &mut usize, flag: &str) -> Result<&'a str, String> {
224 *i += 1;
225 rest.get(*i)
226 .map(String::as_str)
227 .ok_or_else(|| format!("{flag} needs a value"))
228 }
229 fn parse<T: std::str::FromStr>(s: &str, flag: &str) -> Result<T, String> {
230 s.parse().map_err(|_| format!("{flag}: cannot parse `{s}`"))
231 }
232 while i < rest.len() {
233 let arg = rest[i].as_str();
234 match arg {
235 "-h" | "--help" => return Ok(None),
236 "--json" => a.json = true,
237 "--json-output" => a.json_output = Some(value(rest, &mut i, arg)?.into()),
238 "--x0-file" => a.x0_file = Some(value(rest, &mut i, arg)?.into()),
239 "--sol" => a.sol = Some(value(rest, &mut i, arg)?.into()),
240 "--point-label" => a.point_label = Some(value(rest, &mut i, arg)?.to_string()),
241 "--feas-tol" => a.opts.feas_tol = parse(value(rest, &mut i, arg)?, arg)?,
242 "--domain-tol" => a.opts.domain_tol = parse(value(rest, &mut i, arg)?, arg)?,
243 "--rank-tol" => a.opts.rank_rel_tol = Some(parse(value(rest, &mut i, arg)?, arg)?),
244 "--max-dense-entries" => {
245 a.opts.max_dense_entries = parse(value(rest, &mut i, arg)?, arg)?
246 }
247 "--max-dense-dim" => a.opts.max_dense_dim = parse(value(rest, &mut i, arg)?, arg)?,
248 "--max-dependent-sets" => {
249 a.opts.max_dependent_sets = parse(value(rest, &mut i, arg)?, arg)?
250 }
251 "--max-dependent-search" => {
252 a.opts.max_dependent_search = parse(value(rest, &mut i, arg)?, arg)?
253 }
254 "--wide-range" => a.opts.wide_range = parse(value(rest, &mut i, arg)?, arg)?,
255 "--max-per-code" => a.opts.max_per_code = parse(value(rest, &mut i, arg)?, arg)?,
256 "--extra-point" => a.extra_points.push(value(rest, &mut i, arg)?.into()),
257 "--opt-tol" => a.opts.opt_tol = parse(value(rest, &mut i, arg)?, arg)?,
258 "--weak-band" => a.opts.weak_band = parse(value(rest, &mut i, arg)?, arg)?,
259 "--seed" => a.opts.seed = parse(value(rest, &mut i, arg)?, arg)?,
260 "--convexity-samples" => {
261 a.opts.convexity_samples = parse(value(rest, &mut i, arg)?, arg)?
262 }
263 "--sample-radius" => a.opts.sample_radius = parse(value(rest, &mut i, arg)?, arg)?,
264 "--convexity-tol" => a.opts.convexity_tol = parse(value(rest, &mut i, arg)?, arg)?,
265 s if s.starts_with('-') => return Err(format!("unknown option `{s}`")),
266 s => {
267 if a.nl.is_some() {
268 return Err(format!("unexpected extra argument `{s}`"));
269 }
270 a.nl = Some(s.into());
271 }
272 }
273 i += 1;
274 }
275 if a.nl.is_none() {
276 return Err("expected a <problem.nl> argument".into());
277 }
278 if a.x0_file.is_some() && a.sol.is_some() {
279 return Err("--x0-file and --sol are mutually exclusive".into());
280 }
281 Ok(Some(a))
282}
283
284pub fn run(args: &DiagnoseArgs) -> ExitCode {
285 let report = match diagnose_file(args) {
286 Ok(r) => r,
287 Err(msg) => {
288 eprintln!("pounce diagnose: {msg}");
289 return ExitCode::from(2);
290 }
291 };
292 let json_text =
293 || serde_json::to_string_pretty(&report.to_json()).unwrap_or_else(|_| "{}".into());
294 if args.json {
295 println!("{}", json_text());
296 } else {
297 print!("{}", report.to_text());
298 }
299 if let Some(path) = &args.json_output {
300 if let Err(e) = std::fs::write(path, json_text()) {
301 eprintln!(
302 "pounce diagnose: failed to write report {}: {e}",
303 path.display()
304 );
305 return ExitCode::from(2);
306 }
307 if !args.json {
308 println!(" report: {}", path.display());
309 }
310 }
311 if report.has_errors() {
312 ExitCode::from(22)
313 } else {
314 ExitCode::SUCCESS
315 }
316}
317
318pub fn diagnose_file(args: &DiagnoseArgs) -> Result<Report, String> {
320 let path = args.nl.as_ref().ok_or("expected a <problem.nl> argument")?;
321 let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
322 let prob = nl_reader::read_nl_file(path)?;
323 if !prob.complementarity.is_empty() {
324 return Err(format!(
325 "{} declares {} complementarity pair(s), which diagnose does not model",
326 path.display(),
327 prob.complementarity.len()
328 ));
329 }
330 let read_values = |p: &PathBuf| -> Result<Vec<Number>, String> {
331 let text =
332 std::fs::read_to_string(p).map_err(|e| format!("cannot read {}: {e}", p.display()))?;
333 let vals: Result<Vec<Number>, _> = text.split_whitespace().map(str::parse).collect();
334 vals.map_err(|e| format!("{}: bad value: {e}", p.display()))
335 };
336 let mut inputs = Inputs::default();
337 if let Some(p) = &args.sol {
338 let text =
339 std::fs::read_to_string(p).map_err(|e| format!("cannot read {}: {e}", p.display()))?;
340 let sol = crate::verify::parse_sol(&text)?;
341 inputs.point = Some((format!("the solution in {}", p.display()), sol.x));
342 inputs.multipliers = (!sol.lambda.is_empty()).then_some(sol.lambda);
343 if sol.z_l.is_some() || sol.z_u.is_some() {
344 let n = prob.n;
345 inputs.bound_multipliers = Some((
346 sol.z_l.unwrap_or_else(|| vec![0.0; n]),
347 sol.z_u.unwrap_or_else(|| vec![0.0; n]),
348 ));
349 }
350 inputs.is_solution = true;
351 } else if let Some(p) = &args.x0_file {
352 inputs.point = Some((format!("the point in {}", p.display()), read_values(p)?));
353 }
354 if let (Some(pt), Some(l)) = (inputs.point.as_mut(), &args.point_label) {
355 pt.0 = l.clone();
356 }
357 for p in &args.extra_points {
358 inputs
359 .extra_points
360 .push((format!("the point in {}", p.display()), read_values(p)?));
361 }
362 let mut report = diagnose_problem(&prob, &inputs, path.display().to_string(), &args.opts)?;
363 report.sha256 = Some(sha256::hex(&bytes));
364 Ok(report)
365}
366
367#[derive(Debug, Clone, Default)]
369pub struct Inputs {
370 pub point: Option<(String, Vec<Number>)>,
372 pub multipliers: Option<Vec<Number>>,
374 pub bound_multipliers: Option<(Vec<Number>, Vec<Number>)>,
377 pub is_solution: bool,
379 pub extra_points: Vec<(String, Vec<Number>)>,
381}
382
383pub fn diagnose_problem(
385 prob: &NlProblem,
386 inputs: &Inputs,
387 source: String,
388 opts: &DiagnoseOptions,
389) -> Result<Report, String> {
390 let point = inputs.point.clone();
391 let mut report = Report::new(source.clone(), prob.n, prob.m, opts.max_per_code);
392 report.tolerances.insert("feas_tol", opts.feas_tol);
393 report.tolerances.insert("domain_tol", opts.domain_tol);
394 report.tolerances.insert("wide_range", opts.wide_range);
395 report
396 .tolerances
397 .insert("convexity_tol", opts.convexity_tol);
398
399 statics::check_bounds(prob, &mut report);
401 statics::check_linear_rows(prob, opts, &mut report);
402 statics::check_incidence(prob, &mut report);
403 domain::check_domains(prob, opts, &mut report);
404 let quad = quad_row_coefs(prob);
405 scaling::check_coefficients(prob, &quad, opts, &mut report);
406
407 let mut tnlp = NlTnlp::try_new(prob.clone())?;
409 let x = match &point {
410 Some((label, v)) => {
411 if v.len() != prob.n {
412 return Err(format!(
413 "{label} has {} values but the problem has {} variables",
414 v.len(),
415 prob.n
416 ));
417 }
418 v.clone()
419 }
420 None => starting_point(&mut tnlp, prob.n)?,
421 };
422 let label = point
423 .as_ref()
424 .map(|p| p.0.clone())
425 .unwrap_or_else(|| "the model's starting point".to_string());
426 report.point = label.clone();
427
428 let x0_args = CheckX0Args {
429 x0_values: Some((label.clone(), x.clone())),
430 feas_tol: opts.feas_tol,
431 ..Default::default()
432 };
433 let clamped: Vec<Number> = (0..prob.n)
439 .map(|j| clamp_to_interior(x[j], prob.x_l[j], prob.x_u[j], BOUND_PUSH, BOUND_FRAC))
440 .collect();
441 let clamped = (!inputs.is_solution && clamped != x).then_some(clamped);
442 let hess_after_clamp = clamped
443 .as_ref()
444 .and_then(|c| hessian_nonfinite(&mut tnlp, c, prob.m));
445 match check_tnlp_with_quadratics(
446 &mut tnlp,
447 &prob.var_names,
448 &prob.con_names,
449 None,
450 source,
451 &quad,
452 &x0_args,
453 ) {
454 Ok(o) => start_point_findings(
455 &o,
456 &label,
457 inputs.is_solution,
458 hess_after_clamp,
459 &mut report,
460 ),
461 Err(e) => report.unavailable("start_point", e),
462 }
463 let clamped_pt = clamped
464 .map(|c| PointEval::evaluate(&mut tnlp, c, format!("{label} after the interior clamp")));
465 let mut pt = PointEval::evaluate(&mut tnlp, x, label);
466 pt.is_solution = inputs.is_solution;
467 domain::check_nonfinite(prob, &pt, clamped_pt.as_ref(), &mut report);
468 let sr = structure::check_structural(prob, &pt, &mut report);
469 structure::check_numerical(prob, &pt, sr, opts, &mut report);
470 scaling::check_jacobian(prob, &pt, opts, &mut report);
471
472 let mut others: Vec<PointEval> = Vec::new();
475 if point.is_some() {
476 let x0 = starting_point(&mut tnlp, prob.n)?;
477 others.push(PointEval::evaluate(
478 &mut tnlp,
479 x0,
480 "the model's starting point".into(),
481 ));
482 }
483 for (l, v) in &inputs.extra_points {
484 if v.len() != prob.n {
485 return Err(format!(
486 "{l} has {} values but the problem has {} variables",
487 v.len(),
488 prob.n
489 ));
490 }
491 others.push(PointEval::evaluate(&mut tnlp, v.clone(), l.clone()));
492 }
493 robustness::check_robustness(prob, &pt, &others, opts, &mut report);
494 let kkt = kkt::check_kkt(
495 prob,
496 &pt,
497 inputs.multipliers.as_deref(),
498 inputs
499 .bound_multipliers
500 .as_ref()
501 .map(|(l, u)| (l.as_slice(), u.as_slice())),
502 opts,
503 &mut report,
504 );
505 convexity::check_hessian(prob, &mut tnlp, &pt, kkt.as_ref(), opts, &mut report);
506 if !report.checks.iter().any(|c| c.name == "reduced_hessian") {
509 report.unavailable(
510 "reduced_hessian",
511 if kkt.is_none() {
512 "needs a solution with multipliers: pass the solve's .sol with --sol"
513 } else {
514 "the Hessian could not be evaluated here (see hessian_inertia)"
515 },
516 );
517 }
518 convexity::check_sampling(prob, &pt, opts, &mut report);
519 Ok(report)
520}
521
522fn starting_point(tnlp: &mut dyn TNLP, n: usize) -> Result<Vec<Number>, String> {
523 let m = tnlp
524 .get_nlp_info()
525 .map(|i| i.m.max(0) as usize)
526 .unwrap_or(0);
527 let mut x = vec![0.0; n];
528 let (mut zl, mut zu, mut lam) = (vec![0.0; n], vec![0.0; n], vec![0.0; m]);
529 if !tnlp.get_starting_point(StartingPoint {
530 init_x: true,
531 x: &mut x,
532 init_z: false,
533 z_l: &mut zl,
534 z_u: &mut zu,
535 init_lambda: false,
536 lambda: &mut lam,
537 }) {
538 return Err("get_starting_point failed".into());
539 }
540 Ok(x)
541}
542
543fn hessian_nonfinite(tnlp: &mut dyn TNLP, x: &[Number], m: usize) -> Option<usize> {
548 let nnz = tnlp.get_nlp_info()?.nnz_h_lag.max(0) as usize;
549 if nnz == 0 {
550 return Some(0);
551 }
552 let (mut hr, mut hc, mut hv) = (vec![0i32; nnz], vec![0i32; nnz], vec![0.0; nnz]);
553 let lambda = vec![0.0; m];
554 let ok = tnlp.eval_h(
555 None,
556 false,
557 1.0,
558 None,
559 false,
560 SparsityRequest::Structure {
561 irow: &mut hr,
562 jcol: &mut hc,
563 },
564 ) && tnlp.eval_h(
565 Some(x),
566 true,
567 1.0,
568 Some(&lambda),
569 true,
570 SparsityRequest::Values { values: &mut hv },
571 );
572 ok.then(|| hv.iter().filter(|v| !v.is_finite()).count())
573}
574
575fn start_point_findings(
580 o: &CheckX0Outcome,
581 label: &str,
582 is_solution: bool,
583 hess_after_clamp: Option<usize>,
584 report: &mut Report,
585) {
586 report
587 .sections
588 .insert("check_x0", crate::check_x0::report_value(o));
589 let mut sink = report.ran("start_point");
590 let rows =
591 |v: &[crate::verify::RowReport]| v.iter().map(|r| r.name.clone()).collect::<Vec<_>>();
592 if o.n_bound_violations > 0 {
593 sink.push(
594 Finding::new("start_point", "start_violates_variable_bounds", Severity::Warning,
595 Basis::Numerical, Confidence::Certain,
596 format!("{} variable(s) of {label} lie outside their bounds (worst by {:.3e}).", o.n_bound_violations, o.max_bound_violation))
597 .why("The solver projects the start into the box before iteration 0, so the point you supplied is not the point it starts from.")
598 .next("Check how the start was built (units, a stale warm start, an index shift).")
599 .vars(rows(&o.bound_violations))
600 .evidence(json!({"count": o.n_bound_violations, "max_violation": o.max_bound_violation})),
601 );
602 }
603 if o.x0_all_zero && o.n_vars > 0 && !is_solution {
604 sink.push(
605 Finding::new("start_point", "start_all_zero", Severity::Info, Basis::Numerical,
606 Confidence::Certain,
607 format!("Every component of {label} is zero."))
608 .why("Usually means no starting point was supplied; zero is often a poor or singular start for a nonlinear model.")
609 .next("Supply a starting point from the model's meaning, or check one with --x0-file."),
610 );
611 }
612 if o.n_clamp_moved > 0 && !is_solution {
613 sink.push(
614 Finding::new("start_point", "start_moved_by_bound_push", Severity::Info, Basis::Numerical,
615 Confidence::Certain,
616 format!("The interior clamp (bound_push) moves {} component(s) of {label}, by up to {:.3e}.", o.n_clamp_moved, o.max_clamp_move))
617 .why("Components on or too near a bound are pushed inside; a warm start loses exactly those components.")
618 .next("See sections.check_x0.interior_clamp for which components move.")
619 .evidence(json!({"count": o.n_clamp_moved, "max_move": o.max_clamp_move})),
620 );
621 }
622 if o.n_con_violations > 0 {
623 sink.push(
624 Finding::new("start_point", "start_constraint_violation",
625 if is_solution { Severity::Warning } else { Severity::Info }, Basis::Numerical,
626 Confidence::Certain,
627 format!("{} constraint(s) are violated at {label}; the largest violation is {:.3e}.", o.n_con_violations, o.max_con_violation))
628 .why(if is_solution {
629 "A solution that violates its constraints beyond the tolerance is not feasible; check the solve's status."
630 } else {
631 "Infeasible starts are normal for an interior-point method, but a very large violation usually means a wrong or missing starting point."
632 })
633 .next(if is_solution {
634 "Compare with `pounce verify`, and the worst rows in sections.check_x0."
635 } else {
636 "Compare the worst rows (sections.check_x0) with what the start was meant to satisfy."
637 })
638 .cons(rows(&o.con_violations))
639 .evidence(json!({"count": o.n_con_violations, "max_violation": o.max_con_violation})),
640 );
641 }
642 if let Some(h) = o.hess_nonfinite_count
643 && h > 0
644 {
645 let cleared = hess_after_clamp == Some(0);
648 sink.push(
649 Finding::new("start_point", "nonfinite_hessian_at_point",
650 if cleared { Severity::Warning } else { Severity::Error },
651 Basis::Numerical, Confidence::Certain,
652 format!("The Hessian of the Lagrangian has {h} non-finite entr{} at {label}.", if h == 1 { "y" } else { "ies" }))
653 .why(if cleared {
654 "The interior clamp moves this start, and the Hessian is finite at the clamped point, so a default solve does not see it; a solve that evaluates the Hessian here aborts with Invalid_Number_Detected."
655 } else {
656 "A solve that needs the Hessian here aborts with Invalid_Number_Detected."
657 })
658 .next("Look for second-derivative poles (sqrt, log, fractional powers near 0) in the nonlinear rows.")
659 .evidence(json!({"count": h, "count_after_interior_clamp": hess_after_clamp})),
660 );
661 }
662}
663
664#[derive(Debug, Clone)]
667pub struct PointEval {
668 pub label: String,
669 pub x: Vec<Number>,
670 pub f: Option<Number>,
671 pub grad: Vec<Number>,
672 pub grad_ok: bool,
673 pub g: Vec<Number>,
674 pub g_ok: bool,
675 pub jac_irow: Vec<usize>,
677 pub jac_jcol: Vec<usize>,
678 pub jac_val: Vec<Number>,
679 pub jac_structure_ok: bool,
680 pub jac_ok: bool,
681 pub is_solution: bool,
683}
684
685impl PointEval {
686 pub fn evaluate(tnlp: &mut dyn TNLP, x: Vec<Number>, label: String) -> Self {
687 let info = tnlp.get_nlp_info();
688 let (n, m, nnz, off) = match &info {
689 Some(i) => (
690 i.n.max(0) as usize,
691 i.m.max(0) as usize,
692 i.nnz_jac_g.max(0) as usize,
693 usize::from(matches!(
694 i.index_style,
695 pounce_nlp::tnlp::IndexStyle::Fortran
696 )),
697 ),
698 None => (x.len(), 0, 0, 0),
699 };
700 let f = tnlp.eval_f(&x, true);
701 let mut grad = vec![0.0; n];
702 let grad_ok = tnlp.eval_grad_f(&x, false, &mut grad);
703 let mut g = vec![0.0; m];
704 let g_ok = m == 0 || tnlp.eval_g(&x, false, &mut g);
705 let mut irow = vec![0i32; nnz];
706 let mut jcol = vec![0i32; nnz];
707 let mut jval = vec![0.0; nnz];
708 let jac_structure_ok = nnz == 0
709 || tnlp.eval_jac_g(
710 None,
711 false,
712 SparsityRequest::Structure {
713 irow: &mut irow,
714 jcol: &mut jcol,
715 },
716 );
717 let jac_ok = jac_structure_ok
718 && (nnz == 0
719 || tnlp.eval_jac_g(
720 Some(&x),
721 false,
722 SparsityRequest::Values { values: &mut jval },
723 ));
724 PointEval {
725 label,
726 x,
727 f,
728 grad,
729 grad_ok,
730 g,
731 g_ok,
732 jac_irow: irow
733 .iter()
734 .map(|&r| (r as usize).wrapping_sub(off))
735 .collect(),
736 jac_jcol: jcol
737 .iter()
738 .map(|&c| (c as usize).wrapping_sub(off))
739 .collect(),
740 jac_val: jval,
741 jac_structure_ok,
742 jac_ok,
743 is_solution: false,
744 }
745 }
746
747 #[cfg(test)]
749 pub(crate) fn evaluate_nl(prob: &NlProblem, x: Vec<Number>, label: String) -> Self {
750 let mut t = NlTnlp::try_new(prob.clone()).expect("evaluator");
751 Self::evaluate(&mut t, x, label)
752 }
753}
754
755pub(crate) fn vname(prob: &NlProblem, j: usize) -> String {
756 name_at(&prob.var_names, j, 'x')
757}
758
759pub(crate) fn cname(prob: &NlProblem, i: usize) -> String {
760 name_at(&prob.con_names, i, 'c')
761}
762
763pub(crate) fn lo_eff(b: Number) -> Number {
765 if lower_bound_present(b) {
766 b
767 } else {
768 Number::NEG_INFINITY
769 }
770}
771
772pub(crate) fn up_eff(b: Number) -> Number {
774 if upper_bound_present(b) {
775 b
776 } else {
777 Number::INFINITY
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784 use crate::nl_reader::{BinOp, Expr, NlProblemParts, UnaryOp};
785
786 #[test]
787 fn healthy_problem_has_no_errors_or_warnings() {
788 let sq = |j: usize, c: Number| {
790 Expr::Binary(
791 BinOp::Pow,
792 Box::new(Expr::Binary(
793 BinOp::Sub,
794 Box::new(Expr::Var(j)),
795 Box::new(Expr::Const(c)),
796 )),
797 Box::new(Expr::Const(2.0)),
798 )
799 };
800 let p = NlProblem::from_expressions(NlProblemParts {
801 minimize: true,
802 objective: Expr::Binary(BinOp::Add, Box::new(sq(0, 1.0)), Box::new(sq(1, 2.0))),
803 obj_constant: 0.0,
804 constraints: vec![
805 Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1))),
806 Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1))),
807 ],
808 x_l: vec![0.0, 0.0],
809 x_u: vec![10.0, 10.0],
810 x0: vec![1.5, 1.5],
811 g_l: vec![3.0, 1.0],
812 g_u: vec![3.0, 1e19],
813 var_names: vec!["a".into(), "b".into()],
814 con_names: vec!["sum".into(), "prod".into()],
815 })
816 .unwrap();
817 let r = diagnose_problem(
818 &p,
819 &Inputs::default(),
820 "t".into(),
821 &DiagnoseOptions::default(),
822 )
823 .unwrap();
824 let bad: Vec<_> = r
825 .findings
826 .iter()
827 .filter(|f| f.severity != Severity::Info)
828 .map(|f| (f.code, f.observation.clone()))
829 .collect();
830 assert!(bad.is_empty(), "{bad:?}");
831 assert_eq!(r.verdict(), "CLEAN");
832 let names: Vec<_> = r.checks.iter().map(|c| c.name).collect();
834 for c in [
835 "bounds",
836 "linear_rows",
837 "incidence",
838 "expression_domains",
839 "row_enclosures",
840 "coefficient_ranges",
841 "start_point",
842 "nonfinite_localization",
843 "structural_rank",
844 "numerical_rank",
845 "jacobian_scaling",
846 ] {
847 assert!(names.contains(&c), "missing check {c}: {names:?}");
848 }
849 let j = r.to_json();
850 assert_eq!(j["schema"], "pounce.diagnose/v1");
851 assert_eq!(j["verdict"], "CLEAN");
852 }
853
854 #[test]
855 fn nan_start_is_an_error_and_names_the_operator() {
856 let p = NlProblem::from_expressions(NlProblemParts {
857 minimize: true,
858 objective: Expr::Var(0),
859 obj_constant: 0.0,
860 constraints: vec![Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(0)))],
861 x_l: vec![-1e19],
862 x_u: vec![1e19],
863 x0: vec![-1.0],
864 g_l: vec![0.0],
865 g_u: vec![1e19],
866 var_names: vec![],
867 con_names: vec![],
868 })
869 .unwrap();
870 let r = diagnose_problem(
871 &p,
872 &Inputs::default(),
873 "t".into(),
874 &DiagnoseOptions::default(),
875 )
876 .unwrap();
877 assert!(r.has_errors());
878 assert!(
879 r.findings
880 .iter()
881 .any(|f| f.code == "nonfinite_from_domain_violation")
882 );
883 }
884
885 #[test]
886 fn nan_the_interior_clamp_removes_is_a_warning() {
887 let p = NlProblem::from_expressions(NlProblemParts {
890 minimize: true,
891 objective: Expr::Var(0),
892 obj_constant: 0.0,
893 constraints: vec![Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(0)))],
894 x_l: vec![0.0],
895 x_u: vec![10.0],
896 x0: vec![0.0],
897 g_l: vec![-5.0],
898 g_u: vec![1e19],
899 var_names: vec![],
900 con_names: vec![],
901 })
902 .unwrap();
903 let r = diagnose_problem(
904 &p,
905 &Inputs::default(),
906 "t".into(),
907 &DiagnoseOptions::default(),
908 )
909 .unwrap();
910 let f = r
911 .findings
912 .iter()
913 .find(|f| f.code == "nonfinite_from_pole")
914 .expect("localized");
915 assert_eq!(f.severity, Severity::Warning, "{}", f.why_it_matters);
916 let mut q = p.clone();
919 q.x_l[0] = -1e19;
920 let r = diagnose_problem(
921 &q,
922 &Inputs::default(),
923 "t".into(),
924 &DiagnoseOptions::default(),
925 )
926 .unwrap();
927 let f = r
928 .findings
929 .iter()
930 .find(|f| f.code == "nonfinite_from_pole")
931 .expect("localized");
932 assert_eq!(f.severity, Severity::Error);
933 }
934
935 #[test]
936 fn a_solution_point_is_not_clamped() {
937 let p = NlProblem::from_expressions(NlProblemParts {
940 minimize: true,
941 objective: Expr::Var(0),
942 obj_constant: 0.0,
943 constraints: vec![Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(0)))],
944 x_l: vec![0.0],
945 x_u: vec![10.0],
946 x0: vec![0.0],
947 g_l: vec![-5.0],
948 g_u: vec![1e19],
949 var_names: vec![],
950 con_names: vec![],
951 })
952 .unwrap();
953 let inputs = Inputs {
954 point: Some(("a solution".into(), vec![0.0])),
955 multipliers: Some(vec![0.0]),
956 is_solution: true,
957 ..Inputs::default()
958 };
959 let r = diagnose_problem(&p, &inputs, "t".into(), &DiagnoseOptions::default()).unwrap();
960 let f = r
961 .findings
962 .iter()
963 .find(|f| f.code == "nonfinite_from_pole")
964 .expect("localized");
965 assert_eq!(f.severity, Severity::Error);
966 assert!(
967 !r.findings
968 .iter()
969 .any(|f| f.code == "start_moved_by_bound_push")
970 );
971 }
972
973 #[test]
974 fn a_nonfinite_hessian_is_downgraded_only_if_finite_after_the_clamp() {
975 let mk = |shift: Number, x0: Vec<Number>| {
978 NlProblem::from_expressions(NlProblemParts {
979 minimize: true,
980 objective: Expr::Binary(
981 BinOp::Add,
982 Box::new(Expr::Var(0)),
983 Box::new(Expr::Unary(
984 UnaryOp::Log,
985 Box::new(Expr::Binary(
986 BinOp::Sub,
987 Box::new(Expr::Var(1)),
988 Box::new(Expr::Const(shift)),
989 )),
990 )),
991 ),
992 obj_constant: 0.0,
993 constraints: vec![],
994 x_l: vec![0.0, 0.0],
995 x_u: vec![10.0, 10.0],
996 x0,
997 g_l: vec![],
998 g_u: vec![],
999 var_names: vec![],
1000 con_names: vec![],
1001 })
1002 .unwrap()
1003 };
1004 let hess = |p: &NlProblem| {
1005 let r = diagnose_problem(
1006 p,
1007 &Inputs::default(),
1008 "t".into(),
1009 &DiagnoseOptions::default(),
1010 )
1011 .unwrap();
1012 r.findings
1013 .iter()
1014 .find(|f| f.code == "nonfinite_hessian_at_point")
1015 .map(|f| f.severity)
1016 };
1017 assert_eq!(hess(&mk(5.0, vec![0.0, 5.0])), Some(Severity::Error));
1020 assert_eq!(hess(&mk(0.0, vec![0.0, 0.0])), Some(Severity::Warning));
1023 }
1024
1025 #[test]
1026 fn argv_parsing() {
1027 let a = parse_argv(&[
1028 "m.nl".into(),
1029 "--json".into(),
1030 "--rank-tol".into(),
1031 "1e-10".into(),
1032 ])
1033 .unwrap()
1034 .unwrap();
1035 assert!(a.json);
1036 assert_eq!(a.opts.rank_rel_tol, Some(1e-10));
1037 assert!(parse_argv(&[]).is_err());
1038 let b = parse_argv(&["m.nl".into(), "--convexity-tol".into(), "1e-6".into()])
1039 .unwrap()
1040 .unwrap();
1041 assert_eq!(b.opts.convexity_tol, 1e-6);
1042 assert!(parse_argv(&["m.nl".into(), "--bogus".into()]).is_err());
1043 assert!(
1044 parse_argv(&[
1045 "m.nl".into(),
1046 "--sol".into(),
1047 "a".into(),
1048 "--x0-file".into(),
1049 "b".into()
1050 ])
1051 .is_err()
1052 );
1053 assert!(parse_argv(&["--help".into()]).unwrap().is_none());
1054 }
1055}