1use crate::nl_reader;
35use crate::verify::{RowReport, box_violation, name_at, sha256};
36use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
37use pounce_nlp::tnlp::{BoundsInfo, SparsityRequest, StartingPoint, TNLP};
38use std::path::PathBuf;
39use std::process::ExitCode;
40
41#[derive(Debug, Clone)]
43pub struct CheckX0Args {
44 pub nl: Option<PathBuf>,
46 pub builtin: Option<String>,
48 pub x0_file: Option<PathBuf>,
51 pub feas_tol: Number,
53 pub bound_push: Number,
55 pub bound_frac: Number,
57 pub max_list: usize,
59 pub json: bool,
61 pub json_output: Option<PathBuf>,
63}
64
65impl Default for CheckX0Args {
66 fn default() -> Self {
67 CheckX0Args {
68 nl: None,
69 builtin: None,
70 x0_file: None,
71 feas_tol: 1e-6,
72 bound_push: 1e-2,
73 bound_frac: 1e-2,
74 max_list: 5,
75 json: false,
76 json_output: None,
77 }
78 }
79}
80
81const USAGE: &str = "\
82Usage: pounce check-x0 <problem.nl> [OPTIONS]
83 pounce check-x0 --builtin <name> [OPTIONS]
84
85Evaluate the model once at its starting point, before any solve, and
86report what iteration 0 will see: NaN/inf evaluations (fatal), bound
87violations of x0, how far the bound_push interior clamp will move the
88point, initial constraint violation, and derivative scale spread.
89
90Arguments:
91 <problem.nl> AMPL .nl problem (x0 from its initial-guess
92 segment; zeros for variables without one)
93
94Options:
95 --builtin <name> check a built-in problem instead of a .nl file
96 --x0-file <path> override x0 with n whitespace-separated values
97 --feas-tol <t> constraint-violation report threshold (default 1e-6)
98 --bound-push <v> bound_push used for the clamp preview (default 1e-2)
99 --bound-frac <v> bound_frac used for the clamp preview (default 1e-2)
100 --max-list <k> max offenders listed per category (default 5)
101 --json print the JSON report to stdout
102 --json-output <path> write the JSON report to <path>
103 -h, --help print this message
104
105Exit code: 0 = model evaluates cleanly at x0 (warnings allowed),
10621 = NaN/inf at x0 (a solve would abort), 2 = usage/IO error.";
107
108pub fn run_from_argv(rest: &[String]) -> ExitCode {
110 let args = match parse_argv(rest) {
111 Ok(Some(a)) => a,
112 Ok(None) => {
113 println!("{USAGE}");
114 return ExitCode::SUCCESS;
115 }
116 Err(msg) => {
117 eprintln!("pounce check-x0: {msg}");
118 eprintln!("{USAGE}");
119 return ExitCode::from(2);
120 }
121 };
122 run(&args)
123}
124
125fn parse_argv(rest: &[String]) -> Result<Option<CheckX0Args>, String> {
126 let mut a = CheckX0Args::default();
127 let mut positionals: Vec<PathBuf> = Vec::new();
128 let mut it = rest.iter();
129 while let Some(arg) = it.next() {
130 match arg.as_str() {
131 "-h" | "--help" => return Ok(None),
132 "--builtin" => {
133 let v = it.next().ok_or("--builtin requires a value")?;
134 a.builtin = Some(v.clone());
135 }
136 "--x0-file" => {
137 let v = it.next().ok_or("--x0-file requires a value")?;
138 a.x0_file = Some(PathBuf::from(v));
139 }
140 "--feas-tol" => {
141 let v = it.next().ok_or("--feas-tol requires a value")?;
142 a.feas_tol = v.parse().map_err(|e| format!("--feas-tol: {e}"))?;
143 }
144 "--bound-push" => {
145 let v = it.next().ok_or("--bound-push requires a value")?;
146 a.bound_push = v.parse().map_err(|e| format!("--bound-push: {e}"))?;
147 }
148 "--bound-frac" => {
149 let v = it.next().ok_or("--bound-frac requires a value")?;
150 a.bound_frac = v.parse().map_err(|e| format!("--bound-frac: {e}"))?;
151 }
152 "--max-list" => {
153 let v = it.next().ok_or("--max-list requires a value")?;
154 a.max_list = v.parse().map_err(|e| format!("--max-list: {e}"))?;
155 }
156 "--json" => a.json = true,
157 "--json-output" => {
158 let v = it.next().ok_or("--json-output requires a value")?;
159 a.json_output = Some(PathBuf::from(v));
160 }
161 other if other.starts_with('-') => {
162 return Err(format!("unknown flag `{other}`"));
163 }
164 _ => positionals.push(PathBuf::from(arg)),
165 }
166 }
167 match (positionals.len(), &a.builtin) {
168 (0, Some(_)) => Ok(Some(a)),
169 (1, None) => {
170 a.nl = Some(positionals[0].clone());
171 Ok(Some(a))
172 }
173 (0, None) => Err("expected a <problem.nl> argument or --builtin <name>".to_string()),
174 _ => Err("expected exactly one of <problem.nl> or --builtin <name>".to_string()),
175 }
176}
177
178#[derive(Debug, Clone)]
180pub struct NonFinite {
181 pub index: usize,
182 pub name: String,
183 pub value: Number,
184}
185
186#[derive(Debug, Clone)]
188pub struct NonFiniteEntry {
189 pub row: usize,
190 pub col: usize,
191 pub row_name: String,
192 pub col_name: String,
193 pub value: Number,
194}
195
196#[derive(Debug, Clone)]
198pub struct ClampMove {
199 pub index: usize,
200 pub name: String,
201 pub from: Number,
202 pub to: Number,
203 pub distance: Number,
204}
205
206#[derive(Debug, Clone, Default)]
208pub struct ScaleSpread {
209 pub max_abs: Number,
210 pub min_abs_nonzero: Number,
211 pub ratio: Number,
213}
214
215#[derive(Debug)]
217pub struct CheckX0Outcome {
218 pub n_vars: usize,
219 pub n_cons: usize,
220 pub nl_sha256: Option<String>,
221 pub source: String,
222 pub x0_source: String,
223 pub x0_all_zero: bool,
224 pub objective: Option<Number>,
225 pub grad_nonfinite: Vec<NonFinite>,
227 pub grad_nonfinite_count: usize,
228 pub g_nonfinite: Vec<NonFinite>,
229 pub g_nonfinite_count: usize,
230 pub jac_nonfinite: Vec<NonFiniteEntry>,
231 pub jac_nonfinite_count: usize,
232 pub hess_nonfinite_count: Option<usize>,
234 pub bound_violations: Vec<RowReport>,
236 pub n_bound_violations: usize,
237 pub max_bound_violation: Number,
238 pub n_on_bounds: usize,
239 pub clamp_moves: Vec<ClampMove>,
241 pub n_clamp_moved: usize,
242 pub max_clamp_move: Number,
243 pub con_violations: Vec<RowReport>,
245 pub n_con_violations: usize,
246 pub max_con_violation: Number,
247 pub grad_spread: ScaleSpread,
249 pub jac_spread: ScaleSpread,
250 pub warnings: Vec<String>,
252 pub fatal: bool,
253 pub verdict: &'static str,
254}
255
256pub fn run(args: &CheckX0Args) -> ExitCode {
257 let outcome = match evaluate(args) {
258 Ok(o) => o,
259 Err(msg) => {
260 eprintln!("pounce check-x0: {msg}");
261 return ExitCode::from(2);
262 }
263 };
264
265 if args.json {
266 println!("{}", report_json(&outcome));
267 } else {
268 print_report(&outcome);
269 }
270 if let Some(path) = &args.json_output {
271 if let Err(e) = std::fs::write(path, report_json(&outcome).as_bytes()) {
272 eprintln!(
273 "pounce check-x0: failed to write report {}: {e}",
274 path.display()
275 );
276 return ExitCode::from(2);
277 }
278 if !args.json {
279 println!(" report: {}", path.display());
280 }
281 }
282
283 if outcome.fatal {
284 ExitCode::from(21)
285 } else {
286 ExitCode::SUCCESS
287 }
288}
289
290struct LoadedModel {
292 tnlp: std::rc::Rc<std::cell::RefCell<dyn TNLP>>,
293 var_names: Vec<String>,
294 con_names: Vec<String>,
295 nl_sha256: Option<String>,
296 source: String,
297}
298
299fn load_model(args: &CheckX0Args) -> Result<LoadedModel, String> {
300 if let Some(name) = &args.builtin {
301 let tnlp = crate::builtin::lookup(name)
302 .ok_or_else(|| format!("unknown builtin `{name}` (see `pounce --list-problems`)"))?;
303 return Ok(LoadedModel {
304 tnlp,
305 var_names: Vec::new(),
306 con_names: Vec::new(),
307 nl_sha256: None,
308 source: format!("builtin:{name}"),
309 });
310 }
311 let path = args
312 .nl
313 .as_ref()
314 .ok_or("expected a <problem.nl> argument or --builtin <name>")?;
315 let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
316 let sha = sha256::hex(&bytes);
317 let prob = nl_reader::read_nl_file(path)?;
318 let var_names = prob.var_names.clone();
319 let con_names = prob.con_names.clone();
320 let t = nl_reader::NlTnlp::try_new(prob)?;
321 Ok(LoadedModel {
322 tnlp: std::rc::Rc::new(std::cell::RefCell::new(t)),
323 var_names,
324 con_names,
325 nl_sha256: Some(sha),
326 source: path.display().to_string(),
327 })
328}
329
330fn evaluate(args: &CheckX0Args) -> Result<CheckX0Outcome, String> {
331 let model = load_model(args)?;
332 let mut tnlp = model.tnlp.borrow_mut();
333 check_tnlp(
334 &mut *tnlp,
335 &model.var_names,
336 &model.con_names,
337 model.nl_sha256.clone(),
338 model.source.clone(),
339 args,
340 )
341}
342
343pub fn check_tnlp(
346 tnlp: &mut dyn TNLP,
347 var_names: &[String],
348 con_names: &[String],
349 nl_sha256: Option<String>,
350 source: String,
351 args: &CheckX0Args,
352) -> Result<CheckX0Outcome, String> {
353 let info = tnlp.get_nlp_info().ok_or("get_nlp_info failed")?;
354 let n = info.n.max(0) as usize;
355 let m = info.m.max(0) as usize;
356 let nnz = info.nnz_jac_g.max(0) as usize;
357 let nnz_h = info.nnz_h_lag.max(0) as usize;
358 let fortran = matches!(info.index_style, pounce_nlp::tnlp::IndexStyle::Fortran);
359 let off = if fortran { 1usize } else { 0usize };
360
361 let mut x_l = vec![0.0; n];
363 let mut x_u = vec![0.0; n];
364 let mut g_l = vec![0.0; m];
365 let mut g_u = vec![0.0; m];
366 if !tnlp.get_bounds_info(BoundsInfo {
367 x_l: &mut x_l,
368 x_u: &mut x_u,
369 g_l: &mut g_l,
370 g_u: &mut g_u,
371 }) {
372 return Err("get_bounds_info failed".to_string());
373 }
374
375 let mut x = vec![0.0; n];
377 let (mut zl_buf, mut zu_buf, mut lam_buf) = (vec![0.0; n], vec![0.0; n], vec![0.0; m]);
378 let x0_source = if let Some(path) = &args.x0_file {
379 let text = std::fs::read_to_string(path)
380 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
381 let vals: Result<Vec<Number>, _> = text
382 .split_whitespace()
383 .map(|t| t.parse::<Number>())
384 .collect();
385 let vals = vals.map_err(|e| format!("{}: bad value: {e}", path.display()))?;
386 if vals.len() != n {
387 return Err(format!(
388 "{} has {} values but the problem has {n} variables",
389 path.display(),
390 vals.len()
391 ));
392 }
393 x.copy_from_slice(&vals);
394 format!("--x0-file {}", path.display())
395 } else {
396 if !tnlp.get_starting_point(StartingPoint {
397 init_x: true,
398 x: &mut x,
399 init_z: false,
400 z_l: &mut zl_buf,
401 z_u: &mut zu_buf,
402 init_lambda: false,
403 lambda: &mut lam_buf,
404 }) {
405 return Err("get_starting_point failed".to_string());
406 }
407 "model".to_string()
408 };
409 let x0_all_zero = n > 0 && x.iter().all(|v| *v == 0.0);
410
411 let objective = tnlp.eval_f(&x, true);
413 let obj_finite = objective.map(|v| v.is_finite()).unwrap_or(false);
414
415 let mut grad_f = vec![0.0; n];
416 let grad_ok = tnlp.eval_grad_f(&x, false, &mut grad_f);
417 let (grad_nonfinite, grad_nonfinite_count) =
418 scan_nonfinite(&grad_f, var_names, 'x', args.max_list, grad_ok);
419
420 let mut g = vec![0.0; m];
421 let g_ok = m == 0 || tnlp.eval_g(&x, false, &mut g);
422 let (g_nonfinite, g_nonfinite_count) = scan_nonfinite(&g, con_names, 'c', args.max_list, g_ok);
423
424 let mut irow = vec![0i32; nnz];
426 let mut jcol = vec![0i32; nnz];
427 let mut jval = vec![0.0; nnz];
428 let mut jac_ok = nnz == 0;
429 if nnz > 0 {
430 jac_ok = tnlp.eval_jac_g(
431 Some(&x),
432 false,
433 SparsityRequest::Structure {
434 irow: &mut irow,
435 jcol: &mut jcol,
436 },
437 ) && tnlp.eval_jac_g(
438 Some(&x),
439 false,
440 SparsityRequest::Values { values: &mut jval },
441 );
442 }
443 let mut jac_nonfinite = Vec::new();
444 let mut jac_nonfinite_count = 0usize;
445 if jac_ok {
446 for k in 0..nnz {
447 if !jval[k].is_finite() {
448 jac_nonfinite_count += 1;
449 if jac_nonfinite.len() < args.max_list {
450 let row = (irow[k] as usize).wrapping_sub(off);
451 let col = (jcol[k] as usize).wrapping_sub(off);
452 jac_nonfinite.push(NonFiniteEntry {
453 row,
454 col,
455 row_name: name_at(con_names, row, 'c'),
456 col_name: name_at(var_names, col, 'x'),
457 value: jval[k],
458 });
459 }
460 }
461 }
462 } else if nnz > 0 {
463 jac_nonfinite_count = usize::MAX; }
465
466 let hess_nonfinite_count = if nnz_h > 0 {
469 let mut hrow = vec![0i32; nnz_h];
470 let mut hcol = vec![0i32; nnz_h];
471 let mut hval = vec![0.0; nnz_h];
472 let lambda0 = vec![0.0; m];
473 let ok = tnlp.eval_h(
474 None,
475 false,
476 1.0,
477 None,
478 false,
479 SparsityRequest::Structure {
480 irow: &mut hrow,
481 jcol: &mut hcol,
482 },
483 ) && tnlp.eval_h(
484 Some(&x),
485 false,
486 1.0,
487 Some(&lambda0),
488 true,
489 SparsityRequest::Values { values: &mut hval },
490 );
491 if ok {
492 Some(hval.iter().filter(|v| !v.is_finite()).count())
493 } else {
494 None
495 }
496 } else {
497 None
498 };
499
500 let mut bound_violations: Vec<RowReport> = Vec::new();
502 let mut n_bound_violations = 0usize;
503 let mut max_bound_violation = 0.0_f64;
504 let mut n_on_bounds = 0usize;
505 for j in 0..n {
506 let viol = box_violation(x[j], x_l[j], x_u[j]);
507 if viol > args.feas_tol {
508 n_bound_violations += 1;
509 max_bound_violation = max_bound_violation.max(viol);
510 push_worst(
511 &mut bound_violations,
512 RowReport {
513 index: j,
514 name: name_at(var_names, j, 'x'),
515 value: x[j],
516 lo: x_l[j],
517 hi: x_u[j],
518 violation: viol,
519 },
520 args.max_list,
521 );
522 }
523 if x[j].is_finite() {
524 let at_lo =
525 lower_bound_present(x_l[j]) && (x[j] - x_l[j]).abs() <= 1e-8 * (1.0 + x_l[j].abs());
526 let at_hi =
527 upper_bound_present(x_u[j]) && (x_u[j] - x[j]).abs() <= 1e-8 * (1.0 + x_u[j].abs());
528 if at_lo || at_hi {
529 n_on_bounds += 1;
530 }
531 }
532 }
533
534 let mut clamp_moves: Vec<ClampMove> = Vec::new();
536 let mut n_clamp_moved = 0usize;
537 let mut max_clamp_move = 0.0_f64;
538 for j in 0..n {
539 if !x[j].is_finite() {
540 continue;
541 }
542 let to = clamp_to_interior(x[j], x_l[j], x_u[j], args.bound_push, args.bound_frac);
543 let d = (to - x[j]).abs();
544 if d > 0.0 {
545 n_clamp_moved += 1;
546 max_clamp_move = max_clamp_move.max(d);
547 if clamp_moves.len() < args.max_list
548 || clamp_moves.last().map(|w| d > w.distance).unwrap_or(false)
549 {
550 clamp_moves.push(ClampMove {
551 index: j,
552 name: name_at(var_names, j, 'x'),
553 from: x[j],
554 to,
555 distance: d,
556 });
557 clamp_moves.sort_by(|a, b| {
558 b.distance
559 .partial_cmp(&a.distance)
560 .unwrap_or(std::cmp::Ordering::Equal)
561 });
562 clamp_moves.truncate(args.max_list);
563 }
564 }
565 }
566
567 let mut con_violations: Vec<RowReport> = Vec::new();
569 let mut n_con_violations = 0usize;
570 let mut max_con_violation = 0.0_f64;
571 if g_ok {
572 for i in 0..m {
573 let viol = box_violation(g[i], g_l[i], g_u[i]);
574 if viol > args.feas_tol {
575 n_con_violations += 1;
576 if viol.is_finite() {
577 max_con_violation = max_con_violation.max(viol);
578 }
579 push_worst(
580 &mut con_violations,
581 RowReport {
582 index: i,
583 name: name_at(con_names, i, 'c'),
584 value: g[i],
585 lo: g_l[i],
586 hi: g_u[i],
587 violation: viol,
588 },
589 args.max_list,
590 );
591 }
592 }
593 }
594
595 let grad_spread = scale_spread(grad_f.iter().copied());
597 let jac_spread = scale_spread(jval.iter().copied());
598
599 let mut warnings = Vec::new();
601 let eval_failed = !grad_ok || !g_ok || (!jac_ok && nnz > 0) || objective.is_none();
602 let nonfinite_total = grad_nonfinite_count.min(usize::MAX - 1)
603 + g_nonfinite_count.min(usize::MAX - 1)
604 + if jac_nonfinite_count == usize::MAX {
605 0
606 } else {
607 jac_nonfinite_count
608 }
609 + hess_nonfinite_count.unwrap_or(0)
610 + usize::from(!obj_finite && objective.is_some());
611 let fatal = eval_failed || nonfinite_total > 0;
612 if eval_failed {
613 warnings.push(
614 "an evaluation callback failed outright at the starting point; \
615 the solver cannot start from this x0"
616 .to_string(),
617 );
618 }
619 if nonfinite_total > 0 {
620 warnings.push(format!(
621 "{nonfinite_total} non-finite value(s) at the starting point; a solve \
622 would abort with Invalid_Number_Detected. The interior clamp only \
623 repairs bound violations, not domain errors — move x0 into the \
624 domain or add bounds that keep it there"
625 ));
626 }
627 if x0_all_zero {
628 warnings.push(
629 "the starting point is all zeros: the model supplies no initial \
630 guess (or an explicitly zero one)"
631 .to_string(),
632 );
633 }
634 if n_bound_violations > 0 {
635 warnings.push(format!(
636 "x0 violates {n_bound_violations} variable bound(s) (max {max_bound_violation:.3e}); \
637 the initializer will clamp them inside"
638 ));
639 }
640 if n_on_bounds > 0 {
641 warnings.push(format!(
642 "{n_on_bounds} component(s) of x0 sit exactly on a bound and will be \
643 pushed into the interior (bound_push={:.1e}); if x0 is a previous \
644 solution, use the warm-start recipe (warm_start_init_point=yes with \
645 tightened warm_start_bound_push/_frac)",
646 args.bound_push
647 ));
648 }
649 if max_con_violation > 1e4 {
650 warnings.push(format!(
651 "very large initial infeasibility (max constraint violation \
652 {max_con_violation:.3e}); consider a better starting point or \
653 least_square_init_primal=yes"
654 ));
655 }
656 for (label, s) in [("gradient", &grad_spread), ("Jacobian", &jac_spread)] {
657 if s.ratio > 1e8 || s.max_abs > 1e8 {
658 warnings.push(format!(
659 "{label} magnitudes at x0 span a large range (max {:.3e}, min \
660 nonzero {:.3e}); see the scaling reference page",
661 s.max_abs, s.min_abs_nonzero
662 ));
663 }
664 }
665
666 let verdict = if fatal {
667 "FATAL"
668 } else if warnings.is_empty() {
669 "CLEAN"
670 } else {
671 "WARNINGS"
672 };
673
674 Ok(CheckX0Outcome {
675 n_vars: n,
676 n_cons: m,
677 nl_sha256,
678 source,
679 x0_source,
680 x0_all_zero,
681 objective,
682 grad_nonfinite,
683 grad_nonfinite_count,
684 g_nonfinite,
685 g_nonfinite_count,
686 jac_nonfinite,
687 jac_nonfinite_count: if jac_nonfinite_count == usize::MAX {
688 0
689 } else {
690 jac_nonfinite_count
691 },
692 hess_nonfinite_count,
693 bound_violations,
694 n_bound_violations,
695 max_bound_violation,
696 n_on_bounds,
697 clamp_moves,
698 n_clamp_moved,
699 max_clamp_move,
700 con_violations,
701 n_con_violations,
702 max_con_violation,
703 grad_spread,
704 jac_spread,
705 warnings,
706 fatal,
707 verdict,
708 })
709}
710
711pub fn clamp_to_interior(
716 x: Number,
717 lo: Number,
718 hi: Number,
719 bound_push: Number,
720 bound_frac: Number,
721) -> Number {
722 match (lower_bound_present(lo), upper_bound_present(hi)) {
723 (true, true) => {
724 let span = hi - lo;
725 let p_l = (bound_push * lo.abs().max(1.0)).min(bound_frac * span);
726 let p_u = (bound_push * hi.abs().max(1.0)).min(bound_frac * span);
727 x.max(lo + p_l).min(hi - p_u)
728 }
729 (true, false) => x.max(lo + bound_push * lo.abs().max(1.0)),
730 (false, true) => x.min(hi - bound_push * hi.abs().max(1.0)),
731 (false, false) => x,
732 }
733}
734
735fn scan_nonfinite(
736 values: &[Number],
737 names: &[String],
738 kind: char,
739 cap: usize,
740 eval_ok: bool,
741) -> (Vec<NonFinite>, usize) {
742 if !eval_ok {
743 return (Vec::new(), 0);
744 }
745 let mut out = Vec::new();
746 let mut count = 0usize;
747 for (i, v) in values.iter().enumerate() {
748 if !v.is_finite() {
749 count += 1;
750 if out.len() < cap {
751 out.push(NonFinite {
752 index: i,
753 name: name_at(names, i, kind),
754 value: *v,
755 });
756 }
757 }
758 }
759 (out, count)
760}
761
762fn push_worst(list: &mut Vec<RowReport>, r: RowReport, cap: usize) {
764 list.push(r);
765 list.sort_by(|a, b| {
766 b.violation
767 .partial_cmp(&a.violation)
768 .unwrap_or(std::cmp::Ordering::Equal)
769 });
770 list.truncate(cap);
771}
772
773fn scale_spread(values: impl Iterator<Item = Number>) -> ScaleSpread {
774 let mut max_abs = 0.0_f64;
775 let mut min_abs = Number::INFINITY;
776 for v in values {
777 let a = v.abs();
778 if a.is_finite() && a > 0.0 {
779 max_abs = max_abs.max(a);
780 min_abs = min_abs.min(a);
781 }
782 }
783 if max_abs == 0.0 {
784 ScaleSpread::default()
785 } else {
786 ScaleSpread {
787 max_abs,
788 min_abs_nonzero: min_abs,
789 ratio: max_abs / min_abs,
790 }
791 }
792}
793
794fn print_report(o: &CheckX0Outcome) {
799 println!("pounce check-x0 — starting-point preflight");
800 println!(
801 " problem : {} ({} vars, {} cons)",
802 o.source, o.n_vars, o.n_cons
803 );
804 if let Some(sha) = &o.nl_sha256 {
805 println!(" sha256:{sha}");
806 }
807 println!(
808 " x0 : {}{}",
809 o.x0_source,
810 if o.x0_all_zero { " (all zeros)" } else { "" }
811 );
812 println!();
813
814 println!(" evaluation at x0:");
815 match o.objective {
816 Some(v) if v.is_finite() => println!(" objective: {v:.10e}"),
817 Some(v) => println!(" objective: {v} <- NON-FINITE"),
818 None => println!(" objective: EVALUATION FAILED"),
819 }
820 print_nonfinite("gradient", o.grad_nonfinite_count, &o.grad_nonfinite);
821 print_nonfinite("constraints", o.g_nonfinite_count, &o.g_nonfinite);
822 if o.jac_nonfinite_count > 0 {
823 println!(
824 " Jacobian : {} non-finite entr{}",
825 o.jac_nonfinite_count,
826 if o.jac_nonfinite_count == 1 {
827 "y"
828 } else {
829 "ies"
830 }
831 );
832 for e in &o.jac_nonfinite {
833 println!(" d{}/d{} = {}", e.row_name, e.col_name, e.value);
834 }
835 } else {
836 println!(" Jacobian : finite");
837 }
838 match o.hess_nonfinite_count {
839 Some(0) => println!(" Hessian : finite (lambda=0)"),
840 Some(k) => println!(" Hessian : {k} non-finite entries (lambda=0)"),
841 None => println!(" Hessian : not checked (quasi-Newton or declined)"),
842 }
843 println!();
844
845 println!(" x0 vs bounds:");
846 println!(
847 " violations: {} on-bound components: {}",
848 o.n_bound_violations, o.n_on_bounds
849 );
850 for r in &o.bound_violations {
851 println!(
852 " {}: value {:.6e} outside [{:.6e}, {:.6e}] by {:.3e}",
853 r.name, r.value, r.lo, r.hi, r.violation
854 );
855 }
856 println!(
857 " interior clamp moves {} component(s), max move {:.3e}",
858 o.n_clamp_moved, o.max_clamp_move
859 );
860 for c in &o.clamp_moves {
861 println!(
862 " {}: {:.6e} -> {:.6e} (moved {:.3e})",
863 c.name, c.from, c.to, c.distance
864 );
865 }
866 println!();
867
868 println!(" initial constraint violation:");
869 println!(
870 " rows violated: {} max violation: {:.3e}",
871 o.n_con_violations, o.max_con_violation
872 );
873 for r in &o.con_violations {
874 println!(
875 " {}: g = {:.6e}, bounds [{:.6e}, {:.6e}], violation {:.3e}",
876 r.name, r.value, r.lo, r.hi, r.violation
877 );
878 }
879 println!();
880
881 println!(" derivative scale at x0:");
882 println!(
883 " gradient: max |.| {:.3e}, min nonzero |.| {:.3e}",
884 o.grad_spread.max_abs, o.grad_spread.min_abs_nonzero
885 );
886 println!(
887 " Jacobian: max |.| {:.3e}, min nonzero |.| {:.3e}",
888 o.jac_spread.max_abs, o.jac_spread.min_abs_nonzero
889 );
890 println!();
891
892 if !o.warnings.is_empty() {
893 println!(" warnings:");
894 for w in &o.warnings {
895 println!(" - {w}");
896 }
897 println!();
898 }
899 println!(" VERDICT: {}", o.verdict);
900}
901
902fn print_nonfinite(label: &str, count: usize, list: &[NonFinite]) {
903 if count > 0 {
904 println!(
905 " {label:<9}: {count} non-finite entr{}",
906 if count == 1 { "y" } else { "ies" }
907 );
908 for e in list {
909 println!(" {} = {}", e.name, e.value);
910 }
911 } else {
912 println!(" {label:<9}: finite");
913 }
914}
915
916fn report_json(o: &CheckX0Outcome) -> String {
917 use serde_json::json;
918 let row = |r: &RowReport| {
919 json!({
920 "index": r.index, "name": r.name, "value": r.value,
921 "lower": r.lo, "upper": r.hi, "violation": r.violation,
922 })
923 };
924 let nf =
925 |e: &NonFinite| json!({"index": e.index, "name": e.name, "value": e.value.to_string()});
926 let report = json!({
927 "pounce_check_x0_version": 1,
928 "schema": "pounce.check-x0/v1",
929 "solver": format!("pounce {}", env!("CARGO_PKG_VERSION")),
930 "problem": {
931 "source": o.source,
932 "sha256": o.nl_sha256,
933 "n_vars": o.n_vars,
934 "n_cons": o.n_cons,
935 },
936 "x0": { "source": o.x0_source, "all_zero": o.x0_all_zero },
937 "evaluation": {
938 "objective": o.objective.filter(|v| v.is_finite()),
939 "objective_finite": o.objective.map(|v| v.is_finite()).unwrap_or(false),
940 "grad_nonfinite_count": o.grad_nonfinite_count,
941 "grad_nonfinite": o.grad_nonfinite.iter().map(nf).collect::<Vec<_>>(),
942 "constraints_nonfinite_count": o.g_nonfinite_count,
943 "constraints_nonfinite": o.g_nonfinite.iter().map(nf).collect::<Vec<_>>(),
944 "jacobian_nonfinite_count": o.jac_nonfinite_count,
945 "jacobian_nonfinite": o.jac_nonfinite.iter().map(|e| json!({
946 "row": e.row, "col": e.col,
947 "row_name": e.row_name, "col_name": e.col_name,
948 "value": e.value.to_string(),
949 })).collect::<Vec<_>>(),
950 "hessian_nonfinite_count": o.hess_nonfinite_count,
951 },
952 "bounds": {
953 "n_violations": o.n_bound_violations,
954 "max_violation": o.max_bound_violation,
955 "n_on_bounds": o.n_on_bounds,
956 "worst": o.bound_violations.iter().map(row).collect::<Vec<_>>(),
957 },
958 "interior_clamp": {
959 "n_moved": o.n_clamp_moved,
960 "max_move": o.max_clamp_move,
961 "worst": o.clamp_moves.iter().map(|c| json!({
962 "index": c.index, "name": c.name,
963 "from": c.from, "to": c.to, "distance": c.distance,
964 })).collect::<Vec<_>>(),
965 },
966 "constraint_violation": {
967 "n_violated": o.n_con_violations,
968 "max_violation": o.max_con_violation,
969 "worst": o.con_violations.iter().map(row).collect::<Vec<_>>(),
970 },
971 "derivative_scale": {
972 "gradient": {
973 "max_abs": o.grad_spread.max_abs,
974 "min_abs_nonzero": o.grad_spread.min_abs_nonzero,
975 "ratio": o.grad_spread.ratio,
976 },
977 "jacobian": {
978 "max_abs": o.jac_spread.max_abs,
979 "min_abs_nonzero": o.jac_spread.min_abs_nonzero,
980 "ratio": o.jac_spread.ratio,
981 },
982 },
983 "warnings": o.warnings,
984 "fatal": o.fatal,
985 "verdict": o.verdict,
986 });
987 serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
988}
989
990#[cfg(test)]
991mod tests {
992 use super::*;
993 use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
994 use pounce_nlp::tnlp::{IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution};
995
996 struct DomainTrap {
999 x0: Vec<Number>,
1000 }
1001
1002 impl TNLP for DomainTrap {
1003 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1004 Some(NlpInfo {
1005 n: 2,
1006 m: 1,
1007 nnz_jac_g: 2,
1008 nnz_h_lag: 0,
1009 index_style: IndexStyle::C,
1010 })
1011 }
1012 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1013 b.x_l.copy_from_slice(&[0.0, NLP_LOWER_BOUND_INF]);
1014 b.x_u
1015 .copy_from_slice(&[NLP_UPPER_BOUND_INF, NLP_UPPER_BOUND_INF]);
1016 b.g_l[0] = 1.0;
1017 b.g_u[0] = 1.0;
1018 true
1019 }
1020 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1021 if sp.init_x {
1022 sp.x.copy_from_slice(&self.x0);
1023 }
1024 true
1025 }
1026 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
1027 Some(1.0 / x[0] + x[1])
1028 }
1029 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
1030 grad_f[0] = -1.0 / (x[0] * x[0]);
1031 grad_f[1] = 1.0;
1032 true
1033 }
1034 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1035 g[0] = x[0] + x[1];
1036 true
1037 }
1038 fn eval_jac_g(
1039 &mut self,
1040 _x: Option<&[Number]>,
1041 _new_x: bool,
1042 mode: SparsityRequest<'_>,
1043 ) -> bool {
1044 match mode {
1045 SparsityRequest::Structure { irow, jcol } => {
1046 irow.copy_from_slice(&[0, 0]);
1047 jcol.copy_from_slice(&[0, 1]);
1048 }
1049 SparsityRequest::Values { values } => {
1050 values.copy_from_slice(&[1.0, 1.0]);
1051 }
1052 }
1053 true
1054 }
1055 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _c: &IpoptCq) {}
1056 }
1057
1058 fn check(x0: Vec<Number>) -> CheckX0Outcome {
1059 let mut t = DomainTrap { x0 };
1060 check_tnlp(
1061 &mut t,
1062 &[],
1063 &[],
1064 None,
1065 "test".into(),
1066 &CheckX0Args::default(),
1067 )
1068 .expect("check")
1069 }
1070
1071 #[test]
1072 fn nan_at_x0_is_fatal() {
1073 let o = check(vec![0.0, 0.0]);
1075 assert!(o.fatal);
1076 assert_eq!(o.verdict, "FATAL");
1077 assert!(o.grad_nonfinite_count >= 1);
1078 assert!(o.x0_all_zero);
1079 }
1080
1081 #[test]
1082 fn clean_interior_point_passes() {
1083 let o = check(vec![0.5, 0.5]);
1084 assert!(!o.fatal);
1085 assert_eq!(o.n_bound_violations, 0);
1086 assert_eq!(o.n_con_violations, 0);
1088 assert_eq!(o.verdict, "CLEAN");
1089 assert!((o.objective.unwrap() - 2.5).abs() < 1e-12);
1090 }
1091
1092 #[test]
1093 fn on_bound_component_is_flagged_and_clamped() {
1094 let o = check(vec![1e-12, 1.0]);
1097 assert!(o.n_on_bounds >= 1);
1098 assert!(o.n_clamp_moved >= 1);
1099 assert!((o.max_clamp_move - 1e-2).abs() < 1e-9);
1100 assert!(
1101 o.warnings
1102 .iter()
1103 .any(|w| w.contains("warm_start_bound_push"))
1104 );
1105 }
1106
1107 #[test]
1108 fn bound_violation_reported() {
1109 let o = check(vec![-3.0, 1.0]);
1110 assert_eq!(o.n_bound_violations, 1);
1111 assert!((o.max_bound_violation - 3.0).abs() < 1e-12);
1112 assert!(o.n_clamp_moved >= 1);
1114 }
1115
1116 #[test]
1117 fn infeasible_start_is_not_fatal() {
1118 let o = check(vec![5.0, 5.0]);
1119 assert!(!o.fatal);
1120 assert_eq!(o.n_con_violations, 1);
1121 assert!((o.max_con_violation - 9.0).abs() < 1e-12);
1122 }
1123
1124 #[test]
1125 fn clamp_formula_matches_default_initializer() {
1126 assert!((clamp_to_interior(1.0, 1.0, 5.0, 1e-2, 1e-2) - 1.01).abs() < 1e-15);
1129 assert_eq!(clamp_to_interior(3.0, 1.0, 5.0, 1e-2, 1e-2), 3.0);
1131 assert_eq!(
1133 clamp_to_interior(-7.0, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, 1e-2, 1e-2),
1134 -7.0
1135 );
1136 assert!(
1138 (clamp_to_interior(100.0, NLP_LOWER_BOUND_INF, 100.0, 1e-2, 1e-2) - 99.0).abs() < 1e-12
1139 );
1140 }
1141
1142 #[test]
1143 fn scale_spread_ignores_zeros_and_nonfinite() {
1144 let s = scale_spread(vec![0.0, 1e-6, 1e3, Number::NAN].into_iter());
1145 assert!((s.max_abs - 1e3).abs() < 1e-9);
1146 assert!((s.min_abs_nonzero - 1e-6).abs() < 1e-18);
1147 assert!((s.ratio - 1e9).abs() / 1e9 < 1e-9);
1148 }
1149}