Skip to main content

pounce_cli/diagnose/
mod.rs

1//! `pounce diagnose <problem.nl>` — a model doctor that runs no solve.
2//!
3//! # Why this exists
4//!
5//! pounce's best model diagnostics used to be reachable only by solving:
6//! the debugger's `diagnose` and `print rank` run inside `--debug`, Studio
7//! reads a finished solve report, and the presolve structural checks surface
8//! only as presolve warnings. A user holding an `Infeasible_Problem_Detected`
9//! or `Invalid_Number_Detected` had no single command that asks "what is
10//! wrong with this model?". This is that command (gh#1160, phases 1–2).
11//!
12//! # What it checks, in order
13//!
14//! | check | basis | source |
15//! |---|---|---|
16//! | `bounds` | proof | [`statics::check_bounds`] |
17//! | `linear_rows` | proof | [`statics::check_linear_rows`] |
18//! | `incidence` | proof | [`statics::check_incidence`] |
19//! | `expression_domains`, `row_enclosures` | proof / heuristic | [`domain::check_domains`] |
20//! | `coefficient_ranges` | proof | [`scaling::check_coefficients`] |
21//! | `start_point` | numerical | `check-x0`, embedded as a section |
22//! | `nonfinite_localization` | numerical | [`domain::check_nonfinite`] |
23//! | `structural_rank` | structural | [`structure::check_structural`] |
24//! | `numerical_rank` | numerical | [`structure::check_numerical`] |
25//! | `jacobian_scaling` | numerical | [`scaling::check_jacobian`] |
26//! | `rank_robustness` | numerical | [`robustness::check_robustness`] (phase 2) |
27//! | `kkt_audit`, `licq`, `mfcq` | numerical | [`kkt::check_kkt`] (phase 2, needs `--sol`) |
28//! | `hessian_inertia`, `reduced_hessian` | numerical | [`convexity::check_hessian`] (phase 2) |
29//! | `convexity_sampling` | numerical | [`convexity::check_sampling`] (phase 2) |
30//!
31//! The numerical checks run at one point — the model's start, `--x0-file`,
32//! or the primal values of a `.sol` (`--sol`) — and say so in every finding.
33//! A finding about one point is never a claim about the model.
34//!
35//! The command never modifies the model and never solves it. Exit code `0`
36//! when no finding is an error, `22` when one is, `2` on a usage or I/O
37//! error. The schema is `pounce.diagnose/v1` (see [`report`]).
38//!
39//! User-facing documentation: `docs/src/diagnose.md`.
40
41pub mod convexity;
42pub mod domain;
43pub mod kkt;
44pub mod report;
45pub mod robustness;
46pub mod scaling;
47pub mod statics;
48pub mod structure;
49
50#[cfg(test)]
51mod phase2_tests;
52
53use crate::check_x0::{
54    CheckX0Args, CheckX0Outcome, check_tnlp_with_quadratics, clamp_to_interior, quad_row_coefs,
55};
56use crate::nl_reader::{self, NlProblem, NlTnlp};
57use crate::verify::{name_at, sha256};
58use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
59use pounce_nlp::tnlp::{SparsityRequest, StartingPoint, TNLP};
60use report::{Basis, Confidence, Finding, Report, Severity};
61use serde_json::json;
62use std::path::PathBuf;
63use std::process::ExitCode;
64
65/// The solver's default `bound_push` / `bound_frac`, for the clamp preview.
66const BOUND_PUSH: Number = 1e-2;
67const BOUND_FRAC: Number = 1e-2;
68
69/// Tunables shared by the checks.
70#[derive(Debug, Clone)]
71pub struct DiagnoseOptions {
72    /// Absolute/relative slack before a row or bound is called violated or
73    /// empty (default 1e-6, the `check-x0` default).
74    pub feas_tol: Number,
75    /// Slack before an operator argument counts as outside its domain, to
76    /// absorb outward rounding (default 1e-12).
77    pub domain_tol: Number,
78    /// Singular-value threshold relative to σ_max. Default
79    /// `max(m, n)·ε`, the LAPACK/NumPy convention.
80    pub rank_rel_tol: Option<Number>,
81    /// Dense SVD limits: total entries and smaller dimension.
82    pub max_dense_entries: usize,
83    pub max_dense_dim: usize,
84    /// How many minimal dependent sets to localize.
85    pub max_dependent_sets: usize,
86    /// Largest candidate set the deletion filter will shrink.
87    pub max_dependent_search: usize,
88    /// A magnitude ratio above this is a "wide" range (default 1e8).
89    pub wide_range: Number,
90    /// Findings kept per code (default 25).
91    pub max_per_code: usize,
92    /// Stationarity / zero-multiplier tolerance, relative to `max(1, ‖∇f‖∞)`
93    /// (default 1e-6).
94    pub opt_tol: Number,
95    /// Inactive inequalities within this relative band of their bound are
96    /// reported as near-active (default 1e-4).
97    pub weak_band: Number,
98    /// Seed for the convexity sampler (default 0).
99    pub seed: u64,
100    /// Sample pairs per function (default 64).
101    pub convexity_samples: usize,
102    /// Half-width of the sampling box, relative to `max(1, |x_j|)` (default 1).
103    pub sample_radius: Number,
104    /// Relative slack before a Jensen gap counts (default 1e-9).
105    pub convexity_tol: Number,
106}
107
108impl Default for DiagnoseOptions {
109    fn default() -> Self {
110        DiagnoseOptions {
111            feas_tol: 1e-6,
112            domain_tol: 1e-12,
113            rank_rel_tol: None,
114            max_dense_entries: 4_000_000,
115            max_dense_dim: 3_000,
116            max_dependent_sets: 5,
117            max_dependent_search: 200,
118            wide_range: 1e8,
119            max_per_code: 25,
120            opt_tol: 1e-6,
121            weak_band: 1e-4,
122            seed: 0,
123            convexity_samples: 64,
124            sample_radius: 1.0,
125            convexity_tol: 1e-9,
126        }
127    }
128}
129
130/// Parsed `diagnose` subcommand arguments.
131#[derive(Debug, Clone, Default)]
132pub struct DiagnoseArgs {
133    pub nl: Option<PathBuf>,
134    pub x0_file: Option<PathBuf>,
135    pub sol: Option<PathBuf>,
136    /// Replaces the point's label in the report (`--point-label`).
137    pub point_label: Option<String>,
138    /// Further points for `rank_robustness` (`--extra-point`, repeatable).
139    pub extra_points: Vec<PathBuf>,
140    pub json: bool,
141    pub json_output: Option<PathBuf>,
142    pub opts: DiagnoseOptions,
143}
144
145const USAGE: &str = "\
146Usage: pounce diagnose <problem.nl> [OPTIONS]
147
148Check a model without solving it. Reports, with the evidence for each:
149contradictory bounds and rows (proved from the algebra), duplicate and
150parallel rows, single-variable rows that are really bounds, variables that
151appear nowhere, unbounded objective directions, operators whose arguments
152the bounds do not keep inside their domain, rows that can never hold,
153coefficient and scaling ranges, and - at one point - which operator made a
154value or derivative NaN/Inf, structural and numerical rank of the equality
155Jacobian, the smallest sets of dependent equations, and Jacobian scale
156spread. The starting-point checks of `pounce check-x0` are included.
157
158With --sol it also checks the solution: stationarity, multiplier signs and
159complementarity, LICQ (with the dependent active constraints and how far
160the multipliers can move), MFCQ, near-active and weakly active
161constraints, and the reduced Hessian. At every point: whether a rank loss
162survives other tolerances, scalings, a QR, and other points; the Hessian's
163inertia; and sampled counterexamples to convexity.
164
165Arguments:
166  <problem.nl>              AMPL .nl problem
167
168Point for the numerical checks (default: the model's starting point):
169  --x0-file <path>          n whitespace-separated values
170  --sol <path>              the primal values in an AMPL .sol file
171  --point-label <text>      name the point in the report
172  --extra-point <path>      a further point (n values) for rank_robustness;
173                            repeatable
174
175Options:
176  --json                    print the JSON report (pounce.diagnose/v1)
177  --json-output <path>      also write the JSON report to <path>
178  --feas-tol <t>            slack for 'violated' / 'empty' (default 1e-6)
179  --domain-tol <t>          slack for operator domains (default 1e-12)
180  --rank-tol <t>            singular-value threshold relative to sigma_max
181                            (default max(m,n)*eps)
182  --max-dense-entries <k>   dense SVD limit on m*n (default 4000000)
183  --max-dense-dim <k>       dense SVD limit on min(m,n) (default 3000)
184  --max-dependent-sets <k>  minimal dependent sets to localize (default 5)
185  --max-dependent-search <k>
186                            largest set the minimal-set search shrinks
187                            (default 200)
188  --wide-range <r>          magnitude ratio reported as wide (default 1e8)
189  --max-per-code <k>        findings kept per code (default 25)
190  --opt-tol <t>             stationarity / zero-multiplier tolerance,
191                            relative to max(1, |grad f|) (default 1e-6)
192  --weak-band <t>           near-active band, relative (default 1e-4)
193  --seed <k>                convexity sampler seed (default 0)
194  --convexity-samples <k>   sample pairs per function (default 64)
195  --sample-radius <r>       sampling box half-width, relative (default 1)
196  --convexity-tol <t>       relative Jensen gap that counts as a
197                            counterexample (default 1e-9)
198  -h, --help                print this message
199
200Exit code: 0 = no error findings (warnings allowed), 22 = at least one
201error finding, 2 = usage/IO error.";
202
203/// Entry point dispatched from `main` when argv[1] == "diagnose".
204pub fn run_from_argv(rest: &[String]) -> ExitCode {
205    let args = match parse_argv(rest) {
206        Ok(Some(a)) => a,
207        Ok(None) => {
208            println!("{USAGE}");
209            return ExitCode::SUCCESS;
210        }
211        Err(msg) => {
212            eprintln!("pounce diagnose: {msg}");
213            eprintln!("{USAGE}");
214            return ExitCode::from(2);
215        }
216    };
217    run(&args)
218}
219
220fn parse_argv(rest: &[String]) -> Result<Option<DiagnoseArgs>, String> {
221    let mut a = DiagnoseArgs::default();
222    let mut i = 0;
223    fn value<'a>(rest: &'a [String], i: &mut usize, flag: &str) -> Result<&'a str, String> {
224        *i += 1;
225        rest.get(*i)
226            .map(String::as_str)
227            .ok_or_else(|| format!("{flag} needs a value"))
228    }
229    fn parse<T: std::str::FromStr>(s: &str, flag: &str) -> Result<T, String> {
230        s.parse().map_err(|_| format!("{flag}: cannot parse `{s}`"))
231    }
232    while i < rest.len() {
233        let arg = rest[i].as_str();
234        match arg {
235            "-h" | "--help" => return Ok(None),
236            "--json" => a.json = true,
237            "--json-output" => a.json_output = Some(value(rest, &mut i, arg)?.into()),
238            "--x0-file" => a.x0_file = Some(value(rest, &mut i, arg)?.into()),
239            "--sol" => a.sol = Some(value(rest, &mut i, arg)?.into()),
240            "--point-label" => a.point_label = Some(value(rest, &mut i, arg)?.to_string()),
241            "--feas-tol" => a.opts.feas_tol = parse(value(rest, &mut i, arg)?, arg)?,
242            "--domain-tol" => a.opts.domain_tol = parse(value(rest, &mut i, arg)?, arg)?,
243            "--rank-tol" => a.opts.rank_rel_tol = Some(parse(value(rest, &mut i, arg)?, arg)?),
244            "--max-dense-entries" => {
245                a.opts.max_dense_entries = parse(value(rest, &mut i, arg)?, arg)?
246            }
247            "--max-dense-dim" => a.opts.max_dense_dim = parse(value(rest, &mut i, arg)?, arg)?,
248            "--max-dependent-sets" => {
249                a.opts.max_dependent_sets = parse(value(rest, &mut i, arg)?, arg)?
250            }
251            "--max-dependent-search" => {
252                a.opts.max_dependent_search = parse(value(rest, &mut i, arg)?, arg)?
253            }
254            "--wide-range" => a.opts.wide_range = parse(value(rest, &mut i, arg)?, arg)?,
255            "--max-per-code" => a.opts.max_per_code = parse(value(rest, &mut i, arg)?, arg)?,
256            "--extra-point" => a.extra_points.push(value(rest, &mut i, arg)?.into()),
257            "--opt-tol" => a.opts.opt_tol = parse(value(rest, &mut i, arg)?, arg)?,
258            "--weak-band" => a.opts.weak_band = parse(value(rest, &mut i, arg)?, arg)?,
259            "--seed" => a.opts.seed = parse(value(rest, &mut i, arg)?, arg)?,
260            "--convexity-samples" => {
261                a.opts.convexity_samples = parse(value(rest, &mut i, arg)?, arg)?
262            }
263            "--sample-radius" => a.opts.sample_radius = parse(value(rest, &mut i, arg)?, arg)?,
264            "--convexity-tol" => a.opts.convexity_tol = parse(value(rest, &mut i, arg)?, arg)?,
265            s if s.starts_with('-') => return Err(format!("unknown option `{s}`")),
266            s => {
267                if a.nl.is_some() {
268                    return Err(format!("unexpected extra argument `{s}`"));
269                }
270                a.nl = Some(s.into());
271            }
272        }
273        i += 1;
274    }
275    if a.nl.is_none() {
276        return Err("expected a <problem.nl> argument".into());
277    }
278    if a.x0_file.is_some() && a.sol.is_some() {
279        return Err("--x0-file and --sol are mutually exclusive".into());
280    }
281    Ok(Some(a))
282}
283
284pub fn run(args: &DiagnoseArgs) -> ExitCode {
285    let report = match diagnose_file(args) {
286        Ok(r) => r,
287        Err(msg) => {
288            eprintln!("pounce diagnose: {msg}");
289            return ExitCode::from(2);
290        }
291    };
292    let json_text =
293        || serde_json::to_string_pretty(&report.to_json()).unwrap_or_else(|_| "{}".into());
294    if args.json {
295        println!("{}", json_text());
296    } else {
297        print!("{}", report.to_text());
298    }
299    if let Some(path) = &args.json_output {
300        if let Err(e) = std::fs::write(path, json_text()) {
301            eprintln!(
302                "pounce diagnose: failed to write report {}: {e}",
303                path.display()
304            );
305            return ExitCode::from(2);
306        }
307        if !args.json {
308            println!("  report: {}", path.display());
309        }
310    }
311    if report.has_errors() {
312        ExitCode::from(22)
313    } else {
314        ExitCode::SUCCESS
315    }
316}
317
318/// Load the `.nl`, pick the point, and run every check.
319pub fn diagnose_file(args: &DiagnoseArgs) -> Result<Report, String> {
320    let path = args.nl.as_ref().ok_or("expected a <problem.nl> argument")?;
321    let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
322    let prob = nl_reader::read_nl_file(path)?;
323    if !prob.complementarity.is_empty() {
324        return Err(format!(
325            "{} declares {} complementarity pair(s), which diagnose does not model",
326            path.display(),
327            prob.complementarity.len()
328        ));
329    }
330    let read_values = |p: &PathBuf| -> Result<Vec<Number>, String> {
331        let text =
332            std::fs::read_to_string(p).map_err(|e| format!("cannot read {}: {e}", p.display()))?;
333        let vals: Result<Vec<Number>, _> = text.split_whitespace().map(str::parse).collect();
334        vals.map_err(|e| format!("{}: bad value: {e}", p.display()))
335    };
336    let mut inputs = Inputs::default();
337    if let Some(p) = &args.sol {
338        let text =
339            std::fs::read_to_string(p).map_err(|e| format!("cannot read {}: {e}", p.display()))?;
340        let sol = crate::verify::parse_sol(&text)?;
341        inputs.point = Some((format!("the solution in {}", p.display()), sol.x));
342        inputs.multipliers = (!sol.lambda.is_empty()).then_some(sol.lambda);
343        if sol.z_l.is_some() || sol.z_u.is_some() {
344            let n = prob.n;
345            inputs.bound_multipliers = Some((
346                sol.z_l.unwrap_or_else(|| vec![0.0; n]),
347                sol.z_u.unwrap_or_else(|| vec![0.0; n]),
348            ));
349        }
350        inputs.is_solution = true;
351    } else if let Some(p) = &args.x0_file {
352        inputs.point = Some((format!("the point in {}", p.display()), read_values(p)?));
353    }
354    if let (Some(pt), Some(l)) = (inputs.point.as_mut(), &args.point_label) {
355        pt.0 = l.clone();
356    }
357    for p in &args.extra_points {
358        inputs
359            .extra_points
360            .push((format!("the point in {}", p.display()), read_values(p)?));
361    }
362    let mut report = diagnose_problem(&prob, &inputs, path.display().to_string(), &args.opts)?;
363    report.sha256 = Some(sha256::hex(&bytes));
364    Ok(report)
365}
366
367/// What to evaluate at, beyond the model itself.
368#[derive(Debug, Clone, Default)]
369pub struct Inputs {
370    /// Overrides the model's starting point: (label, values).
371    pub point: Option<(String, Vec<Number>)>,
372    /// Constraint multipliers at `point`, as a `.sol` carries them.
373    pub multipliers: Option<Vec<Number>>,
374    /// Bound multipliers `(z_L, z_U)` at `point`, from the `.sol`'s
375    /// `ipopt_zL_out` / `ipopt_zU_out` suffixes when present.
376    pub bound_multipliers: Option<(Vec<Number>, Vec<Number>)>,
377    /// `point` is where a solve stopped (enables the KKT checks).
378    pub is_solution: bool,
379    /// Further points for `rank_robustness`.
380    pub extra_points: Vec<(String, Vec<Number>)>,
381}
382
383/// Run every check on an in-memory problem.
384pub fn diagnose_problem(
385    prob: &NlProblem,
386    inputs: &Inputs,
387    source: String,
388    opts: &DiagnoseOptions,
389) -> Result<Report, String> {
390    let point = inputs.point.clone();
391    let mut report = Report::new(source.clone(), prob.n, prob.m, opts.max_per_code);
392    report.tolerances.insert("feas_tol", opts.feas_tol);
393    report.tolerances.insert("domain_tol", opts.domain_tol);
394    report.tolerances.insert("wide_range", opts.wide_range);
395    report
396        .tolerances
397        .insert("convexity_tol", opts.convexity_tol);
398
399    // Static checks: the model as written.
400    statics::check_bounds(prob, &mut report);
401    statics::check_linear_rows(prob, opts, &mut report);
402    statics::check_incidence(prob, &mut report);
403    domain::check_domains(prob, opts, &mut report);
404    let quad = quad_row_coefs(prob);
405    scaling::check_coefficients(prob, &quad, opts, &mut report);
406
407    // Point checks.
408    let mut tnlp = NlTnlp::try_new(prob.clone())?;
409    let x = match &point {
410        Some((label, v)) => {
411            if v.len() != prob.n {
412                return Err(format!(
413                    "{label} has {} values but the problem has {} variables",
414                    v.len(),
415                    prob.n
416                ));
417            }
418            v.clone()
419        }
420        None => starting_point(&mut tnlp, prob.n)?,
421    };
422    let label = point
423        .as_ref()
424        .map(|p| p.0.clone())
425        .unwrap_or_else(|| "the model's starting point".to_string());
426    report.point = label.clone();
427
428    let x0_args = CheckX0Args {
429        x0_values: Some((label.clone(), x.clone())),
430        feas_tol: opts.feas_tol,
431        ..Default::default()
432    };
433    // A solve starts from a point only after its interior clamp; if the
434    // clamp moves it, evaluate there too so a non-finite value the clamp
435    // removes is not reported as fatal. A solution is a point a solve
436    // already reached — often exactly on an active bound — so it is never
437    // clamped: what is non-finite there is non-finite.
438    let clamped: Vec<Number> = (0..prob.n)
439        .map(|j| clamp_to_interior(x[j], prob.x_l[j], prob.x_u[j], BOUND_PUSH, BOUND_FRAC))
440        .collect();
441    let clamped = (!inputs.is_solution && clamped != x).then_some(clamped);
442    let hess_after_clamp = clamped
443        .as_ref()
444        .and_then(|c| hessian_nonfinite(&mut tnlp, c, prob.m));
445    match check_tnlp_with_quadratics(
446        &mut tnlp,
447        &prob.var_names,
448        &prob.con_names,
449        None,
450        source,
451        &quad,
452        &x0_args,
453    ) {
454        Ok(o) => start_point_findings(
455            &o,
456            &label,
457            inputs.is_solution,
458            hess_after_clamp,
459            &mut report,
460        ),
461        Err(e) => report.unavailable("start_point", e),
462    }
463    let clamped_pt = clamped
464        .map(|c| PointEval::evaluate(&mut tnlp, c, format!("{label} after the interior clamp")));
465    let mut pt = PointEval::evaluate(&mut tnlp, x, label);
466    pt.is_solution = inputs.is_solution;
467    domain::check_nonfinite(prob, &pt, clamped_pt.as_ref(), &mut report);
468    let sr = structure::check_structural(prob, &pt, &mut report);
469    structure::check_numerical(prob, &pt, sr, opts, &mut report);
470    scaling::check_jacobian(prob, &pt, opts, &mut report);
471
472    // Phase 2. Further points for the robustness check: the model's start
473    // (when the main point is another one) and each --extra-point.
474    let mut others: Vec<PointEval> = Vec::new();
475    if point.is_some() {
476        let x0 = starting_point(&mut tnlp, prob.n)?;
477        others.push(PointEval::evaluate(
478            &mut tnlp,
479            x0,
480            "the model's starting point".into(),
481        ));
482    }
483    for (l, v) in &inputs.extra_points {
484        if v.len() != prob.n {
485            return Err(format!(
486                "{l} has {} values but the problem has {} variables",
487                v.len(),
488                prob.n
489            ));
490        }
491        others.push(PointEval::evaluate(&mut tnlp, v.clone(), l.clone()));
492    }
493    robustness::check_robustness(prob, &pt, &others, opts, &mut report);
494    let kkt = kkt::check_kkt(
495        prob,
496        &pt,
497        inputs.multipliers.as_deref(),
498        inputs
499            .bound_multipliers
500            .as_ref()
501            .map(|(l, u)| (l.as_slice(), u.as_slice())),
502        opts,
503        &mut report,
504    );
505    convexity::check_hessian(prob, &mut tnlp, &pt, kkt.as_ref(), opts, &mut report);
506    // Every check either ran or says why not: reduced_hessian is skipped
507    // without a solution, and also whenever hessian_inertia returned early.
508    if !report.checks.iter().any(|c| c.name == "reduced_hessian") {
509        report.unavailable(
510            "reduced_hessian",
511            if kkt.is_none() {
512                "needs a solution with multipliers: pass the solve's .sol with --sol"
513            } else {
514                "the Hessian could not be evaluated here (see hessian_inertia)"
515            },
516        );
517    }
518    convexity::check_sampling(prob, &pt, opts, &mut report);
519    Ok(report)
520}
521
522fn starting_point(tnlp: &mut dyn TNLP, n: usize) -> Result<Vec<Number>, String> {
523    let m = tnlp
524        .get_nlp_info()
525        .map(|i| i.m.max(0) as usize)
526        .unwrap_or(0);
527    let mut x = vec![0.0; n];
528    let (mut zl, mut zu, mut lam) = (vec![0.0; n], vec![0.0; n], vec![0.0; m]);
529    if !tnlp.get_starting_point(StartingPoint {
530        init_x: true,
531        x: &mut x,
532        init_z: false,
533        z_l: &mut zl,
534        z_u: &mut zu,
535        init_lambda: false,
536        lambda: &mut lam,
537    }) {
538        return Err("get_starting_point failed".into());
539    }
540    Ok(x)
541}
542
543/// The `check-x0` outcome as findings, plus the full report as a section.
544/// Non-finite entries in the Hessian of the Lagrangian at `x` (objective
545/// factor 1, zero multipliers, as `check-x0` evaluates it). `None` when the
546/// evaluator has no exact Hessian.
547fn hessian_nonfinite(tnlp: &mut dyn TNLP, x: &[Number], m: usize) -> Option<usize> {
548    let nnz = tnlp.get_nlp_info()?.nnz_h_lag.max(0) as usize;
549    if nnz == 0 {
550        return Some(0);
551    }
552    let (mut hr, mut hc, mut hv) = (vec![0i32; nnz], vec![0i32; nnz], vec![0.0; nnz]);
553    let lambda = vec![0.0; m];
554    let ok = tnlp.eval_h(
555        None,
556        false,
557        1.0,
558        None,
559        false,
560        SparsityRequest::Structure {
561            irow: &mut hr,
562            jcol: &mut hc,
563        },
564    ) && tnlp.eval_h(
565        Some(x),
566        true,
567        1.0,
568        Some(&lambda),
569        true,
570        SparsityRequest::Values { values: &mut hv },
571    );
572    ok.then(|| hv.iter().filter(|v| !v.is_finite()).count())
573}
574
575/// `is_solution`: the point is a `.sol` a solve reached, so start-point
576/// advice (the clamp, an all-zero start) does not apply. `hess_after_clamp`:
577/// the Hessian's non-finite count at the clamped start, when the clamp
578/// moved it.
579fn start_point_findings(
580    o: &CheckX0Outcome,
581    label: &str,
582    is_solution: bool,
583    hess_after_clamp: Option<usize>,
584    report: &mut Report,
585) {
586    report
587        .sections
588        .insert("check_x0", crate::check_x0::report_value(o));
589    let mut sink = report.ran("start_point");
590    let rows =
591        |v: &[crate::verify::RowReport]| v.iter().map(|r| r.name.clone()).collect::<Vec<_>>();
592    if o.n_bound_violations > 0 {
593        sink.push(
594            Finding::new("start_point", "start_violates_variable_bounds", Severity::Warning,
595                Basis::Numerical, Confidence::Certain,
596                format!("{} variable(s) of {label} lie outside their bounds (worst by {:.3e}).", o.n_bound_violations, o.max_bound_violation))
597            .why("The solver projects the start into the box before iteration 0, so the point you supplied is not the point it starts from.")
598            .next("Check how the start was built (units, a stale warm start, an index shift).")
599            .vars(rows(&o.bound_violations))
600            .evidence(json!({"count": o.n_bound_violations, "max_violation": o.max_bound_violation})),
601        );
602    }
603    if o.x0_all_zero && o.n_vars > 0 && !is_solution {
604        sink.push(
605            Finding::new("start_point", "start_all_zero", Severity::Info, Basis::Numerical,
606                Confidence::Certain,
607                format!("Every component of {label} is zero."))
608            .why("Usually means no starting point was supplied; zero is often a poor or singular start for a nonlinear model.")
609            .next("Supply a starting point from the model's meaning, or check one with --x0-file."),
610        );
611    }
612    if o.n_clamp_moved > 0 && !is_solution {
613        sink.push(
614            Finding::new("start_point", "start_moved_by_bound_push", Severity::Info, Basis::Numerical,
615                Confidence::Certain,
616                format!("The interior clamp (bound_push) moves {} component(s) of {label}, by up to {:.3e}.", o.n_clamp_moved, o.max_clamp_move))
617            .why("Components on or too near a bound are pushed inside; a warm start loses exactly those components.")
618            .next("See sections.check_x0.interior_clamp for which components move.")
619            .evidence(json!({"count": o.n_clamp_moved, "max_move": o.max_clamp_move})),
620        );
621    }
622    if o.n_con_violations > 0 {
623        sink.push(
624            Finding::new("start_point", "start_constraint_violation",
625                if is_solution { Severity::Warning } else { Severity::Info }, Basis::Numerical,
626                Confidence::Certain,
627                format!("{} constraint(s) are violated at {label}; the largest violation is {:.3e}.", o.n_con_violations, o.max_con_violation))
628            .why(if is_solution {
629                "A solution that violates its constraints beyond the tolerance is not feasible; check the solve's status."
630            } else {
631                "Infeasible starts are normal for an interior-point method, but a very large violation usually means a wrong or missing starting point."
632            })
633            .next(if is_solution {
634                "Compare with `pounce verify`, and the worst rows in sections.check_x0."
635            } else {
636                "Compare the worst rows (sections.check_x0) with what the start was meant to satisfy."
637            })
638            .cons(rows(&o.con_violations))
639            .evidence(json!({"count": o.n_con_violations, "max_violation": o.max_con_violation})),
640        );
641    }
642    if let Some(h) = o.hess_nonfinite_count
643        && h > 0
644    {
645        // Downgrade only when the Hessian was re-evaluated at the clamped
646        // start and is finite there.
647        let cleared = hess_after_clamp == Some(0);
648        sink.push(
649                Finding::new("start_point", "nonfinite_hessian_at_point",
650                    if cleared { Severity::Warning } else { Severity::Error },
651                    Basis::Numerical, Confidence::Certain,
652                    format!("The Hessian of the Lagrangian has {h} non-finite entr{} at {label}.", if h == 1 { "y" } else { "ies" }))
653                .why(if cleared {
654                    "The interior clamp moves this start, and the Hessian is finite at the clamped point, so a default solve does not see it; a solve that evaluates the Hessian here aborts with Invalid_Number_Detected."
655                } else {
656                    "A solve that needs the Hessian here aborts with Invalid_Number_Detected."
657                })
658                .next("Look for second-derivative poles (sqrt, log, fractional powers near 0) in the nonlinear rows.")
659                .evidence(json!({"count": h, "count_after_interior_clamp": hess_after_clamp})),
660            );
661    }
662}
663
664/// Values and first derivatives at one point, from the solver's own
665/// evaluator (the same callbacks a solve would call).
666#[derive(Debug, Clone)]
667pub struct PointEval {
668    pub label: String,
669    pub x: Vec<Number>,
670    pub f: Option<Number>,
671    pub grad: Vec<Number>,
672    pub grad_ok: bool,
673    pub g: Vec<Number>,
674    pub g_ok: bool,
675    /// Jacobian triplets, 0-based.
676    pub jac_irow: Vec<usize>,
677    pub jac_jcol: Vec<usize>,
678    pub jac_val: Vec<Number>,
679    pub jac_structure_ok: bool,
680    pub jac_ok: bool,
681    /// The point is where a solve stopped (`--sol`).
682    pub is_solution: bool,
683}
684
685impl PointEval {
686    pub fn evaluate(tnlp: &mut dyn TNLP, x: Vec<Number>, label: String) -> Self {
687        let info = tnlp.get_nlp_info();
688        let (n, m, nnz, off) = match &info {
689            Some(i) => (
690                i.n.max(0) as usize,
691                i.m.max(0) as usize,
692                i.nnz_jac_g.max(0) as usize,
693                usize::from(matches!(
694                    i.index_style,
695                    pounce_nlp::tnlp::IndexStyle::Fortran
696                )),
697            ),
698            None => (x.len(), 0, 0, 0),
699        };
700        let f = tnlp.eval_f(&x, true);
701        let mut grad = vec![0.0; n];
702        let grad_ok = tnlp.eval_grad_f(&x, false, &mut grad);
703        let mut g = vec![0.0; m];
704        let g_ok = m == 0 || tnlp.eval_g(&x, false, &mut g);
705        let mut irow = vec![0i32; nnz];
706        let mut jcol = vec![0i32; nnz];
707        let mut jval = vec![0.0; nnz];
708        let jac_structure_ok = nnz == 0
709            || tnlp.eval_jac_g(
710                None,
711                false,
712                SparsityRequest::Structure {
713                    irow: &mut irow,
714                    jcol: &mut jcol,
715                },
716            );
717        let jac_ok = jac_structure_ok
718            && (nnz == 0
719                || tnlp.eval_jac_g(
720                    Some(&x),
721                    false,
722                    SparsityRequest::Values { values: &mut jval },
723                ));
724        PointEval {
725            label,
726            x,
727            f,
728            grad,
729            grad_ok,
730            g,
731            g_ok,
732            jac_irow: irow
733                .iter()
734                .map(|&r| (r as usize).wrapping_sub(off))
735                .collect(),
736            jac_jcol: jcol
737                .iter()
738                .map(|&c| (c as usize).wrapping_sub(off))
739                .collect(),
740            jac_val: jval,
741            jac_structure_ok,
742            jac_ok,
743            is_solution: false,
744        }
745    }
746
747    /// Evaluate an in-memory problem (builds its evaluator). Test helper.
748    #[cfg(test)]
749    pub(crate) fn evaluate_nl(prob: &NlProblem, x: Vec<Number>, label: String) -> Self {
750        let mut t = NlTnlp::try_new(prob.clone()).expect("evaluator");
751        Self::evaluate(&mut t, x, label)
752    }
753}
754
755pub(crate) fn vname(prob: &NlProblem, j: usize) -> String {
756    name_at(&prob.var_names, j, 'x')
757}
758
759pub(crate) fn cname(prob: &NlProblem, i: usize) -> String {
760    name_at(&prob.con_names, i, 'c')
761}
762
763/// A lower bound as an interval endpoint (`-∞` when absent).
764pub(crate) fn lo_eff(b: Number) -> Number {
765    if lower_bound_present(b) {
766        b
767    } else {
768        Number::NEG_INFINITY
769    }
770}
771
772/// An upper bound as an interval endpoint (`+∞` when absent).
773pub(crate) fn up_eff(b: Number) -> Number {
774    if upper_bound_present(b) {
775        b
776    } else {
777        Number::INFINITY
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use crate::nl_reader::{BinOp, Expr, NlProblemParts, UnaryOp};
785
786    #[test]
787    fn healthy_problem_has_no_errors_or_warnings() {
788        // min (x0-1)^2 + (x1-2)^2  s.t. x0 + x1 = 3, x0*x1 >= 1, x in [0, 10].
789        let sq = |j: usize, c: Number| {
790            Expr::Binary(
791                BinOp::Pow,
792                Box::new(Expr::Binary(
793                    BinOp::Sub,
794                    Box::new(Expr::Var(j)),
795                    Box::new(Expr::Const(c)),
796                )),
797                Box::new(Expr::Const(2.0)),
798            )
799        };
800        let p = NlProblem::from_expressions(NlProblemParts {
801            minimize: true,
802            objective: Expr::Binary(BinOp::Add, Box::new(sq(0, 1.0)), Box::new(sq(1, 2.0))),
803            obj_constant: 0.0,
804            constraints: vec![
805                Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1))),
806                Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1))),
807            ],
808            x_l: vec![0.0, 0.0],
809            x_u: vec![10.0, 10.0],
810            x0: vec![1.5, 1.5],
811            g_l: vec![3.0, 1.0],
812            g_u: vec![3.0, 1e19],
813            var_names: vec!["a".into(), "b".into()],
814            con_names: vec!["sum".into(), "prod".into()],
815        })
816        .unwrap();
817        let r = diagnose_problem(
818            &p,
819            &Inputs::default(),
820            "t".into(),
821            &DiagnoseOptions::default(),
822        )
823        .unwrap();
824        let bad: Vec<_> = r
825            .findings
826            .iter()
827            .filter(|f| f.severity != Severity::Info)
828            .map(|f| (f.code, f.observation.clone()))
829            .collect();
830        assert!(bad.is_empty(), "{bad:?}");
831        assert_eq!(r.verdict(), "CLEAN");
832        // Every check either ran or said why not.
833        let names: Vec<_> = r.checks.iter().map(|c| c.name).collect();
834        for c in [
835            "bounds",
836            "linear_rows",
837            "incidence",
838            "expression_domains",
839            "row_enclosures",
840            "coefficient_ranges",
841            "start_point",
842            "nonfinite_localization",
843            "structural_rank",
844            "numerical_rank",
845            "jacobian_scaling",
846        ] {
847            assert!(names.contains(&c), "missing check {c}: {names:?}");
848        }
849        let j = r.to_json();
850        assert_eq!(j["schema"], "pounce.diagnose/v1");
851        assert_eq!(j["verdict"], "CLEAN");
852    }
853
854    #[test]
855    fn nan_start_is_an_error_and_names_the_operator() {
856        let p = NlProblem::from_expressions(NlProblemParts {
857            minimize: true,
858            objective: Expr::Var(0),
859            obj_constant: 0.0,
860            constraints: vec![Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(0)))],
861            x_l: vec![-1e19],
862            x_u: vec![1e19],
863            x0: vec![-1.0],
864            g_l: vec![0.0],
865            g_u: vec![1e19],
866            var_names: vec![],
867            con_names: vec![],
868        })
869        .unwrap();
870        let r = diagnose_problem(
871            &p,
872            &Inputs::default(),
873            "t".into(),
874            &DiagnoseOptions::default(),
875        )
876        .unwrap();
877        assert!(r.has_errors());
878        assert!(
879            r.findings
880                .iter()
881                .any(|f| f.code == "nonfinite_from_domain_violation")
882        );
883    }
884
885    #[test]
886    fn nan_the_interior_clamp_removes_is_a_warning() {
887        // log(x), x in [0, 10], starting on the bound x = 0: log(0) = -inf at
888        // the raw start, but the solver clamps the start to x = 0.01 first.
889        let p = NlProblem::from_expressions(NlProblemParts {
890            minimize: true,
891            objective: Expr::Var(0),
892            obj_constant: 0.0,
893            constraints: vec![Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(0)))],
894            x_l: vec![0.0],
895            x_u: vec![10.0],
896            x0: vec![0.0],
897            g_l: vec![-5.0],
898            g_u: vec![1e19],
899            var_names: vec![],
900            con_names: vec![],
901        })
902        .unwrap();
903        let r = diagnose_problem(
904            &p,
905            &Inputs::default(),
906            "t".into(),
907            &DiagnoseOptions::default(),
908        )
909        .unwrap();
910        let f = r
911            .findings
912            .iter()
913            .find(|f| f.code == "nonfinite_from_pole")
914            .expect("localized");
915        assert_eq!(f.severity, Severity::Warning, "{}", f.why_it_matters);
916        // Off the bound's reach of the clamp (x0 = 0 with no lower bound),
917        // the same NaN is an error.
918        let mut q = p.clone();
919        q.x_l[0] = -1e19;
920        let r = diagnose_problem(
921            &q,
922            &Inputs::default(),
923            "t".into(),
924            &DiagnoseOptions::default(),
925        )
926        .unwrap();
927        let f = r
928            .findings
929            .iter()
930            .find(|f| f.code == "nonfinite_from_pole")
931            .expect("localized");
932        assert_eq!(f.severity, Severity::Error);
933    }
934
935    #[test]
936    fn a_solution_point_is_not_clamped() {
937        // The same log(x) at x = 0, but as a .sol point: the clamp models a
938        // solve's start, not a point a solve reached, so no downgrade.
939        let p = NlProblem::from_expressions(NlProblemParts {
940            minimize: true,
941            objective: Expr::Var(0),
942            obj_constant: 0.0,
943            constraints: vec![Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(0)))],
944            x_l: vec![0.0],
945            x_u: vec![10.0],
946            x0: vec![0.0],
947            g_l: vec![-5.0],
948            g_u: vec![1e19],
949            var_names: vec![],
950            con_names: vec![],
951        })
952        .unwrap();
953        let inputs = Inputs {
954            point: Some(("a solution".into(), vec![0.0])),
955            multipliers: Some(vec![0.0]),
956            is_solution: true,
957            ..Inputs::default()
958        };
959        let r = diagnose_problem(&p, &inputs, "t".into(), &DiagnoseOptions::default()).unwrap();
960        let f = r
961            .findings
962            .iter()
963            .find(|f| f.code == "nonfinite_from_pole")
964            .expect("localized");
965        assert_eq!(f.severity, Severity::Error);
966        assert!(
967            !r.findings
968                .iter()
969                .any(|f| f.code == "start_moved_by_bound_push")
970        );
971    }
972
973    #[test]
974    fn a_nonfinite_hessian_is_downgraded_only_if_finite_after_the_clamp() {
975        // The Hessian check uses zero multipliers, so the pole goes in the
976        // objective: min x0 + log(x1 - shift), x in [0, 10]^2.
977        let mk = |shift: Number, x0: Vec<Number>| {
978            NlProblem::from_expressions(NlProblemParts {
979                minimize: true,
980                objective: Expr::Binary(
981                    BinOp::Add,
982                    Box::new(Expr::Var(0)),
983                    Box::new(Expr::Unary(
984                        UnaryOp::Log,
985                        Box::new(Expr::Binary(
986                            BinOp::Sub,
987                            Box::new(Expr::Var(1)),
988                            Box::new(Expr::Const(shift)),
989                        )),
990                    )),
991                ),
992                obj_constant: 0.0,
993                constraints: vec![],
994                x_l: vec![0.0, 0.0],
995                x_u: vec![10.0, 10.0],
996                x0,
997                g_l: vec![],
998                g_u: vec![],
999                var_names: vec![],
1000                con_names: vec![],
1001            })
1002            .unwrap()
1003        };
1004        let hess = |p: &NlProblem| {
1005            let r = diagnose_problem(
1006                p,
1007                &Inputs::default(),
1008                "t".into(),
1009                &DiagnoseOptions::default(),
1010            )
1011            .unwrap();
1012            r.findings
1013                .iter()
1014                .find(|f| f.code == "nonfinite_hessian_at_point")
1015                .map(|f| f.severity)
1016        };
1017        // x = (0, 5), shift 5: the clamp moves only x0, the pole at x1 = 5
1018        // stays, so a default solve hits it: an error.
1019        assert_eq!(hess(&mk(5.0, vec![0.0, 5.0])), Some(Severity::Error));
1020        // x = (0, 0), shift 0: the clamp moves x1 off the pole, and the
1021        // Hessian is finite there: a warning.
1022        assert_eq!(hess(&mk(0.0, vec![0.0, 0.0])), Some(Severity::Warning));
1023    }
1024
1025    #[test]
1026    fn argv_parsing() {
1027        let a = parse_argv(&[
1028            "m.nl".into(),
1029            "--json".into(),
1030            "--rank-tol".into(),
1031            "1e-10".into(),
1032        ])
1033        .unwrap()
1034        .unwrap();
1035        assert!(a.json);
1036        assert_eq!(a.opts.rank_rel_tol, Some(1e-10));
1037        assert!(parse_argv(&[]).is_err());
1038        let b = parse_argv(&["m.nl".into(), "--convexity-tol".into(), "1e-6".into()])
1039            .unwrap()
1040            .unwrap();
1041        assert_eq!(b.opts.convexity_tol, 1e-6);
1042        assert!(parse_argv(&["m.nl".into(), "--bogus".into()]).is_err());
1043        assert!(
1044            parse_argv(&[
1045                "m.nl".into(),
1046                "--sol".into(),
1047                "a".into(),
1048                "--x0-file".into(),
1049                "b".into()
1050            ])
1051            .is_err()
1052        );
1053        assert!(parse_argv(&["--help".into()]).unwrap().is_none());
1054    }
1055}