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