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