Skip to main content

pounce_cli/
dispatch.rs

1//! Solver routing for the LP/QP/QCQP dispatch.
2//!
3//! See `dev-notes/lp-qp-routing.md`. This module sits between problem
4//! loading and the call to `optimize_tnlp`. It does three things:
5//!
6//! 1. **Classify** the parsed problem into a [`ProblemClass`] by walking
7//!    the nonlinear expression trees the `.nl` reader already produced.
8//! 2. **Resolve** that class against the user's `solver_selection`
9//!    option into a [`SolverChoice`].
10//! 3. **Dispatch** to the chosen solver (in `main.rs`).
11//!
12//! All solvers are wired: `auto` routes an LP/convex-QP to `pounce-convex`'s
13//! interior-point solver, a convex QCQP to the same crate's conic (SOCP)
14//! driver, and everything else to the existing filter-IPM (`Nlp`).
15//!
16//! ## Classification
17//!
18//! The `.nl` format has no dedicated quadratic section: each row's
19//! linear part lives in the `G`/`J` coefficient segments (already split
20//! out into [`NlProblem::obj_linear`] / [`NlProblem::con_linear`]),
21//! while any higher-order term — including a QP's quadratic terms — is
22//! written into the nonlinear expression tree as `Mul`/`Pow` nodes. So:
23//!
24//! - no nonlinear parts at all → **LP**;
25//! - all nonlinear parts are degree-2 polynomials → **QP** family
26//!   (convex / nonconvex / QCQP split by curvature);
27//! - anything else (transcendental, higher degree) → **NLP**.
28//!
29//! ### Conservative fallback (correctness guard)
30//!
31//! Misclassifying an indefinite or non-quadratic problem *into* a convex
32//! solver would return a spurious KKT point as if globally optimal.
33//! Whenever the walk cannot *prove* the stronger class, the classifier
34//! falls back to the more general one, ultimately `Nlp`. The convexity
35//! (PSD) test uses a tolerance and routes "inconclusive within
36//! tolerance" to the safe side, never to the convex path.
37
38use crate::nl_reader::NlProblem;
39use pounce_common::types::{lower_bound_present, upper_bound_present};
40// `PSD_TOL` and `psd_band` live in `pounce_convex` (moved there, gh#1147 PR
41// review) so the C API's `PounceQp*` handle screens at the same band.
42#[cfg(test)]
43use pounce_convex::PSD_TOL;
44use pounce_convex::{Triplet, certify_psd_lower_triangle, psd_band};
45
46/// Budget on the **structural** cost of putting a convex QCQP into conic form,
47/// above which it is routed to the general NLP solver instead.
48///
49/// This is one of **two** independent guards on the conic path, and the split
50/// is the point. The `n · m` budget it replaces was silently doing two jobs:
51/// bounding the reformulation, and bounding the conic solve itself. Only the
52/// first was ever explained, and only the first has been fixed.
53///
54/// The reformulation cost genuinely used to scale with the problem's width —
55/// [`crate::qp_extract::extract_socp_with_map`] built a dense `n×n` Hessian and
56/// an `n`-column factor *per quadratic row*. It no longer does: rows are
57/// factored on their own support, and a diagonal row is factored in `O(k)`. So
58/// the model is now the actual work performed:
59///
60/// ```text
61/// Σ_rows  k³  if the row's Hessian has off-diagonal entries
62///         k   if it is diagonal (one √d per entry; no factorization)
63/// ```
64///
65/// where `k` is the number of variables that row couples. Units are
66/// floating-point operations, so the budget is a real time bound rather than a
67/// dimensionless guess. Measured: `qssp180` costs 1.96e5 flops and `nql180`
68/// 6.48e4 — both three orders of magnitude inside this budget, where the old
69/// proxy scored them at `n · m` ≈ 1e10 and rejected them.
70///
71/// **The value is deliberately conservative.** `2e7` sits just above `256³`,
72/// the per-row width the previous guard allowed, so every problem that routed
73/// to NLP for *reformulation* reasons still does — including the
74/// `qcqp1000-*`/`qcqp1500-*` rows, which Q0 measured solving well on the NLP
75/// path. Raising it to admit the dense thousand-variable rows is a separate
76/// experiment, now cheap to run because the `k³` term states its price.
77const SOCP_REFORM_FLOP_BUDGET: u128 = 20_000_000;
78
79/// The second guard: an **empirical** cap on the size of problem handed to the
80/// conic solve, independent of how cheap the reformulation is.
81///
82/// This exists because measurement, not theory, says so. With the extractor
83/// fixed, `qssp180` and `nql180` reformulate almost for free and the conic
84/// solver reaches an accurate optimum on both — and is *slower* doing it:
85///
86/// ```text
87///            NLP filter-IPM        conic IPM
88/// qssp180    47.1 s / 27 it        178.5 s / 73 it     3.8x slower
89/// nql180     57.8 s / 36 it        156.5 s / 83 it     2.7x slower
90/// ```
91///
92/// Both reach `Optimal` with KKT error ≤ 1.5e-12, so this is a performance
93/// choice and not a soundness one. The conic path takes roughly 2.5x the
94/// iterations at this scale; until that is understood, the larger problems keep
95/// the solver that measurably wins.
96///
97/// `1e8` reproduces the routing the `n · m` proxy happened to produce for these
98/// two instances — the right answer for a reason that was never stated. It is a
99/// placeholder for a real conic-solve cost model, and it is deliberately the
100/// *only* thing still keyed to `n · m`.
101const SOCP_SOLVE_SIZE_CAP: u64 = 100_000_000;
102
103/// The mathematical class of a loaded problem, from most to least
104/// specialized. See the module docs and `dev-notes/lp-qp-routing.md`.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ProblemClass {
107    /// Linear objective, linear constraints.
108    Lp,
109    /// Convex quadratic objective, linear constraints (Hessian PSD).
110    ConvexQp,
111    /// Convex quadratic objective and/or convex quadratic constraints.
112    /// SOCP-representable; routes to the conic (SOCP) interior-point solver.
113    ConvexQcqp,
114    /// Quadratic objective with an indefinite (sense-adjusted) Hessian and
115    /// **linear** constraints. `auto` falls through to the NLP solver for a
116    /// local minimum; `solver_selection=qp-active-set` solves it directly with
117    /// the `pounce-qp` active-set engine, which takes an indefinite `H`
118    /// (gh #786) and returns a *local* minimum.
119    ///
120    /// What "local minimum" means on that path is narrower than it reads, and
121    /// was narrower still before gh #848. The §4.5 inertia control shifts
122    /// `H -> H + delta*I` so the *local model* is convex; it does not move the
123    /// iterate, and at a saddle `g = 0` makes the shifted step zero. So the
124    /// engine used to stop there and certify `Optimal` on the first-order
125    /// evidence alone — vanishing projected gradient, sign-admissible
126    /// working-set multipliers — which a saddle, and the constrained *maximum*
127    /// of `min x0*x1` on `x0 + x1 = 2`, satisfy exactly. The engine now
128    /// exhibits a direction `d` with `A_W d = 0` and `d'Hd < 0` and follows it
129    /// before certifying, so an `Optimal` here is second-order. It is still
130    /// only local: nothing on this path rules out a better minimum elsewhere.
131    ///
132    /// The linear-constraints half of that is load-bearing, not descriptive:
133    /// both consumers reach the model through
134    /// [`crate::qp_extract::extract_qp_with_map`], which keeps only the
135    /// degree-≤1 part of every row. A quadratic row here would be silently
136    /// dropped, so a model carrying one classifies [`Self::Nlp`] instead —
137    /// see [`ClassReason::NonconvexQcqp`].
138    NonconvexQp,
139    /// General nonlinear (transcendental terms, higher-degree
140    /// polynomials, or anything the classifier cannot prove quadratic).
141    Nlp,
142}
143
144impl ProblemClass {
145    /// Human-readable name for diagnostics and the
146    /// forced-solver-mismatch error message.
147    pub fn name(self) -> &'static str {
148        match self {
149            ProblemClass::Lp => "LP",
150            ProblemClass::ConvexQp => "convex QP",
151            ProblemClass::ConvexQcqp => "convex QCQP",
152            ProblemClass::NonconvexQp => "nonconvex QP",
153            ProblemClass::Nlp => "NLP",
154        }
155    }
156}
157
158/// Why [`classify_problem`] reached the class it did.
159///
160/// Every arm is a place the classifier *stops* — either because it proved a
161/// class or because it could not, and fell back to the more general one. The
162/// distinction matters most for a convex QCQP: four different findings
163/// (a nonconvex row, a nonconvex *sense*, an unaffordable reformulation, an
164/// oversized conic solve) all route to `Nlp`, and a user staring at
165/// `Problem class: NLP` on a model they know is a QCQP has so far had no way
166/// to tell which. `POUNCE_DBG_CLASSIFY=1` prints it.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ClassReason {
169    /// Neither the objective nor any row carries a nonlinear part.
170    NoNonlinearParts,
171    /// Nonlinear parts exist but all of them expanded to nothing of
172    /// degree 2 or higher — the model is linear after expansion.
173    NonlinearPartsCancelled,
174    /// The objective's nonlinear part is not a degree-2 polynomial.
175    ObjectiveNotQuadratic,
176    /// Row `row`'s nonlinear part is not a degree-2 polynomial.
177    ConstraintNotQuadratic { row: usize },
178    /// The objective is degree ≤ 2, but the recognizer *lost* a term
179    /// reaching its coefficients, so they are not the whole objective.
180    ObjectiveTermsDropped,
181    /// Row `row` is degree ≤ 2, but the recognizer *lost* a term reaching
182    /// its coefficients, so they are not the whole row (gh #685).
183    ///
184    /// "Lost", not merely "dropped": a term that cancels exactly is not
185    /// missing from the form, so those rows keep the fast path (gh #687).
186    ConstraintTermsDropped { row: usize },
187    /// The sense-adjusted objective Hessian has a negative eigenvalue, and
188    /// every constraint row is linear — a nonconvex **QP**.
189    ObjectiveHessianIndefinite,
190    /// The sense-adjusted objective Hessian has a negative eigenvalue *and*
191    /// some row carries curvature — a nonconvex **QCQP**, which is not a QP
192    /// and must not be classified as one (see [`ProblemClass::NonconvexQp`]).
193    NonconvexQcqp { row: usize },
194    /// Row `row` is quadratic with a PSD Hessian, but its bound sense
195    /// (`>=`, `=`, or two-sided) carves a nonconvex feasible set.
196    ConstraintSenseNonconvex { row: usize },
197    /// Row `row`'s quadratic Hessian is not PSD.
198    ConstraintHessianIndefinite { row: usize },
199    /// Convex QCQP, but the cone reformulation exceeds
200    /// [`SOCP_REFORM_FLOP_BUDGET`].
201    QcqpReformTooCostly { flops: u128 },
202    /// Convex QCQP whose reformulation is affordable, but whose conic
203    /// solve exceeds [`SOCP_SOLVE_SIZE_CAP`].
204    QcqpTooLargeToSolve { size: u64 },
205    /// Convex quadratic objective, linear constraints.
206    ConvexQuadraticObjective,
207    /// Convex QCQP inside both guards — the conic path is taken.
208    ConvexQcqpWithinBudgets { flops: u128, size: u64 },
209}
210
211impl ClassReason {
212    /// One-line explanation, for the `POUNCE_DBG_CLASSIFY` log.
213    pub fn explain(self) -> String {
214        match self {
215            ClassReason::NoNonlinearParts => {
216                "no nonlinear part in the objective or any row".to_string()
217            }
218            ClassReason::NonlinearPartsCancelled => {
219                "every nonlinear part expanded to a linear (or constant) polynomial".to_string()
220            }
221            ClassReason::ObjectiveNotQuadratic => {
222                "the objective's nonlinear part is not a degree-2 polynomial".to_string()
223            }
224            ClassReason::ConstraintNotQuadratic { row } => {
225                format!("row {row}'s nonlinear part is not a degree-2 polynomial")
226            }
227            ClassReason::ObjectiveTermsDropped => {
228                "the objective's quadratic form lost a term to an inexact \
229                 floating-point fold or a flush to zero, so its coefficients are \
230                 not the whole objective"
231                    .to_string()
232            }
233            ClassReason::ConstraintTermsDropped { row } => format!(
234                "row {row}'s quadratic form lost a term to an inexact \
235                 floating-point fold or a flush to zero, so its coefficients are \
236                 not the whole row"
237            ),
238            ClassReason::ObjectiveHessianIndefinite => {
239                "the objective Hessian (sense-adjusted for minimization) is not PSD, \
240                 and every row is linear"
241                    .to_string()
242            }
243            ClassReason::NonconvexQcqp { row } => format!(
244                "the objective Hessian (sense-adjusted for minimization) is not PSD \
245                 and row {row} is quadratic, so this is a nonconvex QCQP rather than \
246                 a nonconvex QP"
247            ),
248            ClassReason::ConstraintSenseNonconvex { row } => format!(
249                "row {row} is a convex quadratic but its bound sense (>=, =, or \
250                 two-sided) makes the feasible set nonconvex"
251            ),
252            ClassReason::ConstraintHessianIndefinite { row } => {
253                format!("row {row}'s quadratic Hessian is not PSD")
254            }
255            ClassReason::QcqpReformTooCostly { flops } => format!(
256                "convex QCQP downgraded: cone reformulation costs {flops} flops \
257                 (budget {SOCP_REFORM_FLOP_BUDGET})"
258            ),
259            ClassReason::QcqpTooLargeToSolve { size } => format!(
260                "convex QCQP downgraded: conic solve size n·m = {size} \
261                 (cap {SOCP_SOLVE_SIZE_CAP})"
262            ),
263            ClassReason::ConvexQuadraticObjective => {
264                "convex quadratic objective, linear rows".to_string()
265            }
266            ClassReason::ConvexQcqpWithinBudgets { flops, size } => format!(
267                "convex QCQP inside both guards: reformulation {flops} flops \
268                 (budget {SOCP_REFORM_FLOP_BUDGET}), conic solve size n·m = {size} \
269                 (cap {SOCP_SOLVE_SIZE_CAP})"
270            ),
271        }
272    }
273}
274
275/// The resolved solver to dispatch to, after combining a
276/// [`ProblemClass`] with the `solver_selection` option.
277///
278/// `auto` resolves an LP/convex-QP to [`SolverChoice::LpIpm`]/[`SolverChoice::QpIpm`],
279/// a convex QCQP to [`SolverChoice::SocpIpm`], and everything else to
280/// [`SolverChoice::Nlp`]; a forced `solver_selection` can pin any of them.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum SolverChoice {
283    /// The existing Wächter-Biegler filter-IPM.
284    Nlp,
285    /// LP interior-point in `pounce-convex`.
286    LpIpm,
287    /// Convex-QP interior-point in `pounce-convex`.
288    QpIpm,
289    /// Conic (SOCP) IPM in `pounce-convex`: convex QCQP, reformulated to
290    /// second-order cones.
291    SocpIpm,
292    /// Active-set QP in `pounce-qp` (parallel track).
293    QpActiveSet,
294}
295
296impl SolverChoice {
297    /// Human-readable description of the dispatched solver, for the
298    /// banner-level "Solving as …" log line. Names the algorithm and the
299    /// crate that implements it so a reader can tell which of pounce's
300    /// solvers actually ran.
301    pub fn describe(self) -> &'static str {
302        match self {
303            SolverChoice::Nlp => "NLP filter line-search interior-point (pounce-nlp)",
304            SolverChoice::LpIpm => "LP interior-point (pounce-convex)",
305            SolverChoice::QpIpm => "convex QP interior-point (pounce-convex)",
306            SolverChoice::SocpIpm => "convex QCQP conic interior-point (pounce-convex)",
307            SolverChoice::QpActiveSet => "active-set QP (pounce-qp)",
308        }
309    }
310}
311
312/// Parsed `solver_selection` option value.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum SolverSelection {
315    /// Pick the most specialized solver matching the class. Default.
316    Auto,
317    /// Force the NLP solver regardless of class (current behavior).
318    Nlp,
319    /// Force IPM-LP; error if the problem is not an LP.
320    LpIpm,
321    /// Force IPM-QP; error if the problem is not LP/convex-QP.
322    QpIpm,
323    /// Force the conic (SOCP) IPM; error if the problem is not a convex
324    /// LP / QP / QCQP (all of which the conic solver handles).
325    Socp,
326    /// Force active-set QP; error if the problem is not LP/convex-QP.
327    QpActiveSet,
328}
329
330impl SolverSelection {
331    /// Parse the `solver_selection` option string. Returns `None` for an
332    /// unrecognized value so the caller can surface a tidy error.
333    pub fn parse(s: &str) -> Option<Self> {
334        match s {
335            "auto" => Some(SolverSelection::Auto),
336            "nlp" => Some(SolverSelection::Nlp),
337            "lp-ipm" => Some(SolverSelection::LpIpm),
338            "qp-ipm" => Some(SolverSelection::QpIpm),
339            "socp" => Some(SolverSelection::Socp),
340            "qp-active-set" => Some(SolverSelection::QpActiveSet),
341            _ => None,
342        }
343    }
344
345    /// The accepted values, for error messages and option registration.
346    pub const VALUES: &'static [&'static str] =
347        &["auto", "nlp", "lp-ipm", "qp-ipm", "socp", "qp-active-set"];
348}
349
350/// Classify a parsed `.nl` problem.
351///
352/// Works off the already-split linear / nonlinear representation in
353/// [`NlProblem`]: a row contributes to the class only through its
354/// nonlinear `Expr` (the linear part is, by construction, linear). The
355/// classifier is deliberately conservative — see the module docs.
356pub fn classify_problem(prob: &NlProblem) -> ProblemClass {
357    classify_problem_explained(prob).0
358}
359
360/// [`classify_problem`], plus the finding that produced the class.
361///
362/// Also the single place the `POUNCE_DBG_CLASSIFY=1` routing log is
363/// emitted, so every caller — including the tests — sees the same line.
364pub fn classify_problem_explained(prob: &NlProblem) -> (ProblemClass, ClassReason) {
365    let verdict = classify_inner(prob);
366    if std::env::var_os("POUNCE_DBG_CLASSIFY").is_some() {
367        eprintln!(
368            "pounce: problem class {} — {} [{}]",
369            verdict.0.name(),
370            verdict.1.explain(),
371            header_census(prob)
372        );
373    }
374    verdict
375}
376
377/// The `.nl` header's nonlinearity census next to what the parsed trees
378/// actually say, for the `POUNCE_DBG_CLASSIFY` line.
379///
380/// The two are allowed to differ in exactly one direction — the header can
381/// over-state, because `parse_nl_text` folds a constant `C` body into the
382/// row bounds after AMPL took its census (`gh #492`). A header that
383/// *under*-states is a non-conforming writer, and the note says so rather
384/// than letting the discrepancy pass silently; nothing in the classifier
385/// trusts the header, so it is a diagnostic, not a guard.
386fn header_census(prob: &NlProblem) -> String {
387    let tree_rows = prob
388        .con_nonlinear
389        .iter()
390        .filter(|b| !b.is_trivially_zero())
391        .count();
392    let tree_obj = usize::from(!prob.obj_nonlinear.is_trivially_zero());
393    let Some(c) = prob.nl_counts else {
394        return format!("no .nl header census; trees: nl_rows={tree_rows} nl_obj={tree_obj}");
395    };
396    let flag = if c.nl_cons < tree_rows || c.nl_objs < tree_obj {
397        " — HEADER UNDER-STATES the trees; writer is non-conforming"
398    } else {
399        ""
400    };
401    format!(
402        "header nlc={} nlo={} nlvc={} nlvo={} nlvb={} ({} of {} vars nonlinear); \
403         trees: nl_rows={tree_rows} nl_obj={tree_obj}{flag}",
404        c.nl_cons,
405        c.nl_objs,
406        c.nl_vars_cons,
407        c.nl_vars_objs,
408        c.nl_vars_both,
409        c.nonlinear_vars(),
410        prob.n,
411    )
412}
413
414fn classify_inner(prob: &NlProblem) -> (ProblemClass, ClassReason) {
415    // Fast path: no nonlinear parts anywhere ⇒ LP.
416    //
417    // This deliberately stays a walk over the parsed rows rather than the
418    // O(1) header read (`nlc == 0 && nlo == 0`) the design note proposed.
419    // Two reasons, both found by writing it out: the test is already O(1)
420    // *per row* — `is_trivially_zero` matches the root node, it does not
421    // walk an `Expr` — so the header saves a pointer compare per row and
422    // nothing else; and the header answer is the writer's claim, while this
423    // one is a fact about the trees pounce will evaluate. Trusting the
424    // claim would route a model to the LP solver on the strength of a
425    // header field, which is not a trade this classifier makes anywhere
426    // else. The header is logged beside the verdict instead.
427    let obj_nl = !prob.obj_nonlinear.is_trivially_zero();
428    let cons_nl = prob.con_nonlinear.iter().any(|b| !b.is_trivially_zero());
429    if !obj_nl && !cons_nl {
430        return (ProblemClass::Lp, ClassReason::NoNonlinearParts);
431    }
432
433    // Objective curvature.
434    //
435    // The `None` arm covers two findings, and they are not the same
436    // finding: a genuinely non-quadratic term, and a degree-≤2 form the
437    // recognizer could not carry every coefficient of (gh #685). Both route
438    // NLP — the second *must*, since every consumer past this point reads
439    // those coefficients out — but a user reading `POUNCE_DBG_CLASSIFY=1` on
440    // a model they know is a QP is owed the difference.
441    let obj_quad = match prob.obj_nonlinear.analyze_quadratic() {
442        Some(q) => q,
443        None if prob.obj_nonlinear.quad_terms_dropped() => {
444            return (ProblemClass::Nlp, ClassReason::ObjectiveTermsDropped);
445        }
446        // Objective has a non-quadratic nonlinear term ⇒ NLP.
447        None => return (ProblemClass::Nlp, ClassReason::ObjectiveNotQuadratic),
448    };
449
450    // Constraint curvature. A quadratic constraint makes this a QCQP;
451    // any non-quadratic constraint term makes the whole problem NLP.
452    let mut any_quadratic_constraint = false;
453    let mut first_quadratic_row = 0usize;
454    for (row, c) in prob.con_nonlinear.iter().enumerate() {
455        if c.is_trivially_zero() {
456            continue;
457        }
458        match c.analyze_quadratic() {
459            // Purely linear after all — and provably so. `analyze_quadratic`
460            // refuses a form that dropped a term, so an empty Hessian here
461            // is the absence of curvature rather than the failure to keep
462            // it. Before gh #685 it was not: a row whose quadratic
463            // coefficients cancelled arrived here empty, classified linear,
464            // and then vanished out of the extracted LP entirely.
465            Some(q) if q.is_empty() => {}
466            Some(_) => {
467                if !any_quadratic_constraint {
468                    first_quadratic_row = row;
469                }
470                any_quadratic_constraint = true;
471            }
472            None if c.quad_terms_dropped() => {
473                return (
474                    ProblemClass::Nlp,
475                    ClassReason::ConstraintTermsDropped { row },
476                );
477            }
478            None => {
479                return (
480                    ProblemClass::Nlp,
481                    ClassReason::ConstraintNotQuadratic { row },
482                );
483            }
484        }
485    }
486
487    // Objective Hessian definiteness, as the *minimizer* sees it. A
488    // `maximize` problem is internally negated to a minimization, so a
489    // concave-up (PSD-Hessian) maximize is a nonconvex minimize. Test the
490    // sense-adjusted Hessian, not the raw one, or maximize-of-convex slips
491    // through to the convex IPM and produces a wrong (max/saddle) answer.
492    if !obj_quad.is_empty() {
493        let effective: QuadHessian = if prob.minimize {
494            obj_quad.clone()
495        } else {
496            obj_quad.iter().map(|(k, v)| (*k, -v)).collect()
497        };
498        if !hessian_is_psd(&effective, prob.n) {
499            // A nonconvex objective over *quadratic* rows is a nonconvex QCQP,
500            // not a nonconvex QP, and the distinction is a correctness one now
501            // that `ProblemClass::NonconvexQp` has a consumer: the QP extractor
502            // keeps only the degree-≤1 part of each row, so calling this a QP
503            // would hand `qp-active-set` a model with its curved constraints
504            // quietly deleted. NLP solves it soundly either way, which is where
505            // both used to go.
506            if any_quadratic_constraint {
507                return (
508                    ProblemClass::Nlp,
509                    ClassReason::NonconvexQcqp {
510                        row: first_quadratic_row,
511                    },
512                );
513            }
514            return (
515                ProblemClass::NonconvexQp,
516                ClassReason::ObjectiveHessianIndefinite,
517            );
518        }
519    }
520
521    if any_quadratic_constraint {
522        // Convex QCQP requires every quadratic constraint to be convex *as a
523        // feasible set*, not merely to have a PSD Hessian. A quadratic
524        // `g(x) = ½xᵀQx + … ` carves a convex region only when it is a
525        // one-sided **upper** bound `g(x) ≤ g_u` *and* `Q ⪰ 0`. The other
526        // senses are nonconvex even with a PSD Hessian:
527        //   - `g(x) ≥ g_l` (finite lower bound): the super-level set of a
528        //     convex function is nonconvex;
529        //   - a quadratic equality `g(x) = c`;
530        //   - a two-sided range `g_l ≤ g(x) ≤ g_u` (includes the `≥` side).
531        // This sense test matters now that ConvexQcqp is dispatched to the
532        // conic solver (it is SOC-representable only in the convex case); a
533        // misclassified nonconvex row would return a spurious "optimum".
534        // Anything not provably convex falls back to NLP (sound: the
535        // filter-IPM finds a local minimum either way).
536        let mut reform_flops: u128 = 0;
537        for (row, c) in prob.con_nonlinear.iter().enumerate() {
538            if c.is_trivially_zero() {
539                continue;
540            }
541            match c.analyze_quadratic() {
542                Some(q) if q.is_empty() => {} // purely linear after all
543                Some(q) => {
544                    let lo = prob.g_l[row];
545                    let hi = prob.g_u[row];
546                    // Presence is directional (gh #401). The symmetric
547                    // `|v| < 1e19` test this used to run called a row with a
548                    // real bound past the *opposite* sentinel — `g(x) >= 5e20`
549                    // arrives as `g_l = 5e20`, `g_u = 1e19` — free on both
550                    // sides, and `continue` below then dropped a real
551                    // constraint from the convexity decision.
552                    let lo_present = lower_bound_present(lo);
553                    let hi_present = upper_bound_present(hi);
554                    let vacuous = !lo_present && !hi_present;
555                    let upper_only = hi_present && !lo_present;
556                    if vacuous {
557                        // Free row: imposes nothing, so it cannot make the
558                        // problem nonconvex. Ignore it.
559                        continue;
560                    }
561                    // Convexity (cheap sparse certificate) gates the QCQP
562                    // class; the per-row coupling guard then gates the *conic*
563                    // path: a convex but heavily-coupled constraint Hessian is
564                    // ruinous to put in SOC form, so route the whole QCQP to
565                    // NLP (which solves it soundly) rather than burn the budget
566                    // in the reformulation — the mittelmann `qcqp1000-*` rows.
567                    if !upper_only {
568                        return (
569                            ProblemClass::Nlp,
570                            ClassReason::ConstraintSenseNonconvex { row },
571                        );
572                    }
573                    if !hessian_is_psd(&q, prob.n) {
574                        return (
575                            ProblemClass::Nlp,
576                            ClassReason::ConstraintHessianIndefinite { row },
577                        );
578                    }
579                    reform_flops = reform_flops.saturating_add(socp_reform_flops(&q));
580                }
581                // Both `None` arms are defensive here: the curvature loop
582                // above walks the same rows and has already returned for
583                // any row that answers `None`. They are kept so this
584                // `match` stays correct on its own terms rather than on
585                // the order of two loops.
586                None if c.quad_terms_dropped() => {
587                    return (
588                        ProblemClass::Nlp,
589                        ClassReason::ConstraintTermsDropped { row },
590                    );
591                }
592                None => {
593                    return (
594                        ProblemClass::Nlp,
595                        ClassReason::ConstraintNotQuadratic { row },
596                    );
597                }
598            }
599        }
600        // Two independent guards, for two different costs. A convex QCQP whose
601        // *reformulation* is too expensive falls back to NLP (see
602        // `SOCP_REFORM_FLOP_BUDGET`); so does one whose reformulation is cheap
603        // but whose *conic solve* is measurably slower than the filter-IPM at
604        // that scale (see `SOCP_SOLVE_SIZE_CAP`). Both fall back to a solver
605        // that answers the same question soundly, so either is a performance
606        // decision only.
607        let solve_size = (prob.n as u64).saturating_mul(prob.m as u64);
608        let too_costly_to_reform = reform_flops > SOCP_REFORM_FLOP_BUDGET;
609        let too_large_to_solve = solve_size > SOCP_SOLVE_SIZE_CAP;
610        if std::env::var_os("POUNCE_DBG_SOCP_COST").is_some() {
611            eprintln!(
612                "pounce: QCQP conic reformulation cost {reform_flops} flops \
613                 (budget {SOCP_REFORM_FLOP_BUDGET}), conic solve size n·m \
614                 {solve_size} (cap {SOCP_SOLVE_SIZE_CAP}) → {}",
615                if too_costly_to_reform || too_large_to_solve {
616                    "NLP"
617                } else {
618                    "ConvexQcqp"
619                }
620            );
621        }
622        if too_costly_to_reform {
623            return (
624                ProblemClass::Nlp,
625                ClassReason::QcqpReformTooCostly {
626                    flops: reform_flops,
627                },
628            );
629        }
630        if too_large_to_solve {
631            return (
632                ProblemClass::Nlp,
633                ClassReason::QcqpTooLargeToSolve { size: solve_size },
634            );
635        }
636        return (
637            ProblemClass::ConvexQcqp,
638            ClassReason::ConvexQcqpWithinBudgets {
639                flops: reform_flops,
640                size: solve_size,
641            },
642        );
643    }
644
645    // Quadratic (or linear) convex objective with linear constraints.
646    if obj_quad.is_empty() {
647        // Objective nonlinear part collapsed to nothing quadratic and no
648        // constraints are quadratic — it was effectively linear.
649        (ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
650    } else {
651        (
652            ProblemClass::ConvexQp,
653            ClassReason::ConvexQuadraticObjective,
654        )
655    }
656}
657
658/// Resolve a [`ProblemClass`] and a [`SolverSelection`] into the solver
659/// to dispatch to, or an error string when a forced selection does not
660/// match the detected class.
661///
662/// `auto` routes LP / convex QP to the convex IPM (`QpIpm`) and convex
663/// QCQP to the conic IPM (`SocpIpm`); nonconvex QP and general NLP resolve
664/// to `Nlp`. A forced selection that does not match the detected class is
665/// rejected with a clear message.
666///
667/// `QpActiveSet` is the one forced selection that is **not** restricted to the
668/// convex classes: `pounce-qp` handles an indefinite Hessian by construction
669/// (§4.5 inertia control), which is what `docs/src/choosing-a-solver.md` has
670/// always advertised, so a `NonconvexQp` is accepted here and dispatched to it
671/// (gh #786). `auto` still sends that class to the NLP filter-IPM — the class
672/// is our inference, and the general path is the safer default for it — so
673/// this is reachable only by asking for the engine by name.
674///
675/// **What the verdict on an indefinite QP does and does not mean (gh #848).**
676/// This comment used to say the engine returns "a *local* solution", reading
677/// §4.5 inertia control as a second-order guarantee. It is not one: inertia
678/// control shifts the KKT diagonal so each *factorization* has the right
679/// inertia, which makes the linear algebra work and says nothing about the
680/// curvature of `P` on the feasible directions at the point finally returned.
681/// On `P = [[1, 5], [5, 1]]` over `[-1, 1]²` the engine reported `Optimal` at
682/// the strict saddle `x = 0`, `f = 0`, where `x = (1, -1)` is feasible at
683/// `f = -4`.
684///
685/// Second-order evidence is now part of the verdict, from two guards that
686/// cover different classes (both described on
687/// [`pounce_convex::solve_qp_active_set_inertia`]). The engine certifies the
688/// reduced Hessian on its working set's null space and, where it finds a
689/// witness of negative curvature, escapes along it and returns the *better
690/// point* — so the fix is usually a better answer rather than a worse status.
691/// The driver then screens what comes back by exhibition, which reaches the
692/// degenerate-active-bound class the first cannot, and refuses a verdict only
693/// where it holds a strictly better feasible point in hand.
694///
695/// Local, still, and not even that in general: seeing past every working set
696/// is the NP-hard part of nonconvex QP. `sqp_qp_certify_second_order` turns
697/// the engine-side check off.
698pub fn resolve_solver(
699    class: ProblemClass,
700    selection: SolverSelection,
701) -> Result<SolverChoice, String> {
702    use ProblemClass as P;
703    use SolverSelection as S;
704
705    // Is this class within the convex-QP family (LP or convex QP)?
706    let is_lp = class == P::Lp;
707    let is_convex_qp = matches!(class, P::Lp | P::ConvexQp);
708    // The conic solver handles the whole convex cone family: LP, convex QP,
709    // and (reformulated to second-order cones) convex QCQP.
710    let is_conic = matches!(class, P::Lp | P::ConvexQp | P::ConvexQcqp);
711
712    match selection {
713        // `auto`: route LP and convex QP to the specialized convex IPM
714        // (`pounce-convex`) and convex QCQP to the same crate's conic
715        // (SOCP) IPM; nonconvex QP and general NLP fall through to the NLP
716        // filter-IPM. LP is solved by the same QP IPM (P = 0), so it
717        // resolves to `QpIpm` rather than a distinct LP entry point.
718        S::Auto => match class {
719            P::Lp | P::ConvexQp => Ok(SolverChoice::QpIpm),
720            P::ConvexQcqp => Ok(SolverChoice::SocpIpm),
721            _ => Ok(SolverChoice::Nlp),
722        },
723        S::Nlp => Ok(SolverChoice::Nlp),
724        S::LpIpm => {
725            if is_lp {
726                Ok(SolverChoice::LpIpm)
727            } else {
728                Err(mismatch_msg(class, "lp-ipm", "an LP"))
729            }
730        }
731        S::QpIpm => {
732            if is_convex_qp {
733                Ok(SolverChoice::QpIpm)
734            } else {
735                Err(mismatch_msg(class, "qp-ipm", "an LP or convex QP"))
736            }
737        }
738        S::Socp => {
739            if is_conic {
740                Ok(SolverChoice::SocpIpm)
741            } else {
742                Err(mismatch_msg(class, "socp", "a convex LP, QP, or QCQP"))
743            }
744        }
745        S::QpActiveSet => {
746            if is_convex_qp || class == P::NonconvexQp {
747                Ok(SolverChoice::QpActiveSet)
748            } else {
749                Err(mismatch_msg(
750                    class,
751                    "qp-active-set",
752                    "an LP or a QP with linear constraints, convex or indefinite",
753                ))
754            }
755        }
756    }
757}
758
759fn mismatch_msg(class: ProblemClass, forced: &str, expected: &str) -> String {
760    format!(
761        "problem class {} does not match forced solver {} (expected {})",
762        class.name(),
763        forced,
764        expected
765    )
766}
767
768// ---------------------------------------------------------------------
769// Quadratic-form analysis
770// ---------------------------------------------------------------------
771
772// The recognizer itself lives in `pounce-nl` (`nl_quadratic`), next to the
773// `Expr` DAG it walks, so that the consumers that are not this binary can
774// use it — see that module's docs. What stays here is the *routing*: which
775// `ProblemClass` a recognized form implies, and which guards a QCQP has to
776// clear to reach the conic path. These re-exports keep the call sites in
777// `qp_extract` and in the tests below spelled as they were.
778pub(crate) use pounce_nl::nl_quadratic::QuadHessian;
779#[cfg(test)]
780use pounce_nl::nl_quadratic::analyze_quadratic;
781
782// ---------------------------------------------------------------------
783// PSD test
784// ---------------------------------------------------------------------
785
786/// Number of distinct variables that couple inside a quadratic form — the
787/// dimension `k` of the matrix that would be factored.
788fn hessian_active_vars(h: &QuadHessian) -> usize {
789    let mut active: Vec<usize> = Vec::with_capacity(2 * h.len());
790    for (i, j) in h.keys() {
791        active.push(*i);
792        active.push(*j);
793    }
794    active.sort_unstable();
795    active.dedup();
796    active.len()
797}
798
799/// Flops [`crate::qp_extract::socp_factor_rows`] will spend putting one
800/// quadratic row into cone form.
801///
802/// A diagonal Hessian takes the `O(k)` path — one `√d` per entry, no
803/// factorization — which is why the very large diagonal QCQPs are cheap to
804/// reformulate despite their width. Anything with an off-diagonal entry gets a
805/// pivoted Cholesky on a dense `k×k`, i.e. `O(k³)`.
806fn socp_reform_flops(h: &QuadHessian) -> u128 {
807    let k = hessian_active_vars(h) as u128;
808    if h.keys().any(|(i, j)| i != j) {
809        k.saturating_mul(k).saturating_mul(k)
810    } else {
811        k
812    }
813}
814
815/// Is the (symmetric, sparse) Hessian positive semidefinite?
816///
817/// A diagonal Hessian is settled in `O(nnz)` by sign before converting to the
818/// reusable triplet API. A coupled Hessian is certified from the inertia of
819/// `H + tol·I`. An inconclusive certificate conservatively routes to NLP.
820///
821/// `tol` is [`pounce_convex::PSD_TOL`] scaled by `‖H‖∞`, but **only
822/// downwards** — see [`pounce_convex::psd_band`]. An eigenvalue is in the
823/// units of `H`, so a fixed `1e-9` is an absolute threshold on a
824/// scale-dependent quantity, and the failure it produced (gh#872) is worse than a widened band: on a `‖H‖∞ ~ 1e-10` model
825/// the shift *dominates* `H`, so the inertia count certifies `1e-9·I` rather
826/// than `H` and the certificate is vacuous. A pure change of variable units —
827/// metres to micrometres — was enough to route a strongly indefinite Hessian
828/// (`|λ_min| / λ_max = 0.667`) to the convex engine, which returned the saddle
829/// at the start point as `Optimal Solution Found` with zero iterations.
830///
831/// The relative form keeps a constant ~7 orders of margin over the
832/// factorization's own roundoff (`ε·‖H‖∞`) at every scale, where the absolute
833/// one had that margin only near `‖H‖∞ ≈ 1`. On badly scaled, rank-deficient
834/// PSD matrices the shift can still fall below floating-point resolution, in
835/// which case FERAL reports a zero pivot and this dispatch takes the slower
836/// NLP path.
837fn hessian_is_psd(h: &QuadHessian, n: usize) -> bool {
838    if h.is_empty() {
839        return true;
840    }
841    let tol = psd_band(h.values().copied());
842    if h.keys().all(|(i, j)| i == j) {
843        return h.values().all(|value| *value >= -tol);
844    }
845
846    let lower: Vec<_> = h
847        .iter()
848        // QuadHessian is upper-triangular (i <= j). The certificate wants the
849        // lower triangle, so (i, j) is emitted at (row = j, col = i).
850        .map(|(&(i, j), &val)| Triplet::new(j, i, val))
851        .collect();
852    certify_psd_lower_triangle(n, &lower, tol, || {
853        Box::new(pounce_feral::FeralSolverInterface::with_config(
854            pounce_feral::FeralConfig::default(),
855        ))
856    })
857    .unwrap_or(false)
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use crate::nl_reader::NlBody;
864    use crate::nl_reader::{BinOp, Expr, UnaryOp, parse_nl_text};
865
866    // --- SolverSelection parsing ---
867
868    #[test]
869    fn parse_selection_values() {
870        assert_eq!(SolverSelection::parse("auto"), Some(SolverSelection::Auto));
871        assert_eq!(SolverSelection::parse("nlp"), Some(SolverSelection::Nlp));
872        assert_eq!(
873            SolverSelection::parse("lp-ipm"),
874            Some(SolverSelection::LpIpm)
875        );
876        assert_eq!(
877            SolverSelection::parse("qp-ipm"),
878            Some(SolverSelection::QpIpm)
879        );
880        assert_eq!(
881            SolverSelection::parse("qp-active-set"),
882            Some(SolverSelection::QpActiveSet)
883        );
884        assert_eq!(SolverSelection::parse("lp-simplex"), None);
885        assert_eq!(SolverSelection::parse("bogus"), None);
886    }
887
888    // --- resolve_solver: auto routes LP/convex-QP to the convex IPM,
889    // everything else to NLP ---
890
891    #[test]
892    fn auto_routes_convex_qp_family_to_qp_ipm() {
893        assert_eq!(
894            resolve_solver(ProblemClass::Lp, SolverSelection::Auto),
895            Ok(SolverChoice::QpIpm),
896            "auto should route LP to the convex IPM (P=0)"
897        );
898        assert_eq!(
899            resolve_solver(ProblemClass::ConvexQp, SolverSelection::Auto),
900            Ok(SolverChoice::QpIpm),
901            "auto should route convex QP to the convex IPM"
902        );
903    }
904
905    #[test]
906    fn auto_routes_convex_qcqp_to_socp() {
907        assert_eq!(
908            resolve_solver(ProblemClass::ConvexQcqp, SolverSelection::Auto),
909            Ok(SolverChoice::SocpIpm),
910            "auto should route convex QCQP to the conic IPM"
911        );
912    }
913
914    #[test]
915    fn auto_routes_nonconvex_to_nlp() {
916        for class in [ProblemClass::NonconvexQp, ProblemClass::Nlp] {
917            assert_eq!(
918                resolve_solver(class, SolverSelection::Auto),
919                Ok(SolverChoice::Nlp),
920                "auto must resolve to Nlp for {:?}",
921                class
922            );
923        }
924    }
925
926    #[test]
927    fn forced_socp_accepts_convex_cone_family_only() {
928        for class in [
929            ProblemClass::Lp,
930            ProblemClass::ConvexQp,
931            ProblemClass::ConvexQcqp,
932        ] {
933            assert_eq!(
934                resolve_solver(class, SolverSelection::Socp),
935                Ok(SolverChoice::SocpIpm),
936                "socp should accept {:?}",
937                class
938            );
939        }
940        assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::Socp).is_err());
941        assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::Socp).is_err());
942    }
943
944    #[test]
945    fn forced_nlp_always_ok() {
946        assert_eq!(
947            resolve_solver(ProblemClass::ConvexQp, SolverSelection::Nlp),
948            Ok(SolverChoice::Nlp)
949        );
950    }
951
952    #[test]
953    fn forced_lp_on_nlp_errors() {
954        let err = resolve_solver(ProblemClass::Nlp, SolverSelection::LpIpm).unwrap_err();
955        assert!(err.contains("NLP"), "msg should name detected class: {err}");
956        assert!(
957            err.contains("lp-ipm"),
958            "msg should name forced solver: {err}"
959        );
960    }
961
962    #[test]
963    fn forced_lp_on_lp_ok() {
964        assert_eq!(
965            resolve_solver(ProblemClass::Lp, SolverSelection::LpIpm),
966            Ok(SolverChoice::LpIpm)
967        );
968    }
969
970    /// gh #786: `qp-active-set` is the one forced selection that takes an
971    /// indefinite Hessian — `pounce-qp` controls the inertia of the reduced
972    /// Hessian by construction, which is what `choosing-a-solver.md` has always
973    /// advertised. It still refuses a class it cannot *extract*: a QCQP carries
974    /// curvature in its rows, and the QP extractor would drop it.
975    #[test]
976    fn forced_qp_active_set_accepts_indefinite_qp_but_not_a_qcqp() {
977        for class in [
978            ProblemClass::Lp,
979            ProblemClass::ConvexQp,
980            ProblemClass::NonconvexQp,
981        ] {
982            assert_eq!(
983                resolve_solver(class, SolverSelection::QpActiveSet),
984                Ok(SolverChoice::QpActiveSet),
985                "qp-active-set should accept {class:?}"
986            );
987        }
988        for class in [ProblemClass::ConvexQcqp, ProblemClass::Nlp] {
989            let err = resolve_solver(class, SolverSelection::QpActiveSet).unwrap_err();
990            assert!(err.contains("qp-active-set"), "{err}");
991            assert!(err.contains(class.name()), "{err}");
992        }
993    }
994
995    /// The lift above is scoped to the *named* engine. `auto` keeps sending a
996    /// nonconvex QP to the NLP filter-IPM: the class is our inference, and the
997    /// general path is the safer default for it.
998    #[test]
999    fn auto_still_routes_a_nonconvex_qp_to_nlp() {
1000        assert_eq!(
1001            resolve_solver(ProblemClass::NonconvexQp, SolverSelection::Auto),
1002            Ok(SolverChoice::Nlp)
1003        );
1004    }
1005
1006    #[test]
1007    fn forced_qp_accepts_lp_and_convex_qp_only() {
1008        assert_eq!(
1009            resolve_solver(ProblemClass::Lp, SolverSelection::QpIpm),
1010            Ok(SolverChoice::QpIpm)
1011        );
1012        assert_eq!(
1013            resolve_solver(ProblemClass::ConvexQp, SolverSelection::QpIpm),
1014            Ok(SolverChoice::QpIpm)
1015        );
1016        assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::QpIpm).is_err());
1017        assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::QpIpm).is_err());
1018    }
1019
1020    // --- Poly / quadratic analysis unit tests ---
1021
1022    #[test]
1023    fn poly_of_quadratic_diagonal() {
1024        // (x0 - 1)^2  =>  x0^2 - 2 x0 + 1
1025        let e = Expr::Binary(
1026            BinOp::Pow,
1027            Box::new(Expr::Binary(
1028                BinOp::Sub,
1029                Box::new(Expr::Var(0)),
1030                Box::new(Expr::Const(1.0)),
1031            )),
1032            Box::new(Expr::Const(2.0)),
1033        );
1034        let h = analyze_quadratic(&e).expect("degree-2 polynomial");
1035        // d²/dx0² (x0²) = 2
1036        assert_eq!(h.get(&(0, 0)), Some(&2.0));
1037    }
1038
1039    #[test]
1040    fn poly_rejects_transcendental() {
1041        // sin(x0) is not polynomial.
1042        let e = Expr::Unary(UnaryOp::Sin, Box::new(Expr::Var(0)));
1043        assert!(analyze_quadratic(&e).is_none());
1044    }
1045
1046    #[test]
1047    fn poly_rejects_cubic() {
1048        // x0^3
1049        let e = Expr::Binary(
1050            BinOp::Pow,
1051            Box::new(Expr::Var(0)),
1052            Box::new(Expr::Const(3.0)),
1053        );
1054        assert!(analyze_quadratic(&e).is_none());
1055    }
1056
1057    #[test]
1058    fn cross_term_hessian() {
1059        // x0 * x1  =>  H[0,1] = 1
1060        let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1061        let h = analyze_quadratic(&e).expect("degree-2");
1062        assert_eq!(h.get(&(0, 1)), Some(&1.0));
1063    }
1064
1065    #[test]
1066    fn large_quadratic_sum_lowers_without_quadratic_blowup() {
1067        // Regression guard for the `solver_selection=auto` classifier hang
1068        // (mittelmann QCQP/bearing_400/qssp180 emitted zero iterations and
1069        // burned the full CPU budget). A quadratic expressed as a large
1070        // `Sum` of monomials must lower in O(N log N): the recognizer used
1071        // to re-scan the whole accumulated polynomial for zeros on every
1072        // merged item, so an N-monomial sum was O(N²) and spun for >300 s
1073        // before the solver started (Ipopt solved the same problems in
1074        // seconds). Build a 5000-term sum of distinct squares and confirm
1075        // the full diagonal Hessian is recovered.
1076        //
1077        // That fix covered the n-ary `Sum` node only. The same quadratic
1078        // written as a chain of binary `Add`s — which is what a `.nl` writer
1079        // emitting `o0` produces — kept the re-scan until Q3 moved the
1080        // per-merge zero check onto the merged terms; see
1081        // `wide_diagonal_convex_qcqp_keeps_conic` for that shape.
1082        const N: usize = 5000;
1083        let terms: Vec<Expr> = (0..N)
1084            .map(|i| Expr::Binary(BinOp::Mul, Box::new(Expr::Var(i)), Box::new(Expr::Var(i))))
1085            .collect();
1086        let e = Expr::Sum(terms);
1087        let h = analyze_quadratic(&e).expect("degree-2 sum of squares is a QP");
1088        assert_eq!(h.len(), N, "every xᵢ² contributes one diagonal entry");
1089        assert_eq!(h.get(&(0, 0)), Some(&2.0));
1090        assert_eq!(h.get(&(N - 1, N - 1)), Some(&2.0));
1091    }
1092
1093    // --- PSD test ---
1094
1095    #[test]
1096    fn psd_accepts_convex_separable() {
1097        // diag(2, 4): both eigenvalues positive.
1098        let mut h = QuadHessian::new();
1099        h.insert((0, 0), 2.0);
1100        h.insert((1, 1), 4.0);
1101        assert!(hessian_is_psd(&h, 2));
1102    }
1103
1104    #[test]
1105    fn psd_rejects_indefinite() {
1106        // [[0,1],[1,0]] has eigenvalues ±1.
1107        let mut h = QuadHessian::new();
1108        h.insert((0, 1), 1.0);
1109        assert!(!hessian_is_psd(&h, 2));
1110    }
1111
1112    #[test]
1113    fn psd_accepts_psd_with_zero_eigenvalue() {
1114        // [[1,1],[1,1]] is PSD (eigenvalues 0 and 2).
1115        let mut h = QuadHessian::new();
1116        h.insert((0, 0), 1.0);
1117        h.insert((0, 1), 1.0);
1118        h.insert((1, 1), 1.0);
1119        assert!(hessian_is_psd(&h, 2));
1120    }
1121
1122    // --- A1: ±PSD_TOL boundary of the convexity test (silent-misroute guard) ---
1123
1124    /// The safety-critical case: a *real* negative direction — even a small
1125    /// one, well beyond `PSD_TOL` — must read non-PSD so an indefinite QP
1126    /// routes to NLP, never to the convex IPM (which would return a spurious
1127    /// "optimal" at a saddle/maximum).
1128    #[test]
1129    fn psd_rejects_small_but_real_negative_curvature() {
1130        // diag(2, −1e-3): min eigenvalue −1e-3 ≪ −PSD_TOL.
1131        let mut h = QuadHessian::new();
1132        h.insert((0, 0), 2.0);
1133        h.insert((1, 1), -1e-3);
1134        assert!(
1135            !hessian_is_psd(&h, 2),
1136            "a −1e-3 eigenvalue must read indefinite, not be rounded to PSD"
1137        );
1138    }
1139
1140    /// Pin the band at `±PSD_TOL` (1e-9) for a Hessian whose own scale is at
1141    /// or above 1 — where [`psd_band`]'s `.min(1.0)` leaves it untouched.
1142    ///
1143    /// Within the band a tiny negative eigenvalue rounds to PSD **by design**:
1144    /// a genuinely semidefinite Hessian whose smallest eigenvalue computes as
1145    /// a tiny negative (roundoff) must not be misread as nonconvex. At this
1146    /// scale the band is far below the error of solving a convex QP with that
1147    /// much curvature, so it is the sound tradeoff — see the A1 Finding in
1148    /// `dev-notes/pr70-hardening.md`. (Diagonal Hessians take the exact sign
1149    /// path, so this is deterministic.)
1150    #[test]
1151    fn psd_threshold_is_psd_tol_at_unit_scale() {
1152        let mut just_inside = QuadHessian::new();
1153        just_inside.insert((0, 0), 1.0);
1154        just_inside.insert((1, 1), -1e-10); // |λ| < PSD_TOL ⇒ treated as zero
1155        assert!(
1156            hessian_is_psd(&just_inside, 2),
1157            "−1e-10 against ‖H‖ = 1 is within tolerance and must round to PSD"
1158        );
1159
1160        let mut just_outside = QuadHessian::new();
1161        just_outside.insert((0, 0), 1.0);
1162        just_outside.insert((1, 1), -1e-7); // |λ| > PSD_TOL ⇒ genuine negative
1163        assert!(
1164            !hessian_is_psd(&just_outside, 2),
1165            "−1e-7 against ‖H‖ = 1 is beyond tolerance and must read indefinite"
1166        );
1167    }
1168
1169    /// The band scales with `‖H‖∞`, so the verdict survives a pure change of
1170    /// variable units (gh#872).
1171    ///
1172    /// `H = K⁻² · [[1, 5], [5, 1]]` is strongly indefinite at every `K` —
1173    /// `|λ_min| / λ_max = 2/3`, nowhere near roundoff — and the objective
1174    /// *values* of the model it comes from do not depend on `K` at all. Under
1175    /// the old absolute `1e-9` this read PSD from `K = 1e5` on, and the convex
1176    /// engine then returned the saddle at the start point as `Optimal` with
1177    /// zero iterations and an objective wrong by 100%.
1178    ///
1179    /// Both branches of `hessian_is_psd` are exercised: the coupled matrix
1180    /// goes through the factorization certificate, the diagonal one through
1181    /// the `O(nnz)` sign path. A band that is relative in one and absolute in
1182    /// the other would pass a test that used only the first.
1183    #[test]
1184    fn psd_verdict_is_invariant_under_a_change_of_units() {
1185        for k in [1.0_f64, 1e2, 1e5, 1e8, 1e-4] {
1186            let f = k.powi(-2);
1187
1188            let mut coupled = QuadHessian::new();
1189            coupled.insert((0, 0), f);
1190            coupled.insert((0, 1), 5.0 * f);
1191            coupled.insert((1, 1), f);
1192            assert!(
1193                !hessian_is_psd(&coupled, 2),
1194                "K = {k:e}: [[1,5],[5,1]] scaled by K⁻² is indefinite at every \
1195                 scale (|λ_min|/λ_max = 2/3); an absolute band hides it"
1196            );
1197
1198            let mut diagonal = QuadHessian::new();
1199            diagonal.insert((0, 0), 6.0 * f);
1200            diagonal.insert((1, 1), -4.0 * f);
1201            assert!(
1202                !hessian_is_psd(&diagonal, 2),
1203                "K = {k:e}: diag(6, −4) scaled by K⁻² is indefinite at every scale"
1204            );
1205
1206            let mut convex = QuadHessian::new();
1207            convex.insert((0, 0), 6.0 * f);
1208            convex.insert((1, 1), 4.0 * f);
1209            assert!(
1210                hessian_is_psd(&convex, 2),
1211                "K = {k:e}: diag(6, 4) scaled by K⁻² is PD at every scale and \
1212                 must keep reaching the convex engine"
1213            );
1214        }
1215    }
1216
1217    /// The band never *widens*, which is the half of gh#872's fix that is not
1218    /// "make it relative".
1219    ///
1220    /// A plain `PSD_TOL * ‖H‖∞` would put the band at `1e-3` on a `‖H‖∞ = 1e6`
1221    /// model, handing the convex engine Hessians it correctly rejects today —
1222    /// a wrong answer traded for a wrong answer. `psd_band` clamps the factor
1223    /// at 1, so above unit scale the threshold is exactly what it always was.
1224    #[test]
1225    fn psd_band_does_not_widen_above_unit_scale() {
1226        let mut h = QuadHessian::new();
1227        h.insert((0, 0), 1e6);
1228        h.insert((1, 1), -1e-7);
1229        assert!(
1230            !hessian_is_psd(&h, 2),
1231            "−1e-7 must stay indefinite however large the rest of H is"
1232        );
1233        assert_eq!(
1234            psd_band(h.values().copied()),
1235            PSD_TOL,
1236            "the band is clamped at PSD_TOL for ‖H‖∞ ≥ 1"
1237        );
1238    }
1239
1240    // --- Sparse-factorization PSD certificate (CVXQP family) ---
1241
1242    /// A large *diagonal* Hessian must take the O(nnz) sign fast path — no
1243    /// factorization at all — and read PSD. This is the large separable /
1244    /// least-squares QP shape (AUG2D, LISWET, …) that stays on the convex
1245    /// fast path.
1246    #[test]
1247    fn large_diagonal_hessian_is_cheap_and_psd() {
1248        let n = 50_000;
1249        let mut h = QuadHessian::new();
1250        for i in 0..n {
1251            h.insert((i, i), 2.0);
1252        }
1253        assert!(
1254            hessian_is_psd(&h, n),
1255            "diag(2,…,2) is PSD and must be settled by the O(nnz) sign path"
1256        );
1257    }
1258
1259    /// A large *coupled* convex Hessian (off-diagonal terms over many
1260    /// variables) is the CVXQP-family shape that the old dense-Jacobi cap
1261    /// refused to certify (routing it to NLP). The sparse-factorization
1262    /// certificate now proves it PSD cheaply, so it reaches the convex
1263    /// solver. This is the regression fix.
1264    #[test]
1265    fn large_coupled_convex_hessian_is_certified_psd() {
1266        let k = 1_000;
1267        let mut h = QuadHessian::new();
1268        // Diagonally dominant tridiagonal: SPD. 2 on the diagonal, 0.1 on
1269        // the off-diagonal coupling chain ⇒ strictly diagonally dominant.
1270        for i in 0..k {
1271            h.insert((i, i), 2.0);
1272        }
1273        for i in 0..(k - 1) {
1274            h.insert((i, i + 1), 0.1);
1275        }
1276        assert!(
1277            hessian_is_psd(&h, k),
1278            "a diagonally-dominant coupled Hessian over {k} vars must be \
1279             certified PSD by the sparse factorization (CVXQP regression)"
1280        );
1281    }
1282
1283    /// The sparse certificate must still *reject* a large coupled Hessian
1284    /// that is genuinely indefinite — size does not buy a free pass.
1285    #[test]
1286    fn large_coupled_indefinite_hessian_is_rejected() {
1287        let k = 1_000;
1288        let mut h = QuadHessian::new();
1289        for i in 0..k {
1290            h.insert((i, i), 2.0);
1291        }
1292        for i in 0..(k - 1) {
1293            h.insert((i, i + 1), 0.1);
1294        }
1295        // Flip one diagonal strongly negative ⇒ an indefinite direction.
1296        h.insert((0, 0), -5.0);
1297        assert!(
1298            !hessian_is_psd(&h, k),
1299            "a coupled Hessian with a strong negative-curvature direction \
1300             must be rejected regardless of size"
1301        );
1302    }
1303
1304    /// A *small* coupled Hessian is certified by the same sparse path.
1305    #[test]
1306    fn small_coupled_hessian_is_certified_psd() {
1307        // [[2, 1], [1, 2]] — eigenvalues 1 and 3, PSD.
1308        let mut h = QuadHessian::new();
1309        h.insert((0, 0), 2.0);
1310        h.insert((0, 1), 1.0);
1311        h.insert((1, 1), 2.0);
1312        assert!(hessian_is_psd(&h, 2));
1313    }
1314
1315    // --- End-to-end classify_problem on parsed .nl text ---
1316
1317    /// Minimal `g`-format `.nl` text builder is overkill; instead use the
1318    /// reader's own fixtures via parse_nl_text on hand-written stubs.
1319    /// These cover the header LP fast-path and the AST walk.
1320
1321    #[test]
1322    fn classify_pure_lp() {
1323        // minimize x0 + x1 s.t. x0 + x1 <= 1, no nonlinear parts.
1324        // Build an NlProblem directly for a hermetic test.
1325        let prob = NlProblem {
1326            src: None,
1327            cse_bodies: Vec::new(),
1328            complementarity: Vec::new(),
1329            n: 2,
1330            m: 1,
1331            num_obj: 1,
1332            minimize: true,
1333            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1334            obj_linear: vec![(0, 1.0), (1, 1.0)],
1335            obj_constant: 0.0,
1336            con_nonlinear: vec![NlBody::Tree(Expr::Const(0.0))],
1337            con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
1338            x_l: vec![0.0, 0.0],
1339            x_u: vec![f64::INFINITY, f64::INFINITY],
1340            g_l: vec![f64::NEG_INFINITY],
1341            g_u: vec![1.0],
1342            x0: vec![0.0, 0.0],
1343            lambda0: vec![0.0],
1344            suffixes: Default::default(),
1345            imported_funcs: Vec::new(),
1346            ampl_options: Vec::new(),
1347            nl_counts: None,
1348            n_discrete: None,
1349            var_names: Vec::new(),
1350            con_names: Vec::new(),
1351        };
1352        assert_eq!(classify_problem(&prob), ProblemClass::Lp);
1353    }
1354
1355    #[test]
1356    fn classify_convex_qp() {
1357        // minimize x0^2 + x1^2 s.t. linear; convex (H = diag(2,2)).
1358        let obj = Expr::Binary(
1359            BinOp::Add,
1360            Box::new(Expr::Binary(
1361                BinOp::Pow,
1362                Box::new(Expr::Var(0)),
1363                Box::new(Expr::Const(2.0)),
1364            )),
1365            Box::new(Expr::Binary(
1366                BinOp::Pow,
1367                Box::new(Expr::Var(1)),
1368                Box::new(Expr::Const(2.0)),
1369            )),
1370        );
1371        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1372        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
1373    }
1374
1375    /// **gh #401.** A quadratic row whose real bound lies past the *opposite*
1376    /// sentinel must not be waved through as a free row.
1377    ///
1378    /// `x0² + x1² >= 5e20` arrives as `g_l = 5e20` (real), `g_u = 1e19`
1379    /// (the absent-upper sentinel). The symmetric `|v| < 1e19` test called
1380    /// *both* sides infinite, so `vacuous` was true and the row was skipped
1381    /// with "Free row: imposes nothing" — and the model then classified as a
1382    /// convex QCQP and went to the conic solver as if the constraint were not
1383    /// there. It is a reverse-convex row: the honest answer is NLP.
1384    #[test]
1385    fn a_quadratic_row_bounded_past_the_sentinel_is_not_vacuous() {
1386        let con = Expr::Binary(
1387            BinOp::Add,
1388            Box::new(Expr::Binary(
1389                BinOp::Pow,
1390                Box::new(Expr::Var(0)),
1391                Box::new(Expr::Const(2.0)),
1392            )),
1393            Box::new(Expr::Binary(
1394                BinOp::Pow,
1395                Box::new(Expr::Var(1)),
1396                Box::new(Expr::Const(2.0)),
1397            )),
1398        );
1399        let mut prob = qp_stub(Expr::Const(0.0), vec![con]);
1400        prob.obj_linear = vec![(0, 1.0)];
1401        prob.g_l = vec![5e20]; // real lower bound
1402        prob.g_u = vec![1e19]; // absent-upper sentinel
1403        assert_eq!(
1404            classify_problem(&prob),
1405            ProblemClass::Nlp,
1406            "a `>=` quadratic row is reverse-convex and must route to NLP; \
1407             treating it as a free row sent the model to the conic solver \
1408             with the constraint silently dropped"
1409        );
1410    }
1411
1412    #[test]
1413    fn classify_nonconvex_qp() {
1414        // minimize x0 * x1 (indefinite Hessian) s.t. linear.
1415        let obj = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1416        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1417        assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1418    }
1419
1420    /// gh #786: an indefinite objective over a *quadratic* row is a nonconvex
1421    /// QCQP, and must not be handed the `NonconvexQp` label.
1422    ///
1423    /// This is not a naming preference. `NonconvexQp` now has a consumer —
1424    /// `solver_selection=qp-active-set` reaches the QP extractor through it —
1425    /// and that extractor keeps only the degree-≤1 part of every row. Labelling
1426    /// this a QP would send the engine a model with `x0² + x1² ≤ 1` deleted,
1427    /// and it would report an "optimal" outside the ball.
1428    #[test]
1429    fn classify_indefinite_objective_with_quadratic_row_is_not_a_qp() {
1430        let obj = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1431        let ball = Expr::Binary(
1432            BinOp::Add,
1433            Box::new(Expr::Binary(
1434                BinOp::Pow,
1435                Box::new(Expr::Var(0)),
1436                Box::new(Expr::Const(2.0)),
1437            )),
1438            Box::new(Expr::Binary(
1439                BinOp::Pow,
1440                Box::new(Expr::Var(1)),
1441                Box::new(Expr::Const(2.0)),
1442            )),
1443        );
1444        let mut prob = qp_stub(obj, vec![ball]);
1445        prob.g_l = vec![f64::NEG_INFINITY];
1446        prob.g_u = vec![1.0];
1447        let (class, reason) = classify_problem_explained(&prob);
1448        assert_eq!(class, ProblemClass::Nlp);
1449        assert_eq!(reason, ClassReason::NonconvexQcqp { row: 0 });
1450        // And the label it must not get, stated as the routing consequence.
1451        assert!(
1452            resolve_solver(class, SolverSelection::QpActiveSet).is_err(),
1453            "a nonconvex QCQP must not reach the active-set QP engine"
1454        );
1455    }
1456
1457    #[test]
1458    fn classify_nlp_from_transcendental_objective() {
1459        let obj = Expr::Unary(UnaryOp::Exp, Box::new(Expr::Var(0)));
1460        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1461        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1462    }
1463
1464    /// Regression: a `maximize` of a PSD-Hessian objective is a *concave*
1465    /// maximization ⇒ nonconvex minimization. The convexity test must run
1466    /// on the sense-adjusted Hessian, or this slips through to the convex
1467    /// IPM and returns a wrong (maximum/saddle) answer.
1468    #[test]
1469    fn classify_maximize_psd_objective_is_nonconvex() {
1470        // maximize x0^2 + x1^2 (H = diag(2,2), PSD) — concave max.
1471        let obj = Expr::Binary(
1472            BinOp::Add,
1473            Box::new(Expr::Binary(
1474                BinOp::Pow,
1475                Box::new(Expr::Var(0)),
1476                Box::new(Expr::Const(2.0)),
1477            )),
1478            Box::new(Expr::Binary(
1479                BinOp::Pow,
1480                Box::new(Expr::Var(1)),
1481                Box::new(Expr::Const(2.0)),
1482            )),
1483        );
1484        let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1485        prob.minimize = false;
1486        assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1487    }
1488
1489    /// Mirror: `maximize` of a concave (NSD-Hessian) objective is a convex
1490    /// minimization once negated, so it is a legitimate `ConvexQp`.
1491    #[test]
1492    fn classify_maximize_concave_objective_is_convex() {
1493        // maximize −(x0^2 + x1^2) (H = diag(−2,−2)); negated ⇒ PSD.
1494        let neg_sq = |v: usize| {
1495            Expr::Unary(
1496                UnaryOp::Neg,
1497                Box::new(Expr::Binary(
1498                    BinOp::Pow,
1499                    Box::new(Expr::Var(v)),
1500                    Box::new(Expr::Const(2.0)),
1501                )),
1502            )
1503        };
1504        let obj = Expr::Binary(BinOp::Add, Box::new(neg_sq(0)), Box::new(neg_sq(1)));
1505        let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1506        prob.minimize = false;
1507        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
1508    }
1509
1510    #[test]
1511    fn classify_convex_qcqp() {
1512        // convex quadratic objective + a convex quadratic constraint.
1513        let obj = Expr::Binary(
1514            BinOp::Pow,
1515            Box::new(Expr::Var(0)),
1516            Box::new(Expr::Const(2.0)),
1517        );
1518        let con = Expr::Binary(
1519            BinOp::Add,
1520            Box::new(Expr::Binary(
1521                BinOp::Pow,
1522                Box::new(Expr::Var(0)),
1523                Box::new(Expr::Const(2.0)),
1524            )),
1525            Box::new(Expr::Binary(
1526                BinOp::Pow,
1527                Box::new(Expr::Var(1)),
1528                Box::new(Expr::Const(2.0)),
1529            )),
1530        );
1531        let prob = qp_stub(obj, vec![con]);
1532        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1533    }
1534
1535    /// Build a convex QCQP (linear objective + one convex quadratic
1536    /// constraint `x0² ≤ 1`) at an arbitrary declared `n`/`m`, padding the
1537    /// extra constraints with trivially-zero rows. Used to exercise the
1538    /// two routing caps (`SOCP_REFORM_FLOP_BUDGET`, `SOCP_SOLVE_SIZE_CAP`)
1539    /// without allocating `n×n` data.
1540    fn convex_qcqp_at_size(n: usize, m: usize) -> NlProblem {
1541        let mut con_nonlinear = vec![NlBody::Tree(Expr::Const(0.0)); m];
1542        con_nonlinear[0] = NlBody::Tree(Expr::Binary(
1543            BinOp::Pow,
1544            Box::new(Expr::Var(0)),
1545            Box::new(Expr::Const(2.0)),
1546        ));
1547        let g_l = vec![f64::NEG_INFINITY; m];
1548        let mut g_u = vec![f64::INFINITY; m];
1549        g_u[0] = 1.0; // upper-only bound ⇒ convex feasible set
1550        NlProblem {
1551            src: None,
1552            cse_bodies: Vec::new(),
1553            complementarity: Vec::new(),
1554            n,
1555            m,
1556            num_obj: 1,
1557            minimize: true,
1558            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1559            obj_linear: vec![(0, 1.0)],
1560            obj_constant: 0.0,
1561            con_nonlinear,
1562            con_linear: vec![vec![]; m],
1563            x_l: vec![f64::NEG_INFINITY; n],
1564            x_u: vec![f64::INFINITY; n],
1565            g_l,
1566            g_u,
1567            x0: vec![0.0; n],
1568            lambda0: vec![0.0; m],
1569            suffixes: Default::default(),
1570            imported_funcs: Vec::new(),
1571            ampl_options: Vec::new(),
1572            nl_counts: None,
1573            n_discrete: None,
1574            var_names: Vec::new(),
1575            con_names: Vec::new(),
1576        }
1577    }
1578
1579    /// A convex QCQP small enough to keep the conic path (n·m ≤ budget).
1580    #[test]
1581    fn small_convex_qcqp_routes_to_conic() {
1582        let prob = convex_qcqp_at_size(100, 100); // n·m = 1e4 ≪ budget
1583        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1584    }
1585
1586    /// The two guards are independent, and this problem shows why both are
1587    /// needed. Its one quadratic row is `x0² ≤ 1` — a single variable,
1588    /// diagonal, one flop to put in cone form — so the *reformulation* guard
1589    /// passes easily. It is still routed to NLP, by `SOCP_SOLVE_SIZE_CAP`,
1590    /// because at 1e4 × 1e4 the conic solve itself measured slower than the
1591    /// filter-IPM on exactly this shape (`nql180`, `qssp180`).
1592    ///
1593    /// Keeping the two apart matters: the old single `n·m` proxy conflated
1594    /// them, so fixing the extractor's cost premise silently moved a *routing*
1595    /// decision that measurement says should not move.
1596    #[test]
1597    fn large_qcqp_cheap_to_reform_still_falls_back_on_solve_size() {
1598        let prob = convex_qcqp_at_size(10_001, 10_001);
1599        assert!((prob.n as u64) * (prob.m as u64) > SOCP_SOLVE_SIZE_CAP);
1600        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1601    }
1602
1603    /// The same shape just *under* the solve-size cap keeps the conic path —
1604    /// confirming the fallback above is the size cap talking and not the
1605    /// reformulation budget rejecting a one-variable diagonal row.
1606    #[test]
1607    fn large_qcqp_under_the_solve_size_cap_keeps_conic() {
1608        let prob = convex_qcqp_at_size(10_000, 9_000);
1609        assert!((prob.n as u64) * (prob.m as u64) <= SOCP_SOLVE_SIZE_CAP);
1610        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1611    }
1612
1613    /// Four different findings route a QCQP to `Nlp`, and until now the log
1614    /// said only "NLP" for all of them. Each guard must name itself.
1615    #[test]
1616    fn qcqp_downgrades_say_which_guard_fired() {
1617        // Solve-size cap: one diagonal row, trivial to reform.
1618        let (class, reason) = classify_problem_explained(&convex_qcqp_at_size(10_001, 10_001));
1619        assert_eq!(class, ProblemClass::Nlp);
1620        assert!(
1621            matches!(reason, ClassReason::QcqpTooLargeToSolve { size } if size == 10_001 * 10_001),
1622            "expected the solve-size cap, got {reason:?}"
1623        );
1624
1625        // Reformulation budget: one dense row coupling 400 variables, k³ ≫ 2e7.
1626        let (class, reason) = classify_problem_explained(&coupled_convex_qcqp_with_k_vars(400));
1627        assert_eq!(class, ProblemClass::Nlp);
1628        assert!(
1629            matches!(reason, ClassReason::QcqpReformTooCostly { .. }),
1630            "expected the reformulation budget, got {reason:?}"
1631        );
1632
1633        // Inside both guards: the reason carries the numbers that decided it.
1634        let (class, reason) = classify_problem_explained(&convex_qcqp_at_size(100, 100));
1635        assert_eq!(class, ProblemClass::ConvexQcqp);
1636        assert!(
1637            matches!(
1638                reason,
1639                ClassReason::ConvexQcqpWithinBudgets { size, .. } if size == 10_000
1640            ),
1641            "expected a within-budget QCQP, got {reason:?}"
1642        );
1643    }
1644
1645    /// A quadratic row that is convex as a *function* but bounded from
1646    /// below carves a nonconvex set. That is a different finding from a row
1647    /// whose Hessian is indefinite, and the two used to be one `return`.
1648    #[test]
1649    fn nonconvex_sense_and_indefinite_hessian_are_distinct_reasons() {
1650        // x0² ≥ 1 — PSD Hessian, nonconvex feasible set.
1651        let mut prob = convex_qcqp_at_size(10, 10);
1652        prob.g_l[0] = 1.0;
1653        prob.g_u[0] = f64::INFINITY;
1654        let (class, reason) = classify_problem_explained(&prob);
1655        assert_eq!(class, ProblemClass::Nlp);
1656        assert_eq!(reason, ClassReason::ConstraintSenseNonconvex { row: 0 });
1657
1658        // −x0² ≤ 1 — upper-bounded, but the Hessian is negative definite.
1659        let mut prob = convex_qcqp_at_size(10, 10);
1660        prob.con_nonlinear[0] = NlBody::Tree(Expr::Unary(
1661            UnaryOp::Neg,
1662            Box::new(Expr::Binary(
1663                BinOp::Pow,
1664                Box::new(Expr::Var(0)),
1665                Box::new(Expr::Const(2.0)),
1666            )),
1667        ));
1668        let (class, reason) = classify_problem_explained(&prob);
1669        assert_eq!(class, ProblemClass::Nlp);
1670        assert_eq!(reason, ClassReason::ConstraintHessianIndefinite { row: 0 });
1671    }
1672
1673    /// The LP fast path reports *why* it is an LP, and the ways of getting
1674    /// there are told apart: nothing nonlinear at all, versus a nonlinear
1675    /// part that expanded to something of degree ≤ 1 — versus one that only
1676    /// *looks* that way because a coefficient was dropped.
1677    #[test]
1678    fn lp_reasons_distinguish_absent_from_cancelled() {
1679        let prob = qp_stub(Expr::Const(0.0), vec![Expr::Const(0.0)]);
1680        assert_eq!(
1681            classify_problem_explained(&prob),
1682            (ProblemClass::Lp, ClassReason::NoNonlinearParts)
1683        );
1684
1685        // 2·x0 in the objective's nonlinear part: present, nonlinear to the
1686        // header, degree 1 once expanded, and nothing dropped getting there.
1687        let expands = Expr::Binary(
1688            BinOp::Mul,
1689            Box::new(Expr::Const(2.0)),
1690            Box::new(Expr::Var(0)),
1691        );
1692        let prob = qp_stub(expands, vec![Expr::Const(0.0)]);
1693        assert_eq!(
1694            classify_problem_explained(&prob),
1695            (ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
1696        );
1697
1698        // x0 − x0 reaches degree 0 the other way: by the two coefficients
1699        // summing to zero and the term being dropped. That sum is *exact*
1700        // — `fl(1) + fl(−1)` loses nothing — so the empty form really is
1701        // the objective, and this stays LP. (gh #685 sent it to NLP; gh
1702        // #687 sharpened the gate to the inexact fold and handed the reach
1703        // back.)
1704        let cancels = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(Expr::Var(0)));
1705        let prob = qp_stub(cancels, vec![Expr::Const(0.0)]);
1706        assert_eq!(
1707            classify_problem_explained(&prob),
1708            (ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
1709        );
1710
1711        // 2⁵³·x0 + x0 − 2⁵³·x0 empties the same map, but the `x0` was lost
1712        // at `fl(2⁵³ + 1) = 2⁵³` — an inexact add — so the read-out is a
1713        // whole `x0` short of the objective and the LP built from it would
1714        // be a different problem. This is the case that must not route LP
1715        // (gh #685).
1716        let big = 9007199254740992.0_f64; // 2⁵³
1717        let loses = Expr::Binary(
1718            BinOp::Sub,
1719            Box::new(Expr::Binary(
1720                BinOp::Add,
1721                Box::new(Expr::Binary(
1722                    BinOp::Mul,
1723                    Box::new(Expr::Const(big)),
1724                    Box::new(Expr::Var(0)),
1725                )),
1726                Box::new(Expr::Var(0)),
1727            )),
1728            Box::new(Expr::Binary(
1729                BinOp::Mul,
1730                Box::new(Expr::Const(big)),
1731                Box::new(Expr::Var(0)),
1732            )),
1733        );
1734        let prob = qp_stub(loses, vec![Expr::Const(0.0)]);
1735        assert_eq!(
1736            classify_problem_explained(&prob),
1737            (ProblemClass::Nlp, ClassReason::ObjectiveTermsDropped)
1738        );
1739    }
1740
1741    /// Build a convex QCQP whose single quadratic constraint `(Σ xᵢ)² ≤ 1`
1742    /// couples all `k` variables (a dense rank-1 PSD Hessian over `k` vars),
1743    /// with `n = k`, `m = 1`. Exercises the per-row conic-reformulation guard
1744    /// independently of the `n·m` budget.
1745    fn coupled_convex_qcqp_with_k_vars(k: usize) -> NlProblem {
1746        // sum = x0 + x1 + … + x_{k-1}
1747        let mut sum = Expr::Var(0);
1748        for i in 1..k {
1749            sum = Expr::Binary(BinOp::Add, Box::new(sum), Box::new(Expr::Var(i)));
1750        }
1751        // constraint (Σ xᵢ)² ≤ 1 — convex feasible set, Hessian = 2·(all-ones),
1752        // PSD (rank 1) and fully coupled across all k variables.
1753        let con = Expr::Binary(BinOp::Pow, Box::new(sum), Box::new(Expr::Const(2.0)));
1754        NlProblem {
1755            src: None,
1756            cse_bodies: Vec::new(),
1757            complementarity: Vec::new(),
1758            n: k,
1759            m: 1,
1760            num_obj: 1,
1761            minimize: true,
1762            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1763            obj_linear: vec![(0, 1.0)],
1764            obj_constant: 0.0,
1765            con_nonlinear: vec![NlBody::Tree(con)],
1766            con_linear: vec![vec![]],
1767            x_l: vec![f64::NEG_INFINITY; k],
1768            x_u: vec![f64::INFINITY; k],
1769            g_l: vec![f64::NEG_INFINITY],
1770            g_u: vec![1.0],
1771            x0: vec![0.0; k],
1772            lambda0: vec![0.0],
1773            suffixes: Default::default(),
1774            imported_funcs: Vec::new(),
1775            ampl_options: Vec::new(),
1776            nl_counts: None,
1777            n_discrete: None,
1778            var_names: Vec::new(),
1779            con_names: Vec::new(),
1780        }
1781    }
1782
1783    /// Build a convex QCQP whose single quadratic constraint `Σ xᵢ² ≤ 1` is
1784    /// **separable** — a diagonal Hessian over `k` variables, no coupling.
1785    /// This is the `qssp180`/`nql180` shape: very wide, trivially factorable.
1786    fn separable_convex_qcqp_with_k_vars(k: usize) -> NlProblem {
1787        let sq = |i: usize| {
1788            Expr::Binary(
1789                BinOp::Pow,
1790                Box::new(Expr::Var(i)),
1791                Box::new(Expr::Const(2.0)),
1792            )
1793        };
1794        let mut con = sq(0);
1795        for i in 1..k {
1796            con = Expr::Binary(BinOp::Add, Box::new(con), Box::new(sq(i)));
1797        }
1798        NlProblem {
1799            src: None,
1800            cse_bodies: Vec::new(),
1801            complementarity: Vec::new(),
1802            n: k,
1803            m: 1,
1804            num_obj: 1,
1805            minimize: true,
1806            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1807            obj_linear: vec![(0, 1.0)],
1808            obj_constant: 0.0,
1809            con_nonlinear: vec![NlBody::Tree(con)],
1810            con_linear: vec![vec![]],
1811            x_l: vec![f64::NEG_INFINITY; k],
1812            x_u: vec![f64::INFINITY; k],
1813            g_l: vec![f64::NEG_INFINITY],
1814            g_u: vec![1.0],
1815            x0: vec![0.0; k],
1816            lambda0: vec![0.0],
1817            suffixes: Default::default(),
1818            imported_funcs: Vec::new(),
1819            ampl_options: Vec::new(),
1820            nl_counts: None,
1821            n_discrete: None,
1822            var_names: Vec::new(),
1823            con_names: Vec::new(),
1824        }
1825    }
1826
1827    /// The converse: a *small* problem can still be too expensive to
1828    /// reformulate. This one is `n·m = 300`, but its single row couples all
1829    /// 300 variables densely, so the pivoted Cholesky costs `300³ = 2.7e7`
1830    /// flops — over budget. Route to NLP. This is the mittelmann `qcqp1000-*`
1831    /// shape (few constraints, ~1000-var coupled rows), and Q0 measured those
1832    /// solving well on the NLP path.
1833    #[test]
1834    fn heavily_coupled_convex_qcqp_falls_back_to_nlp() {
1835        let k = 300;
1836        let prob = coupled_convex_qcqp_with_k_vars(k);
1837        assert!((k as u128).pow(3) > SOCP_REFORM_FLOP_BUDGET);
1838        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1839    }
1840
1841    /// The companion to the guard: a convex QCQP whose dense row is narrow
1842    /// enough to factor inside the budget keeps the conic path.
1843    /// `250³ = 1.56e7 ≤ 2e7`.
1844    #[test]
1845    fn lightly_coupled_convex_qcqp_keeps_conic() {
1846        let k = 250;
1847        let prob = coupled_convex_qcqp_with_k_vars(k);
1848        assert!((k as u128).pow(3) <= SOCP_REFORM_FLOP_BUDGET);
1849        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1850    }
1851
1852    /// A diagonal row is factored in `O(k)`, so width alone must not push a
1853    /// separable QCQP off the conic path — 100 000 uncoupled variables cost
1854    /// 100 000 flops, where the same width densely coupled would cost 1e15.
1855    ///
1856    /// `k` was held at 1 000 by an unrelated defect rather than by the cost
1857    /// model: the constraint is a left-deep `Add` tree, the recognizer walked
1858    /// it recursively, and a sum of a few thousand squares overflowed the
1859    /// stack during classification (Q1 found this; a left-deep `o0` chain is
1860    /// what a `.nl` writer emits for a long sum). Q3 made the recognizer
1861    /// iterative, so the cost model can now be tested at a width that means
1862    /// something. See `pounce_nl::nl_quadratic` for the depth tests
1863    /// themselves.
1864    ///
1865    /// The problem is leaked rather than dropped: `Expr`'s derived `Drop` is
1866    /// still recursive and would overflow tearing a tree this deep down. That
1867    /// is a real remaining defect on the same shape — the Python bindings
1868    /// work around it with a big-stack worker thread (pounce#472) — and it is
1869    /// not this test's subject.
1870    #[test]
1871    fn wide_diagonal_convex_qcqp_keeps_conic() {
1872        let k = 100_000;
1873        let prob = separable_convex_qcqp_with_k_vars(k);
1874        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1875        std::mem::forget(prob);
1876    }
1877
1878    /// Classification mirror of the boundary guard: a QP whose only
1879    /// curvature is a genuine (beyond-tolerance) negative direction is
1880    /// `NonconvexQp`, so `auto` routes it to NLP rather than the convex IPM.
1881    /// `minimize −x0²` is concave for a minimizer ⇒ indefinite.
1882    #[test]
1883    fn classify_concave_minimize_is_nonconvex() {
1884        let obj = Expr::Unary(
1885            UnaryOp::Neg,
1886            Box::new(Expr::Binary(
1887                BinOp::Pow,
1888                Box::new(Expr::Var(0)),
1889                Box::new(Expr::Const(2.0)),
1890            )),
1891        );
1892        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1893        assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1894    }
1895
1896    /// Conservative QCQP guard: a convex quadratic objective with an
1897    /// *indefinite* quadratic constraint must fall back to NLP — never be
1898    /// called `ConvexQcqp` and handed to the conic path, which would treat a
1899    /// nonconvex feasible region as convex.
1900    #[test]
1901    fn classify_qcqp_with_indefinite_constraint_falls_back_to_nlp() {
1902        // obj x0² (convex); constraint x0·x1 (indefinite Hessian).
1903        let obj = Expr::Binary(
1904            BinOp::Pow,
1905            Box::new(Expr::Var(0)),
1906            Box::new(Expr::Const(2.0)),
1907        );
1908        let con = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1909        let prob = qp_stub(obj, vec![con]);
1910        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1911    }
1912
1913    /// Sense guard: a PSD-Hessian quadratic constraint is convex only as an
1914    /// **upper** bound. With a finite *lower* bound (`g(x) ≥ g_l`) the
1915    /// feasible set is the nonconvex super-level set, so it must fall back to
1916    /// NLP — never be routed to the conic solver as if convex.
1917    #[test]
1918    fn classify_psd_quadratic_with_lower_bound_is_nonconvex() {
1919        let obj = Expr::Binary(
1920            BinOp::Pow,
1921            Box::new(Expr::Var(0)),
1922            Box::new(Expr::Const(2.0)),
1923        );
1924        let con = Expr::Binary(
1925            BinOp::Add,
1926            Box::new(Expr::Binary(
1927                BinOp::Pow,
1928                Box::new(Expr::Var(0)),
1929                Box::new(Expr::Const(2.0)),
1930            )),
1931            Box::new(Expr::Binary(
1932                BinOp::Pow,
1933                Box::new(Expr::Var(1)),
1934                Box::new(Expr::Const(2.0)),
1935            )),
1936        );
1937        let mut prob = qp_stub(obj, vec![con]);
1938        // g(x) ≥ 1  (finite lower, infinite upper) — convex function, but the
1939        // ≥ side is a nonconvex region.
1940        prob.g_l = vec![1.0];
1941        prob.g_u = vec![f64::INFINITY];
1942        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1943    }
1944
1945    /// Sense guard: a quadratic *equality* (`g(x) = c`) is nonconvex even
1946    /// with a PSD Hessian, so it must fall back to NLP, not ConvexQcqp.
1947    #[test]
1948    fn classify_quadratic_equality_is_nonconvex() {
1949        let obj = Expr::Const(0.0);
1950        let con = Expr::Binary(
1951            BinOp::Pow,
1952            Box::new(Expr::Var(0)),
1953            Box::new(Expr::Const(2.0)),
1954        );
1955        let mut prob = qp_stub(obj, vec![con]);
1956        prob.g_l = vec![1.0];
1957        prob.g_u = vec![1.0]; // x0² = 1 — nonconvex.
1958        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1959    }
1960
1961    /// A nonlinear objective whose quadratic part cancels has an empty
1962    /// Hessian — and whether that empty map is evidence depends on how it
1963    /// got empty. `x0² − x0²` cancels exactly, so the map is the whole
1964    /// objective and the model is an LP. `2⁵³·x0² + x0² − 2⁵³·x0²` empties
1965    /// the same map having lost an entire `x0²` at `fl(2⁵³ + 1)`, so the
1966    /// read-out is not the objective and the model must go to NLP (gh
1967    /// #685, gated on the inexact fold per gh #687).
1968    ///
1969    /// The spurious-QP concern this test was written for is unaffected in
1970    /// either case: an empty Hessian never reaches the QP IPM.
1971    #[test]
1972    fn classify_cancelling_quadratic_objective_routes_on_exactness() {
1973        // x0² − x0²  ≡ 0: the degree-2 terms cancel in the polynomial walk,
1974        // and the sum that cancels them is exact.
1975        let sq = |c: f64| {
1976            let p = Expr::Binary(
1977                BinOp::Pow,
1978                Box::new(Expr::Var(0)),
1979                Box::new(Expr::Const(2.0)),
1980            );
1981            if c == 1.0 {
1982                p
1983            } else {
1984                Expr::Binary(BinOp::Mul, Box::new(Expr::Const(c)), Box::new(p))
1985            }
1986        };
1987        let obj = Expr::Binary(BinOp::Sub, Box::new(sq(1.0)), Box::new(sq(1.0)));
1988        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1989        assert_eq!(
1990            classify_problem_explained(&prob),
1991            (ProblemClass::Lp, ClassReason::NonlinearPartsCancelled)
1992        );
1993
1994        // 2⁵³·x0² + x0² − 2⁵³·x0² ≡ x0²: same empty map, one lost term.
1995        let big = 9007199254740992.0_f64; // 2⁵³
1996        let obj = Expr::Binary(
1997            BinOp::Sub,
1998            Box::new(Expr::Binary(
1999                BinOp::Add,
2000                Box::new(sq(big)),
2001                Box::new(sq(1.0)),
2002            )),
2003            Box::new(sq(big)),
2004        );
2005        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
2006        assert_eq!(
2007            classify_problem_explained(&prob),
2008            (ProblemClass::Nlp, ClassReason::ObjectiveTermsDropped)
2009        );
2010    }
2011
2012    #[test]
2013    fn classify_nlp_from_transcendental_constraint() {
2014        let obj = Expr::Binary(
2015            BinOp::Pow,
2016            Box::new(Expr::Var(0)),
2017            Box::new(Expr::Const(2.0)),
2018        );
2019        let con = Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(1)));
2020        let prob = qp_stub(obj, vec![con]);
2021        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
2022    }
2023
2024    /// Build a 2-var, 1-con problem stub with the given nonlinear
2025    /// objective and per-constraint nonlinear parts. Linear parts and
2026    /// bounds are filled with benign defaults.
2027    fn qp_stub(obj_nonlinear: Expr, con_nonlinear: Vec<Expr>) -> NlProblem {
2028        let obj_nonlinear = NlBody::Tree(obj_nonlinear);
2029        let con_nonlinear: Vec<NlBody> = con_nonlinear.into_iter().map(NlBody::Tree).collect();
2030        let m = con_nonlinear.len();
2031        NlProblem {
2032            src: None,
2033            cse_bodies: Vec::new(),
2034            complementarity: Vec::new(),
2035            n: 2,
2036            m,
2037            num_obj: 1,
2038            minimize: true,
2039            obj_nonlinear,
2040            obj_linear: vec![],
2041            obj_constant: 0.0,
2042            con_nonlinear,
2043            con_linear: vec![vec![]; m],
2044            x_l: vec![f64::NEG_INFINITY; 2],
2045            x_u: vec![f64::INFINITY; 2],
2046            g_l: vec![f64::NEG_INFINITY; m],
2047            g_u: vec![0.0; m],
2048            x0: vec![0.0; 2],
2049            lambda0: vec![0.0; m],
2050            suffixes: Default::default(),
2051            imported_funcs: Vec::new(),
2052            ampl_options: Vec::new(),
2053            nl_counts: None,
2054            n_discrete: None,
2055            var_names: Vec::new(),
2056            con_names: Vec::new(),
2057        }
2058    }
2059
2060    // Keep parse_nl_text reachable for a future header-fast-path test
2061    // against a committed .nl fixture.
2062    #[allow(dead_code)]
2063    fn _parse(txt: &str) -> NlProblem {
2064        parse_nl_text(txt).expect("valid .nl")
2065    }
2066
2067    /// **gh #492.** `min −x0 − 2·x1  s.t.  x0 + x1 + 3 <= 6, x ∈ [0,3]²`,
2068    /// with the `3` written into the row's expression segment. `body` is
2069    /// the `C0` token stream for that constant.
2070    fn lp_with_row_constant(body: &str) -> NlProblem {
2071        let nl = format!(
2072            "g3 1 1 0
2073 2 1 1 0 0
2074 1 0 0 0 0 0
2075 0 0
2076 1 0 0
2077 0 0 0 1
2078 0 0 0 0 0
2079 2 2
2080 0 0
2081 0 0 0 0 0
2082C0
2083{body}
2084O0 0
2085n0
2086r
20871 6.0
2088b
20890 0 3
20900 0 3
2091k1
20921
2093J0 2
20940 1
20951 1
2096G0 2
20970 -1
20981 -2
2099"
2100        );
2101        parse_nl_text(&nl).expect("valid .nl")
2102    }
2103
2104    /// The classifier's fast path asks `is_trivially_zero` of every
2105    /// `con_nonlinear` entry, which is an *identity* test — it cannot tell
2106    /// "this row has a nonlinear part" from "this row's part is the
2107    /// constant 3". A bare literal survived that anyway, because the
2108    /// fallback polynomial walk lowers `Const` and finds no quadratic
2109    /// term; what it does not do is keep the constant, so the row's `+3`
2110    /// lived on only as `qp_extract`'s `const_shift`. After the parse-time
2111    /// fold the bound carries it and the fast path is exact.
2112    #[test]
2113    fn a_literal_row_constant_classifies_lp_and_moves_the_bound() {
2114        let prob = lp_with_row_constant("n3");
2115        assert_eq!(classify_problem(&prob), ProblemClass::Lp);
2116        // `x0 + x1 + 3 <= 6` is `x0 + x1 <= 3`.
2117        assert!((prob.g_u[0] - 3.0).abs() < 1e-12, "g_u = {}", prob.g_u[0]);
2118        assert!(
2119            prob.con_nonlinear[0].is_trivially_zero(),
2120            "the row body should be the identity zero: {:?}",
2121            prob.con_nonlinear[0]
2122        );
2123    }
2124
2125    /// The case the polynomial walk cannot rescue: a constant it has to
2126    /// *compute*. `sqrt(9)` is not a degree-≤2 polynomial in any variable,
2127    /// so `analyze_quadratic` returns `None` and the row made the whole
2128    /// model NLP — an LP that never reached the convex route. The fold
2129    /// settles it at parse, where the value is known.
2130    #[test]
2131    fn a_computed_row_constant_does_not_make_an_lp_classify_nlp() {
2132        let prob = lp_with_row_constant("o39\nn9");
2133        assert_eq!(classify_problem(&prob), ProblemClass::Lp);
2134        assert!((prob.g_u[0] - 3.0).abs() < 1e-12, "g_u = {}", prob.g_u[0]);
2135    }
2136}