1use crate::nl_reader;
40use crate::verify::{RowReport, box_violation, name_at, sha256};
41use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
42use pounce_nlp::orig_ipopt_nlp::{gradient_obj_scale, gradient_row_scale, gradient_scaling_fires};
43use pounce_nlp::tnlp::{BoundsInfo, SparsityRequest, StartingPoint, TNLP};
44use std::path::PathBuf;
45use std::process::ExitCode;
46
47#[derive(Debug, Clone)]
49pub struct CheckX0Args {
50 pub nl: Option<PathBuf>,
52 pub builtin: Option<String>,
54 pub x0_file: Option<PathBuf>,
57 pub x0_values: Option<(String, Vec<Number>)>,
61 pub feas_tol: Number,
63 pub bound_push: Number,
65 pub bound_frac: Number,
67 pub max_list: usize,
69 pub scaling_max_gradient: Number,
71 pub json: bool,
73 pub json_output: Option<PathBuf>,
75}
76
77impl Default for CheckX0Args {
78 fn default() -> Self {
79 CheckX0Args {
80 nl: None,
81 builtin: None,
82 x0_file: None,
83 x0_values: None,
84 feas_tol: 1e-6,
85 bound_push: 1e-2,
86 bound_frac: 1e-2,
87 max_list: 5,
88 scaling_max_gradient: NLP_SCALING_MAX_GRADIENT,
89 json: false,
90 json_output: None,
91 }
92 }
93}
94
95const USAGE: &str = "\
96Usage: pounce check-x0 <problem.nl> [OPTIONS]
97 pounce check-x0 --builtin <name> [OPTIONS]
98
99Evaluate the model once at its starting point, before any solve, and
100report what iteration 0 will see: NaN/inf evaluations (fatal), bound
101violations of x0, how far the bound_push interior clamp will move the
102point, initial constraint violation, derivative scale spread, and the
103factors automatic (gradient-based) scaling will pick here.
104
105Arguments:
106 <problem.nl> AMPL .nl problem (x0 from its initial-guess
107 segment; zeros for variables without one)
108
109Options:
110 --builtin <name> check a built-in problem instead of a .nl file
111 --x0-file <path> override x0 with n whitespace-separated values
112 --feas-tol <t> constraint-violation report threshold (default 1e-6)
113 --bound-push <v> bound_push used for the clamp preview (default 1e-2)
114 --bound-frac <v> bound_frac used for the clamp preview (default 1e-2)
115 --max-list <k> max offenders listed per category (default 5)
116 --scaling-max-gradient <v>
117 nlp_scaling_max_gradient for the scaling
118 preview (default 100)
119 --json print the JSON report to stdout
120 --json-output <path> write the JSON report to <path>
121 -h, --help print this message
122
123Exit code: 0 = model evaluates cleanly at x0 (warnings allowed),
12421 = NaN/inf at x0 (a solve would abort), 2 = usage/IO error.";
125
126pub fn run_from_argv(rest: &[String]) -> ExitCode {
128 let args = match parse_argv(rest) {
129 Ok(Some(a)) => a,
130 Ok(None) => {
131 println!("{USAGE}");
132 return ExitCode::SUCCESS;
133 }
134 Err(msg) => {
135 eprintln!("pounce check-x0: {msg}");
136 eprintln!("{USAGE}");
137 return ExitCode::from(2);
138 }
139 };
140 run(&args)
141}
142
143fn parse_argv(rest: &[String]) -> Result<Option<CheckX0Args>, String> {
144 let mut a = CheckX0Args::default();
145 let mut positionals: Vec<PathBuf> = Vec::new();
146 let mut it = rest.iter();
147 while let Some(arg) = it.next() {
148 match arg.as_str() {
149 "-h" | "--help" => return Ok(None),
150 "--builtin" => {
151 let v = it.next().ok_or("--builtin requires a value")?;
152 a.builtin = Some(v.clone());
153 }
154 "--x0-file" => {
155 let v = it.next().ok_or("--x0-file requires a value")?;
156 a.x0_file = Some(PathBuf::from(v));
157 }
158 "--feas-tol" => {
159 let v = it.next().ok_or("--feas-tol requires a value")?;
160 a.feas_tol = v.parse().map_err(|e| format!("--feas-tol: {e}"))?;
161 }
162 "--bound-push" => {
163 let v = it.next().ok_or("--bound-push requires a value")?;
164 a.bound_push = v.parse().map_err(|e| format!("--bound-push: {e}"))?;
165 }
166 "--bound-frac" => {
167 let v = it.next().ok_or("--bound-frac requires a value")?;
168 a.bound_frac = v.parse().map_err(|e| format!("--bound-frac: {e}"))?;
169 }
170 "--max-list" => {
171 let v = it.next().ok_or("--max-list requires a value")?;
172 a.max_list = v.parse().map_err(|e| format!("--max-list: {e}"))?;
173 }
174 "--scaling-max-gradient" => {
175 let v = it.next().ok_or("--scaling-max-gradient requires a value")?;
176 a.scaling_max_gradient = v
177 .parse()
178 .map_err(|e| format!("--scaling-max-gradient: {e}"))?;
179 if a.scaling_max_gradient.is_nan() || a.scaling_max_gradient <= 0.0 {
180 return Err("--scaling-max-gradient must be positive".to_string());
181 }
182 }
183 "--json" => a.json = true,
184 "--json-output" => {
185 let v = it.next().ok_or("--json-output requires a value")?;
186 a.json_output = Some(PathBuf::from(v));
187 }
188 other if other.starts_with('-') => {
189 return Err(format!("unknown flag `{other}`"));
190 }
191 _ => positionals.push(PathBuf::from(arg)),
192 }
193 }
194 match (positionals.len(), &a.builtin) {
195 (0, Some(_)) => Ok(Some(a)),
196 (1, None) => {
197 a.nl = Some(positionals[0].clone());
198 Ok(Some(a))
199 }
200 (0, None) => Err("expected a <problem.nl> argument or --builtin <name>".to_string()),
201 _ => Err("expected exactly one of <problem.nl> or --builtin <name>".to_string()),
202 }
203}
204
205#[derive(Debug, Clone)]
207pub struct NonFinite {
208 pub index: usize,
209 pub name: String,
210 pub value: Number,
211}
212
213#[derive(Debug, Clone)]
215pub struct NonFiniteEntry {
216 pub row: usize,
217 pub col: usize,
218 pub row_name: String,
219 pub col_name: String,
220 pub value: Number,
221}
222
223#[derive(Debug, Clone)]
225pub struct ClampMove {
226 pub index: usize,
227 pub name: String,
228 pub from: Number,
229 pub to: Number,
230 pub distance: Number,
231}
232
233#[derive(Debug, Clone, Default)]
235pub struct ScaleSpread {
236 pub max_abs: Number,
237 pub min_abs_nonzero: Number,
238 pub ratio: Number,
240}
241
242pub const NLP_SCALING_MAX_GRADIENT: Number = 100.0;
246
247pub const NLP_SCALING_MIN_VALUE: Number = 1e-8;
249
250#[derive(Debug, Clone, Default)]
256pub struct RowScaleBlock {
257 pub n_rows: usize,
258 pub fires: bool,
260 pub n_scaled: usize,
262 pub min_scale: Number,
264 pub n_at_floor: usize,
266 pub n_zero_jac: usize,
270}
271
272#[derive(Debug, Clone, Default)]
294pub struct ScalingPreview {
295 pub max_gradient: Number,
297 pub max_grad_f: Number,
299 pub obj_scale: Number,
301 pub c: RowScaleBlock,
303 pub d: RowScaleBlock,
305 pub quad_rows: Vec<QuadRowScale>,
308 pub n_quad_rows: usize,
310 pub n_quad_unscaled: usize,
312 pub n_quad_zero_jac: usize,
314 pub max_quad_mismatch: Number,
317}
318
319#[derive(Debug, Clone)]
328pub struct QuadRowScale {
329 pub index: usize,
330 pub name: String,
331 pub curvature: Number,
333 pub linear: Number,
336 pub rhs: Number,
339 pub jac_at_x0: Number,
341 pub scale: Number,
343 pub mismatch: Number,
348}
349
350pub use pounce_nl::nl_scaling::{QuadRowCoef, quad_row_coefs};
355
356fn scaling_preview(
363 jac_row_max: &[Number],
364 g_l: &[Number],
365 g_u: &[Number],
366 max_grad_f: Number,
367 quad_coefs: &[QuadRowCoef],
368 con_names: &[String],
369 args: &CheckX0Args,
370) -> ScalingPreview {
371 let max_gradient = args.scaling_max_gradient;
372 let max_list = args.max_list;
373 let m = jac_row_max.len();
374 let is_equality =
375 |i: usize| lower_bound_present(g_l[i]) && upper_bound_present(g_u[i]) && g_l[i] == g_u[i];
376 let c_rows: Vec<Number> = (0..m)
377 .filter(|&i| is_equality(i))
378 .map(|i| jac_row_max[i])
379 .collect();
380 let d_rows: Vec<Number> = (0..m)
381 .filter(|&i| !is_equality(i))
382 .map(|i| jac_row_max[i])
383 .collect();
384
385 let block = |rows: &[Number]| -> RowScaleBlock {
386 let fires = gradient_scaling_fires(rows, max_gradient, 0.0);
387 let mut b = RowScaleBlock {
388 n_rows: rows.len(),
389 fires,
390 min_scale: 1.0,
391 ..Default::default()
392 };
393 for &r in rows {
394 if r <= 0.0 || r == Number::MIN_POSITIVE {
395 b.n_zero_jac += 1;
396 }
397 if !fires {
398 continue;
399 }
400 let s = gradient_row_scale(r, max_gradient, NLP_SCALING_MIN_VALUE, 0.0);
401 if s < 1.0 {
402 b.n_scaled += 1;
403 }
404 if s <= NLP_SCALING_MIN_VALUE {
405 b.n_at_floor += 1;
406 }
407 b.min_scale = b.min_scale.min(s);
408 }
409 b
410 };
411 let c = block(&c_rows);
412 let d = block(&d_rows);
413
414 let mut quad_rows: Vec<QuadRowScale> = quad_coefs
416 .iter()
417 .map(|q| {
418 let i = q.index;
419 let fires = if is_equality(i) { c.fires } else { d.fires };
420 let scale = if fires {
421 gradient_row_scale(jac_row_max[i], max_gradient, NLP_SCALING_MIN_VALUE, 0.0)
422 } else {
423 1.0
424 };
425 let raw = jac_row_max[i];
426 QuadRowScale {
427 index: i,
428 name: name_at(con_names, i, 'c'),
429 curvature: q.curvature,
430 linear: q.linear,
431 rhs: q.rhs,
432 jac_at_x0: if raw == Number::MIN_POSITIVE {
433 0.0
434 } else {
435 raw
436 },
437 scale,
438 mismatch: if q.curvature > 0.0 {
439 q.rhs / q.curvature
440 } else {
441 0.0
442 },
443 }
444 })
445 .collect();
446
447 let n_quad_rows = quad_rows.len();
448 let n_quad_unscaled = quad_rows.iter().filter(|q| q.scale >= 1.0).count();
449 let n_quad_zero_jac = quad_rows.iter().filter(|q| q.jac_at_x0 == 0.0).count();
450 let max_quad_mismatch = quad_rows.iter().fold(0.0_f64, |m, q| m.max(q.mismatch));
451
452 quad_rows.sort_by(|a, b| {
453 b.mismatch
454 .partial_cmp(&a.mismatch)
455 .unwrap_or(std::cmp::Ordering::Equal)
456 .then(a.index.cmp(&b.index))
457 });
458 quad_rows.truncate(max_list);
459
460 ScalingPreview {
461 max_gradient,
462 max_grad_f,
463 obj_scale: gradient_obj_scale(max_grad_f, max_gradient, NLP_SCALING_MIN_VALUE, 0.0),
464 c,
465 d,
466 quad_rows,
467 n_quad_rows,
468 n_quad_unscaled,
469 n_quad_zero_jac,
470 max_quad_mismatch,
471 }
472}
473
474#[derive(Debug)]
476pub struct CheckX0Outcome {
477 pub n_vars: usize,
478 pub n_cons: usize,
479 pub nl_sha256: Option<String>,
480 pub source: String,
481 pub x0_source: String,
482 pub x0_all_zero: bool,
483 pub objective: Option<Number>,
484 pub grad_nonfinite: Vec<NonFinite>,
486 pub grad_nonfinite_count: usize,
487 pub g_nonfinite: Vec<NonFinite>,
488 pub g_nonfinite_count: usize,
489 pub jac_nonfinite: Vec<NonFiniteEntry>,
490 pub jac_nonfinite_count: usize,
491 pub hess_nonfinite_count: Option<usize>,
493 pub bound_violations: Vec<RowReport>,
495 pub n_bound_violations: usize,
496 pub max_bound_violation: Number,
497 pub n_on_bounds: usize,
498 pub clamp_moves: Vec<ClampMove>,
500 pub n_clamp_moved: usize,
501 pub max_clamp_move: Number,
502 pub con_violations: Vec<RowReport>,
504 pub n_con_violations: usize,
505 pub max_con_violation: Number,
506 pub grad_spread: ScaleSpread,
508 pub jac_spread: ScaleSpread,
509 pub scaling: ScalingPreview,
511 pub warnings: Vec<String>,
513 pub fatal: bool,
514 pub verdict: &'static str,
515}
516
517pub fn run(args: &CheckX0Args) -> ExitCode {
518 let outcome = match evaluate(args) {
519 Ok(o) => o,
520 Err(msg) => {
521 eprintln!("pounce check-x0: {msg}");
522 return ExitCode::from(2);
523 }
524 };
525
526 if args.json {
527 println!("{}", report_json(&outcome));
528 } else {
529 print_report(&outcome);
530 }
531 if let Some(path) = &args.json_output {
532 if let Err(e) = std::fs::write(path, report_json(&outcome).as_bytes()) {
533 eprintln!(
534 "pounce check-x0: failed to write report {}: {e}",
535 path.display()
536 );
537 return ExitCode::from(2);
538 }
539 if !args.json {
540 println!(" report: {}", path.display());
541 }
542 }
543
544 if outcome.fatal {
545 ExitCode::from(21)
546 } else {
547 ExitCode::SUCCESS
548 }
549}
550
551struct LoadedModel {
553 tnlp: std::rc::Rc<std::cell::RefCell<dyn TNLP>>,
554 var_names: Vec<String>,
555 con_names: Vec<String>,
556 nl_sha256: Option<String>,
557 source: String,
558 quad_coefs: Vec<QuadRowCoef>,
562}
563
564fn load_model(args: &CheckX0Args) -> Result<LoadedModel, String> {
565 if let Some(name) = &args.builtin {
566 let tnlp = crate::builtin::lookup(name)
567 .ok_or_else(|| format!("unknown builtin `{name}` (see `pounce --list-problems`)"))?;
568 return Ok(LoadedModel {
569 tnlp,
570 var_names: Vec::new(),
571 con_names: Vec::new(),
572 nl_sha256: None,
573 source: format!("builtin:{name}"),
574 quad_coefs: Vec::new(),
575 });
576 }
577 let path = args
578 .nl
579 .as_ref()
580 .ok_or("expected a <problem.nl> argument or --builtin <name>")?;
581 let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
582 let sha = sha256::hex(&bytes);
583 let prob = nl_reader::read_nl_file(path)?;
584 if !prob.complementarity.is_empty() {
585 return Err(format!(
588 "{} declares {} complementarity pair(s), which check-x0 does not model",
589 path.display(),
590 prob.complementarity.len()
591 )
592 .into());
593 }
594 let var_names = prob.var_names.clone();
595 let con_names = prob.con_names.clone();
596 let quad_coefs = quad_row_coefs(&prob);
599 let t = nl_reader::NlTnlp::try_new(prob)?;
600 Ok(LoadedModel {
601 tnlp: std::rc::Rc::new(std::cell::RefCell::new(t)),
602 var_names,
603 con_names,
604 nl_sha256: Some(sha),
605 source: path.display().to_string(),
606 quad_coefs,
607 })
608}
609
610fn evaluate(args: &CheckX0Args) -> Result<CheckX0Outcome, String> {
611 let model = load_model(args)?;
612 let mut tnlp = model.tnlp.borrow_mut();
613 check_tnlp_with_quadratics(
614 &mut *tnlp,
615 &model.var_names,
616 &model.con_names,
617 model.nl_sha256.clone(),
618 model.source.clone(),
619 &model.quad_coefs,
620 args,
621 )
622}
623
624pub fn check_tnlp(
632 tnlp: &mut dyn TNLP,
633 var_names: &[String],
634 con_names: &[String],
635 nl_sha256: Option<String>,
636 source: String,
637 args: &CheckX0Args,
638) -> Result<CheckX0Outcome, String> {
639 check_tnlp_with_quadratics(tnlp, var_names, con_names, nl_sha256, source, &[], args)
640}
641
642#[allow(clippy::too_many_arguments)]
646pub fn check_tnlp_with_quadratics(
647 tnlp: &mut dyn TNLP,
648 var_names: &[String],
649 con_names: &[String],
650 nl_sha256: Option<String>,
651 source: String,
652 quad_coefs: &[QuadRowCoef],
653 args: &CheckX0Args,
654) -> Result<CheckX0Outcome, String> {
655 let info = tnlp.get_nlp_info().ok_or("get_nlp_info failed")?;
656 let n = info.n.max(0) as usize;
657 let m = info.m.max(0) as usize;
658 let nnz = info.nnz_jac_g.max(0) as usize;
659 let nnz_h = info.nnz_h_lag.max(0) as usize;
660 let fortran = matches!(info.index_style, pounce_nlp::tnlp::IndexStyle::Fortran);
661 let off = if fortran { 1usize } else { 0usize };
662
663 let mut x_l = vec![0.0; n];
665 let mut x_u = vec![0.0; n];
666 let mut g_l = vec![0.0; m];
667 let mut g_u = vec![0.0; m];
668 if !tnlp.get_bounds_info(BoundsInfo {
669 x_l: &mut x_l,
670 x_u: &mut x_u,
671 g_l: &mut g_l,
672 g_u: &mut g_u,
673 }) {
674 return Err("get_bounds_info failed".to_string());
675 }
676
677 let mut x = vec![0.0; n];
679 let (mut zl_buf, mut zu_buf, mut lam_buf) = (vec![0.0; n], vec![0.0; n], vec![0.0; m]);
680 let x0_source = if let Some((label, vals)) = &args.x0_values {
681 if vals.len() != n {
682 return Err(format!(
683 "{label} has {} values but the problem has {n} variables",
684 vals.len()
685 ));
686 }
687 x.copy_from_slice(vals);
688 label.clone()
689 } else if let Some(path) = &args.x0_file {
690 let text = std::fs::read_to_string(path)
691 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
692 let vals: Result<Vec<Number>, _> = text
693 .split_whitespace()
694 .map(|t| t.parse::<Number>())
695 .collect();
696 let vals = vals.map_err(|e| format!("{}: bad value: {e}", path.display()))?;
697 if vals.len() != n {
698 return Err(format!(
699 "{} has {} values but the problem has {n} variables",
700 path.display(),
701 vals.len()
702 ));
703 }
704 x.copy_from_slice(&vals);
705 format!("--x0-file {}", path.display())
706 } else {
707 if !tnlp.get_starting_point(StartingPoint {
708 init_x: true,
709 x: &mut x,
710 init_z: false,
711 z_l: &mut zl_buf,
712 z_u: &mut zu_buf,
713 init_lambda: false,
714 lambda: &mut lam_buf,
715 }) {
716 return Err("get_starting_point failed".to_string());
717 }
718 "model".to_string()
719 };
720 let x0_all_zero = n > 0 && x.iter().all(|v| *v == 0.0);
721
722 let objective = tnlp.eval_f(&x, true);
724 let obj_finite = objective.map(|v| v.is_finite()).unwrap_or(false);
725
726 let mut grad_f = vec![0.0; n];
727 let grad_ok = tnlp.eval_grad_f(&x, false, &mut grad_f);
728 let (grad_nonfinite, grad_nonfinite_count) =
729 scan_nonfinite(&grad_f, var_names, 'x', args.max_list, grad_ok);
730
731 let mut g = vec![0.0; m];
732 let g_ok = m == 0 || tnlp.eval_g(&x, false, &mut g);
733 let (g_nonfinite, g_nonfinite_count) = scan_nonfinite(&g, con_names, 'c', args.max_list, g_ok);
734
735 let mut irow = vec![0i32; nnz];
737 let mut jcol = vec![0i32; nnz];
738 let mut jval = vec![0.0; nnz];
739 let mut jac_ok = nnz == 0;
740 if nnz > 0 {
741 jac_ok = tnlp.eval_jac_g(
742 Some(&x),
743 false,
744 SparsityRequest::Structure {
745 irow: &mut irow,
746 jcol: &mut jcol,
747 },
748 ) && tnlp.eval_jac_g(
749 Some(&x),
750 false,
751 SparsityRequest::Values { values: &mut jval },
752 );
753 }
754 let mut jac_nonfinite = Vec::new();
755 let mut jac_nonfinite_count = 0usize;
756 if jac_ok {
757 for k in 0..nnz {
758 if !jval[k].is_finite() {
759 jac_nonfinite_count += 1;
760 if jac_nonfinite.len() < args.max_list {
761 let row = (irow[k] as usize).wrapping_sub(off);
762 let col = (jcol[k] as usize).wrapping_sub(off);
763 jac_nonfinite.push(NonFiniteEntry {
764 row,
765 col,
766 row_name: name_at(con_names, row, 'c'),
767 col_name: name_at(var_names, col, 'x'),
768 value: jval[k],
769 });
770 }
771 }
772 }
773 } else if nnz > 0 {
774 jac_nonfinite_count = usize::MAX; }
776
777 let hess_nonfinite_count = if nnz_h > 0 {
780 let mut hrow = vec![0i32; nnz_h];
781 let mut hcol = vec![0i32; nnz_h];
782 let mut hval = vec![0.0; nnz_h];
783 let lambda0 = vec![0.0; m];
784 let ok = tnlp.eval_h(
785 None,
786 false,
787 1.0,
788 None,
789 false,
790 SparsityRequest::Structure {
791 irow: &mut hrow,
792 jcol: &mut hcol,
793 },
794 ) && tnlp.eval_h(
795 Some(&x),
796 false,
797 1.0,
798 Some(&lambda0),
799 true,
800 SparsityRequest::Values { values: &mut hval },
801 );
802 if ok {
803 Some(hval.iter().filter(|v| !v.is_finite()).count())
804 } else {
805 None
806 }
807 } else {
808 None
809 };
810
811 let mut bound_violations: Vec<RowReport> = Vec::new();
813 let mut n_bound_violations = 0usize;
814 let mut max_bound_violation = 0.0_f64;
815 let mut n_on_bounds = 0usize;
816 for j in 0..n {
817 let viol = box_violation(x[j], x_l[j], x_u[j]);
818 if viol > args.feas_tol {
819 n_bound_violations += 1;
820 max_bound_violation = max_bound_violation.max(viol);
821 push_worst(
822 &mut bound_violations,
823 RowReport {
824 index: j,
825 name: name_at(var_names, j, 'x'),
826 value: x[j],
827 lo: x_l[j],
828 hi: x_u[j],
829 violation: viol,
830 },
831 args.max_list,
832 );
833 }
834 if x[j].is_finite() {
835 let at_lo =
836 lower_bound_present(x_l[j]) && (x[j] - x_l[j]).abs() <= 1e-8 * (1.0 + x_l[j].abs());
837 let at_hi =
838 upper_bound_present(x_u[j]) && (x_u[j] - x[j]).abs() <= 1e-8 * (1.0 + x_u[j].abs());
839 if at_lo || at_hi {
840 n_on_bounds += 1;
841 }
842 }
843 }
844
845 let mut clamp_moves: Vec<ClampMove> = Vec::new();
847 let mut n_clamp_moved = 0usize;
848 let mut max_clamp_move = 0.0_f64;
849 for j in 0..n {
850 if !x[j].is_finite() {
851 continue;
852 }
853 let to = clamp_to_interior(x[j], x_l[j], x_u[j], args.bound_push, args.bound_frac);
854 let d = (to - x[j]).abs();
855 if d > 0.0 {
856 n_clamp_moved += 1;
857 max_clamp_move = max_clamp_move.max(d);
858 if clamp_moves.len() < args.max_list
859 || clamp_moves.last().map(|w| d > w.distance).unwrap_or(false)
860 {
861 clamp_moves.push(ClampMove {
862 index: j,
863 name: name_at(var_names, j, 'x'),
864 from: x[j],
865 to,
866 distance: d,
867 });
868 clamp_moves.sort_by(|a, b| {
869 b.distance
870 .partial_cmp(&a.distance)
871 .unwrap_or(std::cmp::Ordering::Equal)
872 });
873 clamp_moves.truncate(args.max_list);
874 }
875 }
876 }
877
878 let mut con_violations: Vec<RowReport> = Vec::new();
880 let mut n_con_violations = 0usize;
881 let mut max_con_violation = 0.0_f64;
882 if g_ok {
883 for i in 0..m {
884 let viol = box_violation(g[i], g_l[i], g_u[i]);
885 if viol > args.feas_tol {
886 n_con_violations += 1;
887 if viol.is_finite() {
888 max_con_violation = max_con_violation.max(viol);
889 }
890 push_worst(
891 &mut con_violations,
892 RowReport {
893 index: i,
894 name: name_at(con_names, i, 'c'),
895 value: g[i],
896 lo: g_l[i],
897 hi: g_u[i],
898 violation: viol,
899 },
900 args.max_list,
901 );
902 }
903 }
904 }
905
906 let grad_spread = scale_spread(grad_f.iter().copied());
908 let jac_spread = scale_spread(jval.iter().copied());
909
910 let mut jac_row_max = vec![Number::MIN_POSITIVE; m];
915 let fixed: Vec<bool> = (0..n)
924 .map(|j| lower_bound_present(x_l[j]) && upper_bound_present(x_u[j]) && x_l[j] == x_u[j])
925 .collect();
926 let mut lifted = x.clone();
927 let mut moved = false;
928 for j in 0..n {
929 if fixed[j] && lifted[j] != x_l[j] {
930 lifted[j] = x_l[j];
931 moved = true;
932 }
933 }
934 let (scale_grad, scale_jval) = if moved {
935 let mut gf = vec![0.0; n];
936 let mut jv = vec![0.0; nnz];
937 let gok = tnlp.eval_grad_f(&lifted, true, &mut gf);
938 let jok = nnz == 0
939 || tnlp.eval_jac_g(
940 Some(&lifted),
941 true,
942 SparsityRequest::Values { values: &mut jv },
943 );
944 (
945 if gok { gf } else { grad_f.clone() },
946 if jok { jv } else { jval.clone() },
947 )
948 } else {
949 (grad_f.clone(), jval.clone())
950 };
951 let max_grad_f = if grad_ok {
952 (0..n)
953 .filter(|&j| !fixed[j])
954 .fold(0.0_f64, |acc, j| acc.max(scale_grad[j].abs()))
955 } else {
956 0.0
957 };
958 if jac_ok {
959 for k in 0..nnz {
960 let row = (irow[k] as usize).wrapping_sub(off);
961 if row < m {
962 let v = scale_jval[k].abs();
963 if v > jac_row_max[row] {
964 jac_row_max[row] = v;
965 }
966 }
967 }
968 }
969 let scaling = scaling_preview(
970 &jac_row_max,
971 &g_l,
972 &g_u,
973 max_grad_f,
974 quad_coefs,
975 con_names,
976 args,
977 );
978
979 let mut warnings = Vec::new();
981 let eval_failed = !grad_ok || !g_ok || (!jac_ok && nnz > 0) || objective.is_none();
982 let nonfinite_total = grad_nonfinite_count.min(usize::MAX - 1)
983 + g_nonfinite_count.min(usize::MAX - 1)
984 + if jac_nonfinite_count == usize::MAX {
985 0
986 } else {
987 jac_nonfinite_count
988 }
989 + hess_nonfinite_count.unwrap_or(0)
990 + usize::from(!obj_finite && objective.is_some());
991 let fatal = eval_failed || nonfinite_total > 0;
992 if eval_failed {
993 warnings.push(
994 "an evaluation callback failed outright at the starting point; \
995 the solver cannot start from this x0"
996 .to_string(),
997 );
998 }
999 if nonfinite_total > 0 {
1000 warnings.push(format!(
1001 "{nonfinite_total} non-finite value(s) at the starting point; a solve \
1002 would abort with Invalid_Number_Detected. The interior clamp only \
1003 repairs bound violations, not domain errors — move x0 into the \
1004 domain or add bounds that keep it there"
1005 ));
1006 }
1007 if x0_all_zero {
1008 warnings.push(
1009 "the starting point is all zeros: the model supplies no initial \
1010 guess (or an explicitly zero one)"
1011 .to_string(),
1012 );
1013 }
1014 if n_bound_violations > 0 {
1015 warnings.push(format!(
1016 "x0 violates {n_bound_violations} variable bound(s) (max {max_bound_violation:.3e}); \
1017 the initializer will clamp them inside"
1018 ));
1019 }
1020 if n_on_bounds > 0 {
1021 warnings.push(format!(
1022 "{n_on_bounds} component(s) of x0 sit exactly on a bound and will be \
1023 pushed into the interior (bound_push={:.1e}); if x0 is a previous \
1024 solution, use the warm-start recipe (warm_start_init_point=yes with \
1025 tightened warm_start_bound_push/_frac)",
1026 args.bound_push
1027 ));
1028 }
1029 if max_con_violation > 1e4 {
1030 warnings.push(format!(
1031 "very large initial infeasibility (max constraint violation \
1032 {max_con_violation:.3e}); consider a better starting point or \
1033 least_square_init_primal=yes"
1034 ));
1035 }
1036 if scaling.n_quad_zero_jac > 0 && scaling.max_quad_mismatch > 1e2 {
1041 warnings.push(format!(
1042 "{} quadratic row(s) have an identically-zero Jacobian at x0, so \
1043 gradient-based scaling leaves them at factor 1.0 — and their \
1044 right-hand sides run up to {:.3e}x their curvature ‖Q‖_∞. The \
1045 automatic scaler samples derivatives at x0 and cannot see this; \
1046 set per-row factors with nlp_scaling_method=user-scaling, or \
1047 rewrite the rows about a point where their gradient is nonzero",
1048 scaling.n_quad_zero_jac, scaling.max_quad_mismatch
1049 ));
1050 }
1051 for (label, s) in [("gradient", &grad_spread), ("Jacobian", &jac_spread)] {
1052 if s.ratio > 1e8 || s.max_abs > 1e8 {
1053 warnings.push(format!(
1054 "{label} magnitudes at x0 span a large range (max {:.3e}, min \
1055 nonzero {:.3e}); see the scaling reference page",
1056 s.max_abs, s.min_abs_nonzero
1057 ));
1058 }
1059 }
1060
1061 let verdict = if fatal {
1062 "FATAL"
1063 } else if warnings.is_empty() {
1064 "CLEAN"
1065 } else {
1066 "WARNINGS"
1067 };
1068
1069 Ok(CheckX0Outcome {
1070 n_vars: n,
1071 n_cons: m,
1072 nl_sha256,
1073 source,
1074 x0_source,
1075 x0_all_zero,
1076 objective,
1077 grad_nonfinite,
1078 grad_nonfinite_count,
1079 g_nonfinite,
1080 g_nonfinite_count,
1081 jac_nonfinite,
1082 jac_nonfinite_count: if jac_nonfinite_count == usize::MAX {
1083 0
1084 } else {
1085 jac_nonfinite_count
1086 },
1087 hess_nonfinite_count,
1088 bound_violations,
1089 n_bound_violations,
1090 max_bound_violation,
1091 n_on_bounds,
1092 clamp_moves,
1093 n_clamp_moved,
1094 max_clamp_move,
1095 con_violations,
1096 n_con_violations,
1097 max_con_violation,
1098 grad_spread,
1099 jac_spread,
1100 scaling,
1101 warnings,
1102 fatal,
1103 verdict,
1104 })
1105}
1106
1107pub fn clamp_to_interior(
1112 x: Number,
1113 lo: Number,
1114 hi: Number,
1115 bound_push: Number,
1116 bound_frac: Number,
1117) -> Number {
1118 match (lower_bound_present(lo), upper_bound_present(hi)) {
1119 (true, true) => {
1120 let span = hi - lo;
1121 let p_l = (bound_push * lo.abs().max(1.0)).min(bound_frac * span);
1122 let p_u = (bound_push * hi.abs().max(1.0)).min(bound_frac * span);
1123 x.max(lo + p_l).min(hi - p_u)
1124 }
1125 (true, false) => x.max(lo + bound_push * lo.abs().max(1.0)),
1126 (false, true) => x.min(hi - bound_push * hi.abs().max(1.0)),
1127 (false, false) => x,
1128 }
1129}
1130
1131fn scan_nonfinite(
1132 values: &[Number],
1133 names: &[String],
1134 kind: char,
1135 cap: usize,
1136 eval_ok: bool,
1137) -> (Vec<NonFinite>, usize) {
1138 if !eval_ok {
1139 return (Vec::new(), 0);
1140 }
1141 let mut out = Vec::new();
1142 let mut count = 0usize;
1143 for (i, v) in values.iter().enumerate() {
1144 if !v.is_finite() {
1145 count += 1;
1146 if out.len() < cap {
1147 out.push(NonFinite {
1148 index: i,
1149 name: name_at(names, i, kind),
1150 value: *v,
1151 });
1152 }
1153 }
1154 }
1155 (out, count)
1156}
1157
1158fn push_worst(list: &mut Vec<RowReport>, r: RowReport, cap: usize) {
1160 list.push(r);
1161 list.sort_by(|a, b| {
1162 b.violation
1163 .partial_cmp(&a.violation)
1164 .unwrap_or(std::cmp::Ordering::Equal)
1165 });
1166 list.truncate(cap);
1167}
1168
1169fn scale_spread(values: impl Iterator<Item = Number>) -> ScaleSpread {
1170 let mut max_abs = 0.0_f64;
1171 let mut min_abs = Number::INFINITY;
1172 for v in values {
1173 let a = v.abs();
1174 if a.is_finite() && a > 0.0 {
1175 max_abs = max_abs.max(a);
1176 min_abs = min_abs.min(a);
1177 }
1178 }
1179 if max_abs == 0.0 {
1180 ScaleSpread::default()
1181 } else {
1182 ScaleSpread {
1183 max_abs,
1184 min_abs_nonzero: min_abs,
1185 ratio: max_abs / min_abs,
1186 }
1187 }
1188}
1189
1190fn print_report(o: &CheckX0Outcome) {
1195 println!("pounce check-x0 — starting-point preflight");
1196 println!(
1197 " problem : {} ({} vars, {} cons)",
1198 o.source, o.n_vars, o.n_cons
1199 );
1200 if let Some(sha) = &o.nl_sha256 {
1201 println!(" sha256:{sha}");
1202 }
1203 println!(
1204 " x0 : {}{}",
1205 o.x0_source,
1206 if o.x0_all_zero { " (all zeros)" } else { "" }
1207 );
1208 println!();
1209
1210 println!(" evaluation at x0:");
1211 match o.objective {
1212 Some(v) if v.is_finite() => println!(" objective: {v:.10e}"),
1213 Some(v) => println!(" objective: {v} <- NON-FINITE"),
1214 None => println!(" objective: EVALUATION FAILED"),
1215 }
1216 print_nonfinite("gradient", o.grad_nonfinite_count, &o.grad_nonfinite);
1217 print_nonfinite("constraints", o.g_nonfinite_count, &o.g_nonfinite);
1218 if o.jac_nonfinite_count > 0 {
1219 println!(
1220 " Jacobian : {} non-finite entr{}",
1221 o.jac_nonfinite_count,
1222 if o.jac_nonfinite_count == 1 {
1223 "y"
1224 } else {
1225 "ies"
1226 }
1227 );
1228 for e in &o.jac_nonfinite {
1229 println!(" d{}/d{} = {}", e.row_name, e.col_name, e.value);
1230 }
1231 } else {
1232 println!(" Jacobian : finite");
1233 }
1234 match o.hess_nonfinite_count {
1235 Some(0) => println!(" Hessian : finite (lambda=0)"),
1236 Some(k) => println!(" Hessian : {k} non-finite entries (lambda=0)"),
1237 None => println!(" Hessian : not checked (quasi-Newton or declined)"),
1238 }
1239 println!();
1240
1241 println!(" x0 vs bounds:");
1242 println!(
1243 " violations: {} on-bound components: {}",
1244 o.n_bound_violations, o.n_on_bounds
1245 );
1246 for r in &o.bound_violations {
1247 println!(
1248 " {}: value {:.6e} outside [{:.6e}, {:.6e}] by {:.3e}",
1249 r.name, r.value, r.lo, r.hi, r.violation
1250 );
1251 }
1252 println!(
1253 " interior clamp moves {} component(s), max move {:.3e}",
1254 o.n_clamp_moved, o.max_clamp_move
1255 );
1256 for c in &o.clamp_moves {
1257 println!(
1258 " {}: {:.6e} -> {:.6e} (moved {:.3e})",
1259 c.name, c.from, c.to, c.distance
1260 );
1261 }
1262 println!();
1263
1264 println!(" initial constraint violation:");
1265 println!(
1266 " rows violated: {} max violation: {:.3e}",
1267 o.n_con_violations, o.max_con_violation
1268 );
1269 for r in &o.con_violations {
1270 println!(
1271 " {}: g = {:.6e}, bounds [{:.6e}, {:.6e}], violation {:.3e}",
1272 r.name, r.value, r.lo, r.hi, r.violation
1273 );
1274 }
1275 println!();
1276
1277 println!(" derivative scale at x0:");
1278 println!(
1279 " gradient: max |.| {:.3e}, min nonzero |.| {:.3e}",
1280 o.grad_spread.max_abs, o.grad_spread.min_abs_nonzero
1281 );
1282 println!(
1283 " Jacobian: max |.| {:.3e}, min nonzero |.| {:.3e}",
1284 o.jac_spread.max_abs, o.jac_spread.min_abs_nonzero
1285 );
1286 println!();
1287
1288 print_scaling(&o.scaling);
1289
1290 if !o.warnings.is_empty() {
1291 println!(" warnings:");
1292 for w in &o.warnings {
1293 println!(" - {w}");
1294 }
1295 println!();
1296 }
1297 println!(" VERDICT: {}", o.verdict);
1298}
1299
1300fn print_scaling(s: &ScalingPreview) {
1302 println!(
1303 " automatic scaling at x0 (nlp_scaling_method=gradient-based, \
1304 nlp_scaling_max_gradient={}):",
1305 s.max_gradient
1306 );
1307 println!(
1308 " objective: ||grad f|| {:.3e} -> factor {:.3e}{}",
1309 s.max_grad_f,
1310 s.obj_scale,
1311 if s.obj_scale >= 1.0 {
1312 " (below the cutoff: unscaled)"
1313 } else {
1314 ""
1315 }
1316 );
1317 for (label, b) in [("equalities", &s.c), ("inequalities", &s.d)] {
1318 if b.n_rows == 0 {
1319 continue;
1320 }
1321 if !b.fires {
1322 println!(
1323 " {label:<12}: {} row(s), no row above the cutoff -> the whole \
1324 block is unscaled",
1325 b.n_rows
1326 );
1327 } else {
1328 println!(
1329 " {label:<12}: {} row(s), {} scaled down, min factor {:.3e}{}",
1330 b.n_rows,
1331 b.n_scaled,
1332 b.min_scale,
1333 if b.n_at_floor > 0 {
1334 format!(
1335 " ({} at the {:.0e} floor)",
1336 b.n_at_floor, NLP_SCALING_MIN_VALUE
1337 )
1338 } else {
1339 String::new()
1340 }
1341 );
1342 }
1343 if b.n_zero_jac > 0 {
1344 println!(
1345 " {} row(s) have an all-zero Jacobian at x0 \
1346 (the sample cannot scale them)",
1347 b.n_zero_jac
1348 );
1349 }
1350 }
1351 if s.n_quad_rows > 0 {
1352 println!(
1353 " quadratic rows: {} recognized; {} left at factor 1.0, {} with a \
1354 zero Jacobian at x0",
1355 s.n_quad_rows, s.n_quad_unscaled, s.n_quad_zero_jac
1356 );
1357 println!(
1358 " worst |b|/||Q||_inf mismatch {:.3e}",
1359 s.max_quad_mismatch
1360 );
1361 for q in &s.quad_rows {
1362 println!(
1363 " {}: ||Q||_inf {:.3e}, ||a||_inf {:.3e}, |b| {:.3e}, \
1364 ||grad g(x0)||_inf {:.3e} -> factor {:.3e}, mismatch {:.3e}",
1365 q.name, q.curvature, q.linear, q.rhs, q.jac_at_x0, q.scale, q.mismatch
1366 );
1367 }
1368 }
1369 println!();
1370}
1371
1372fn print_nonfinite(label: &str, count: usize, list: &[NonFinite]) {
1373 if count > 0 {
1374 println!(
1375 " {label:<9}: {count} non-finite entr{}",
1376 if count == 1 { "y" } else { "ies" }
1377 );
1378 for e in list {
1379 println!(" {} = {}", e.name, e.value);
1380 }
1381 } else {
1382 println!(" {label:<9}: finite");
1383 }
1384}
1385
1386fn block_json(b: &RowScaleBlock) -> serde_json::Value {
1387 serde_json::json!({
1388 "n_rows": b.n_rows,
1389 "fires": b.fires,
1390 "n_scaled": b.n_scaled,
1391 "min_factor": b.min_scale,
1392 "n_at_floor": b.n_at_floor,
1393 "n_zero_jacobian_at_x0": b.n_zero_jac,
1394 })
1395}
1396
1397fn report_json(o: &CheckX0Outcome) -> String {
1398 serde_json::to_string_pretty(&report_value(o)).unwrap_or_else(|_| "{}".to_string())
1399}
1400
1401pub fn report_value(o: &CheckX0Outcome) -> serde_json::Value {
1404 use serde_json::json;
1405 let row = |r: &RowReport| {
1406 json!({
1407 "index": r.index, "name": r.name, "value": r.value,
1408 "lower": r.lo, "upper": r.hi, "violation": r.violation,
1409 })
1410 };
1411 let nf =
1412 |e: &NonFinite| json!({"index": e.index, "name": e.name, "value": e.value.to_string()});
1413 json!({
1414 "pounce_check_x0_version": 1,
1415 "schema": "pounce.check-x0/v1",
1416 "solver": format!("pounce {}", env!("CARGO_PKG_VERSION")),
1417 "problem": {
1418 "source": o.source,
1419 "sha256": o.nl_sha256,
1420 "n_vars": o.n_vars,
1421 "n_cons": o.n_cons,
1422 },
1423 "x0": { "source": o.x0_source, "all_zero": o.x0_all_zero },
1424 "evaluation": {
1425 "objective": o.objective.filter(|v| v.is_finite()),
1426 "objective_finite": o.objective.map(|v| v.is_finite()).unwrap_or(false),
1427 "grad_nonfinite_count": o.grad_nonfinite_count,
1428 "grad_nonfinite": o.grad_nonfinite.iter().map(nf).collect::<Vec<_>>(),
1429 "constraints_nonfinite_count": o.g_nonfinite_count,
1430 "constraints_nonfinite": o.g_nonfinite.iter().map(nf).collect::<Vec<_>>(),
1431 "jacobian_nonfinite_count": o.jac_nonfinite_count,
1432 "jacobian_nonfinite": o.jac_nonfinite.iter().map(|e| json!({
1433 "row": e.row, "col": e.col,
1434 "row_name": e.row_name, "col_name": e.col_name,
1435 "value": e.value.to_string(),
1436 })).collect::<Vec<_>>(),
1437 "hessian_nonfinite_count": o.hess_nonfinite_count,
1438 },
1439 "bounds": {
1440 "n_violations": o.n_bound_violations,
1441 "max_violation": o.max_bound_violation,
1442 "n_on_bounds": o.n_on_bounds,
1443 "worst": o.bound_violations.iter().map(row).collect::<Vec<_>>(),
1444 },
1445 "interior_clamp": {
1446 "n_moved": o.n_clamp_moved,
1447 "max_move": o.max_clamp_move,
1448 "worst": o.clamp_moves.iter().map(|c| json!({
1449 "index": c.index, "name": c.name,
1450 "from": c.from, "to": c.to, "distance": c.distance,
1451 })).collect::<Vec<_>>(),
1452 },
1453 "constraint_violation": {
1454 "n_violated": o.n_con_violations,
1455 "max_violation": o.max_con_violation,
1456 "worst": o.con_violations.iter().map(row).collect::<Vec<_>>(),
1457 },
1458 "derivative_scale": {
1459 "gradient": {
1460 "max_abs": o.grad_spread.max_abs,
1461 "min_abs_nonzero": o.grad_spread.min_abs_nonzero,
1462 "ratio": o.grad_spread.ratio,
1463 },
1464 "jacobian": {
1465 "max_abs": o.jac_spread.max_abs,
1466 "min_abs_nonzero": o.jac_spread.min_abs_nonzero,
1467 "ratio": o.jac_spread.ratio,
1468 },
1469 },
1470 "scaling": {
1471 "method": "gradient-based",
1472 "nlp_scaling_max_gradient": o.scaling.max_gradient,
1473 "nlp_scaling_min_value": NLP_SCALING_MIN_VALUE,
1474 "objective": {
1475 "max_abs_grad_f": o.scaling.max_grad_f,
1476 "factor": o.scaling.obj_scale,
1477 },
1478 "equalities": block_json(&o.scaling.c),
1479 "inequalities": block_json(&o.scaling.d),
1480 "quadratic_rows": {
1481 "n_rows": o.scaling.n_quad_rows,
1482 "n_unscaled": o.scaling.n_quad_unscaled,
1483 "n_zero_jacobian_at_x0": o.scaling.n_quad_zero_jac,
1484 "max_mismatch": o.scaling.max_quad_mismatch,
1485 "worst": o.scaling.quad_rows.iter().map(|q| json!({
1486 "index": q.index, "name": q.name,
1487 "curvature_inf_norm": q.curvature,
1488 "linear_inf_norm": q.linear,
1489 "rhs_abs": q.rhs,
1490 "jacobian_inf_norm_at_x0": q.jac_at_x0,
1491 "factor": q.scale,
1492 "mismatch": q.mismatch,
1493 })).collect::<Vec<_>>(),
1494 },
1495 },
1496 "warnings": o.warnings,
1497 "fatal": o.fatal,
1498 "verdict": o.verdict,
1499 })
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504 use super::*;
1505 use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
1506 use pounce_nlp::tnlp::{IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution};
1507
1508 struct DomainTrap {
1511 x0: Vec<Number>,
1512 }
1513
1514 impl TNLP for DomainTrap {
1515 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1516 Some(NlpInfo {
1517 n: 2,
1518 m: 1,
1519 nnz_jac_g: 2,
1520 nnz_h_lag: 0,
1521 index_style: IndexStyle::C,
1522 })
1523 }
1524 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1525 b.x_l.copy_from_slice(&[0.0, NLP_LOWER_BOUND_INF]);
1526 b.x_u
1527 .copy_from_slice(&[NLP_UPPER_BOUND_INF, NLP_UPPER_BOUND_INF]);
1528 b.g_l[0] = 1.0;
1529 b.g_u[0] = 1.0;
1530 true
1531 }
1532 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1533 if sp.init_x {
1534 sp.x.copy_from_slice(&self.x0);
1535 }
1536 true
1537 }
1538 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
1539 Some(1.0 / x[0] + x[1])
1540 }
1541 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
1542 grad_f[0] = -1.0 / (x[0] * x[0]);
1543 grad_f[1] = 1.0;
1544 true
1545 }
1546 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1547 g[0] = x[0] + x[1];
1548 true
1549 }
1550 fn eval_jac_g(
1551 &mut self,
1552 _x: Option<&[Number]>,
1553 _new_x: bool,
1554 mode: SparsityRequest<'_>,
1555 ) -> bool {
1556 match mode {
1557 SparsityRequest::Structure { irow, jcol } => {
1558 irow.copy_from_slice(&[0, 0]);
1559 jcol.copy_from_slice(&[0, 1]);
1560 }
1561 SparsityRequest::Values { values } => {
1562 values.copy_from_slice(&[1.0, 1.0]);
1563 }
1564 }
1565 true
1566 }
1567 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _c: &IpoptCq) {}
1568 }
1569
1570 fn check(x0: Vec<Number>) -> CheckX0Outcome {
1571 let mut t = DomainTrap { x0 };
1572 check_tnlp(
1573 &mut t,
1574 &[],
1575 &[],
1576 None,
1577 "test".into(),
1578 &CheckX0Args::default(),
1579 )
1580 .expect("check")
1581 }
1582
1583 #[test]
1584 fn nan_at_x0_is_fatal() {
1585 let o = check(vec![0.0, 0.0]);
1587 assert!(o.fatal);
1588 assert_eq!(o.verdict, "FATAL");
1589 assert!(o.grad_nonfinite_count >= 1);
1590 assert!(o.x0_all_zero);
1591 }
1592
1593 #[test]
1594 fn clean_interior_point_passes() {
1595 let o = check(vec![0.5, 0.5]);
1596 assert!(!o.fatal);
1597 assert_eq!(o.n_bound_violations, 0);
1598 assert_eq!(o.n_con_violations, 0);
1600 assert_eq!(o.verdict, "CLEAN");
1601 assert!((o.objective.unwrap() - 2.5).abs() < 1e-12);
1602 }
1603
1604 #[test]
1605 fn on_bound_component_is_flagged_and_clamped() {
1606 let o = check(vec![1e-12, 1.0]);
1609 assert!(o.n_on_bounds >= 1);
1610 assert!(o.n_clamp_moved >= 1);
1611 assert!((o.max_clamp_move - 1e-2).abs() < 1e-9);
1612 assert!(
1613 o.warnings
1614 .iter()
1615 .any(|w| w.contains("warm_start_bound_push"))
1616 );
1617 }
1618
1619 #[test]
1620 fn bound_violation_reported() {
1621 let o = check(vec![-3.0, 1.0]);
1622 assert_eq!(o.n_bound_violations, 1);
1623 assert!((o.max_bound_violation - 3.0).abs() < 1e-12);
1624 assert!(o.n_clamp_moved >= 1);
1626 }
1627
1628 #[test]
1629 fn infeasible_start_is_not_fatal() {
1630 let o = check(vec![5.0, 5.0]);
1631 assert!(!o.fatal);
1632 assert_eq!(o.n_con_violations, 1);
1633 assert!((o.max_con_violation - 9.0).abs() < 1e-12);
1634 }
1635
1636 #[test]
1637 fn clamp_formula_matches_default_initializer() {
1638 assert!((clamp_to_interior(1.0, 1.0, 5.0, 1e-2, 1e-2) - 1.01).abs() < 1e-15);
1641 assert_eq!(clamp_to_interior(3.0, 1.0, 5.0, 1e-2, 1e-2), 3.0);
1643 assert_eq!(
1645 clamp_to_interior(-7.0, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, 1e-2, 1e-2),
1646 -7.0
1647 );
1648 assert!(
1650 (clamp_to_interior(100.0, NLP_LOWER_BOUND_INF, 100.0, 1e-2, 1e-2) - 99.0).abs() < 1e-12
1651 );
1652 }
1653
1654 #[test]
1655 fn scale_spread_ignores_zeros_and_nonfinite() {
1656 let s = scale_spread(vec![0.0, 1e-6, 1e3, Number::NAN].into_iter());
1657 assert!((s.max_abs - 1e3).abs() < 1e-9);
1658 assert!((s.min_abs_nonzero - 1e-6).abs() < 1e-18);
1659 assert!((s.ratio - 1e9).abs() / 1e9 < 1e-9);
1660 }
1661
1662 struct OneIneqLargeOffset;
1675
1676 impl TNLP for OneIneqLargeOffset {
1677 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1678 Some(NlpInfo {
1679 n: 1,
1680 m: 1,
1681 nnz_jac_g: 1,
1682 nnz_h_lag: 0,
1683 index_style: IndexStyle::C,
1684 })
1685 }
1686 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1687 b.x_l[0] = NLP_LOWER_BOUND_INF;
1688 b.x_u[0] = NLP_UPPER_BOUND_INF;
1689 b.g_l[0] = 4.0e6;
1690 b.g_u[0] = NLP_UPPER_BOUND_INF;
1691 true
1692 }
1693 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1694 sp.x[0] = 5000.0;
1695 true
1696 }
1697 fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
1698 Some(10.0 * x[0])
1699 }
1700 fn eval_grad_f(&mut self, _: &[Number], _: bool, g: &mut [Number]) -> bool {
1701 g[0] = 10.0;
1702 true
1703 }
1704 fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
1705 g[0] = 1000.0 * x[0];
1706 true
1707 }
1708 fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, req: SparsityRequest<'_>) -> bool {
1709 match req {
1710 SparsityRequest::Structure { irow, jcol } => {
1711 irow[0] = 0;
1712 jcol[0] = 0;
1713 }
1714 SparsityRequest::Values { values } => values[0] = 1000.0,
1715 }
1716 true
1717 }
1718 fn eval_h(
1719 &mut self,
1720 _: Option<&[Number]>,
1721 _: bool,
1722 _: Number,
1723 _: Option<&[Number]>,
1724 _: bool,
1725 _: SparsityRequest<'_>,
1726 ) -> bool {
1727 true
1728 }
1729 fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
1730 }
1731
1732 #[test]
1733 fn scaling_preview_reproduces_the_solvers_factors() {
1734 let mut t = OneIneqLargeOffset;
1735 let o = check_tnlp(
1736 &mut t,
1737 &[],
1738 &[],
1739 None,
1740 "test".to_string(),
1741 &CheckX0Args::default(),
1742 )
1743 .unwrap();
1744 let s = &o.scaling;
1745 assert_eq!(s.max_gradient, 100.0);
1746 assert_eq!(s.max_grad_f, 10.0);
1748 assert_eq!(s.obj_scale, 1.0);
1749 assert_eq!(s.c.n_rows, 0);
1752 assert_eq!(s.d.n_rows, 1);
1753 assert!(s.d.fires);
1754 assert_eq!(s.d.n_scaled, 1);
1755 assert!((s.d.min_scale - 0.1).abs() < 1e-15);
1756 assert_eq!(s.d.n_zero_jac, 0);
1757 assert_eq!(s.n_quad_rows, 0);
1759 }
1760
1761 #[test]
1762 fn a_row_below_the_cutoff_leaves_the_whole_block_unscaled() {
1763 let mut t = DomainTrap { x0: vec![1.0, 0.0] };
1766 let o = check_tnlp(
1767 &mut t,
1768 &[],
1769 &[],
1770 None,
1771 "test".to_string(),
1772 &CheckX0Args::default(),
1773 )
1774 .unwrap();
1775 assert_eq!(o.scaling.c.n_rows, 1);
1776 assert!(!o.scaling.c.fires);
1777 assert_eq!(o.scaling.c.n_scaled, 0);
1778 assert_eq!(o.scaling.c.min_scale, 1.0);
1779 }
1780
1781 const QUAD_AT_ORIGIN_NL: &str = "\
1786g3 0 1 0
1787 2 2 1 0 0
1788 1 0
1789 0 0
1790 2 2 2
1791 0 0 0 1
1792 0 0 0 0 0
1793 4 2
1794 0 0
1795 0 0 0 0 0
1796b
17973
17983
1799r
18001 100000
18011 7
1802C0
1803o54
18042
1805o2
1806n0.5
1807o2
1808o2
1809n4.0
1810v0
1811v0
1812o2
1813n0.5
1814o2
1815o2
1816n2.0
1817v1
1818v1
1819C1
1820n0
1821O0 0
1822n0
1823k1
18242
1825J0 2
18260 0
18271 0
1828J1 2
18290 1
18301 1
1831";
1832
1833 fn quad_at_origin_outcome(args: &CheckX0Args) -> CheckX0Outcome {
1834 let prob = crate::nl_reader::parse_nl_text(QUAD_AT_ORIGIN_NL).expect("parse");
1835 let coefs = quad_row_coefs(&prob);
1836 let mut t = crate::nl_reader::NlTnlp::try_new(prob).expect("build");
1837 check_tnlp_with_quadratics(&mut t, &[], &[], None, "test".to_string(), &coefs, args)
1838 .expect("check")
1839 }
1840
1841 #[test]
1842 fn quadratic_row_written_about_the_origin_is_invisible_to_the_scaler() {
1843 let o = quad_at_origin_outcome(&CheckX0Args::default());
1844 let s = &o.scaling;
1845 assert_eq!(s.n_quad_rows, 1, "the ≤ row is the only quadratic one");
1846 assert_eq!(s.n_quad_zero_jac, 1, "∇g(0) = 0 for ½xᵀQx about the origin");
1847 assert_eq!(s.n_quad_unscaled, 1, "so the row keeps factor 1.0");
1848
1849 let q = &s.quad_rows[0];
1850 assert!((q.curvature - 4.0).abs() < 1e-12);
1852 assert_eq!(q.linear, 0.0);
1853 assert!((q.rhs - 1.0e5).abs() < 1e-9);
1854 assert_eq!(q.jac_at_x0, 0.0);
1855 assert_eq!(q.scale, 1.0);
1856 assert!((q.mismatch - 2.5e4).abs() < 1e-6);
1857
1858 assert!(
1861 o.warnings
1862 .iter()
1863 .any(|w| w.contains("identically-zero Jacobian")),
1864 "expected the zero-Jacobian scaling warning, got {:?}",
1865 o.warnings
1866 );
1867 }
1868
1869 #[test]
1875 fn preview_objective_factor_matches_the_installed_one() {
1876 use pounce_nlp::orig_ipopt_nlp::{NoScaling, OrigIpoptNlp, ScalingMethod};
1877 use pounce_nlp::tnlp_adapter::TNLPAdapter;
1878 use std::cell::RefCell;
1879 use std::rc::Rc;
1880
1881 for tnlp in [
1882 Rc::new(RefCell::new(BigObjGradient)) as Rc<RefCell<dyn TNLP>>,
1883 Rc::new(RefCell::new(OneIneqLargeOffset)) as Rc<RefCell<dyn TNLP>>,
1884 ] {
1885 let preview = {
1886 let mut t = tnlp.borrow_mut();
1887 check_tnlp(
1888 &mut *t,
1889 &[],
1890 &[],
1891 None,
1892 "test".to_string(),
1893 &CheckX0Args::default(),
1894 )
1895 .unwrap()
1896 .scaling
1897 .obj_scale
1898 };
1899 let adapter = Rc::new(RefCell::new(TNLPAdapter::new(Rc::clone(&tnlp)).unwrap()));
1900 let mut nlp = OrigIpoptNlp::new(adapter, Rc::new(NoScaling)).unwrap();
1901 nlp.determine_scaling_from_starting_point(
1902 ScalingMethod::GradientBased,
1903 NLP_SCALING_MAX_GRADIENT,
1904 NLP_SCALING_MIN_VALUE,
1905 0.0,
1906 0.0,
1907 );
1908 assert_eq!(
1909 preview,
1910 nlp.obj_scale_factor(),
1911 "preview and installed objective factor disagree"
1912 );
1913 }
1914 }
1915
1916 struct BigObjGradient;
1924
1925 impl TNLP for BigObjGradient {
1926 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1927 Some(NlpInfo {
1928 n: 2,
1929 m: 1,
1930 nnz_jac_g: 2,
1931 nnz_h_lag: 0,
1932 index_style: IndexStyle::C,
1933 })
1934 }
1935 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1936 b.x_l[0] = NLP_LOWER_BOUND_INF;
1937 b.x_u[0] = NLP_UPPER_BOUND_INF;
1938 b.x_l[1] = 3.0;
1939 b.x_u[1] = 3.0;
1940 b.g_l[0] = NLP_LOWER_BOUND_INF;
1941 b.g_u[0] = 10.0;
1942 true
1943 }
1944 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1945 sp.x[0] = 0.0;
1946 sp.x[1] = 0.0;
1947 true
1948 }
1949 fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
1950 Some(x[0] + 1.0e6 * x[1])
1951 }
1952 fn eval_grad_f(&mut self, _: &[Number], _: bool, g: &mut [Number]) -> bool {
1953 g[0] = 1.0;
1954 g[1] = 1.0e6;
1955 true
1956 }
1957 fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
1958 g[0] = x[0] + x[1];
1959 true
1960 }
1961 fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, req: SparsityRequest<'_>) -> bool {
1962 match req {
1963 SparsityRequest::Structure { irow, jcol } => {
1964 irow[0] = 0;
1965 jcol[0] = 0;
1966 irow[1] = 0;
1967 jcol[1] = 1;
1968 }
1969 SparsityRequest::Values { values } => {
1970 values[0] = 1.0;
1971 values[1] = 1.0;
1972 }
1973 }
1974 true
1975 }
1976 fn eval_h(
1977 &mut self,
1978 _: Option<&[Number]>,
1979 _: bool,
1980 _: Number,
1981 _: Option<&[Number]>,
1982 _: bool,
1983 _: SparsityRequest<'_>,
1984 ) -> bool {
1985 true
1986 }
1987 fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
1988 }
1989
1990 #[test]
1991 fn moving_the_cutoff_moves_the_preview_but_not_the_blind_spot() {
1992 let args = CheckX0Args {
1997 scaling_max_gradient: 1e-6,
1998 ..Default::default()
1999 };
2000 let o = quad_at_origin_outcome(&args);
2001 assert!(o.scaling.d.fires, "the linear row is above a 1e-6 cutoff");
2002 assert_eq!(o.scaling.n_quad_unscaled, 1);
2003 assert_eq!(o.scaling.quad_rows[0].scale, 1.0);
2004 }
2005}