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//!
25//! The checks are read-only and cost one evaluation of each callback:
26//! `O(nnz)` work, no factorization, no solve.
27//!
28//! Verdict / exit code: `0` when the model evaluates cleanly at x0
29//! (warnings allowed); `21` when an evaluation produced NaN/inf (the
30//! solver would fail); `2` on a usage or I/O error.
31//!
32//! User-facing background: `docs/src/initialization.md`.
33
34use crate::nl_reader;
35use crate::verify::{RowReport, box_violation, name_at, sha256};
36use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
37use pounce_nlp::tnlp::{BoundsInfo, SparsityRequest, StartingPoint, TNLP};
38use std::path::PathBuf;
39use std::process::ExitCode;
40
41/// Parsed `check-x0` subcommand arguments.
42#[derive(Debug, Clone)]
43pub struct CheckX0Args {
44    /// `.nl` path, or `None` when `--builtin` is used.
45    pub nl: Option<PathBuf>,
46    /// Built-in problem name (`--builtin rosenbrock`).
47    pub builtin: Option<String>,
48    /// Optional whitespace-separated file of `n` values overriding the
49    /// model's starting point (`--x0-file`).
50    pub x0_file: Option<PathBuf>,
51    /// Violations above this are counted in `n_violated` (default 1e-6).
52    pub feas_tol: Number,
53    /// `bound_push` used for the clamp preview (default 1e-2).
54    pub bound_push: Number,
55    /// `bound_frac` used for the clamp preview (default 1e-2).
56    pub bound_frac: Number,
57    /// Max offenders listed per category (default 5).
58    pub max_list: usize,
59    /// Print the JSON report to stdout instead of the text report.
60    pub json: bool,
61    /// Also write the JSON report to this path.
62    pub json_output: Option<PathBuf>,
63}
64
65impl Default for CheckX0Args {
66    fn default() -> Self {
67        CheckX0Args {
68            nl: None,
69            builtin: None,
70            x0_file: None,
71            feas_tol: 1e-6,
72            bound_push: 1e-2,
73            bound_frac: 1e-2,
74            max_list: 5,
75            json: false,
76            json_output: None,
77        }
78    }
79}
80
81const USAGE: &str = "\
82Usage: pounce check-x0 <problem.nl> [OPTIONS]
83       pounce check-x0 --builtin <name> [OPTIONS]
84
85Evaluate the model once at its starting point, before any solve, and
86report what iteration 0 will see: NaN/inf evaluations (fatal), bound
87violations of x0, how far the bound_push interior clamp will move the
88point, initial constraint violation, and derivative scale spread.
89
90Arguments:
91  <problem.nl>           AMPL .nl problem (x0 from its initial-guess
92                         segment; zeros for variables without one)
93
94Options:
95  --builtin <name>       check a built-in problem instead of a .nl file
96  --x0-file <path>       override x0 with n whitespace-separated values
97  --feas-tol <t>         constraint-violation report threshold (default 1e-6)
98  --bound-push <v>       bound_push used for the clamp preview (default 1e-2)
99  --bound-frac <v>       bound_frac used for the clamp preview (default 1e-2)
100  --max-list <k>         max offenders listed per category (default 5)
101  --json                 print the JSON report to stdout
102  --json-output <path>   write the JSON report to <path>
103  -h, --help             print this message
104
105Exit code: 0 = model evaluates cleanly at x0 (warnings allowed),
10621 = NaN/inf at x0 (a solve would abort), 2 = usage/IO error.";
107
108/// Entry point dispatched from `main` when argv[1] == "check-x0".
109pub fn run_from_argv(rest: &[String]) -> ExitCode {
110    let args = match parse_argv(rest) {
111        Ok(Some(a)) => a,
112        Ok(None) => {
113            println!("{USAGE}");
114            return ExitCode::SUCCESS;
115        }
116        Err(msg) => {
117            eprintln!("pounce check-x0: {msg}");
118            eprintln!("{USAGE}");
119            return ExitCode::from(2);
120        }
121    };
122    run(&args)
123}
124
125fn parse_argv(rest: &[String]) -> Result<Option<CheckX0Args>, String> {
126    let mut a = CheckX0Args::default();
127    let mut positionals: Vec<PathBuf> = Vec::new();
128    let mut it = rest.iter();
129    while let Some(arg) = it.next() {
130        match arg.as_str() {
131            "-h" | "--help" => return Ok(None),
132            "--builtin" => {
133                let v = it.next().ok_or("--builtin requires a value")?;
134                a.builtin = Some(v.clone());
135            }
136            "--x0-file" => {
137                let v = it.next().ok_or("--x0-file requires a value")?;
138                a.x0_file = Some(PathBuf::from(v));
139            }
140            "--feas-tol" => {
141                let v = it.next().ok_or("--feas-tol requires a value")?;
142                a.feas_tol = v.parse().map_err(|e| format!("--feas-tol: {e}"))?;
143            }
144            "--bound-push" => {
145                let v = it.next().ok_or("--bound-push requires a value")?;
146                a.bound_push = v.parse().map_err(|e| format!("--bound-push: {e}"))?;
147            }
148            "--bound-frac" => {
149                let v = it.next().ok_or("--bound-frac requires a value")?;
150                a.bound_frac = v.parse().map_err(|e| format!("--bound-frac: {e}"))?;
151            }
152            "--max-list" => {
153                let v = it.next().ok_or("--max-list requires a value")?;
154                a.max_list = v.parse().map_err(|e| format!("--max-list: {e}"))?;
155            }
156            "--json" => a.json = true,
157            "--json-output" => {
158                let v = it.next().ok_or("--json-output requires a value")?;
159                a.json_output = Some(PathBuf::from(v));
160            }
161            other if other.starts_with('-') => {
162                return Err(format!("unknown flag `{other}`"));
163            }
164            _ => positionals.push(PathBuf::from(arg)),
165        }
166    }
167    match (positionals.len(), &a.builtin) {
168        (0, Some(_)) => Ok(Some(a)),
169        (1, None) => {
170            a.nl = Some(positionals[0].clone());
171            Ok(Some(a))
172        }
173        (0, None) => Err("expected a <problem.nl> argument or --builtin <name>".to_string()),
174        _ => Err("expected exactly one of <problem.nl> or --builtin <name>".to_string()),
175    }
176}
177
178/// One non-finite evaluation entry.
179#[derive(Debug, Clone)]
180pub struct NonFinite {
181    pub index: usize,
182    pub name: String,
183    pub value: Number,
184}
185
186/// One Jacobian/Hessian non-finite entry (row/col in matrix coordinates).
187#[derive(Debug, Clone)]
188pub struct NonFiniteEntry {
189    pub row: usize,
190    pub col: usize,
191    pub row_name: String,
192    pub col_name: String,
193    pub value: Number,
194}
195
196/// One interior-clamp displacement entry.
197#[derive(Debug, Clone)]
198pub struct ClampMove {
199    pub index: usize,
200    pub name: String,
201    pub from: Number,
202    pub to: Number,
203    pub distance: Number,
204}
205
206/// Max/min-nonzero magnitude summary of a derivative array at x0.
207#[derive(Debug, Clone, Default)]
208pub struct ScaleSpread {
209    pub max_abs: Number,
210    pub min_abs_nonzero: Number,
211    /// `max_abs / min_abs_nonzero`, or 0 when there are no nonzeros.
212    pub ratio: Number,
213}
214
215/// The fully-evaluated preflight result.
216#[derive(Debug)]
217pub struct CheckX0Outcome {
218    pub n_vars: usize,
219    pub n_cons: usize,
220    pub nl_sha256: Option<String>,
221    pub source: String,
222    pub x0_source: String,
223    pub x0_all_zero: bool,
224    pub objective: Option<Number>,
225    // non-finite scans (counts are totals; lists are capped at max_list)
226    pub grad_nonfinite: Vec<NonFinite>,
227    pub grad_nonfinite_count: usize,
228    pub g_nonfinite: Vec<NonFinite>,
229    pub g_nonfinite_count: usize,
230    pub jac_nonfinite: Vec<NonFiniteEntry>,
231    pub jac_nonfinite_count: usize,
232    /// `None` when the TNLP declines exact Hessians (quasi-Newton).
233    pub hess_nonfinite_count: Option<usize>,
234    // x0 vs bounds
235    pub bound_violations: Vec<RowReport>,
236    pub n_bound_violations: usize,
237    pub max_bound_violation: Number,
238    pub n_on_bounds: usize,
239    // interior-clamp preview
240    pub clamp_moves: Vec<ClampMove>,
241    pub n_clamp_moved: usize,
242    pub max_clamp_move: Number,
243    // initial constraint violation
244    pub con_violations: Vec<RowReport>,
245    pub n_con_violations: usize,
246    pub max_con_violation: Number,
247    // derivative scale spread
248    pub grad_spread: ScaleSpread,
249    pub jac_spread: ScaleSpread,
250    // rollup
251    pub warnings: Vec<String>,
252    pub fatal: bool,
253    pub verdict: &'static str,
254}
255
256pub fn run(args: &CheckX0Args) -> ExitCode {
257    let outcome = match evaluate(args) {
258        Ok(o) => o,
259        Err(msg) => {
260            eprintln!("pounce check-x0: {msg}");
261            return ExitCode::from(2);
262        }
263    };
264
265    if args.json {
266        println!("{}", report_json(&outcome));
267    } else {
268        print_report(&outcome);
269    }
270    if let Some(path) = &args.json_output {
271        if let Err(e) = std::fs::write(path, report_json(&outcome).as_bytes()) {
272            eprintln!(
273                "pounce check-x0: failed to write report {}: {e}",
274                path.display()
275            );
276            return ExitCode::from(2);
277        }
278        if !args.json {
279            println!("  report: {}", path.display());
280        }
281    }
282
283    if outcome.fatal {
284        ExitCode::from(21)
285    } else {
286        ExitCode::SUCCESS
287    }
288}
289
290/// A model loaded for preflight: the evaluator plus its provenance.
291struct LoadedModel {
292    tnlp: std::rc::Rc<std::cell::RefCell<dyn TNLP>>,
293    var_names: Vec<String>,
294    con_names: Vec<String>,
295    nl_sha256: Option<String>,
296    source: String,
297}
298
299fn load_model(args: &CheckX0Args) -> Result<LoadedModel, String> {
300    if let Some(name) = &args.builtin {
301        let tnlp = crate::builtin::lookup(name)
302            .ok_or_else(|| format!("unknown builtin `{name}` (see `pounce --list-problems`)"))?;
303        return Ok(LoadedModel {
304            tnlp,
305            var_names: Vec::new(),
306            con_names: Vec::new(),
307            nl_sha256: None,
308            source: format!("builtin:{name}"),
309        });
310    }
311    let path = args
312        .nl
313        .as_ref()
314        .ok_or("expected a <problem.nl> argument or --builtin <name>")?;
315    let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
316    let sha = sha256::hex(&bytes);
317    let prob = nl_reader::read_nl_file(path)?;
318    let var_names = prob.var_names.clone();
319    let con_names = prob.con_names.clone();
320    let t = nl_reader::NlTnlp::try_new(prob)?;
321    Ok(LoadedModel {
322        tnlp: std::rc::Rc::new(std::cell::RefCell::new(t)),
323        var_names,
324        con_names,
325        nl_sha256: Some(sha),
326        source: path.display().to_string(),
327    })
328}
329
330fn evaluate(args: &CheckX0Args) -> Result<CheckX0Outcome, String> {
331    let model = load_model(args)?;
332    let mut tnlp = model.tnlp.borrow_mut();
333    check_tnlp(
334        &mut *tnlp,
335        &model.var_names,
336        &model.con_names,
337        model.nl_sha256.clone(),
338        model.source.clone(),
339        args,
340    )
341}
342
343/// The core preflight over any TNLP. Public so the debugger / tests can
344/// reuse it without going through a file.
345pub fn check_tnlp(
346    tnlp: &mut dyn TNLP,
347    var_names: &[String],
348    con_names: &[String],
349    nl_sha256: Option<String>,
350    source: String,
351    args: &CheckX0Args,
352) -> Result<CheckX0Outcome, String> {
353    let info = tnlp.get_nlp_info().ok_or("get_nlp_info failed")?;
354    let n = info.n.max(0) as usize;
355    let m = info.m.max(0) as usize;
356    let nnz = info.nnz_jac_g.max(0) as usize;
357    let nnz_h = info.nnz_h_lag.max(0) as usize;
358    let fortran = matches!(info.index_style, pounce_nlp::tnlp::IndexStyle::Fortran);
359    let off = if fortran { 1usize } else { 0usize };
360
361    // --- bounds ---
362    let mut x_l = vec![0.0; n];
363    let mut x_u = vec![0.0; n];
364    let mut g_l = vec![0.0; m];
365    let mut g_u = vec![0.0; m];
366    if !tnlp.get_bounds_info(BoundsInfo {
367        x_l: &mut x_l,
368        x_u: &mut x_u,
369        g_l: &mut g_l,
370        g_u: &mut g_u,
371    }) {
372        return Err("get_bounds_info failed".to_string());
373    }
374
375    // --- starting point ---
376    let mut x = vec![0.0; n];
377    let (mut zl_buf, mut zu_buf, mut lam_buf) = (vec![0.0; n], vec![0.0; n], vec![0.0; m]);
378    let x0_source = if let Some(path) = &args.x0_file {
379        let text = std::fs::read_to_string(path)
380            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
381        let vals: Result<Vec<Number>, _> = text
382            .split_whitespace()
383            .map(|t| t.parse::<Number>())
384            .collect();
385        let vals = vals.map_err(|e| format!("{}: bad value: {e}", path.display()))?;
386        if vals.len() != n {
387            return Err(format!(
388                "{} has {} values but the problem has {n} variables",
389                path.display(),
390                vals.len()
391            ));
392        }
393        x.copy_from_slice(&vals);
394        format!("--x0-file {}", path.display())
395    } else {
396        if !tnlp.get_starting_point(StartingPoint {
397            init_x: true,
398            x: &mut x,
399            init_z: false,
400            z_l: &mut zl_buf,
401            z_u: &mut zu_buf,
402            init_lambda: false,
403            lambda: &mut lam_buf,
404        }) {
405            return Err("get_starting_point failed".to_string());
406        }
407        "model".to_string()
408    };
409    let x0_all_zero = n > 0 && x.iter().all(|v| *v == 0.0);
410
411    // --- evaluations at x0 ---
412    let objective = tnlp.eval_f(&x, true);
413    let obj_finite = objective.map(|v| v.is_finite()).unwrap_or(false);
414
415    let mut grad_f = vec![0.0; n];
416    let grad_ok = tnlp.eval_grad_f(&x, false, &mut grad_f);
417    let (grad_nonfinite, grad_nonfinite_count) =
418        scan_nonfinite(&grad_f, var_names, 'x', args.max_list, grad_ok);
419
420    let mut g = vec![0.0; m];
421    let g_ok = m == 0 || tnlp.eval_g(&x, false, &mut g);
422    let (g_nonfinite, g_nonfinite_count) = scan_nonfinite(&g, con_names, 'c', args.max_list, g_ok);
423
424    // Jacobian: structure then values.
425    let mut irow = vec![0i32; nnz];
426    let mut jcol = vec![0i32; nnz];
427    let mut jval = vec![0.0; nnz];
428    let mut jac_ok = nnz == 0;
429    if nnz > 0 {
430        jac_ok = tnlp.eval_jac_g(
431            Some(&x),
432            false,
433            SparsityRequest::Structure {
434                irow: &mut irow,
435                jcol: &mut jcol,
436            },
437        ) && tnlp.eval_jac_g(
438            Some(&x),
439            false,
440            SparsityRequest::Values { values: &mut jval },
441        );
442    }
443    let mut jac_nonfinite = Vec::new();
444    let mut jac_nonfinite_count = 0usize;
445    if jac_ok {
446        for k in 0..nnz {
447            if !jval[k].is_finite() {
448                jac_nonfinite_count += 1;
449                if jac_nonfinite.len() < args.max_list {
450                    let row = (irow[k] as usize).wrapping_sub(off);
451                    let col = (jcol[k] as usize).wrapping_sub(off);
452                    jac_nonfinite.push(NonFiniteEntry {
453                        row,
454                        col,
455                        row_name: name_at(con_names, row, 'c'),
456                        col_name: name_at(var_names, col, 'x'),
457                        value: jval[k],
458                    });
459                }
460            }
461        }
462    } else if nnz > 0 {
463        jac_nonfinite_count = usize::MAX; // "evaluation itself failed"
464    }
465
466    // Hessian of the Lagrangian at (x0, lambda=0, obj_factor=1) — catches
467    // second-derivative domain errors. Optional: quasi-Newton TNLPs decline.
468    let hess_nonfinite_count = if nnz_h > 0 {
469        let mut hrow = vec![0i32; nnz_h];
470        let mut hcol = vec![0i32; nnz_h];
471        let mut hval = vec![0.0; nnz_h];
472        let lambda0 = vec![0.0; m];
473        let ok = tnlp.eval_h(
474            None,
475            false,
476            1.0,
477            None,
478            false,
479            SparsityRequest::Structure {
480                irow: &mut hrow,
481                jcol: &mut hcol,
482            },
483        ) && tnlp.eval_h(
484            Some(&x),
485            false,
486            1.0,
487            Some(&lambda0),
488            true,
489            SparsityRequest::Values { values: &mut hval },
490        );
491        if ok {
492            Some(hval.iter().filter(|v| !v.is_finite()).count())
493        } else {
494            None
495        }
496    } else {
497        None
498    };
499
500    // --- x0 vs bounds ---
501    let mut bound_violations: Vec<RowReport> = Vec::new();
502    let mut n_bound_violations = 0usize;
503    let mut max_bound_violation = 0.0_f64;
504    let mut n_on_bounds = 0usize;
505    for j in 0..n {
506        let viol = box_violation(x[j], x_l[j], x_u[j]);
507        if viol > args.feas_tol {
508            n_bound_violations += 1;
509            max_bound_violation = max_bound_violation.max(viol);
510            push_worst(
511                &mut bound_violations,
512                RowReport {
513                    index: j,
514                    name: name_at(var_names, j, 'x'),
515                    value: x[j],
516                    lo: x_l[j],
517                    hi: x_u[j],
518                    violation: viol,
519                },
520                args.max_list,
521            );
522        }
523        if x[j].is_finite() {
524            let at_lo =
525                lower_bound_present(x_l[j]) && (x[j] - x_l[j]).abs() <= 1e-8 * (1.0 + x_l[j].abs());
526            let at_hi =
527                upper_bound_present(x_u[j]) && (x_u[j] - x[j]).abs() <= 1e-8 * (1.0 + x_u[j].abs());
528            if at_lo || at_hi {
529                n_on_bounds += 1;
530            }
531        }
532    }
533
534    // --- interior-clamp preview (DefaultIterateInitializer::push_to_interior) ---
535    let mut clamp_moves: Vec<ClampMove> = Vec::new();
536    let mut n_clamp_moved = 0usize;
537    let mut max_clamp_move = 0.0_f64;
538    for j in 0..n {
539        if !x[j].is_finite() {
540            continue;
541        }
542        let to = clamp_to_interior(x[j], x_l[j], x_u[j], args.bound_push, args.bound_frac);
543        let d = (to - x[j]).abs();
544        if d > 0.0 {
545            n_clamp_moved += 1;
546            max_clamp_move = max_clamp_move.max(d);
547            if clamp_moves.len() < args.max_list
548                || clamp_moves.last().map(|w| d > w.distance).unwrap_or(false)
549            {
550                clamp_moves.push(ClampMove {
551                    index: j,
552                    name: name_at(var_names, j, 'x'),
553                    from: x[j],
554                    to,
555                    distance: d,
556                });
557                clamp_moves.sort_by(|a, b| {
558                    b.distance
559                        .partial_cmp(&a.distance)
560                        .unwrap_or(std::cmp::Ordering::Equal)
561                });
562                clamp_moves.truncate(args.max_list);
563            }
564        }
565    }
566
567    // --- initial constraint violation ---
568    let mut con_violations: Vec<RowReport> = Vec::new();
569    let mut n_con_violations = 0usize;
570    let mut max_con_violation = 0.0_f64;
571    if g_ok {
572        for i in 0..m {
573            let viol = box_violation(g[i], g_l[i], g_u[i]);
574            if viol > args.feas_tol {
575                n_con_violations += 1;
576                if viol.is_finite() {
577                    max_con_violation = max_con_violation.max(viol);
578                }
579                push_worst(
580                    &mut con_violations,
581                    RowReport {
582                        index: i,
583                        name: name_at(con_names, i, 'c'),
584                        value: g[i],
585                        lo: g_l[i],
586                        hi: g_u[i],
587                        violation: viol,
588                    },
589                    args.max_list,
590                );
591            }
592        }
593    }
594
595    // --- derivative scale spread ---
596    let grad_spread = scale_spread(grad_f.iter().copied());
597    let jac_spread = scale_spread(jval.iter().copied());
598
599    // --- warnings + verdict ---
600    let mut warnings = Vec::new();
601    let eval_failed = !grad_ok || !g_ok || (!jac_ok && nnz > 0) || objective.is_none();
602    let nonfinite_total = grad_nonfinite_count.min(usize::MAX - 1)
603        + g_nonfinite_count.min(usize::MAX - 1)
604        + if jac_nonfinite_count == usize::MAX {
605            0
606        } else {
607            jac_nonfinite_count
608        }
609        + hess_nonfinite_count.unwrap_or(0)
610        + usize::from(!obj_finite && objective.is_some());
611    let fatal = eval_failed || nonfinite_total > 0;
612    if eval_failed {
613        warnings.push(
614            "an evaluation callback failed outright at the starting point; \
615             the solver cannot start from this x0"
616                .to_string(),
617        );
618    }
619    if nonfinite_total > 0 {
620        warnings.push(format!(
621            "{nonfinite_total} non-finite value(s) at the starting point; a solve \
622             would abort with Invalid_Number_Detected. The interior clamp only \
623             repairs bound violations, not domain errors — move x0 into the \
624             domain or add bounds that keep it there"
625        ));
626    }
627    if x0_all_zero {
628        warnings.push(
629            "the starting point is all zeros: the model supplies no initial \
630             guess (or an explicitly zero one)"
631                .to_string(),
632        );
633    }
634    if n_bound_violations > 0 {
635        warnings.push(format!(
636            "x0 violates {n_bound_violations} variable bound(s) (max {max_bound_violation:.3e}); \
637             the initializer will clamp them inside"
638        ));
639    }
640    if n_on_bounds > 0 {
641        warnings.push(format!(
642            "{n_on_bounds} component(s) of x0 sit exactly on a bound and will be \
643             pushed into the interior (bound_push={:.1e}); if x0 is a previous \
644             solution, use the warm-start recipe (warm_start_init_point=yes with \
645             tightened warm_start_bound_push/_frac)",
646            args.bound_push
647        ));
648    }
649    if max_con_violation > 1e4 {
650        warnings.push(format!(
651            "very large initial infeasibility (max constraint violation \
652             {max_con_violation:.3e}); consider a better starting point or \
653             least_square_init_primal=yes"
654        ));
655    }
656    for (label, s) in [("gradient", &grad_spread), ("Jacobian", &jac_spread)] {
657        if s.ratio > 1e8 || s.max_abs > 1e8 {
658            warnings.push(format!(
659                "{label} magnitudes at x0 span a large range (max {:.3e}, min \
660                 nonzero {:.3e}); see the scaling reference page",
661                s.max_abs, s.min_abs_nonzero
662            ));
663        }
664    }
665
666    let verdict = if fatal {
667        "FATAL"
668    } else if warnings.is_empty() {
669        "CLEAN"
670    } else {
671        "WARNINGS"
672    };
673
674    Ok(CheckX0Outcome {
675        n_vars: n,
676        n_cons: m,
677        nl_sha256,
678        source,
679        x0_source,
680        x0_all_zero,
681        objective,
682        grad_nonfinite,
683        grad_nonfinite_count,
684        g_nonfinite,
685        g_nonfinite_count,
686        jac_nonfinite,
687        jac_nonfinite_count: if jac_nonfinite_count == usize::MAX {
688            0
689        } else {
690            jac_nonfinite_count
691        },
692        hess_nonfinite_count,
693        bound_violations,
694        n_bound_violations,
695        max_bound_violation,
696        n_on_bounds,
697        clamp_moves,
698        n_clamp_moved,
699        max_clamp_move,
700        con_violations,
701        n_con_violations,
702        max_con_violation,
703        grad_spread,
704        jac_spread,
705        warnings,
706        fatal,
707        verdict,
708    })
709}
710
711/// The per-component interior clamp from
712/// `DefaultIterateInitializer::push_to_interior` (see
713/// `crates/pounce-algorithm/src/init/default.rs` and
714/// `docs/src/initialization.md`).
715pub fn clamp_to_interior(
716    x: Number,
717    lo: Number,
718    hi: Number,
719    bound_push: Number,
720    bound_frac: Number,
721) -> Number {
722    match (lower_bound_present(lo), upper_bound_present(hi)) {
723        (true, true) => {
724            let span = hi - lo;
725            let p_l = (bound_push * lo.abs().max(1.0)).min(bound_frac * span);
726            let p_u = (bound_push * hi.abs().max(1.0)).min(bound_frac * span);
727            x.max(lo + p_l).min(hi - p_u)
728        }
729        (true, false) => x.max(lo + bound_push * lo.abs().max(1.0)),
730        (false, true) => x.min(hi - bound_push * hi.abs().max(1.0)),
731        (false, false) => x,
732    }
733}
734
735fn scan_nonfinite(
736    values: &[Number],
737    names: &[String],
738    kind: char,
739    cap: usize,
740    eval_ok: bool,
741) -> (Vec<NonFinite>, usize) {
742    if !eval_ok {
743        return (Vec::new(), 0);
744    }
745    let mut out = Vec::new();
746    let mut count = 0usize;
747    for (i, v) in values.iter().enumerate() {
748        if !v.is_finite() {
749            count += 1;
750            if out.len() < cap {
751                out.push(NonFinite {
752                    index: i,
753                    name: name_at(names, i, kind),
754                    value: *v,
755                });
756            }
757        }
758    }
759    (out, count)
760}
761
762/// Keep the `cap` worst entries by violation, descending.
763fn push_worst(list: &mut Vec<RowReport>, r: RowReport, cap: usize) {
764    list.push(r);
765    list.sort_by(|a, b| {
766        b.violation
767            .partial_cmp(&a.violation)
768            .unwrap_or(std::cmp::Ordering::Equal)
769    });
770    list.truncate(cap);
771}
772
773fn scale_spread(values: impl Iterator<Item = Number>) -> ScaleSpread {
774    let mut max_abs = 0.0_f64;
775    let mut min_abs = Number::INFINITY;
776    for v in values {
777        let a = v.abs();
778        if a.is_finite() && a > 0.0 {
779            max_abs = max_abs.max(a);
780            min_abs = min_abs.min(a);
781        }
782    }
783    if max_abs == 0.0 {
784        ScaleSpread::default()
785    } else {
786        ScaleSpread {
787            max_abs,
788            min_abs_nonzero: min_abs,
789            ratio: max_abs / min_abs,
790        }
791    }
792}
793
794// ---------------------------------------------------------------------------
795// Console + JSON rendering.
796// ---------------------------------------------------------------------------
797
798fn print_report(o: &CheckX0Outcome) {
799    println!("pounce check-x0 — starting-point preflight");
800    println!(
801        "  problem : {}  ({} vars, {} cons)",
802        o.source, o.n_vars, o.n_cons
803    );
804    if let Some(sha) = &o.nl_sha256 {
805        println!("            sha256:{sha}");
806    }
807    println!(
808        "  x0      : {}{}",
809        o.x0_source,
810        if o.x0_all_zero { "  (all zeros)" } else { "" }
811    );
812    println!();
813
814    println!("  evaluation at x0:");
815    match o.objective {
816        Some(v) if v.is_finite() => println!("    objective: {v:.10e}"),
817        Some(v) => println!("    objective: {v}  <- NON-FINITE"),
818        None => println!("    objective: EVALUATION FAILED"),
819    }
820    print_nonfinite("gradient", o.grad_nonfinite_count, &o.grad_nonfinite);
821    print_nonfinite("constraints", o.g_nonfinite_count, &o.g_nonfinite);
822    if o.jac_nonfinite_count > 0 {
823        println!(
824            "    Jacobian : {} non-finite entr{}",
825            o.jac_nonfinite_count,
826            if o.jac_nonfinite_count == 1 {
827                "y"
828            } else {
829                "ies"
830            }
831        );
832        for e in &o.jac_nonfinite {
833            println!("        d{}/d{} = {}", e.row_name, e.col_name, e.value);
834        }
835    } else {
836        println!("    Jacobian : finite");
837    }
838    match o.hess_nonfinite_count {
839        Some(0) => println!("    Hessian  : finite (lambda=0)"),
840        Some(k) => println!("    Hessian  : {k} non-finite entries (lambda=0)"),
841        None => println!("    Hessian  : not checked (quasi-Newton or declined)"),
842    }
843    println!();
844
845    println!("  x0 vs bounds:");
846    println!(
847        "    violations: {}  on-bound components: {}",
848        o.n_bound_violations, o.n_on_bounds
849    );
850    for r in &o.bound_violations {
851        println!(
852            "        {}: value {:.6e} outside [{:.6e}, {:.6e}] by {:.3e}",
853            r.name, r.value, r.lo, r.hi, r.violation
854        );
855    }
856    println!(
857        "    interior clamp moves {} component(s), max move {:.3e}",
858        o.n_clamp_moved, o.max_clamp_move
859    );
860    for c in &o.clamp_moves {
861        println!(
862            "        {}: {:.6e} -> {:.6e}  (moved {:.3e})",
863            c.name, c.from, c.to, c.distance
864        );
865    }
866    println!();
867
868    println!("  initial constraint violation:");
869    println!(
870        "    rows violated: {}  max violation: {:.3e}",
871        o.n_con_violations, o.max_con_violation
872    );
873    for r in &o.con_violations {
874        println!(
875            "        {}: g = {:.6e}, bounds [{:.6e}, {:.6e}], violation {:.3e}",
876            r.name, r.value, r.lo, r.hi, r.violation
877        );
878    }
879    println!();
880
881    println!("  derivative scale at x0:");
882    println!(
883        "    gradient: max |.| {:.3e}, min nonzero |.| {:.3e}",
884        o.grad_spread.max_abs, o.grad_spread.min_abs_nonzero
885    );
886    println!(
887        "    Jacobian: max |.| {:.3e}, min nonzero |.| {:.3e}",
888        o.jac_spread.max_abs, o.jac_spread.min_abs_nonzero
889    );
890    println!();
891
892    if !o.warnings.is_empty() {
893        println!("  warnings:");
894        for w in &o.warnings {
895            println!("    - {w}");
896        }
897        println!();
898    }
899    println!("  VERDICT: {}", o.verdict);
900}
901
902fn print_nonfinite(label: &str, count: usize, list: &[NonFinite]) {
903    if count > 0 {
904        println!(
905            "    {label:<9}: {count} non-finite entr{}",
906            if count == 1 { "y" } else { "ies" }
907        );
908        for e in list {
909            println!("        {} = {}", e.name, e.value);
910        }
911    } else {
912        println!("    {label:<9}: finite");
913    }
914}
915
916fn report_json(o: &CheckX0Outcome) -> String {
917    use serde_json::json;
918    let row = |r: &RowReport| {
919        json!({
920            "index": r.index, "name": r.name, "value": r.value,
921            "lower": r.lo, "upper": r.hi, "violation": r.violation,
922        })
923    };
924    let nf =
925        |e: &NonFinite| json!({"index": e.index, "name": e.name, "value": e.value.to_string()});
926    let report = json!({
927        "pounce_check_x0_version": 1,
928        "schema": "pounce.check-x0/v1",
929        "solver": format!("pounce {}", env!("CARGO_PKG_VERSION")),
930        "problem": {
931            "source": o.source,
932            "sha256": o.nl_sha256,
933            "n_vars": o.n_vars,
934            "n_cons": o.n_cons,
935        },
936        "x0": { "source": o.x0_source, "all_zero": o.x0_all_zero },
937        "evaluation": {
938            "objective": o.objective.filter(|v| v.is_finite()),
939            "objective_finite": o.objective.map(|v| v.is_finite()).unwrap_or(false),
940            "grad_nonfinite_count": o.grad_nonfinite_count,
941            "grad_nonfinite": o.grad_nonfinite.iter().map(nf).collect::<Vec<_>>(),
942            "constraints_nonfinite_count": o.g_nonfinite_count,
943            "constraints_nonfinite": o.g_nonfinite.iter().map(nf).collect::<Vec<_>>(),
944            "jacobian_nonfinite_count": o.jac_nonfinite_count,
945            "jacobian_nonfinite": o.jac_nonfinite.iter().map(|e| json!({
946                "row": e.row, "col": e.col,
947                "row_name": e.row_name, "col_name": e.col_name,
948                "value": e.value.to_string(),
949            })).collect::<Vec<_>>(),
950            "hessian_nonfinite_count": o.hess_nonfinite_count,
951        },
952        "bounds": {
953            "n_violations": o.n_bound_violations,
954            "max_violation": o.max_bound_violation,
955            "n_on_bounds": o.n_on_bounds,
956            "worst": o.bound_violations.iter().map(row).collect::<Vec<_>>(),
957        },
958        "interior_clamp": {
959            "n_moved": o.n_clamp_moved,
960            "max_move": o.max_clamp_move,
961            "worst": o.clamp_moves.iter().map(|c| json!({
962                "index": c.index, "name": c.name,
963                "from": c.from, "to": c.to, "distance": c.distance,
964            })).collect::<Vec<_>>(),
965        },
966        "constraint_violation": {
967            "n_violated": o.n_con_violations,
968            "max_violation": o.max_con_violation,
969            "worst": o.con_violations.iter().map(row).collect::<Vec<_>>(),
970        },
971        "derivative_scale": {
972            "gradient": {
973                "max_abs": o.grad_spread.max_abs,
974                "min_abs_nonzero": o.grad_spread.min_abs_nonzero,
975                "ratio": o.grad_spread.ratio,
976            },
977            "jacobian": {
978                "max_abs": o.jac_spread.max_abs,
979                "min_abs_nonzero": o.jac_spread.min_abs_nonzero,
980                "ratio": o.jac_spread.ratio,
981            },
982        },
983        "warnings": o.warnings,
984        "fatal": o.fatal,
985        "verdict": o.verdict,
986    });
987    serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993    use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
994    use pounce_nlp::tnlp::{IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution};
995
996    /// min 1/x0 + x1  s.t. x0 + x1 = 1, with x0 starting AT zero — the
997    /// canonical Invalid_Number_Detected trap.
998    struct DomainTrap {
999        x0: Vec<Number>,
1000    }
1001
1002    impl TNLP for DomainTrap {
1003        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1004            Some(NlpInfo {
1005                n: 2,
1006                m: 1,
1007                nnz_jac_g: 2,
1008                nnz_h_lag: 0,
1009                index_style: IndexStyle::C,
1010            })
1011        }
1012        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1013            b.x_l.copy_from_slice(&[0.0, NLP_LOWER_BOUND_INF]);
1014            b.x_u
1015                .copy_from_slice(&[NLP_UPPER_BOUND_INF, NLP_UPPER_BOUND_INF]);
1016            b.g_l[0] = 1.0;
1017            b.g_u[0] = 1.0;
1018            true
1019        }
1020        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1021            if sp.init_x {
1022                sp.x.copy_from_slice(&self.x0);
1023            }
1024            true
1025        }
1026        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
1027            Some(1.0 / x[0] + x[1])
1028        }
1029        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
1030            grad_f[0] = -1.0 / (x[0] * x[0]);
1031            grad_f[1] = 1.0;
1032            true
1033        }
1034        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1035            g[0] = x[0] + x[1];
1036            true
1037        }
1038        fn eval_jac_g(
1039            &mut self,
1040            _x: Option<&[Number]>,
1041            _new_x: bool,
1042            mode: SparsityRequest<'_>,
1043        ) -> bool {
1044            match mode {
1045                SparsityRequest::Structure { irow, jcol } => {
1046                    irow.copy_from_slice(&[0, 0]);
1047                    jcol.copy_from_slice(&[0, 1]);
1048                }
1049                SparsityRequest::Values { values } => {
1050                    values.copy_from_slice(&[1.0, 1.0]);
1051                }
1052            }
1053            true
1054        }
1055        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _c: &IpoptCq) {}
1056    }
1057
1058    fn check(x0: Vec<Number>) -> CheckX0Outcome {
1059        let mut t = DomainTrap { x0 };
1060        check_tnlp(
1061            &mut t,
1062            &[],
1063            &[],
1064            None,
1065            "test".into(),
1066            &CheckX0Args::default(),
1067        )
1068        .expect("check")
1069    }
1070
1071    #[test]
1072    fn nan_at_x0_is_fatal() {
1073        // x0[0] = 0 → f = 1/0 = inf, grad[0] = -inf.
1074        let o = check(vec![0.0, 0.0]);
1075        assert!(o.fatal);
1076        assert_eq!(o.verdict, "FATAL");
1077        assert!(o.grad_nonfinite_count >= 1);
1078        assert!(o.x0_all_zero);
1079    }
1080
1081    #[test]
1082    fn clean_interior_point_passes() {
1083        let o = check(vec![0.5, 0.5]);
1084        assert!(!o.fatal);
1085        assert_eq!(o.n_bound_violations, 0);
1086        // x0 + x1 = 1 exactly: feasible.
1087        assert_eq!(o.n_con_violations, 0);
1088        assert_eq!(o.verdict, "CLEAN");
1089        assert!((o.objective.unwrap() - 2.5).abs() < 1e-12);
1090    }
1091
1092    #[test]
1093    fn on_bound_component_is_flagged_and_clamped() {
1094        // x0[0] = 1e-12 is (numerically) on its lower bound 0; the clamp
1095        // moves it to ~bound_push = 1e-2 (span is infinite: one-sided).
1096        let o = check(vec![1e-12, 1.0]);
1097        assert!(o.n_on_bounds >= 1);
1098        assert!(o.n_clamp_moved >= 1);
1099        assert!((o.max_clamp_move - 1e-2).abs() < 1e-9);
1100        assert!(
1101            o.warnings
1102                .iter()
1103                .any(|w| w.contains("warm_start_bound_push"))
1104        );
1105    }
1106
1107    #[test]
1108    fn bound_violation_reported() {
1109        let o = check(vec![-3.0, 1.0]);
1110        assert_eq!(o.n_bound_violations, 1);
1111        assert!((o.max_bound_violation - 3.0).abs() < 1e-12);
1112        // clamp brings it inside: from -3 to lo + push
1113        assert!(o.n_clamp_moved >= 1);
1114    }
1115
1116    #[test]
1117    fn infeasible_start_is_not_fatal() {
1118        let o = check(vec![5.0, 5.0]);
1119        assert!(!o.fatal);
1120        assert_eq!(o.n_con_violations, 1);
1121        assert!((o.max_con_violation - 9.0).abs() < 1e-12);
1122    }
1123
1124    #[test]
1125    fn clamp_formula_matches_default_initializer() {
1126        // Two-sided [1, 5], bound_push=bound_frac=1e-2:
1127        // p_l = min(1e-2*1, 1e-2*4) = 0.01 → 1.0 clamps to 1.01.
1128        assert!((clamp_to_interior(1.0, 1.0, 5.0, 1e-2, 1e-2) - 1.01).abs() < 1e-15);
1129        // Interior stays put.
1130        assert_eq!(clamp_to_interior(3.0, 1.0, 5.0, 1e-2, 1e-2), 3.0);
1131        // Free variable untouched.
1132        assert_eq!(
1133            clamp_to_interior(-7.0, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, 1e-2, 1e-2),
1134            -7.0
1135        );
1136        // Upper one-sided: hi=100 → push = 1e-2*100 = 1 → 100 → 99.
1137        assert!(
1138            (clamp_to_interior(100.0, NLP_LOWER_BOUND_INF, 100.0, 1e-2, 1e-2) - 99.0).abs() < 1e-12
1139        );
1140    }
1141
1142    #[test]
1143    fn scale_spread_ignores_zeros_and_nonfinite() {
1144        let s = scale_spread(vec![0.0, 1e-6, 1e3, Number::NAN].into_iter());
1145        assert!((s.max_abs - 1e3).abs() < 1e-9);
1146        assert!((s.min_abs_nonzero - 1e-6).abs() < 1e-18);
1147        assert!((s.ratio - 1e9).abs() / 1e9 < 1e-9);
1148    }
1149}