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