Skip to main content

pounce_cli/
check_x0.rs

1//! `pounce check-x0 <problem.nl>` — starting-point preflight.
2//!
3//! # Why this exists
4//!
5//! A local NLP solver's fate is largely decided at iteration 0, but the
6//! solver only reports starting-point trouble *after* it has tripped over
7//! it (`Invalid_Number_Detected` mid-solve, immediate restoration, a slow
8//! crawl caused by scaling). This subcommand evaluates the model once at
9//! its starting point, before any solve, and reports what the initializer
10//! and the first iteration will actually see:
11//!
12//! * **Non-finite evaluations** — NaN/inf in `f`, `∇f`, `g`, the Jacobian,
13//!   or the Hessian at x0. These are fatal: the solve would abort.
14//! * **Bound violations of x0** and components sitting exactly on a bound
15//!   (the interior clamp will move both; see below).
16//! * **Interior-clamp displacement** — the `bound_push` / `bound_frac`
17//!   clamp (`DefaultIterateInitializer`) applied to x0, so "the solver
18//!   silently moved my point" is visible up front.
19//! * **Initial constraint violation** per row (infeasibility is fine for
20//!   the IPM, but very large violations usually mean a wrong or missing
21//!   starting point).
22//! * **Derivative scale spread** — max/min nonzero magnitudes of `∇f` and
23//!   the Jacobian at x0, the early-warning signal for scaling trouble.
24//! * **Automatic scaling** — the objective and per-row factors
25//!   `nlp_scaling_method=gradient-based` (the default) will pick *at this
26//!   x0*, computed by the solver's own arithmetic; plus, for a `.nl` model,
27//!   the coefficient magnitudes of its quadratic rows, which that sample
28//!   cannot report. See [`ScalingPreview`].
29//!
30//! The checks are read-only and cost one evaluation of each callback:
31//! `O(nnz)` work, no factorization, no solve.
32//!
33//! Verdict / exit code: `0` when the model evaluates cleanly at x0
34//! (warnings allowed); `21` when an evaluation produced NaN/inf (the
35//! solver would fail); `2` on a usage or I/O error.
36//!
37//! User-facing background: `docs/src/initialization.md`.
38
39use 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/// Parsed `check-x0` subcommand arguments.
48#[derive(Debug, Clone)]
49pub struct CheckX0Args {
50    /// `.nl` path, or `None` when `--builtin` is used.
51    pub nl: Option<PathBuf>,
52    /// Built-in problem name (`--builtin rosenbrock`).
53    pub builtin: Option<String>,
54    /// Optional whitespace-separated file of `n` values overriding the
55    /// model's starting point (`--x0-file`).
56    pub x0_file: Option<PathBuf>,
57    /// Violations above this are counted in `n_violated` (default 1e-6).
58    pub feas_tol: Number,
59    /// `bound_push` used for the clamp preview (default 1e-2).
60    pub bound_push: Number,
61    /// `bound_frac` used for the clamp preview (default 1e-2).
62    pub bound_frac: Number,
63    /// Max offenders listed per category (default 5).
64    pub max_list: usize,
65    /// `nlp_scaling_max_gradient` for the scaling preview (default 100).
66    pub scaling_max_gradient: Number,
67    /// Print the JSON report to stdout instead of the text report.
68    pub json: bool,
69    /// Also write the JSON report to this path.
70    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
121/// Entry point dispatched from `main` when argv[1] == "check-x0".
122pub 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/// One non-finite evaluation entry.
201#[derive(Debug, Clone)]
202pub struct NonFinite {
203    pub index: usize,
204    pub name: String,
205    pub value: Number,
206}
207
208/// One Jacobian/Hessian non-finite entry (row/col in matrix coordinates).
209#[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/// One interior-clamp displacement entry.
219#[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/// Max/min-nonzero magnitude summary of a derivative array at x0.
229#[derive(Debug, Clone, Default)]
230pub struct ScaleSpread {
231    pub max_abs: Number,
232    pub min_abs_nonzero: Number,
233    /// `max_abs / min_abs_nonzero`, or 0 when there are no nonzeros.
234    pub ratio: Number,
235}
236
237/// `nlp_scaling_max_gradient`'s default — the cutoff above which
238/// gradient-based scaling fires. Overridable with `--scaling-max-gradient`
239/// so a preflight can preview the same cutoff the solve will run under.
240pub const NLP_SCALING_MAX_GRADIENT: Number = 100.0;
241
242/// `nlp_scaling_min_value`'s default — the floor on a computed scale.
243pub const NLP_SCALING_MIN_VALUE: Number = 1e-8;
244
245/// One row block's share of the gradient-based scaling preview.
246///
247/// The equalities (`c`) and the inequalities (`d`) are gated *separately*
248/// upstream: unless some row of the block exceeds the cutoff, the whole
249/// block is left unscaled. So `fires` is not redundant with `n_scaled`.
250#[derive(Debug, Clone, Default)]
251pub struct RowScaleBlock {
252    pub n_rows: usize,
253    /// Whether the block gets a scale vector at all.
254    pub fires: bool,
255    /// Rows the block would scale down (factor < 1). Zero when `!fires`.
256    pub n_scaled: usize,
257    /// Smallest factor assigned, or 1.0 when nothing is scaled.
258    pub min_scale: Number,
259    /// Rows driven all the way to `nlp_scaling_min_value`.
260    pub n_at_floor: usize,
261    /// Rows whose Jacobian is **entirely zero** at x0. These are the rows
262    /// the sample cannot see; they come out at 1.0 whatever their
263    /// coefficients are.
264    pub n_zero_jac: usize,
265}
266
267/// What `nlp_scaling_method=gradient-based` will do to this model at this
268/// x0 — the solver's own arithmetic
269/// ([`gradient_obj_scale`] / [`gradient_row_scale`]), not a copy of it.
270///
271/// # Why a preflight reports this
272///
273/// Gradient-based scaling is a **point sample**: it reads `∇f` and the
274/// Jacobian once, at x0, and never looks again. That is a good estimator
275/// of a row's magnitude when the row's derivative at x0 is representative
276/// of its derivative elsewhere, and no estimator at all when it is not.
277/// The extreme case is a row whose Jacobian *vanishes* at x0 — a
278/// `½xᵀQx ≤ b` written about the origin, started from `x = 0`. The sample
279/// reads zero, the row is left at factor 1.0, and however badly `Q` and `b`
280/// disagree in magnitude the scaler has no way to know it. That is how
281/// AMPL emits `qcqp1000-2c` — every variable free, no initial guess, and
282/// `k` rows of pure `½xᵀQᵢx ≤ bᵢ`.
283///
284/// So the two halves of this report are complementary: the block below
285/// says what the sample decided, and [`Self::quad_rows`] says what the
286/// sample could not see. See `dev-notes/quadratic-structure-exploitation.md`
287/// §8 (gh #703).
288#[derive(Debug, Clone, Default)]
289pub struct ScalingPreview {
290    /// The `nlp_scaling_max_gradient` cutoff this preview assumed.
291    pub max_gradient: Number,
292    /// `‖∇f(x0)‖_∞`.
293    pub max_grad_f: Number,
294    /// The objective factor `df` the scaler will pick.
295    pub obj_scale: Number,
296    /// Equality rows (`g_l == g_u`).
297    pub c: RowScaleBlock,
298    /// Everything else — the inequality and range rows.
299    pub d: RowScaleBlock,
300    /// Rows recognized as quadratic, worst mismatch first. `.nl` models
301    /// only; empty for a builtin or a model with no quadratic row.
302    pub quad_rows: Vec<QuadRowScale>,
303    /// Total quadratic rows found (`quad_rows` is capped at `--max-list`).
304    pub n_quad_rows: usize,
305    /// Of those, how many the sample leaves at factor 1.0.
306    pub n_quad_unscaled: usize,
307    /// Of those, how many have an identically-zero Jacobian row at x0.
308    pub n_quad_zero_jac: usize,
309    /// Largest `rhs / curvature` over the quadratic rows, or 0 when there
310    /// are none.
311    pub max_quad_mismatch: Number,
312}
313
314/// The coefficient magnitudes of one quadratic constraint row, read off
315/// the `.nl` without reference to any point, paired with what the
316/// gradient sample at x0 made of it.
317///
318/// `curvature` is `‖Q‖_∞` (the largest absolute row sum of the row's
319/// Hessian), which is Gershgorin's bound on `λ_max(Q)` and is the exact
320/// quantity §8's second-stage row scale `eᵢ = 1/max(‖Qᵢ‖_∞, ‖aᵢ‖_∞, |bᵢ|)`
321/// is built from. It is an upper bound on the curvature, not the curvature.
322#[derive(Debug, Clone)]
323pub struct QuadRowScale {
324    pub index: usize,
325    pub name: String,
326    /// `‖Q‖_∞` — see the type docs.
327    pub curvature: Number,
328    /// `‖a‖_∞` over the `.nl` linear section plus the degree-1 terms the
329    /// writer folded into the nonlinear tree.
330    pub linear: Number,
331    /// `|b|` — the finite bound the row is written against, shifted by the
332    /// folded constant. A range row reports the larger magnitude.
333    pub rhs: Number,
334    /// `‖∇g(x0)‖_∞` — what gradient-based scaling actually samples.
335    pub jac_at_x0: Number,
336    /// The factor that sample assigns the row.
337    pub scale: Number,
338    /// `rhs / curvature`. §8's statistic: the `qcqp1500-1c` right-hand
339    /// sides are 1.58e5–1.80e5 against `λ_max(Qᵢ) ≈ 1.6e3`, a 100×
340    /// mismatch that biases `sᵢ = −gᵢ(x)` and hence the `−sᵢ/λᵢ` KKT
341    /// diagonal. Zero when the curvature is zero.
342    pub mismatch: Number,
343}
344
345// `QuadRowCoef` / `quad_row_coefs` live in `pounce_nl::nl_scaling`, where
346// `nlp_scaling_method=curvature-based` also reads them (gh #703). One
347// implementation: a preflight that reported different magnitudes from the
348// ones the scaler acts on would be worse than no preflight.
349pub use pounce_nl::nl_scaling::{QuadRowCoef, quad_row_coefs};
350
351/// Reproduce gradient-based scaling's decision at x0.
352///
353/// `jac_row_max[i]` is row `i`'s Jacobian ∞-norm at x0, seeded the way
354/// upstream seeds it (`f64::MIN_POSITIVE`) so an all-zero row is
355/// distinguishable from a row of zeros that never appeared — both come out
356/// at 1.0, which is the point.
357fn 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    // Each quadratic row's factor comes from whichever block it is in.
410    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/// The fully-evaluated preflight result.
470#[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    // non-finite scans (counts are totals; lists are capped at max_list)
480    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    /// `None` when the TNLP declines exact Hessians (quasi-Newton).
487    pub hess_nonfinite_count: Option<usize>,
488    // x0 vs bounds
489    pub bound_violations: Vec<RowReport>,
490    pub n_bound_violations: usize,
491    pub max_bound_violation: Number,
492    pub n_on_bounds: usize,
493    // interior-clamp preview
494    pub clamp_moves: Vec<ClampMove>,
495    pub n_clamp_moved: usize,
496    pub max_clamp_move: Number,
497    // initial constraint violation
498    pub con_violations: Vec<RowReport>,
499    pub n_con_violations: usize,
500    pub max_con_violation: Number,
501    // derivative scale spread
502    pub grad_spread: ScaleSpread,
503    pub jac_spread: ScaleSpread,
504    // what the automatic scaler will make of this x0
505    pub scaling: ScalingPreview,
506    // rollup
507    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
546/// A model loaded for preflight: the evaluator plus its provenance.
547struct 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    /// Quadratic-row coefficients, read off the `.nl` before the problem
554    /// is consumed by the evaluator. Empty for a builtin, which has no
555    /// file to read them from.
556    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    // Read the quadratic coefficients before `prob` is consumed: they are
582    // a property of the file, and the evaluator does not hand them back.
583    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
609/// The core preflight over any TNLP. Public so the debugger / tests can
610/// reuse it without going through a file.
611///
612/// The scaling preview's quadratic-row census is empty here: a bare TNLP
613/// exposes derivatives, not coefficients, so there is nothing to read it
614/// from. [`check_tnlp_with_quadratics`] takes the census a `.nl` model can
615/// supply.
616pub 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/// [`check_tnlp`] plus the quadratic-row coefficients read off the model's
628/// `.nl` (see [`quad_row_coefs`]), which is the half of the scaling report
629/// no evaluation at x0 can produce.
630#[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    // --- bounds ---
649    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    // --- starting point ---
663    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    // --- evaluations at x0 ---
699    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    // Jacobian: structure then values.
712    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; // "evaluation itself failed"
751    }
752
753    // Hessian of the Lagrangian at (x0, lambda=0, obj_factor=1) — catches
754    // second-derivative domain errors. Optional: quasi-Newton TNLPs decline.
755    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    // --- x0 vs bounds ---
788    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    // --- interior-clamp preview (DefaultIterateInitializer::push_to_interior) ---
822    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    // --- initial constraint violation ---
855    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    // --- derivative scale spread ---
883    let grad_spread = scale_spread(grad_f.iter().copied());
884    let jac_spread = scale_spread(jval.iter().copied());
885
886    // --- what gradient-based scaling will make of this x0 ---
887    // Row maxima seeded the way upstream seeds them, so a row with no
888    // Jacobian entry at all and a row whose entries are all zero reach
889    // `gradient_row_scale` identically — which is how the solver sees them.
890    let mut jac_row_max = vec![Number::MIN_POSITIVE; m];
891    // The solver samples at the point `TNLPAdapter::GetStartingPoint`
892    // hands over, which pins every fixed variable (`x_l == x_u`) to its
893    // value; and it takes the objective's ∞-norm over the *non-fixed*
894    // variables only, because fixed ones are not in the algorithm's x.
895    // Both matter: on `flosp2hm` an unpinned x0 moved ‖∇f‖∞ by five orders
896    // of magnitude and decided whether the objective got scaled at all
897    // (see `scale_gradient_based`). So the preview lifts first, and
898    // re-evaluates when the lift actually moved something.
899    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    // --- warnings + verdict ---
956    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    // A quadratic row the sample cannot see is not a curiosity when its
1013    // coefficients disagree by orders of magnitude: the row keeps factor
1014    // 1.0, its slack `s = −g(x)` inherits the right-hand side's scale, and
1015    // the `−s/λ` KKT diagonal inherits it too (gh #703).
1016    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
1083/// The per-component interior clamp from
1084/// `DefaultIterateInitializer::push_to_interior` (see
1085/// `crates/pounce-algorithm/src/init/default.rs` and
1086/// `docs/src/initialization.md`).
1087pub 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
1134/// Keep the `cap` worst entries by violation, descending.
1135fn 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
1166// ---------------------------------------------------------------------------
1167// Console + JSON rendering.
1168// ---------------------------------------------------------------------------
1169
1170fn 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
1276/// The `nlp_scaling_method=gradient-based` section of the text report.
1277fn 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    /// min 1/x0 + x1  s.t. x0 + x1 = 1, with x0 starting AT zero — the
1480    /// canonical Invalid_Number_Detected trap.
1481    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        // x0[0] = 0 → f = 1/0 = inf, grad[0] = -inf.
1557        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        // x0 + x1 = 1 exactly: feasible.
1570        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        // x0[0] = 1e-12 is (numerically) on its lower bound 0; the clamp
1578        // moves it to ~bound_push = 1e-2 (span is infinite: one-sided).
1579        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        // clamp brings it inside: from -3 to lo + push
1596        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        // Two-sided [1, 5], bound_push=bound_frac=1e-2:
1610        // p_l = min(1e-2*1, 1e-2*4) = 0.01 → 1.0 clamps to 1.01.
1611        assert!((clamp_to_interior(1.0, 1.0, 5.0, 1e-2, 1e-2) - 1.01).abs() < 1e-15);
1612        // Interior stays put.
1613        assert_eq!(clamp_to_interior(3.0, 1.0, 5.0, 1e-2, 1e-2), 3.0);
1614        // Free variable untouched.
1615        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        // Upper one-sided: hi=100 → push = 1e-2*100 = 1 → 100 → 99.
1620        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    // ---------------------------------------------------------------
1634    // gh #703 — the scaling preview
1635    // ---------------------------------------------------------------
1636
1637    /// `min 10·x  s.t.  1000·x ≥ 4e6`, started at `x = 5000`.
1638    ///
1639    /// The same fixture as `orig_ipopt_nlp`'s
1640    /// `gradient_based_scaling_scales_d_l_and_d_u` / `..._obj_target_gradient`
1641    /// pair, restated here so the preview is pinned against a case whose
1642    /// factors that module already asserts the *solver* produces: row max
1643    /// 1000 against the cutoff 100 gives `d_scale = 0.1`, and
1644    /// `‖∇f‖ = 10 < 100` leaves the objective at 1.0.
1645    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        // ‖∇f‖ = 10 is below the cutoff, so the objective is unscaled.
1718        assert_eq!(s.max_grad_f, 10.0);
1719        assert_eq!(s.obj_scale, 1.0);
1720        // The single row is an inequality; row max 1000 > 100, so the
1721        // block fires and the factor is 100/1000.
1722        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        // No `.nl`, so no coefficient census.
1729        assert_eq!(s.n_quad_rows, 0);
1730    }
1731
1732    #[test]
1733    fn a_row_below_the_cutoff_leaves_the_whole_block_unscaled() {
1734        // `gradient_scaling_fires` is a per-block gate, not per-row: the
1735        // DomainTrap equality's Jacobian is [1, 1], far below 100.
1736        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    /// `½·(4x₀² + 2x₁²) ≤ 1e5` written about the origin, plus a linear
1753    /// row so the model is not degenerate. Every variable is free and no
1754    /// initial guess is supplied, so `x0 = 0` and the quadratic row's
1755    /// Jacobian there is identically zero.
1756    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        // Q = diag(4, 2) ⇒ ‖Q‖_∞ = 4; no linear part; b = 1e5.
1822        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        // The mismatch is 2.5e4, well past the 1e2 threshold, so the
1830        // preflight says so rather than leaving it in the numbers.
1831        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    /// The objective factor the preview predicts must be the one
1841    /// `OrigIpoptNlp` actually installs. Both call
1842    /// [`gradient_obj_scale`], so the arithmetic cannot drift; what this
1843    /// pins is everything *around* it — which point is sampled, and which
1844    /// variables the ∞-norm is taken over.
1845    #[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    /// `min x₀ + 1e6·x₁` where `x₁` is **fixed** at 3.
1888    ///
1889    /// The big gradient component belongs to the fixed variable, which is
1890    /// not in the algorithm's `x`, so the scaler's ∞-norm is 1 and the
1891    /// objective comes out unscaled. A preview that took the norm over all
1892    /// `n` components would read 1e6 and predict a factor of 1e-4 — which
1893    /// is what this fixture is here to catch.
1894    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        // At a cutoff of 1 the *linear* row (coefficients 1) still does
1964        // not exceed it, but the quadratic row is unreachable at any
1965        // cutoff: 100/0 and 1/0 both clamp to 1.0. The blind spot is not
1966        // a tuning problem.
1967        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}