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 let var_names = prob.var_names.clone();
580 let con_names = prob.con_names.clone();
581 let quad_coefs = quad_row_coefs(&prob);
584 let t = nl_reader::NlTnlp::try_new(prob)?;
585 Ok(LoadedModel {
586 tnlp: std::rc::Rc::new(std::cell::RefCell::new(t)),
587 var_names,
588 con_names,
589 nl_sha256: Some(sha),
590 source: path.display().to_string(),
591 quad_coefs,
592 })
593}
594
595fn evaluate(args: &CheckX0Args) -> Result<CheckX0Outcome, String> {
596 let model = load_model(args)?;
597 let mut tnlp = model.tnlp.borrow_mut();
598 check_tnlp_with_quadratics(
599 &mut *tnlp,
600 &model.var_names,
601 &model.con_names,
602 model.nl_sha256.clone(),
603 model.source.clone(),
604 &model.quad_coefs,
605 args,
606 )
607}
608
609pub fn check_tnlp(
617 tnlp: &mut dyn TNLP,
618 var_names: &[String],
619 con_names: &[String],
620 nl_sha256: Option<String>,
621 source: String,
622 args: &CheckX0Args,
623) -> Result<CheckX0Outcome, String> {
624 check_tnlp_with_quadratics(tnlp, var_names, con_names, nl_sha256, source, &[], args)
625}
626
627#[allow(clippy::too_many_arguments)]
631pub fn check_tnlp_with_quadratics(
632 tnlp: &mut dyn TNLP,
633 var_names: &[String],
634 con_names: &[String],
635 nl_sha256: Option<String>,
636 source: String,
637 quad_coefs: &[QuadRowCoef],
638 args: &CheckX0Args,
639) -> Result<CheckX0Outcome, String> {
640 let info = tnlp.get_nlp_info().ok_or("get_nlp_info failed")?;
641 let n = info.n.max(0) as usize;
642 let m = info.m.max(0) as usize;
643 let nnz = info.nnz_jac_g.max(0) as usize;
644 let nnz_h = info.nnz_h_lag.max(0) as usize;
645 let fortran = matches!(info.index_style, pounce_nlp::tnlp::IndexStyle::Fortran);
646 let off = if fortran { 1usize } else { 0usize };
647
648 let mut x_l = vec![0.0; n];
650 let mut x_u = vec![0.0; n];
651 let mut g_l = vec![0.0; m];
652 let mut g_u = vec![0.0; m];
653 if !tnlp.get_bounds_info(BoundsInfo {
654 x_l: &mut x_l,
655 x_u: &mut x_u,
656 g_l: &mut g_l,
657 g_u: &mut g_u,
658 }) {
659 return Err("get_bounds_info failed".to_string());
660 }
661
662 let mut x = vec![0.0; n];
664 let (mut zl_buf, mut zu_buf, mut lam_buf) = (vec![0.0; n], vec![0.0; n], vec![0.0; m]);
665 let x0_source = if let Some(path) = &args.x0_file {
666 let text = std::fs::read_to_string(path)
667 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
668 let vals: Result<Vec<Number>, _> = text
669 .split_whitespace()
670 .map(|t| t.parse::<Number>())
671 .collect();
672 let vals = vals.map_err(|e| format!("{}: bad value: {e}", path.display()))?;
673 if vals.len() != n {
674 return Err(format!(
675 "{} has {} values but the problem has {n} variables",
676 path.display(),
677 vals.len()
678 ));
679 }
680 x.copy_from_slice(&vals);
681 format!("--x0-file {}", path.display())
682 } else {
683 if !tnlp.get_starting_point(StartingPoint {
684 init_x: true,
685 x: &mut x,
686 init_z: false,
687 z_l: &mut zl_buf,
688 z_u: &mut zu_buf,
689 init_lambda: false,
690 lambda: &mut lam_buf,
691 }) {
692 return Err("get_starting_point failed".to_string());
693 }
694 "model".to_string()
695 };
696 let x0_all_zero = n > 0 && x.iter().all(|v| *v == 0.0);
697
698 let objective = tnlp.eval_f(&x, true);
700 let obj_finite = objective.map(|v| v.is_finite()).unwrap_or(false);
701
702 let mut grad_f = vec![0.0; n];
703 let grad_ok = tnlp.eval_grad_f(&x, false, &mut grad_f);
704 let (grad_nonfinite, grad_nonfinite_count) =
705 scan_nonfinite(&grad_f, var_names, 'x', args.max_list, grad_ok);
706
707 let mut g = vec![0.0; m];
708 let g_ok = m == 0 || tnlp.eval_g(&x, false, &mut g);
709 let (g_nonfinite, g_nonfinite_count) = scan_nonfinite(&g, con_names, 'c', args.max_list, g_ok);
710
711 let mut irow = vec![0i32; nnz];
713 let mut jcol = vec![0i32; nnz];
714 let mut jval = vec![0.0; nnz];
715 let mut jac_ok = nnz == 0;
716 if nnz > 0 {
717 jac_ok = tnlp.eval_jac_g(
718 Some(&x),
719 false,
720 SparsityRequest::Structure {
721 irow: &mut irow,
722 jcol: &mut jcol,
723 },
724 ) && tnlp.eval_jac_g(
725 Some(&x),
726 false,
727 SparsityRequest::Values { values: &mut jval },
728 );
729 }
730 let mut jac_nonfinite = Vec::new();
731 let mut jac_nonfinite_count = 0usize;
732 if jac_ok {
733 for k in 0..nnz {
734 if !jval[k].is_finite() {
735 jac_nonfinite_count += 1;
736 if jac_nonfinite.len() < args.max_list {
737 let row = (irow[k] as usize).wrapping_sub(off);
738 let col = (jcol[k] as usize).wrapping_sub(off);
739 jac_nonfinite.push(NonFiniteEntry {
740 row,
741 col,
742 row_name: name_at(con_names, row, 'c'),
743 col_name: name_at(var_names, col, 'x'),
744 value: jval[k],
745 });
746 }
747 }
748 }
749 } else if nnz > 0 {
750 jac_nonfinite_count = usize::MAX; }
752
753 let hess_nonfinite_count = if nnz_h > 0 {
756 let mut hrow = vec![0i32; nnz_h];
757 let mut hcol = vec![0i32; nnz_h];
758 let mut hval = vec![0.0; nnz_h];
759 let lambda0 = vec![0.0; m];
760 let ok = tnlp.eval_h(
761 None,
762 false,
763 1.0,
764 None,
765 false,
766 SparsityRequest::Structure {
767 irow: &mut hrow,
768 jcol: &mut hcol,
769 },
770 ) && tnlp.eval_h(
771 Some(&x),
772 false,
773 1.0,
774 Some(&lambda0),
775 true,
776 SparsityRequest::Values { values: &mut hval },
777 );
778 if ok {
779 Some(hval.iter().filter(|v| !v.is_finite()).count())
780 } else {
781 None
782 }
783 } else {
784 None
785 };
786
787 let mut bound_violations: Vec<RowReport> = Vec::new();
789 let mut n_bound_violations = 0usize;
790 let mut max_bound_violation = 0.0_f64;
791 let mut n_on_bounds = 0usize;
792 for j in 0..n {
793 let viol = box_violation(x[j], x_l[j], x_u[j]);
794 if viol > args.feas_tol {
795 n_bound_violations += 1;
796 max_bound_violation = max_bound_violation.max(viol);
797 push_worst(
798 &mut bound_violations,
799 RowReport {
800 index: j,
801 name: name_at(var_names, j, 'x'),
802 value: x[j],
803 lo: x_l[j],
804 hi: x_u[j],
805 violation: viol,
806 },
807 args.max_list,
808 );
809 }
810 if x[j].is_finite() {
811 let at_lo =
812 lower_bound_present(x_l[j]) && (x[j] - x_l[j]).abs() <= 1e-8 * (1.0 + x_l[j].abs());
813 let at_hi =
814 upper_bound_present(x_u[j]) && (x_u[j] - x[j]).abs() <= 1e-8 * (1.0 + x_u[j].abs());
815 if at_lo || at_hi {
816 n_on_bounds += 1;
817 }
818 }
819 }
820
821 let mut clamp_moves: Vec<ClampMove> = Vec::new();
823 let mut n_clamp_moved = 0usize;
824 let mut max_clamp_move = 0.0_f64;
825 for j in 0..n {
826 if !x[j].is_finite() {
827 continue;
828 }
829 let to = clamp_to_interior(x[j], x_l[j], x_u[j], args.bound_push, args.bound_frac);
830 let d = (to - x[j]).abs();
831 if d > 0.0 {
832 n_clamp_moved += 1;
833 max_clamp_move = max_clamp_move.max(d);
834 if clamp_moves.len() < args.max_list
835 || clamp_moves.last().map(|w| d > w.distance).unwrap_or(false)
836 {
837 clamp_moves.push(ClampMove {
838 index: j,
839 name: name_at(var_names, j, 'x'),
840 from: x[j],
841 to,
842 distance: d,
843 });
844 clamp_moves.sort_by(|a, b| {
845 b.distance
846 .partial_cmp(&a.distance)
847 .unwrap_or(std::cmp::Ordering::Equal)
848 });
849 clamp_moves.truncate(args.max_list);
850 }
851 }
852 }
853
854 let mut con_violations: Vec<RowReport> = Vec::new();
856 let mut n_con_violations = 0usize;
857 let mut max_con_violation = 0.0_f64;
858 if g_ok {
859 for i in 0..m {
860 let viol = box_violation(g[i], g_l[i], g_u[i]);
861 if viol > args.feas_tol {
862 n_con_violations += 1;
863 if viol.is_finite() {
864 max_con_violation = max_con_violation.max(viol);
865 }
866 push_worst(
867 &mut con_violations,
868 RowReport {
869 index: i,
870 name: name_at(con_names, i, 'c'),
871 value: g[i],
872 lo: g_l[i],
873 hi: g_u[i],
874 violation: viol,
875 },
876 args.max_list,
877 );
878 }
879 }
880 }
881
882 let grad_spread = scale_spread(grad_f.iter().copied());
884 let jac_spread = scale_spread(jval.iter().copied());
885
886 let mut jac_row_max = vec![Number::MIN_POSITIVE; m];
891 let fixed: Vec<bool> = (0..n)
900 .map(|j| lower_bound_present(x_l[j]) && upper_bound_present(x_u[j]) && x_l[j] == x_u[j])
901 .collect();
902 let mut lifted = x.clone();
903 let mut moved = false;
904 for j in 0..n {
905 if fixed[j] && lifted[j] != x_l[j] {
906 lifted[j] = x_l[j];
907 moved = true;
908 }
909 }
910 let (scale_grad, scale_jval) = if moved {
911 let mut gf = vec![0.0; n];
912 let mut jv = vec![0.0; nnz];
913 let gok = tnlp.eval_grad_f(&lifted, true, &mut gf);
914 let jok = nnz == 0
915 || tnlp.eval_jac_g(
916 Some(&lifted),
917 true,
918 SparsityRequest::Values { values: &mut jv },
919 );
920 (
921 if gok { gf } else { grad_f.clone() },
922 if jok { jv } else { jval.clone() },
923 )
924 } else {
925 (grad_f.clone(), jval.clone())
926 };
927 let max_grad_f = if grad_ok {
928 (0..n)
929 .filter(|&j| !fixed[j])
930 .fold(0.0_f64, |acc, j| acc.max(scale_grad[j].abs()))
931 } else {
932 0.0
933 };
934 if jac_ok {
935 for k in 0..nnz {
936 let row = (irow[k] as usize).wrapping_sub(off);
937 if row < m {
938 let v = scale_jval[k].abs();
939 if v > jac_row_max[row] {
940 jac_row_max[row] = v;
941 }
942 }
943 }
944 }
945 let scaling = scaling_preview(
946 &jac_row_max,
947 &g_l,
948 &g_u,
949 max_grad_f,
950 quad_coefs,
951 con_names,
952 args,
953 );
954
955 let mut warnings = Vec::new();
957 let eval_failed = !grad_ok || !g_ok || (!jac_ok && nnz > 0) || objective.is_none();
958 let nonfinite_total = grad_nonfinite_count.min(usize::MAX - 1)
959 + g_nonfinite_count.min(usize::MAX - 1)
960 + if jac_nonfinite_count == usize::MAX {
961 0
962 } else {
963 jac_nonfinite_count
964 }
965 + hess_nonfinite_count.unwrap_or(0)
966 + usize::from(!obj_finite && objective.is_some());
967 let fatal = eval_failed || nonfinite_total > 0;
968 if eval_failed {
969 warnings.push(
970 "an evaluation callback failed outright at the starting point; \
971 the solver cannot start from this x0"
972 .to_string(),
973 );
974 }
975 if nonfinite_total > 0 {
976 warnings.push(format!(
977 "{nonfinite_total} non-finite value(s) at the starting point; a solve \
978 would abort with Invalid_Number_Detected. The interior clamp only \
979 repairs bound violations, not domain errors — move x0 into the \
980 domain or add bounds that keep it there"
981 ));
982 }
983 if x0_all_zero {
984 warnings.push(
985 "the starting point is all zeros: the model supplies no initial \
986 guess (or an explicitly zero one)"
987 .to_string(),
988 );
989 }
990 if n_bound_violations > 0 {
991 warnings.push(format!(
992 "x0 violates {n_bound_violations} variable bound(s) (max {max_bound_violation:.3e}); \
993 the initializer will clamp them inside"
994 ));
995 }
996 if n_on_bounds > 0 {
997 warnings.push(format!(
998 "{n_on_bounds} component(s) of x0 sit exactly on a bound and will be \
999 pushed into the interior (bound_push={:.1e}); if x0 is a previous \
1000 solution, use the warm-start recipe (warm_start_init_point=yes with \
1001 tightened warm_start_bound_push/_frac)",
1002 args.bound_push
1003 ));
1004 }
1005 if max_con_violation > 1e4 {
1006 warnings.push(format!(
1007 "very large initial infeasibility (max constraint violation \
1008 {max_con_violation:.3e}); consider a better starting point or \
1009 least_square_init_primal=yes"
1010 ));
1011 }
1012 if scaling.n_quad_zero_jac > 0 && scaling.max_quad_mismatch > 1e2 {
1017 warnings.push(format!(
1018 "{} quadratic row(s) have an identically-zero Jacobian at x0, so \
1019 gradient-based scaling leaves them at factor 1.0 — and their \
1020 right-hand sides run up to {:.3e}x their curvature ‖Q‖_∞. The \
1021 automatic scaler samples derivatives at x0 and cannot see this; \
1022 set per-row factors with nlp_scaling_method=user-scaling, or \
1023 rewrite the rows about a point where their gradient is nonzero",
1024 scaling.n_quad_zero_jac, scaling.max_quad_mismatch
1025 ));
1026 }
1027 for (label, s) in [("gradient", &grad_spread), ("Jacobian", &jac_spread)] {
1028 if s.ratio > 1e8 || s.max_abs > 1e8 {
1029 warnings.push(format!(
1030 "{label} magnitudes at x0 span a large range (max {:.3e}, min \
1031 nonzero {:.3e}); see the scaling reference page",
1032 s.max_abs, s.min_abs_nonzero
1033 ));
1034 }
1035 }
1036
1037 let verdict = if fatal {
1038 "FATAL"
1039 } else if warnings.is_empty() {
1040 "CLEAN"
1041 } else {
1042 "WARNINGS"
1043 };
1044
1045 Ok(CheckX0Outcome {
1046 n_vars: n,
1047 n_cons: m,
1048 nl_sha256,
1049 source,
1050 x0_source,
1051 x0_all_zero,
1052 objective,
1053 grad_nonfinite,
1054 grad_nonfinite_count,
1055 g_nonfinite,
1056 g_nonfinite_count,
1057 jac_nonfinite,
1058 jac_nonfinite_count: if jac_nonfinite_count == usize::MAX {
1059 0
1060 } else {
1061 jac_nonfinite_count
1062 },
1063 hess_nonfinite_count,
1064 bound_violations,
1065 n_bound_violations,
1066 max_bound_violation,
1067 n_on_bounds,
1068 clamp_moves,
1069 n_clamp_moved,
1070 max_clamp_move,
1071 con_violations,
1072 n_con_violations,
1073 max_con_violation,
1074 grad_spread,
1075 jac_spread,
1076 scaling,
1077 warnings,
1078 fatal,
1079 verdict,
1080 })
1081}
1082
1083pub fn clamp_to_interior(
1088 x: Number,
1089 lo: Number,
1090 hi: Number,
1091 bound_push: Number,
1092 bound_frac: Number,
1093) -> Number {
1094 match (lower_bound_present(lo), upper_bound_present(hi)) {
1095 (true, true) => {
1096 let span = hi - lo;
1097 let p_l = (bound_push * lo.abs().max(1.0)).min(bound_frac * span);
1098 let p_u = (bound_push * hi.abs().max(1.0)).min(bound_frac * span);
1099 x.max(lo + p_l).min(hi - p_u)
1100 }
1101 (true, false) => x.max(lo + bound_push * lo.abs().max(1.0)),
1102 (false, true) => x.min(hi - bound_push * hi.abs().max(1.0)),
1103 (false, false) => x,
1104 }
1105}
1106
1107fn scan_nonfinite(
1108 values: &[Number],
1109 names: &[String],
1110 kind: char,
1111 cap: usize,
1112 eval_ok: bool,
1113) -> (Vec<NonFinite>, usize) {
1114 if !eval_ok {
1115 return (Vec::new(), 0);
1116 }
1117 let mut out = Vec::new();
1118 let mut count = 0usize;
1119 for (i, v) in values.iter().enumerate() {
1120 if !v.is_finite() {
1121 count += 1;
1122 if out.len() < cap {
1123 out.push(NonFinite {
1124 index: i,
1125 name: name_at(names, i, kind),
1126 value: *v,
1127 });
1128 }
1129 }
1130 }
1131 (out, count)
1132}
1133
1134fn push_worst(list: &mut Vec<RowReport>, r: RowReport, cap: usize) {
1136 list.push(r);
1137 list.sort_by(|a, b| {
1138 b.violation
1139 .partial_cmp(&a.violation)
1140 .unwrap_or(std::cmp::Ordering::Equal)
1141 });
1142 list.truncate(cap);
1143}
1144
1145fn scale_spread(values: impl Iterator<Item = Number>) -> ScaleSpread {
1146 let mut max_abs = 0.0_f64;
1147 let mut min_abs = Number::INFINITY;
1148 for v in values {
1149 let a = v.abs();
1150 if a.is_finite() && a > 0.0 {
1151 max_abs = max_abs.max(a);
1152 min_abs = min_abs.min(a);
1153 }
1154 }
1155 if max_abs == 0.0 {
1156 ScaleSpread::default()
1157 } else {
1158 ScaleSpread {
1159 max_abs,
1160 min_abs_nonzero: min_abs,
1161 ratio: max_abs / min_abs,
1162 }
1163 }
1164}
1165
1166fn print_report(o: &CheckX0Outcome) {
1171 println!("pounce check-x0 — starting-point preflight");
1172 println!(
1173 " problem : {} ({} vars, {} cons)",
1174 o.source, o.n_vars, o.n_cons
1175 );
1176 if let Some(sha) = &o.nl_sha256 {
1177 println!(" sha256:{sha}");
1178 }
1179 println!(
1180 " x0 : {}{}",
1181 o.x0_source,
1182 if o.x0_all_zero { " (all zeros)" } else { "" }
1183 );
1184 println!();
1185
1186 println!(" evaluation at x0:");
1187 match o.objective {
1188 Some(v) if v.is_finite() => println!(" objective: {v:.10e}"),
1189 Some(v) => println!(" objective: {v} <- NON-FINITE"),
1190 None => println!(" objective: EVALUATION FAILED"),
1191 }
1192 print_nonfinite("gradient", o.grad_nonfinite_count, &o.grad_nonfinite);
1193 print_nonfinite("constraints", o.g_nonfinite_count, &o.g_nonfinite);
1194 if o.jac_nonfinite_count > 0 {
1195 println!(
1196 " Jacobian : {} non-finite entr{}",
1197 o.jac_nonfinite_count,
1198 if o.jac_nonfinite_count == 1 {
1199 "y"
1200 } else {
1201 "ies"
1202 }
1203 );
1204 for e in &o.jac_nonfinite {
1205 println!(" d{}/d{} = {}", e.row_name, e.col_name, e.value);
1206 }
1207 } else {
1208 println!(" Jacobian : finite");
1209 }
1210 match o.hess_nonfinite_count {
1211 Some(0) => println!(" Hessian : finite (lambda=0)"),
1212 Some(k) => println!(" Hessian : {k} non-finite entries (lambda=0)"),
1213 None => println!(" Hessian : not checked (quasi-Newton or declined)"),
1214 }
1215 println!();
1216
1217 println!(" x0 vs bounds:");
1218 println!(
1219 " violations: {} on-bound components: {}",
1220 o.n_bound_violations, o.n_on_bounds
1221 );
1222 for r in &o.bound_violations {
1223 println!(
1224 " {}: value {:.6e} outside [{:.6e}, {:.6e}] by {:.3e}",
1225 r.name, r.value, r.lo, r.hi, r.violation
1226 );
1227 }
1228 println!(
1229 " interior clamp moves {} component(s), max move {:.3e}",
1230 o.n_clamp_moved, o.max_clamp_move
1231 );
1232 for c in &o.clamp_moves {
1233 println!(
1234 " {}: {:.6e} -> {:.6e} (moved {:.3e})",
1235 c.name, c.from, c.to, c.distance
1236 );
1237 }
1238 println!();
1239
1240 println!(" initial constraint violation:");
1241 println!(
1242 " rows violated: {} max violation: {:.3e}",
1243 o.n_con_violations, o.max_con_violation
1244 );
1245 for r in &o.con_violations {
1246 println!(
1247 " {}: g = {:.6e}, bounds [{:.6e}, {:.6e}], violation {:.3e}",
1248 r.name, r.value, r.lo, r.hi, r.violation
1249 );
1250 }
1251 println!();
1252
1253 println!(" derivative scale at x0:");
1254 println!(
1255 " gradient: max |.| {:.3e}, min nonzero |.| {:.3e}",
1256 o.grad_spread.max_abs, o.grad_spread.min_abs_nonzero
1257 );
1258 println!(
1259 " Jacobian: max |.| {:.3e}, min nonzero |.| {:.3e}",
1260 o.jac_spread.max_abs, o.jac_spread.min_abs_nonzero
1261 );
1262 println!();
1263
1264 print_scaling(&o.scaling);
1265
1266 if !o.warnings.is_empty() {
1267 println!(" warnings:");
1268 for w in &o.warnings {
1269 println!(" - {w}");
1270 }
1271 println!();
1272 }
1273 println!(" VERDICT: {}", o.verdict);
1274}
1275
1276fn print_scaling(s: &ScalingPreview) {
1278 println!(
1279 " automatic scaling at x0 (nlp_scaling_method=gradient-based, \
1280 nlp_scaling_max_gradient={}):",
1281 s.max_gradient
1282 );
1283 println!(
1284 " objective: ||grad f|| {:.3e} -> factor {:.3e}{}",
1285 s.max_grad_f,
1286 s.obj_scale,
1287 if s.obj_scale >= 1.0 {
1288 " (below the cutoff: unscaled)"
1289 } else {
1290 ""
1291 }
1292 );
1293 for (label, b) in [("equalities", &s.c), ("inequalities", &s.d)] {
1294 if b.n_rows == 0 {
1295 continue;
1296 }
1297 if !b.fires {
1298 println!(
1299 " {label:<12}: {} row(s), no row above the cutoff -> the whole \
1300 block is unscaled",
1301 b.n_rows
1302 );
1303 } else {
1304 println!(
1305 " {label:<12}: {} row(s), {} scaled down, min factor {:.3e}{}",
1306 b.n_rows,
1307 b.n_scaled,
1308 b.min_scale,
1309 if b.n_at_floor > 0 {
1310 format!(
1311 " ({} at the {:.0e} floor)",
1312 b.n_at_floor, NLP_SCALING_MIN_VALUE
1313 )
1314 } else {
1315 String::new()
1316 }
1317 );
1318 }
1319 if b.n_zero_jac > 0 {
1320 println!(
1321 " {} row(s) have an all-zero Jacobian at x0 \
1322 (the sample cannot scale them)",
1323 b.n_zero_jac
1324 );
1325 }
1326 }
1327 if s.n_quad_rows > 0 {
1328 println!(
1329 " quadratic rows: {} recognized; {} left at factor 1.0, {} with a \
1330 zero Jacobian at x0",
1331 s.n_quad_rows, s.n_quad_unscaled, s.n_quad_zero_jac
1332 );
1333 println!(
1334 " worst |b|/||Q||_inf mismatch {:.3e}",
1335 s.max_quad_mismatch
1336 );
1337 for q in &s.quad_rows {
1338 println!(
1339 " {}: ||Q||_inf {:.3e}, ||a||_inf {:.3e}, |b| {:.3e}, \
1340 ||grad g(x0)||_inf {:.3e} -> factor {:.3e}, mismatch {:.3e}",
1341 q.name, q.curvature, q.linear, q.rhs, q.jac_at_x0, q.scale, q.mismatch
1342 );
1343 }
1344 }
1345 println!();
1346}
1347
1348fn print_nonfinite(label: &str, count: usize, list: &[NonFinite]) {
1349 if count > 0 {
1350 println!(
1351 " {label:<9}: {count} non-finite entr{}",
1352 if count == 1 { "y" } else { "ies" }
1353 );
1354 for e in list {
1355 println!(" {} = {}", e.name, e.value);
1356 }
1357 } else {
1358 println!(" {label:<9}: finite");
1359 }
1360}
1361
1362fn block_json(b: &RowScaleBlock) -> serde_json::Value {
1363 serde_json::json!({
1364 "n_rows": b.n_rows,
1365 "fires": b.fires,
1366 "n_scaled": b.n_scaled,
1367 "min_factor": b.min_scale,
1368 "n_at_floor": b.n_at_floor,
1369 "n_zero_jacobian_at_x0": b.n_zero_jac,
1370 })
1371}
1372
1373fn report_json(o: &CheckX0Outcome) -> String {
1374 use serde_json::json;
1375 let row = |r: &RowReport| {
1376 json!({
1377 "index": r.index, "name": r.name, "value": r.value,
1378 "lower": r.lo, "upper": r.hi, "violation": r.violation,
1379 })
1380 };
1381 let nf =
1382 |e: &NonFinite| json!({"index": e.index, "name": e.name, "value": e.value.to_string()});
1383 let report = json!({
1384 "pounce_check_x0_version": 1,
1385 "schema": "pounce.check-x0/v1",
1386 "solver": format!("pounce {}", env!("CARGO_PKG_VERSION")),
1387 "problem": {
1388 "source": o.source,
1389 "sha256": o.nl_sha256,
1390 "n_vars": o.n_vars,
1391 "n_cons": o.n_cons,
1392 },
1393 "x0": { "source": o.x0_source, "all_zero": o.x0_all_zero },
1394 "evaluation": {
1395 "objective": o.objective.filter(|v| v.is_finite()),
1396 "objective_finite": o.objective.map(|v| v.is_finite()).unwrap_or(false),
1397 "grad_nonfinite_count": o.grad_nonfinite_count,
1398 "grad_nonfinite": o.grad_nonfinite.iter().map(nf).collect::<Vec<_>>(),
1399 "constraints_nonfinite_count": o.g_nonfinite_count,
1400 "constraints_nonfinite": o.g_nonfinite.iter().map(nf).collect::<Vec<_>>(),
1401 "jacobian_nonfinite_count": o.jac_nonfinite_count,
1402 "jacobian_nonfinite": o.jac_nonfinite.iter().map(|e| json!({
1403 "row": e.row, "col": e.col,
1404 "row_name": e.row_name, "col_name": e.col_name,
1405 "value": e.value.to_string(),
1406 })).collect::<Vec<_>>(),
1407 "hessian_nonfinite_count": o.hess_nonfinite_count,
1408 },
1409 "bounds": {
1410 "n_violations": o.n_bound_violations,
1411 "max_violation": o.max_bound_violation,
1412 "n_on_bounds": o.n_on_bounds,
1413 "worst": o.bound_violations.iter().map(row).collect::<Vec<_>>(),
1414 },
1415 "interior_clamp": {
1416 "n_moved": o.n_clamp_moved,
1417 "max_move": o.max_clamp_move,
1418 "worst": o.clamp_moves.iter().map(|c| json!({
1419 "index": c.index, "name": c.name,
1420 "from": c.from, "to": c.to, "distance": c.distance,
1421 })).collect::<Vec<_>>(),
1422 },
1423 "constraint_violation": {
1424 "n_violated": o.n_con_violations,
1425 "max_violation": o.max_con_violation,
1426 "worst": o.con_violations.iter().map(row).collect::<Vec<_>>(),
1427 },
1428 "derivative_scale": {
1429 "gradient": {
1430 "max_abs": o.grad_spread.max_abs,
1431 "min_abs_nonzero": o.grad_spread.min_abs_nonzero,
1432 "ratio": o.grad_spread.ratio,
1433 },
1434 "jacobian": {
1435 "max_abs": o.jac_spread.max_abs,
1436 "min_abs_nonzero": o.jac_spread.min_abs_nonzero,
1437 "ratio": o.jac_spread.ratio,
1438 },
1439 },
1440 "scaling": {
1441 "method": "gradient-based",
1442 "nlp_scaling_max_gradient": o.scaling.max_gradient,
1443 "nlp_scaling_min_value": NLP_SCALING_MIN_VALUE,
1444 "objective": {
1445 "max_abs_grad_f": o.scaling.max_grad_f,
1446 "factor": o.scaling.obj_scale,
1447 },
1448 "equalities": block_json(&o.scaling.c),
1449 "inequalities": block_json(&o.scaling.d),
1450 "quadratic_rows": {
1451 "n_rows": o.scaling.n_quad_rows,
1452 "n_unscaled": o.scaling.n_quad_unscaled,
1453 "n_zero_jacobian_at_x0": o.scaling.n_quad_zero_jac,
1454 "max_mismatch": o.scaling.max_quad_mismatch,
1455 "worst": o.scaling.quad_rows.iter().map(|q| json!({
1456 "index": q.index, "name": q.name,
1457 "curvature_inf_norm": q.curvature,
1458 "linear_inf_norm": q.linear,
1459 "rhs_abs": q.rhs,
1460 "jacobian_inf_norm_at_x0": q.jac_at_x0,
1461 "factor": q.scale,
1462 "mismatch": q.mismatch,
1463 })).collect::<Vec<_>>(),
1464 },
1465 },
1466 "warnings": o.warnings,
1467 "fatal": o.fatal,
1468 "verdict": o.verdict,
1469 });
1470 serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
1471}
1472
1473#[cfg(test)]
1474mod tests {
1475 use super::*;
1476 use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
1477 use pounce_nlp::tnlp::{IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution};
1478
1479 struct DomainTrap {
1482 x0: Vec<Number>,
1483 }
1484
1485 impl TNLP for DomainTrap {
1486 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1487 Some(NlpInfo {
1488 n: 2,
1489 m: 1,
1490 nnz_jac_g: 2,
1491 nnz_h_lag: 0,
1492 index_style: IndexStyle::C,
1493 })
1494 }
1495 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1496 b.x_l.copy_from_slice(&[0.0, NLP_LOWER_BOUND_INF]);
1497 b.x_u
1498 .copy_from_slice(&[NLP_UPPER_BOUND_INF, NLP_UPPER_BOUND_INF]);
1499 b.g_l[0] = 1.0;
1500 b.g_u[0] = 1.0;
1501 true
1502 }
1503 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1504 if sp.init_x {
1505 sp.x.copy_from_slice(&self.x0);
1506 }
1507 true
1508 }
1509 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
1510 Some(1.0 / x[0] + x[1])
1511 }
1512 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
1513 grad_f[0] = -1.0 / (x[0] * x[0]);
1514 grad_f[1] = 1.0;
1515 true
1516 }
1517 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1518 g[0] = x[0] + x[1];
1519 true
1520 }
1521 fn eval_jac_g(
1522 &mut self,
1523 _x: Option<&[Number]>,
1524 _new_x: bool,
1525 mode: SparsityRequest<'_>,
1526 ) -> bool {
1527 match mode {
1528 SparsityRequest::Structure { irow, jcol } => {
1529 irow.copy_from_slice(&[0, 0]);
1530 jcol.copy_from_slice(&[0, 1]);
1531 }
1532 SparsityRequest::Values { values } => {
1533 values.copy_from_slice(&[1.0, 1.0]);
1534 }
1535 }
1536 true
1537 }
1538 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _c: &IpoptCq) {}
1539 }
1540
1541 fn check(x0: Vec<Number>) -> CheckX0Outcome {
1542 let mut t = DomainTrap { x0 };
1543 check_tnlp(
1544 &mut t,
1545 &[],
1546 &[],
1547 None,
1548 "test".into(),
1549 &CheckX0Args::default(),
1550 )
1551 .expect("check")
1552 }
1553
1554 #[test]
1555 fn nan_at_x0_is_fatal() {
1556 let o = check(vec![0.0, 0.0]);
1558 assert!(o.fatal);
1559 assert_eq!(o.verdict, "FATAL");
1560 assert!(o.grad_nonfinite_count >= 1);
1561 assert!(o.x0_all_zero);
1562 }
1563
1564 #[test]
1565 fn clean_interior_point_passes() {
1566 let o = check(vec![0.5, 0.5]);
1567 assert!(!o.fatal);
1568 assert_eq!(o.n_bound_violations, 0);
1569 assert_eq!(o.n_con_violations, 0);
1571 assert_eq!(o.verdict, "CLEAN");
1572 assert!((o.objective.unwrap() - 2.5).abs() < 1e-12);
1573 }
1574
1575 #[test]
1576 fn on_bound_component_is_flagged_and_clamped() {
1577 let o = check(vec![1e-12, 1.0]);
1580 assert!(o.n_on_bounds >= 1);
1581 assert!(o.n_clamp_moved >= 1);
1582 assert!((o.max_clamp_move - 1e-2).abs() < 1e-9);
1583 assert!(
1584 o.warnings
1585 .iter()
1586 .any(|w| w.contains("warm_start_bound_push"))
1587 );
1588 }
1589
1590 #[test]
1591 fn bound_violation_reported() {
1592 let o = check(vec![-3.0, 1.0]);
1593 assert_eq!(o.n_bound_violations, 1);
1594 assert!((o.max_bound_violation - 3.0).abs() < 1e-12);
1595 assert!(o.n_clamp_moved >= 1);
1597 }
1598
1599 #[test]
1600 fn infeasible_start_is_not_fatal() {
1601 let o = check(vec![5.0, 5.0]);
1602 assert!(!o.fatal);
1603 assert_eq!(o.n_con_violations, 1);
1604 assert!((o.max_con_violation - 9.0).abs() < 1e-12);
1605 }
1606
1607 #[test]
1608 fn clamp_formula_matches_default_initializer() {
1609 assert!((clamp_to_interior(1.0, 1.0, 5.0, 1e-2, 1e-2) - 1.01).abs() < 1e-15);
1612 assert_eq!(clamp_to_interior(3.0, 1.0, 5.0, 1e-2, 1e-2), 3.0);
1614 assert_eq!(
1616 clamp_to_interior(-7.0, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, 1e-2, 1e-2),
1617 -7.0
1618 );
1619 assert!(
1621 (clamp_to_interior(100.0, NLP_LOWER_BOUND_INF, 100.0, 1e-2, 1e-2) - 99.0).abs() < 1e-12
1622 );
1623 }
1624
1625 #[test]
1626 fn scale_spread_ignores_zeros_and_nonfinite() {
1627 let s = scale_spread(vec![0.0, 1e-6, 1e3, Number::NAN].into_iter());
1628 assert!((s.max_abs - 1e3).abs() < 1e-9);
1629 assert!((s.min_abs_nonzero - 1e-6).abs() < 1e-18);
1630 assert!((s.ratio - 1e9).abs() / 1e9 < 1e-9);
1631 }
1632
1633 struct OneIneqLargeOffset;
1646
1647 impl TNLP for OneIneqLargeOffset {
1648 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1649 Some(NlpInfo {
1650 n: 1,
1651 m: 1,
1652 nnz_jac_g: 1,
1653 nnz_h_lag: 0,
1654 index_style: IndexStyle::C,
1655 })
1656 }
1657 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1658 b.x_l[0] = NLP_LOWER_BOUND_INF;
1659 b.x_u[0] = NLP_UPPER_BOUND_INF;
1660 b.g_l[0] = 4.0e6;
1661 b.g_u[0] = NLP_UPPER_BOUND_INF;
1662 true
1663 }
1664 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1665 sp.x[0] = 5000.0;
1666 true
1667 }
1668 fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
1669 Some(10.0 * x[0])
1670 }
1671 fn eval_grad_f(&mut self, _: &[Number], _: bool, g: &mut [Number]) -> bool {
1672 g[0] = 10.0;
1673 true
1674 }
1675 fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
1676 g[0] = 1000.0 * x[0];
1677 true
1678 }
1679 fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, req: SparsityRequest<'_>) -> bool {
1680 match req {
1681 SparsityRequest::Structure { irow, jcol } => {
1682 irow[0] = 0;
1683 jcol[0] = 0;
1684 }
1685 SparsityRequest::Values { values } => values[0] = 1000.0,
1686 }
1687 true
1688 }
1689 fn eval_h(
1690 &mut self,
1691 _: Option<&[Number]>,
1692 _: bool,
1693 _: Number,
1694 _: Option<&[Number]>,
1695 _: bool,
1696 _: SparsityRequest<'_>,
1697 ) -> bool {
1698 true
1699 }
1700 fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
1701 }
1702
1703 #[test]
1704 fn scaling_preview_reproduces_the_solvers_factors() {
1705 let mut t = OneIneqLargeOffset;
1706 let o = check_tnlp(
1707 &mut t,
1708 &[],
1709 &[],
1710 None,
1711 "test".to_string(),
1712 &CheckX0Args::default(),
1713 )
1714 .unwrap();
1715 let s = &o.scaling;
1716 assert_eq!(s.max_gradient, 100.0);
1717 assert_eq!(s.max_grad_f, 10.0);
1719 assert_eq!(s.obj_scale, 1.0);
1720 assert_eq!(s.c.n_rows, 0);
1723 assert_eq!(s.d.n_rows, 1);
1724 assert!(s.d.fires);
1725 assert_eq!(s.d.n_scaled, 1);
1726 assert!((s.d.min_scale - 0.1).abs() < 1e-15);
1727 assert_eq!(s.d.n_zero_jac, 0);
1728 assert_eq!(s.n_quad_rows, 0);
1730 }
1731
1732 #[test]
1733 fn a_row_below_the_cutoff_leaves_the_whole_block_unscaled() {
1734 let mut t = DomainTrap { x0: vec![1.0, 0.0] };
1737 let o = check_tnlp(
1738 &mut t,
1739 &[],
1740 &[],
1741 None,
1742 "test".to_string(),
1743 &CheckX0Args::default(),
1744 )
1745 .unwrap();
1746 assert_eq!(o.scaling.c.n_rows, 1);
1747 assert!(!o.scaling.c.fires);
1748 assert_eq!(o.scaling.c.n_scaled, 0);
1749 assert_eq!(o.scaling.c.min_scale, 1.0);
1750 }
1751
1752 const QUAD_AT_ORIGIN_NL: &str = "\
1757g3 0 1 0
1758 2 2 1 0 0
1759 1 0
1760 0 0
1761 2 2 2
1762 0 0 0 1
1763 0 0 0 0 0
1764 4 2
1765 0 0
1766 0 0 0 0 0
1767b
17683
17693
1770r
17711 100000
17721 7
1773C0
1774o54
17752
1776o2
1777n0.5
1778o2
1779o2
1780n4.0
1781v0
1782v0
1783o2
1784n0.5
1785o2
1786o2
1787n2.0
1788v1
1789v1
1790C1
1791n0
1792O0 0
1793n0
1794k1
17952
1796J0 2
17970 0
17981 0
1799J1 2
18000 1
18011 1
1802";
1803
1804 fn quad_at_origin_outcome(args: &CheckX0Args) -> CheckX0Outcome {
1805 let prob = crate::nl_reader::parse_nl_text(QUAD_AT_ORIGIN_NL).expect("parse");
1806 let coefs = quad_row_coefs(&prob);
1807 let mut t = crate::nl_reader::NlTnlp::try_new(prob).expect("build");
1808 check_tnlp_with_quadratics(&mut t, &[], &[], None, "test".to_string(), &coefs, args)
1809 .expect("check")
1810 }
1811
1812 #[test]
1813 fn quadratic_row_written_about_the_origin_is_invisible_to_the_scaler() {
1814 let o = quad_at_origin_outcome(&CheckX0Args::default());
1815 let s = &o.scaling;
1816 assert_eq!(s.n_quad_rows, 1, "the ≤ row is the only quadratic one");
1817 assert_eq!(s.n_quad_zero_jac, 1, "∇g(0) = 0 for ½xᵀQx about the origin");
1818 assert_eq!(s.n_quad_unscaled, 1, "so the row keeps factor 1.0");
1819
1820 let q = &s.quad_rows[0];
1821 assert!((q.curvature - 4.0).abs() < 1e-12);
1823 assert_eq!(q.linear, 0.0);
1824 assert!((q.rhs - 1.0e5).abs() < 1e-9);
1825 assert_eq!(q.jac_at_x0, 0.0);
1826 assert_eq!(q.scale, 1.0);
1827 assert!((q.mismatch - 2.5e4).abs() < 1e-6);
1828
1829 assert!(
1832 o.warnings
1833 .iter()
1834 .any(|w| w.contains("identically-zero Jacobian")),
1835 "expected the zero-Jacobian scaling warning, got {:?}",
1836 o.warnings
1837 );
1838 }
1839
1840 #[test]
1846 fn preview_objective_factor_matches_the_installed_one() {
1847 use pounce_nlp::orig_ipopt_nlp::{NoScaling, OrigIpoptNlp, ScalingMethod};
1848 use pounce_nlp::tnlp_adapter::TNLPAdapter;
1849 use std::cell::RefCell;
1850 use std::rc::Rc;
1851
1852 for tnlp in [
1853 Rc::new(RefCell::new(BigObjGradient)) as Rc<RefCell<dyn TNLP>>,
1854 Rc::new(RefCell::new(OneIneqLargeOffset)) as Rc<RefCell<dyn TNLP>>,
1855 ] {
1856 let preview = {
1857 let mut t = tnlp.borrow_mut();
1858 check_tnlp(
1859 &mut *t,
1860 &[],
1861 &[],
1862 None,
1863 "test".to_string(),
1864 &CheckX0Args::default(),
1865 )
1866 .unwrap()
1867 .scaling
1868 .obj_scale
1869 };
1870 let adapter = Rc::new(RefCell::new(TNLPAdapter::new(Rc::clone(&tnlp)).unwrap()));
1871 let mut nlp = OrigIpoptNlp::new(adapter, Rc::new(NoScaling)).unwrap();
1872 nlp.determine_scaling_from_starting_point(
1873 ScalingMethod::GradientBased,
1874 NLP_SCALING_MAX_GRADIENT,
1875 NLP_SCALING_MIN_VALUE,
1876 0.0,
1877 0.0,
1878 );
1879 assert_eq!(
1880 preview,
1881 nlp.obj_scale_factor(),
1882 "preview and installed objective factor disagree"
1883 );
1884 }
1885 }
1886
1887 struct BigObjGradient;
1895
1896 impl TNLP for BigObjGradient {
1897 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1898 Some(NlpInfo {
1899 n: 2,
1900 m: 1,
1901 nnz_jac_g: 2,
1902 nnz_h_lag: 0,
1903 index_style: IndexStyle::C,
1904 })
1905 }
1906 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1907 b.x_l[0] = NLP_LOWER_BOUND_INF;
1908 b.x_u[0] = NLP_UPPER_BOUND_INF;
1909 b.x_l[1] = 3.0;
1910 b.x_u[1] = 3.0;
1911 b.g_l[0] = NLP_LOWER_BOUND_INF;
1912 b.g_u[0] = 10.0;
1913 true
1914 }
1915 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1916 sp.x[0] = 0.0;
1917 sp.x[1] = 0.0;
1918 true
1919 }
1920 fn eval_f(&mut self, x: &[Number], _: bool) -> Option<Number> {
1921 Some(x[0] + 1.0e6 * x[1])
1922 }
1923 fn eval_grad_f(&mut self, _: &[Number], _: bool, g: &mut [Number]) -> bool {
1924 g[0] = 1.0;
1925 g[1] = 1.0e6;
1926 true
1927 }
1928 fn eval_g(&mut self, x: &[Number], _: bool, g: &mut [Number]) -> bool {
1929 g[0] = x[0] + x[1];
1930 true
1931 }
1932 fn eval_jac_g(&mut self, _: Option<&[Number]>, _: bool, req: SparsityRequest<'_>) -> bool {
1933 match req {
1934 SparsityRequest::Structure { irow, jcol } => {
1935 irow[0] = 0;
1936 jcol[0] = 0;
1937 irow[1] = 0;
1938 jcol[1] = 1;
1939 }
1940 SparsityRequest::Values { values } => {
1941 values[0] = 1.0;
1942 values[1] = 1.0;
1943 }
1944 }
1945 true
1946 }
1947 fn eval_h(
1948 &mut self,
1949 _: Option<&[Number]>,
1950 _: bool,
1951 _: Number,
1952 _: Option<&[Number]>,
1953 _: bool,
1954 _: SparsityRequest<'_>,
1955 ) -> bool {
1956 true
1957 }
1958 fn finalize_solution(&mut self, _: Solution<'_>, _: &IpoptData, _: &IpoptCq) {}
1959 }
1960
1961 #[test]
1962 fn moving_the_cutoff_moves_the_preview_but_not_the_blind_spot() {
1963 let args = CheckX0Args {
1968 scaling_max_gradient: 1e-6,
1969 ..Default::default()
1970 };
1971 let o = quad_at_origin_outcome(&args);
1972 assert!(o.scaling.d.fires, "the linear row is above a 1e-6 cutoff");
1973 assert_eq!(o.scaling.n_quad_unscaled, 1);
1974 assert_eq!(o.scaling.quad_rows[0].scale, 1.0);
1975 }
1976}