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