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::{BinOp, Expr, NlProblem, UnaryOp};
39use std::collections::BTreeMap;
40
41/// Tolerance for the smallest-eigenvalue sign test in the convexity
42/// check. A Hessian eigenvalue below `-PSD_TOL` is treated as a genuine
43/// negative direction (nonconvex); within `±PSD_TOL` it is treated as
44/// zero. Scaled tolerances would be better once we have problem scaling
45/// in this path; a fixed absolute tolerance is adequate here and errs
46/// toward the safe (more general) class.
47const PSD_TOL: f64 = 1e-9;
48
49/// Size budget (`n · m`) above which a convex QCQP is routed to the general
50/// NLP solver instead of the conic (SOCP) interior-point path.
51///
52/// The QCQP→SOCP reformulation ([`crate::qp_extract::extract_socp_with_map`])
53/// and the conic solve both scale with the problem's variable × constraint
54/// product; for the very large convex QCQPs in the mittelmann set
55/// (`nql180` ≈ 1.3e5 vars × 1.3e5 cons, `qssp180` ≈ 2.0e5 × 1.3e5) the
56/// reformulation alone burns the entire CPU budget before the solver starts.
57/// The pre-classifier baseline routed these to the NLP filter-IPM, which
58/// solves them in well under the time limit (`qssp180` 27 iters, `nql180`
59/// 44 iters). Above this budget we do the same: a convex QCQP is still a
60/// valid NLP, so the fallback is sound — it only forgoes the conic
61/// specialization on a scale the conic path is not yet tuned for.
62///
63/// `1e8` keeps the conic path for small-to-moderate QCQPs (e.g. 1e4 × 1e4)
64/// while bounding the reformulation cost to roughly a second.
65const SOCP_SIZE_BUDGET: u64 = 100_000_000;
66
67/// Per-constraint coupling budget for the QCQP→SOCP conic path.
68///
69/// The `n · m` [`SOCP_SIZE_BUDGET`] catches QCQPs that are large in the
70/// *problem* dimensions, but a problem can have a small `n · m` and still be
71/// ruinously expensive to put in conic form: each convex quadratic *row*
72/// `½xᵀQx ≤ b` is reformulated to a second-order cone via a factorization of
73/// its Hessian `Q` ([`crate::qp_extract::extract_socp_with_map`]), which costs
74/// `O(k³)` in the number of variables `k` that couple inside that one
75/// constraint. The mittelmann `qcqp1000-*` rows have only a handful of
76/// constraints (tiny `n · m`) but each couples ~1000 variables, so the
77/// per-row factorization alone exhausts the CPU budget before the conic solve
78/// starts.
79///
80/// When any single quadratic constraint couples more than this many active
81/// variables we route the whole QCQP to the general NLP filter-IPM, which
82/// solves it soundly without the conic reformulation — exactly what the
83/// classifier did for these rows before the convexity certificate was made
84/// cheap. A *diagonal* (separable) constraint Hessian is exempt: it is
85/// SOC-representable in `O(nnz)` with no factorization, so its size is
86/// harmless. This guard governs only the conic *reformulation* cost; the
87/// convexity test itself is the cheap sparse factorization in
88/// [`coupled_hessian_is_psd`].
89const QCQP_SOCP_COUPLED_VARS: usize = 256;
90
91/// The `.nl` "infinity" sentinel for a missing bound: AMPL writes ±1e20-ish
92/// and upstream Ipopt treats any magnitude ≥ 1e19 as infinite. Used to read
93/// a quadratic constraint's *sense* (one-sided `≤` vs. equality / range / `≥`)
94/// when deciding whether a QCQP is convex — see [`classify_problem`].
95const NL_INF: f64 = 1e19;
96
97#[inline]
98fn is_finite_bound(v: f64) -> bool {
99    v.abs() < NL_INF
100}
101
102/// The mathematical class of a loaded problem, from most to least
103/// specialized. See the module docs and `dev-notes/lp-qp-routing.md`.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum ProblemClass {
106    /// Linear objective, linear constraints.
107    Lp,
108    /// Convex quadratic objective, linear constraints (Hessian PSD).
109    ConvexQp,
110    /// Convex quadratic objective and/or convex quadratic constraints.
111    /// SOCP-representable; routes to the conic (SOCP) interior-point solver.
112    ConvexQcqp,
113    /// Quadratic but with an indefinite Hessian somewhere. Falls through
114    /// to the NLP solver for a local minimum.
115    NonconvexQp,
116    /// General nonlinear (transcendental terms, higher-degree
117    /// polynomials, or anything the classifier cannot prove quadratic).
118    Nlp,
119}
120
121impl ProblemClass {
122    /// Human-readable name for diagnostics and the
123    /// forced-solver-mismatch error message.
124    pub fn name(self) -> &'static str {
125        match self {
126            ProblemClass::Lp => "LP",
127            ProblemClass::ConvexQp => "convex QP",
128            ProblemClass::ConvexQcqp => "convex QCQP",
129            ProblemClass::NonconvexQp => "nonconvex QP",
130            ProblemClass::Nlp => "NLP",
131        }
132    }
133}
134
135/// The resolved solver to dispatch to, after combining a
136/// [`ProblemClass`] with the `solver_selection` option.
137///
138/// `auto` resolves an LP/convex-QP to [`SolverChoice::LpIpm`]/[`SolverChoice::QpIpm`],
139/// a convex QCQP to [`SolverChoice::SocpIpm`], and everything else to
140/// [`SolverChoice::Nlp`]; a forced `solver_selection` can pin any of them.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum SolverChoice {
143    /// The existing Wächter-Biegler filter-IPM.
144    Nlp,
145    /// LP interior-point in `pounce-convex`.
146    LpIpm,
147    /// Convex-QP interior-point in `pounce-convex`.
148    QpIpm,
149    /// Conic (SOCP) IPM in `pounce-convex`: convex QCQP, reformulated to
150    /// second-order cones.
151    SocpIpm,
152    /// Active-set QP in `pounce-qp` (parallel track).
153    QpActiveSet,
154}
155
156impl SolverChoice {
157    /// Human-readable description of the dispatched solver, for the
158    /// banner-level "Solving as …" log line. Names the algorithm and the
159    /// crate that implements it so a reader can tell which of pounce's
160    /// solvers actually ran.
161    pub fn describe(self) -> &'static str {
162        match self {
163            SolverChoice::Nlp => "NLP filter line-search interior-point (pounce-nlp)",
164            SolverChoice::LpIpm => "LP interior-point (pounce-convex)",
165            SolverChoice::QpIpm => "convex QP interior-point (pounce-convex)",
166            SolverChoice::SocpIpm => "convex QCQP conic interior-point (pounce-convex)",
167            SolverChoice::QpActiveSet => "active-set QP (pounce-qp)",
168        }
169    }
170}
171
172/// Parsed `solver_selection` option value.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum SolverSelection {
175    /// Pick the most specialized solver matching the class. Default.
176    Auto,
177    /// Force the NLP solver regardless of class (current behavior).
178    Nlp,
179    /// Force IPM-LP; error if the problem is not an LP.
180    LpIpm,
181    /// Force IPM-QP; error if the problem is not LP/convex-QP.
182    QpIpm,
183    /// Force the conic (SOCP) IPM; error if the problem is not a convex
184    /// LP / QP / QCQP (all of which the conic solver handles).
185    Socp,
186    /// Force active-set QP; error if the problem is not LP/convex-QP.
187    QpActiveSet,
188}
189
190impl SolverSelection {
191    /// Parse the `solver_selection` option string. Returns `None` for an
192    /// unrecognized value so the caller can surface a tidy error.
193    pub fn parse(s: &str) -> Option<Self> {
194        match s {
195            "auto" => Some(SolverSelection::Auto),
196            "nlp" => Some(SolverSelection::Nlp),
197            "lp-ipm" => Some(SolverSelection::LpIpm),
198            "qp-ipm" => Some(SolverSelection::QpIpm),
199            "socp" => Some(SolverSelection::Socp),
200            "qp-active-set" => Some(SolverSelection::QpActiveSet),
201            _ => None,
202        }
203    }
204
205    /// The accepted values, for error messages and option registration.
206    pub const VALUES: &'static [&'static str] =
207        &["auto", "nlp", "lp-ipm", "qp-ipm", "socp", "qp-active-set"];
208}
209
210/// Classify a parsed `.nl` problem.
211///
212/// Works off the already-split linear / nonlinear representation in
213/// [`NlProblem`]: a row contributes to the class only through its
214/// nonlinear `Expr` (the linear part is, by construction, linear). The
215/// classifier is deliberately conservative — see the module docs.
216pub fn classify_problem(prob: &NlProblem) -> ProblemClass {
217    // Fast path: no nonlinear parts anywhere ⇒ LP. (Header-equivalent:
218    // n_nl_objs == 0 && n_nl_cons == 0.)
219    let obj_nl = !is_trivially_zero(&prob.obj_nonlinear);
220    let cons_nl = prob.con_nonlinear.iter().any(|e| !is_trivially_zero(e));
221    if !obj_nl && !cons_nl {
222        return ProblemClass::Lp;
223    }
224
225    // Objective curvature.
226    let obj_quad = match analyze_quadratic(&prob.obj_nonlinear, prob.n) {
227        Some(q) => q,
228        // Objective has a non-quadratic nonlinear term ⇒ NLP.
229        None => return ProblemClass::Nlp,
230    };
231
232    // Constraint curvature. A quadratic constraint makes this a QCQP;
233    // any non-quadratic constraint term makes the whole problem NLP.
234    let mut any_quadratic_constraint = false;
235    for c in &prob.con_nonlinear {
236        if is_trivially_zero(c) {
237            continue;
238        }
239        match analyze_quadratic(c, prob.n) {
240            Some(q) if q.is_empty() => {} // purely linear after all
241            Some(_) => any_quadratic_constraint = true,
242            None => return ProblemClass::Nlp,
243        }
244    }
245
246    // Objective Hessian definiteness, as the *minimizer* sees it. A
247    // `maximize` problem is internally negated to a minimization, so a
248    // concave-up (PSD-Hessian) maximize is a nonconvex minimize. Test the
249    // sense-adjusted Hessian, not the raw one, or maximize-of-convex slips
250    // through to the convex IPM and produces a wrong (max/saddle) answer.
251    if !obj_quad.is_empty() {
252        let effective: QuadHessian = if prob.minimize {
253            obj_quad.clone()
254        } else {
255            obj_quad.iter().map(|(k, v)| (*k, -v)).collect()
256        };
257        if !hessian_is_psd(&effective, prob.n) {
258            return ProblemClass::NonconvexQp;
259        }
260    }
261
262    if any_quadratic_constraint {
263        // Convex QCQP requires every quadratic constraint to be convex *as a
264        // feasible set*, not merely to have a PSD Hessian. A quadratic
265        // `g(x) = ½xᵀQx + … ` carves a convex region only when it is a
266        // one-sided **upper** bound `g(x) ≤ g_u` *and* `Q ⪰ 0`. The other
267        // senses are nonconvex even with a PSD Hessian:
268        //   - `g(x) ≥ g_l` (finite lower bound): the super-level set of a
269        //     convex function is nonconvex;
270        //   - a quadratic equality `g(x) = c`;
271        //   - a two-sided range `g_l ≤ g(x) ≤ g_u` (includes the `≥` side).
272        // This sense test matters now that ConvexQcqp is dispatched to the
273        // conic solver (it is SOC-representable only in the convex case); a
274        // misclassified nonconvex row would return a spurious "optimum".
275        // Anything not provably convex falls back to NLP (sound: the
276        // filter-IPM finds a local minimum either way).
277        for (row, c) in prob.con_nonlinear.iter().enumerate() {
278            if is_trivially_zero(c) {
279                continue;
280            }
281            match analyze_quadratic(c, prob.n) {
282                Some(q) if q.is_empty() => {} // purely linear after all
283                Some(q) => {
284                    let lo = prob.g_l[row];
285                    let hi = prob.g_u[row];
286                    let vacuous = !is_finite_bound(lo) && !is_finite_bound(hi);
287                    let upper_only = is_finite_bound(hi) && !is_finite_bound(lo);
288                    if vacuous {
289                        // Free row: imposes nothing, so it cannot make the
290                        // problem nonconvex. Ignore it.
291                        continue;
292                    }
293                    // Convexity (cheap sparse certificate) gates the QCQP
294                    // class; the per-row coupling guard then gates the *conic*
295                    // path: a convex but heavily-coupled constraint Hessian is
296                    // ruinous to put in SOC form, so route the whole QCQP to
297                    // NLP (which solves it soundly) rather than burn the budget
298                    // in the reformulation — the mittelmann `qcqp1000-*` rows.
299                    if !upper_only
300                        || !hessian_is_psd(&q, prob.n)
301                        || qcqp_constraint_too_costly_for_socp(&q)
302                    {
303                        return ProblemClass::Nlp;
304                    }
305                }
306                None => return ProblemClass::Nlp,
307            }
308        }
309        // A convex QCQP whose scale exceeds the conic path's budget falls
310        // back to NLP: the QCQP→SOCP reformulation and conic solve scale with
311        // `n · m`, and beyond this the setup alone exhausts the CPU budget
312        // (the mittelmann `nql180`/`qssp180` regression). NLP solves the same
313        // problem soundly — see `SOCP_SIZE_BUDGET`.
314        if (prob.n as u64).saturating_mul(prob.m as u64) > SOCP_SIZE_BUDGET {
315            return ProblemClass::Nlp;
316        }
317        return ProblemClass::ConvexQcqp;
318    }
319
320    // Quadratic (or linear) convex objective with linear constraints.
321    if obj_quad.is_empty() {
322        // Objective nonlinear part collapsed to nothing quadratic and no
323        // constraints are quadratic — it was effectively linear.
324        ProblemClass::Lp
325    } else {
326        ProblemClass::ConvexQp
327    }
328}
329
330/// Resolve a [`ProblemClass`] and a [`SolverSelection`] into the solver
331/// to dispatch to, or an error string when a forced selection does not
332/// match the detected class.
333///
334/// `auto` routes LP / convex QP to the convex IPM (`QpIpm`) and convex
335/// QCQP to the conic IPM (`SocpIpm`); nonconvex QP and general NLP resolve
336/// to `Nlp`. A forced selection that does not match the detected class is
337/// rejected with a clear message. (`QpActiveSet` is accepted for LP / convex
338/// QP and dispatched to the active-set SQP engine — see `main.rs`.)
339pub fn resolve_solver(
340    class: ProblemClass,
341    selection: SolverSelection,
342) -> Result<SolverChoice, String> {
343    use ProblemClass as P;
344    use SolverSelection as S;
345
346    // Is this class within the convex-QP family (LP or convex QP)?
347    let is_lp = class == P::Lp;
348    let is_convex_qp = matches!(class, P::Lp | P::ConvexQp);
349    // The conic solver handles the whole convex cone family: LP, convex QP,
350    // and (reformulated to second-order cones) convex QCQP.
351    let is_conic = matches!(class, P::Lp | P::ConvexQp | P::ConvexQcqp);
352
353    match selection {
354        // `auto`: route LP and convex QP to the specialized convex IPM
355        // (`pounce-convex`) and convex QCQP to the same crate's conic
356        // (SOCP) IPM; nonconvex QP and general NLP fall through to the NLP
357        // filter-IPM. LP is solved by the same QP IPM (P = 0), so it
358        // resolves to `QpIpm` rather than a distinct LP entry point.
359        S::Auto => match class {
360            P::Lp | P::ConvexQp => Ok(SolverChoice::QpIpm),
361            P::ConvexQcqp => Ok(SolverChoice::SocpIpm),
362            _ => Ok(SolverChoice::Nlp),
363        },
364        S::Nlp => Ok(SolverChoice::Nlp),
365        S::LpIpm => {
366            if is_lp {
367                Ok(SolverChoice::LpIpm)
368            } else {
369                Err(mismatch_msg(class, "lp-ipm", "an LP"))
370            }
371        }
372        S::QpIpm => {
373            if is_convex_qp {
374                Ok(SolverChoice::QpIpm)
375            } else {
376                Err(mismatch_msg(class, "qp-ipm", "an LP or convex QP"))
377            }
378        }
379        S::Socp => {
380            if is_conic {
381                Ok(SolverChoice::SocpIpm)
382            } else {
383                Err(mismatch_msg(class, "socp", "a convex LP, QP, or QCQP"))
384            }
385        }
386        S::QpActiveSet => {
387            if is_convex_qp {
388                Ok(SolverChoice::QpActiveSet)
389            } else {
390                Err(mismatch_msg(class, "qp-active-set", "an LP or convex QP"))
391            }
392        }
393    }
394}
395
396fn mismatch_msg(class: ProblemClass, forced: &str, expected: &str) -> String {
397    format!(
398        "problem class {} does not match forced solver {} (expected {})",
399        class.name(),
400        forced,
401        expected
402    )
403}
404
405// ---------------------------------------------------------------------
406// Quadratic-form analysis
407// ---------------------------------------------------------------------
408
409/// The symmetric Hessian of a quadratic form, stored as a sparse upper-
410/// triangular (i ≤ j) map of `(i, j) -> ∂²/∂xᵢ∂xⱼ`. Empty means the
411/// expression is (at most) linear.
412pub(crate) type QuadHessian = BTreeMap<(usize, usize), f64>;
413
414/// Full quadratic read-out: `(Hessian, [(var, linear coef), …], constant)`.
415/// The linear and constant parts are the pieces AMPL/Pyomo fold into the
416/// nonlinear objective tree (see [`analyze_quadratic_full`]).
417pub(crate) type QuadForm = (QuadHessian, Vec<(usize, f64)>, f64);
418
419/// Attempt to read an expression as a polynomial of total degree ≤ 2 and
420/// return its Hessian (constant, since the form is quadratic). Returns
421/// `None` if the expression contains any term the classifier cannot
422/// prove is degree-≤2 polynomial (transcendental ops, division by a
423/// non-constant, `Pow` with exponent ∉ {0,1,2}, products of degree > 2,
424/// external calls, …). `None` ⇒ treat as general nonlinear.
425pub(crate) fn analyze_quadratic(e: &Expr, n: usize) -> Option<QuadHessian> {
426    analyze_quadratic_full(e, n).map(|(h, _, _)| h)
427}
428
429/// Like [`analyze_quadratic`] but also returns the degree-1 (linear)
430/// coefficients *and* the degree-0 (constant) term of the form:
431/// `(Hessian, [(var, coef), …], constant)`.
432///
433/// AMPL folds the linear part of a nonlinear term into the objective's
434/// nonlinear expression tree (the `−6·x₀` of `(x₀−3)²`, say) rather than
435/// the linear section. Callers building the QP objective vector `c` must
436/// add these in, exactly as the NLP path's `eval_f` sums the linear
437/// section *and* the nonlinear tree — otherwise the linear shift is
438/// silently dropped and the convex solve minimizes the wrong objective.
439///
440/// The **constant** is returned for the same reason: AMPL/Pyomo also fold
441/// the objective's degree-0 term into the nonlinear tree (the `+9` of
442/// `(x₀−3)²`), where it does *not* land in `NlProblem::obj_constant`. It
443/// is irrelevant to the minimizer but is part of the *reported objective
444/// value*; dropping it makes the convex solve report an objective off by
445/// that constant versus the NLP path (see `qp_extract`).
446pub(crate) fn analyze_quadratic_full(e: &Expr, _n: usize) -> Option<QuadForm> {
447    let poly = to_poly(e)?;
448    if poly.max_degree() > 2 {
449        return None;
450    }
451    let mut h: QuadHessian = BTreeMap::new();
452    let mut lin: Vec<(usize, f64)> = Vec::new();
453    let mut constant = 0.0;
454    for (vars, coef) in &poly.terms {
455        match vars.as_slice() {
456            // Constant term: no gradient/Hessian contribution, but it is
457            // part of the objective *value* — accumulate, don't drop.
458            [] => constant += *coef,
459            // Linear term c·xᵢ.
460            [i] => lin.push((*i, *coef)),
461            // Quadratic term c·xᵢ·xⱼ.
462            [i, j] => {
463                let (i, j) = (*i.min(j), *i.max(j));
464                // ∂²(c·xᵢxⱼ)/∂xᵢ∂xⱼ = c for i≠j; ∂²(c·xᵢ²)/∂xᵢ² = 2c.
465                let contrib = if i == j { 2.0 * coef } else { *coef };
466                *h.entry((i, j)).or_insert(0.0) += contrib;
467            }
468            _ => return None,
469        }
470    }
471    // Drop explicit zeros so `is_empty()` means "linear".
472    h.retain(|_, v| v.abs() > 0.0);
473    Some((h, lin, constant))
474}
475
476/// A multivariate polynomial as a map from a sorted variable-index
477/// multiset (the monomial) to its coefficient. `[]` is the constant
478/// term, `[i]` is `xᵢ`, `[i, i]` is `xᵢ²`, `[i, j]` is `xᵢxⱼ`.
479#[derive(Debug, Clone, Default)]
480struct Poly {
481    terms: BTreeMap<Vec<usize>, f64>,
482}
483
484impl Poly {
485    fn constant(c: f64) -> Self {
486        let mut terms = BTreeMap::new();
487        if c != 0.0 {
488            terms.insert(Vec::new(), c);
489        }
490        Poly { terms }
491    }
492
493    fn var(i: usize) -> Self {
494        let mut terms = BTreeMap::new();
495        terms.insert(vec![i], 1.0);
496        Poly { terms }
497    }
498
499    fn max_degree(&self) -> usize {
500        self.terms.keys().map(|m| m.len()).max().unwrap_or(0)
501    }
502
503    fn as_constant(&self) -> Option<f64> {
504        match self.terms.len() {
505            0 => Some(0.0),
506            1 => self.terms.get(&Vec::new()).copied(),
507            _ => None,
508        }
509    }
510
511    fn add(mut self, other: &Poly) -> Poly {
512        for (m, c) in &other.terms {
513            *self.terms.entry(m.clone()).or_insert(0.0) += c;
514        }
515        self.prune();
516        self
517    }
518
519    fn neg(mut self) -> Poly {
520        for c in self.terms.values_mut() {
521            *c = -*c;
522        }
523        self
524    }
525
526    fn scale(mut self, s: f64) -> Poly {
527        if s == 0.0 {
528            return Poly::default();
529        }
530        for c in self.terms.values_mut() {
531            *c *= s;
532        }
533        self
534    }
535
536    /// Multiply two polynomials, bailing (`None`) if any product
537    /// monomial would exceed total degree 2 — past that the classifier
538    /// gives up and the caller routes to NLP.
539    fn mul(&self, other: &Poly) -> Option<Poly> {
540        let mut out = Poly::default();
541        for (ma, ca) in &self.terms {
542            for (mb, cb) in &other.terms {
543                if ma.len() + mb.len() > 2 {
544                    return None;
545                }
546                let mut m = ma.clone();
547                m.extend_from_slice(mb);
548                m.sort_unstable();
549                *out.terms.entry(m).or_insert(0.0) += ca * cb;
550            }
551        }
552        out.prune();
553        Some(out)
554    }
555
556    fn prune(&mut self) {
557        self.terms.retain(|_, c| c.abs() > 0.0);
558    }
559}
560
561/// Lower an `Expr` to a [`Poly`] of total degree ≤ 2, or `None` if it
562/// contains anything outside that class. `Cse` nodes are inlined (they
563/// are mathematically equivalent to their body).
564fn to_poly(e: &Expr) -> Option<Poly> {
565    match e {
566        Expr::Const(c) => Some(Poly::constant(*c)),
567        Expr::Var(i) => Some(Poly::var(*i)),
568        Expr::Cse(body) => to_poly(body),
569        Expr::Sum(items) => {
570            // Accumulate every monomial into one map, pruning ONCE at the
571            // end. The previous `acc = acc.add(&to_poly(it)?)` called the
572            // self-pruning `add` per item, and `prune` rescans the entire
573            // accumulated map, making an N-term sum O(N²). On QCQP forms
574            // (a quadratic over n vars expands to up to ~n² monomials) this
575            // hung the `solver_selection=auto` classifier for >300 s before
576            // the solver ever started. Merge-then-prune is O(N log N).
577            let mut acc = Poly::default();
578            for it in items {
579                let p = to_poly(it)?;
580                for (m, c) in &p.terms {
581                    *acc.terms.entry(m.clone()).or_insert(0.0) += c;
582                }
583            }
584            acc.prune();
585            Some(acc)
586        }
587        Expr::Unary(op, a) => match op {
588            UnaryOp::Neg => Some(to_poly(a)?.neg()),
589            // Everything else is transcendental / non-polynomial.
590            _ => None,
591        },
592        Expr::Binary(op, a, b) => {
593            let pa = to_poly(a)?;
594            let pb = to_poly(b)?;
595            match op {
596                BinOp::Add => Some(pa.add(&pb)),
597                BinOp::Sub => Some(pa.add(&pb.neg())),
598                BinOp::Mul => pa.mul(&pb),
599                BinOp::Div => {
600                    // Division is polynomial only by a nonzero constant.
601                    let d = pb.as_constant()?;
602                    if d == 0.0 {
603                        None
604                    } else {
605                        Some(pa.scale(1.0 / d))
606                    }
607                }
608                BinOp::Pow => {
609                    // Polynomial only for constant integer exponents in
610                    // {0, 1, 2}.
611                    let exp = pb.as_constant()?;
612                    if exp == 0.0 {
613                        Some(Poly::constant(1.0))
614                    } else if exp == 1.0 {
615                        Some(pa)
616                    } else if exp == 2.0 {
617                        pa.mul(&pa)
618                    } else {
619                        None
620                    }
621                }
622                // atan2 and any other binary opcodes are non-polynomial.
623                _ => None,
624            }
625        }
626        // External function calls are opaque ⇒ not provably polynomial.
627        Expr::Funcall { .. } => None,
628        // Comparisons, logicals, conditionals, and n-ary min/max (the
629        // smooth-/control-flow `.nl` opcodes) are non-polynomial ⇒ not a
630        // convex QP, so the classifier routes them to the NLP solver.
631        _ => None,
632    }
633}
634
635/// True if the expression is the literal constant zero the `.nl` reader
636/// uses for "no nonlinear part".
637fn is_trivially_zero(e: &Expr) -> bool {
638    matches!(e, Expr::Const(c) if *c == 0.0)
639}
640
641// ---------------------------------------------------------------------
642// PSD test
643// ---------------------------------------------------------------------
644
645/// Number of distinct variables that couple inside a quadratic form — the
646/// dimension `k` of the matrix that would be factored.
647fn hessian_active_vars(h: &QuadHessian) -> usize {
648    let mut active: Vec<usize> = Vec::with_capacity(2 * h.len());
649    for (i, j) in h.keys() {
650        active.push(*i);
651        active.push(*j);
652    }
653    active.sort_unstable();
654    active.dedup();
655    active.len()
656}
657
658/// True when reformulating this *convex* quadratic constraint to a
659/// second-order cone would be too costly — a *coupled* (off-diagonal) Hessian
660/// over more than [`QCQP_SOCP_COUPLED_VARS`] active variables, whose per-row
661/// `O(k³)` factorization dominates the budget. A purely diagonal constraint
662/// Hessian is exempt (SOC-representable in `O(nnz)`). Callers route such a
663/// QCQP to the general NLP solver instead of the conic path. This is about
664/// the *reformulation* cost, not convexity: the constraint is already known
665/// convex (PSD) when this is consulted.
666fn qcqp_constraint_too_costly_for_socp(h: &QuadHessian) -> bool {
667    let has_offdiag = h.keys().any(|(i, j)| i != j);
668    has_offdiag && hessian_active_vars(h) > QCQP_SOCP_COUPLED_VARS
669}
670
671/// Is the (symmetric, sparse) Hessian positive semidefinite?
672///
673/// A purely diagonal Hessian is settled in `O(nnz)` by sign — its
674/// eigenvalues *are* its diagonal entries — with no factorization at all;
675/// this keeps large separable / least-squares QPs cheap. A *coupled*
676/// Hessian is certified by a sparse symmetric factorization (see
677/// [`coupled_hessian_is_psd`]): feral's LDLᵀ reports the matrix inertia in
678/// roughly `O(nnz · fill)`, so even the large but sparse coupled Hessians of
679/// the CVXQP family (n ≈ 1000) are classified in well under the solve cost —
680/// no dense `k×k` allocation and no `O(k³)` eigensolve. Returns `true` only
681/// when the smallest eigenvalue is `≥ -PSD_TOL`; an indefinite or
682/// inconclusive result returns `false`, routing to the safe (more general)
683/// class.
684fn hessian_is_psd(h: &QuadHessian, _n: usize) -> bool {
685    if h.is_empty() {
686        return true; // zero matrix is PSD (the linear case)
687    }
688    // Fast path: a diagonal Hessian is PSD iff every diagonal entry is
689    // `≥ -PSD_TOL`. No factorization — essential for large but separable
690    // objectives, where the answer is trivial.
691    if h.keys().all(|(i, j)| i == j) {
692        return h.values().all(|v| *v >= -PSD_TOL);
693    }
694    coupled_hessian_is_psd(h)
695}
696
697/// PSD certificate for a *coupled* Hessian via a sparse symmetric
698/// factorization.
699///
700/// The test is positive-definiteness of the `ε`-shifted matrix `H + ε·I`
701/// with `ε = PSD_TOL`. A genuinely-PSD `H` (smallest eigenvalue `λ_min ≥ 0`,
702/// even a singular one) becomes strictly positive definite after the shift,
703/// so feral factors it with no negative pivots (`inertia.negative == 0`); a
704/// truly indefinite `H` with `λ_min < -PSD_TOL` keeps a strictly-negative
705/// shifted eigenvalue and yields `negative > 0`. The `negative == 0` test on
706/// the shifted matrix is therefore exactly `λ_min ≥ -PSD_TOL` — the same
707/// tolerance the dense path used — and it scales to large sparse Hessians
708/// because the factorization cost tracks the nonzero/fill count, not a dense
709/// `k³`.
710///
711/// The Hessian is compressed to its active variable set so the factored
712/// dimension is `k` (the number of distinct variables in the form). The
713/// [`QuadHessian`] is upper-triangular (`i ≤ j`); feral wants the lower
714/// triangle (`row ≥ col`), so each entry `(i, j)` is emitted at
715/// `(row = j, col = i)`. Every active diagonal is seeded with `ε` (the shift;
716/// `from_triplets` sums it with any diagonal entry already in `H`), which
717/// also guarantees no structurally empty column. A non-`Success`
718/// factorization (singular/fatal — should not occur given the strictly-PD
719/// shift, but possible on a pathological form) is treated conservatively as
720/// not-provably-PSD.
721fn coupled_hessian_is_psd(h: &QuadHessian) -> bool {
722    use feral::{CscMatrix, FactorStatus, Solver};
723
724    // Compress to the active variable set so the factored dimension is `k`.
725    let mut active: Vec<usize> = Vec::with_capacity(2 * h.len());
726    for (i, j) in h.keys() {
727        active.push(*i);
728        active.push(*j);
729    }
730    active.sort_unstable();
731    active.dedup();
732    let k = active.len();
733    let idx = |v: usize| active.binary_search(&v).unwrap();
734
735    // Lower-triangle triplets: H's entry (i ≤ j) maps to (row = j, col = i).
736    // Capacity covers H's nonzeros plus one ε-shift per active diagonal.
737    let mut rows: Vec<usize> = Vec::with_capacity(h.len() + k);
738    let mut cols: Vec<usize> = Vec::with_capacity(h.len() + k);
739    let mut vals: Vec<f64> = Vec::with_capacity(h.len() + k);
740    for ((i, j), v) in h {
741        let (ri, rj) = (idx(*i), idx(*j));
742        // i ≤ j by the upper-tri convention, so rj ≥ ri ⇒ lower triangle.
743        rows.push(rj);
744        cols.push(ri);
745        vals.push(*v);
746    }
747    // εI shift: seed every active diagonal (summed with H's own diagonal).
748    for d in 0..k {
749        rows.push(d);
750        cols.push(d);
751        vals.push(PSD_TOL);
752    }
753
754    let mat = match CscMatrix::from_triplets(k, &rows, &cols, &vals) {
755        Ok(m) => m,
756        Err(_) => return false, // malformed ⇒ be conservative
757    };
758    let mut solver = Solver::new();
759    match solver.factor(&mat, None) {
760        FactorStatus::Success => {
761            // PD ⟺ no negative pivots in the LDLᵀ of the ε-shifted matrix.
762            solver.inertia().map(|i| i.negative == 0).unwrap_or(false)
763        }
764        // Singular / wrong-inertia / fatal: cannot certify ⇒ safe fallback.
765        _ => false,
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772    use crate::nl_reader::parse_nl_text;
773
774    // --- SolverSelection parsing ---
775
776    #[test]
777    fn parse_selection_values() {
778        assert_eq!(SolverSelection::parse("auto"), Some(SolverSelection::Auto));
779        assert_eq!(SolverSelection::parse("nlp"), Some(SolverSelection::Nlp));
780        assert_eq!(
781            SolverSelection::parse("lp-ipm"),
782            Some(SolverSelection::LpIpm)
783        );
784        assert_eq!(
785            SolverSelection::parse("qp-ipm"),
786            Some(SolverSelection::QpIpm)
787        );
788        assert_eq!(
789            SolverSelection::parse("qp-active-set"),
790            Some(SolverSelection::QpActiveSet)
791        );
792        assert_eq!(SolverSelection::parse("lp-simplex"), None);
793        assert_eq!(SolverSelection::parse("bogus"), None);
794    }
795
796    // --- resolve_solver: auto routes LP/convex-QP to the convex IPM,
797    // everything else to NLP ---
798
799    #[test]
800    fn auto_routes_convex_qp_family_to_qp_ipm() {
801        assert_eq!(
802            resolve_solver(ProblemClass::Lp, SolverSelection::Auto),
803            Ok(SolverChoice::QpIpm),
804            "auto should route LP to the convex IPM (P=0)"
805        );
806        assert_eq!(
807            resolve_solver(ProblemClass::ConvexQp, SolverSelection::Auto),
808            Ok(SolverChoice::QpIpm),
809            "auto should route convex QP to the convex IPM"
810        );
811    }
812
813    #[test]
814    fn auto_routes_convex_qcqp_to_socp() {
815        assert_eq!(
816            resolve_solver(ProblemClass::ConvexQcqp, SolverSelection::Auto),
817            Ok(SolverChoice::SocpIpm),
818            "auto should route convex QCQP to the conic IPM"
819        );
820    }
821
822    #[test]
823    fn auto_routes_nonconvex_to_nlp() {
824        for class in [ProblemClass::NonconvexQp, ProblemClass::Nlp] {
825            assert_eq!(
826                resolve_solver(class, SolverSelection::Auto),
827                Ok(SolverChoice::Nlp),
828                "auto must resolve to Nlp for {:?}",
829                class
830            );
831        }
832    }
833
834    #[test]
835    fn forced_socp_accepts_convex_cone_family_only() {
836        for class in [
837            ProblemClass::Lp,
838            ProblemClass::ConvexQp,
839            ProblemClass::ConvexQcqp,
840        ] {
841            assert_eq!(
842                resolve_solver(class, SolverSelection::Socp),
843                Ok(SolverChoice::SocpIpm),
844                "socp should accept {:?}",
845                class
846            );
847        }
848        assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::Socp).is_err());
849        assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::Socp).is_err());
850    }
851
852    #[test]
853    fn forced_nlp_always_ok() {
854        assert_eq!(
855            resolve_solver(ProblemClass::ConvexQp, SolverSelection::Nlp),
856            Ok(SolverChoice::Nlp)
857        );
858    }
859
860    #[test]
861    fn forced_lp_on_nlp_errors() {
862        let err = resolve_solver(ProblemClass::Nlp, SolverSelection::LpIpm).unwrap_err();
863        assert!(err.contains("NLP"), "msg should name detected class: {err}");
864        assert!(
865            err.contains("lp-ipm"),
866            "msg should name forced solver: {err}"
867        );
868    }
869
870    #[test]
871    fn forced_lp_on_lp_ok() {
872        assert_eq!(
873            resolve_solver(ProblemClass::Lp, SolverSelection::LpIpm),
874            Ok(SolverChoice::LpIpm)
875        );
876    }
877
878    #[test]
879    fn forced_qp_accepts_lp_and_convex_qp_only() {
880        assert_eq!(
881            resolve_solver(ProblemClass::Lp, SolverSelection::QpIpm),
882            Ok(SolverChoice::QpIpm)
883        );
884        assert_eq!(
885            resolve_solver(ProblemClass::ConvexQp, SolverSelection::QpIpm),
886            Ok(SolverChoice::QpIpm)
887        );
888        assert!(resolve_solver(ProblemClass::NonconvexQp, SolverSelection::QpIpm).is_err());
889        assert!(resolve_solver(ProblemClass::Nlp, SolverSelection::QpIpm).is_err());
890    }
891
892    // --- Poly / quadratic analysis unit tests ---
893
894    #[test]
895    fn poly_of_quadratic_diagonal() {
896        // (x0 - 1)^2  =>  x0^2 - 2 x0 + 1
897        let e = Expr::Binary(
898            BinOp::Pow,
899            Box::new(Expr::Binary(
900                BinOp::Sub,
901                Box::new(Expr::Var(0)),
902                Box::new(Expr::Const(1.0)),
903            )),
904            Box::new(Expr::Const(2.0)),
905        );
906        let h = analyze_quadratic(&e, 1).expect("degree-2 polynomial");
907        // d²/dx0² (x0²) = 2
908        assert_eq!(h.get(&(0, 0)), Some(&2.0));
909    }
910
911    #[test]
912    fn poly_rejects_transcendental() {
913        // sin(x0) is not polynomial.
914        let e = Expr::Unary(UnaryOp::Sin, Box::new(Expr::Var(0)));
915        assert!(analyze_quadratic(&e, 1).is_none());
916    }
917
918    #[test]
919    fn poly_rejects_cubic() {
920        // x0^3
921        let e = Expr::Binary(
922            BinOp::Pow,
923            Box::new(Expr::Var(0)),
924            Box::new(Expr::Const(3.0)),
925        );
926        assert!(analyze_quadratic(&e, 1).is_none());
927    }
928
929    #[test]
930    fn cross_term_hessian() {
931        // x0 * x1  =>  H[0,1] = 1
932        let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
933        let h = analyze_quadratic(&e, 2).expect("degree-2");
934        assert_eq!(h.get(&(0, 1)), Some(&1.0));
935    }
936
937    #[test]
938    fn large_quadratic_sum_lowers_without_quadratic_blowup() {
939        // Regression guard for the `solver_selection=auto` classifier hang
940        // (mittelmann QCQP/bearing_400/qssp180 emitted zero iterations and
941        // burned the full CPU budget). A quadratic expressed as a large
942        // `Sum` of monomials must lower to a `Poly` in O(N log N): the old
943        // `acc = acc.add(&to_poly(it)?)` ran the self-pruning `add` per
944        // item, and `prune` rescans the whole accumulated map, so an
945        // N-monomial sum was O(N²) and spun for >300 s before the solver
946        // started (Ipopt solved the same problems in seconds). Build a
947        // 5000-term sum of distinct squares and confirm the full diagonal
948        // Hessian is recovered — this path completes effectively instantly
949        // once the per-`add` prune is gone.
950        const N: usize = 5000;
951        let terms: Vec<Expr> = (0..N)
952            .map(|i| Expr::Binary(BinOp::Mul, Box::new(Expr::Var(i)), Box::new(Expr::Var(i))))
953            .collect();
954        let e = Expr::Sum(terms);
955        let h = analyze_quadratic(&e, N).expect("degree-2 sum of squares is a QP");
956        assert_eq!(h.len(), N, "every xᵢ² contributes one diagonal entry");
957        assert_eq!(h.get(&(0, 0)), Some(&2.0));
958        assert_eq!(h.get(&(N - 1, N - 1)), Some(&2.0));
959    }
960
961    // --- PSD test ---
962
963    #[test]
964    fn psd_accepts_convex_separable() {
965        // diag(2, 4): both eigenvalues positive.
966        let mut h = QuadHessian::new();
967        h.insert((0, 0), 2.0);
968        h.insert((1, 1), 4.0);
969        assert!(hessian_is_psd(&h, 2));
970    }
971
972    #[test]
973    fn psd_rejects_indefinite() {
974        // [[0,1],[1,0]] has eigenvalues ±1.
975        let mut h = QuadHessian::new();
976        h.insert((0, 1), 1.0);
977        assert!(!hessian_is_psd(&h, 2));
978    }
979
980    #[test]
981    fn psd_accepts_psd_with_zero_eigenvalue() {
982        // [[1,1],[1,1]] is PSD (eigenvalues 0 and 2).
983        let mut h = QuadHessian::new();
984        h.insert((0, 0), 1.0);
985        h.insert((0, 1), 1.0);
986        h.insert((1, 1), 1.0);
987        assert!(hessian_is_psd(&h, 2));
988    }
989
990    // --- A1: ±PSD_TOL boundary of the convexity test (silent-misroute guard) ---
991
992    /// The safety-critical case: a *real* negative direction — even a small
993    /// one, well beyond `PSD_TOL` — must read non-PSD so an indefinite QP
994    /// routes to NLP, never to the convex IPM (which would return a spurious
995    /// "optimal" at a saddle/maximum).
996    #[test]
997    fn psd_rejects_small_but_real_negative_curvature() {
998        // diag(2, −1e-3): min eigenvalue −1e-3 ≪ −PSD_TOL.
999        let mut h = QuadHessian::new();
1000        h.insert((0, 0), 2.0);
1001        h.insert((1, 1), -1e-3);
1002        assert!(
1003            !hessian_is_psd(&h, 2),
1004            "a −1e-3 eigenvalue must read indefinite, not be rounded to PSD"
1005        );
1006    }
1007
1008    /// Pin the threshold at exactly `±PSD_TOL` (1e-9). Within the band the
1009    /// test rounds a tiny negative eigenvalue to PSD **by design**: a
1010    /// genuinely semidefinite Hessian whose smallest eigenvalue computes as a
1011    /// tiny negative (Jacobi roundoff) must not be misread as nonconvex. The
1012    /// band is far below the error of solving a convex QP with that much
1013    /// curvature, so it is the sound tradeoff — see the A1 Finding in
1014    /// `dev-notes/pr70-hardening.md`. (1×1 Hessians are returned exactly, so
1015    /// this is deterministic.)
1016    #[test]
1017    fn psd_threshold_is_psd_tol() {
1018        let mut just_inside = QuadHessian::new();
1019        just_inside.insert((0, 0), -1e-10); // |λ| < PSD_TOL ⇒ treated as zero
1020        assert!(
1021            hessian_is_psd(&just_inside, 1),
1022            "−1e-10 is within tolerance and must round to PSD"
1023        );
1024
1025        let mut just_outside = QuadHessian::new();
1026        just_outside.insert((0, 0), -1e-7); // |λ| > PSD_TOL ⇒ genuine negative
1027        assert!(
1028            !hessian_is_psd(&just_outside, 1),
1029            "−1e-7 is beyond tolerance and must read indefinite"
1030        );
1031    }
1032
1033    // --- Sparse-factorization PSD certificate (CVXQP family) ---
1034
1035    /// A large *diagonal* Hessian must take the O(nnz) sign fast path — no
1036    /// factorization at all — and read PSD. This is the large separable /
1037    /// least-squares QP shape (AUG2D, LISWET, …) that stays on the convex
1038    /// fast path.
1039    #[test]
1040    fn large_diagonal_hessian_is_cheap_and_psd() {
1041        let n = 50_000;
1042        let mut h = QuadHessian::new();
1043        for i in 0..n {
1044            h.insert((i, i), 2.0);
1045        }
1046        assert!(
1047            hessian_is_psd(&h, n),
1048            "diag(2,…,2) is PSD and must be settled by the O(nnz) sign path"
1049        );
1050    }
1051
1052    /// A large *coupled* convex Hessian (off-diagonal terms over many
1053    /// variables) is the CVXQP-family shape that the old dense-Jacobi cap
1054    /// refused to certify (routing it to NLP). The sparse-factorization
1055    /// certificate now proves it PSD cheaply, so it reaches the convex
1056    /// solver. This is the regression fix.
1057    #[test]
1058    fn large_coupled_convex_hessian_is_certified_psd() {
1059        let k = 1_000;
1060        let mut h = QuadHessian::new();
1061        // Diagonally dominant tridiagonal: SPD. 2 on the diagonal, 0.1 on
1062        // the off-diagonal coupling chain ⇒ strictly diagonally dominant.
1063        for i in 0..k {
1064            h.insert((i, i), 2.0);
1065        }
1066        for i in 0..(k - 1) {
1067            h.insert((i, i + 1), 0.1);
1068        }
1069        assert!(
1070            hessian_is_psd(&h, k),
1071            "a diagonally-dominant coupled Hessian over {k} vars must be \
1072             certified PSD by the sparse factorization (CVXQP regression)"
1073        );
1074    }
1075
1076    /// The sparse certificate must still *reject* a large coupled Hessian
1077    /// that is genuinely indefinite — size does not buy a free pass.
1078    #[test]
1079    fn large_coupled_indefinite_hessian_is_rejected() {
1080        let k = 1_000;
1081        let mut h = QuadHessian::new();
1082        for i in 0..k {
1083            h.insert((i, i), 2.0);
1084        }
1085        for i in 0..(k - 1) {
1086            h.insert((i, i + 1), 0.1);
1087        }
1088        // Flip one diagonal strongly negative ⇒ an indefinite direction.
1089        h.insert((0, 0), -5.0);
1090        assert!(
1091            !hessian_is_psd(&h, k),
1092            "a coupled Hessian with a strong negative-curvature direction \
1093             must be rejected regardless of size"
1094        );
1095    }
1096
1097    /// A *small* coupled Hessian is certified by the same sparse path.
1098    #[test]
1099    fn small_coupled_hessian_is_certified_psd() {
1100        // [[2, 1], [1, 2]] — eigenvalues 1 and 3, PSD.
1101        let mut h = QuadHessian::new();
1102        h.insert((0, 0), 2.0);
1103        h.insert((0, 1), 1.0);
1104        h.insert((1, 1), 2.0);
1105        assert!(hessian_is_psd(&h, 2));
1106    }
1107
1108    // --- End-to-end classify_problem on parsed .nl text ---
1109
1110    /// Minimal `g`-format `.nl` text builder is overkill; instead use the
1111    /// reader's own fixtures via parse_nl_text on hand-written stubs.
1112    /// These cover the header LP fast-path and the AST walk.
1113
1114    #[test]
1115    fn classify_pure_lp() {
1116        // minimize x0 + x1 s.t. x0 + x1 <= 1, no nonlinear parts.
1117        // Build an NlProblem directly for a hermetic test.
1118        let prob = NlProblem {
1119            n: 2,
1120            m: 1,
1121            num_obj: 1,
1122            minimize: true,
1123            obj_nonlinear: Expr::Const(0.0),
1124            obj_linear: vec![(0, 1.0), (1, 1.0)],
1125            obj_constant: 0.0,
1126            con_nonlinear: vec![Expr::Const(0.0)],
1127            con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
1128            x_l: vec![0.0, 0.0],
1129            x_u: vec![f64::INFINITY, f64::INFINITY],
1130            g_l: vec![f64::NEG_INFINITY],
1131            g_u: vec![1.0],
1132            x0: vec![0.0, 0.0],
1133            lambda0: vec![0.0],
1134            suffixes: Default::default(),
1135            imported_funcs: Vec::new(),
1136            var_names: Vec::new(),
1137            con_names: Vec::new(),
1138        };
1139        assert_eq!(classify_problem(&prob), ProblemClass::Lp);
1140    }
1141
1142    #[test]
1143    fn classify_convex_qp() {
1144        // minimize x0^2 + x1^2 s.t. linear; convex (H = diag(2,2)).
1145        let obj = Expr::Binary(
1146            BinOp::Add,
1147            Box::new(Expr::Binary(
1148                BinOp::Pow,
1149                Box::new(Expr::Var(0)),
1150                Box::new(Expr::Const(2.0)),
1151            )),
1152            Box::new(Expr::Binary(
1153                BinOp::Pow,
1154                Box::new(Expr::Var(1)),
1155                Box::new(Expr::Const(2.0)),
1156            )),
1157        );
1158        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1159        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
1160    }
1161
1162    #[test]
1163    fn classify_nonconvex_qp() {
1164        // minimize x0 * x1 (indefinite Hessian) s.t. linear.
1165        let obj = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1166        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1167        assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1168    }
1169
1170    #[test]
1171    fn classify_nlp_from_transcendental_objective() {
1172        let obj = Expr::Unary(UnaryOp::Exp, Box::new(Expr::Var(0)));
1173        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1174        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1175    }
1176
1177    /// Regression: a `maximize` of a PSD-Hessian objective is a *concave*
1178    /// maximization ⇒ nonconvex minimization. The convexity test must run
1179    /// on the sense-adjusted Hessian, or this slips through to the convex
1180    /// IPM and returns a wrong (maximum/saddle) answer.
1181    #[test]
1182    fn classify_maximize_psd_objective_is_nonconvex() {
1183        // maximize x0^2 + x1^2 (H = diag(2,2), PSD) — concave max.
1184        let obj = Expr::Binary(
1185            BinOp::Add,
1186            Box::new(Expr::Binary(
1187                BinOp::Pow,
1188                Box::new(Expr::Var(0)),
1189                Box::new(Expr::Const(2.0)),
1190            )),
1191            Box::new(Expr::Binary(
1192                BinOp::Pow,
1193                Box::new(Expr::Var(1)),
1194                Box::new(Expr::Const(2.0)),
1195            )),
1196        );
1197        let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1198        prob.minimize = false;
1199        assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1200    }
1201
1202    /// Mirror: `maximize` of a concave (NSD-Hessian) objective is a convex
1203    /// minimization once negated, so it is a legitimate `ConvexQp`.
1204    #[test]
1205    fn classify_maximize_concave_objective_is_convex() {
1206        // maximize −(x0^2 + x1^2) (H = diag(−2,−2)); negated ⇒ PSD.
1207        let neg_sq = |v: usize| {
1208            Expr::Unary(
1209                UnaryOp::Neg,
1210                Box::new(Expr::Binary(
1211                    BinOp::Pow,
1212                    Box::new(Expr::Var(v)),
1213                    Box::new(Expr::Const(2.0)),
1214                )),
1215            )
1216        };
1217        let obj = Expr::Binary(BinOp::Add, Box::new(neg_sq(0)), Box::new(neg_sq(1)));
1218        let mut prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1219        prob.minimize = false;
1220        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQp);
1221    }
1222
1223    #[test]
1224    fn classify_convex_qcqp() {
1225        // convex quadratic objective + a convex quadratic constraint.
1226        let obj = Expr::Binary(
1227            BinOp::Pow,
1228            Box::new(Expr::Var(0)),
1229            Box::new(Expr::Const(2.0)),
1230        );
1231        let con = Expr::Binary(
1232            BinOp::Add,
1233            Box::new(Expr::Binary(
1234                BinOp::Pow,
1235                Box::new(Expr::Var(0)),
1236                Box::new(Expr::Const(2.0)),
1237            )),
1238            Box::new(Expr::Binary(
1239                BinOp::Pow,
1240                Box::new(Expr::Var(1)),
1241                Box::new(Expr::Const(2.0)),
1242            )),
1243        );
1244        let prob = qp_stub(obj, vec![con]);
1245        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1246    }
1247
1248    /// Build a convex QCQP (linear objective + one convex quadratic
1249    /// constraint `x0² ≤ 1`) at an arbitrary declared `n`/`m`, padding the
1250    /// extra constraints with trivially-zero rows. Used to exercise the
1251    /// `SOCP_SIZE_BUDGET` routing cap without allocating `n×n` data.
1252    fn convex_qcqp_at_size(n: usize, m: usize) -> NlProblem {
1253        let mut con_nonlinear = vec![Expr::Const(0.0); m];
1254        con_nonlinear[0] = Expr::Binary(
1255            BinOp::Pow,
1256            Box::new(Expr::Var(0)),
1257            Box::new(Expr::Const(2.0)),
1258        );
1259        let g_l = vec![f64::NEG_INFINITY; m];
1260        let mut g_u = vec![f64::INFINITY; m];
1261        g_u[0] = 1.0; // upper-only bound ⇒ convex feasible set
1262        NlProblem {
1263            n,
1264            m,
1265            num_obj: 1,
1266            minimize: true,
1267            obj_nonlinear: Expr::Const(0.0),
1268            obj_linear: vec![(0, 1.0)],
1269            obj_constant: 0.0,
1270            con_nonlinear,
1271            con_linear: vec![vec![]; m],
1272            x_l: vec![f64::NEG_INFINITY; n],
1273            x_u: vec![f64::INFINITY; n],
1274            g_l,
1275            g_u,
1276            x0: vec![0.0; n],
1277            lambda0: vec![0.0; m],
1278            suffixes: Default::default(),
1279            imported_funcs: Vec::new(),
1280            var_names: Vec::new(),
1281            con_names: Vec::new(),
1282        }
1283    }
1284
1285    /// A convex QCQP small enough to keep the conic path (n·m ≤ budget).
1286    #[test]
1287    fn small_convex_qcqp_routes_to_conic() {
1288        let prob = convex_qcqp_at_size(100, 100); // n·m = 1e4 ≪ budget
1289        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1290    }
1291
1292    /// A convex QCQP whose `n·m` exceeds [`SOCP_SIZE_BUDGET`] falls back to
1293    /// NLP rather than the conic path — the mittelmann `nql180`/`qssp180`
1294    /// regression, where the O(n·m) SOCP reformulation burned the whole CPU
1295    /// budget before the solver started.
1296    #[test]
1297    fn oversized_convex_qcqp_falls_back_to_nlp() {
1298        // 10001 · 10001 ≈ 1.0002e8 > SOCP_SIZE_BUDGET (1e8).
1299        let prob = convex_qcqp_at_size(10_001, 10_001);
1300        assert!((prob.n as u64) * (prob.m as u64) > SOCP_SIZE_BUDGET);
1301        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1302    }
1303
1304    /// Build a convex QCQP whose single quadratic constraint `(Σ xᵢ)² ≤ 1`
1305    /// couples all `k` variables (a dense rank-1 PSD Hessian over `k` vars),
1306    /// with `n = k`, `m = 1`. Exercises the per-row conic-reformulation guard
1307    /// independently of the `n·m` budget.
1308    fn coupled_convex_qcqp_with_k_vars(k: usize) -> NlProblem {
1309        // sum = x0 + x1 + … + x_{k-1}
1310        let mut sum = Expr::Var(0);
1311        for i in 1..k {
1312            sum = Expr::Binary(BinOp::Add, Box::new(sum), Box::new(Expr::Var(i)));
1313        }
1314        // constraint (Σ xᵢ)² ≤ 1 — convex feasible set, Hessian = 2·(all-ones),
1315        // PSD (rank 1) and fully coupled across all k variables.
1316        let con = Expr::Binary(BinOp::Pow, Box::new(sum), Box::new(Expr::Const(2.0)));
1317        NlProblem {
1318            n: k,
1319            m: 1,
1320            num_obj: 1,
1321            minimize: true,
1322            obj_nonlinear: Expr::Const(0.0),
1323            obj_linear: vec![(0, 1.0)],
1324            obj_constant: 0.0,
1325            con_nonlinear: vec![con],
1326            con_linear: vec![vec![]],
1327            x_l: vec![f64::NEG_INFINITY; k],
1328            x_u: vec![f64::INFINITY; k],
1329            g_l: vec![f64::NEG_INFINITY],
1330            g_u: vec![1.0],
1331            x0: vec![0.0; k],
1332            lambda0: vec![0.0],
1333            suffixes: Default::default(),
1334            imported_funcs: Vec::new(),
1335            var_names: Vec::new(),
1336            con_names: Vec::new(),
1337        }
1338    }
1339
1340    /// A heavily-coupled *convex* QCQP constraint (here over 300 > 256 vars,
1341    /// `n·m = 300` well under [`SOCP_SIZE_BUDGET`]) must still fall back to NLP:
1342    /// the per-row SOC reformulation is `O(k³)` in the coupling width, which
1343    /// is the mittelmann `qcqp1000-*` hang (small `n·m`, ~1000-var coupled
1344    /// rows). The convexity certificate accepts it; the coupling guard routes
1345    /// it away from the conic path.
1346    #[test]
1347    fn heavily_coupled_convex_qcqp_falls_back_to_nlp() {
1348        let k = QCQP_SOCP_COUPLED_VARS + 44; // 300
1349        let prob = coupled_convex_qcqp_with_k_vars(k);
1350        assert!((prob.n as u64) * (prob.m as u64) <= SOCP_SIZE_BUDGET);
1351        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1352    }
1353
1354    /// The companion to the guard: a convex QCQP whose constraint couples few
1355    /// enough variables keeps the conic path. Same `(Σ xᵢ)² ≤ 1` shape over
1356    /// `k ≤ QCQP_SOCP_COUPLED_VARS` vars ⇒ `ConvexQcqp`.
1357    #[test]
1358    fn lightly_coupled_convex_qcqp_keeps_conic() {
1359        let k = QCQP_SOCP_COUPLED_VARS - 6; // 250 ≤ 256
1360        let prob = coupled_convex_qcqp_with_k_vars(k);
1361        assert_eq!(classify_problem(&prob), ProblemClass::ConvexQcqp);
1362    }
1363
1364    /// Classification mirror of the boundary guard: a QP whose only
1365    /// curvature is a genuine (beyond-tolerance) negative direction is
1366    /// `NonconvexQp`, so `auto` routes it to NLP rather than the convex IPM.
1367    /// `minimize −x0²` is concave for a minimizer ⇒ indefinite.
1368    #[test]
1369    fn classify_concave_minimize_is_nonconvex() {
1370        let obj = Expr::Unary(
1371            UnaryOp::Neg,
1372            Box::new(Expr::Binary(
1373                BinOp::Pow,
1374                Box::new(Expr::Var(0)),
1375                Box::new(Expr::Const(2.0)),
1376            )),
1377        );
1378        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1379        assert_eq!(classify_problem(&prob), ProblemClass::NonconvexQp);
1380    }
1381
1382    /// Conservative QCQP guard: a convex quadratic objective with an
1383    /// *indefinite* quadratic constraint must fall back to NLP — never be
1384    /// called `ConvexQcqp` and handed to the conic path, which would treat a
1385    /// nonconvex feasible region as convex.
1386    #[test]
1387    fn classify_qcqp_with_indefinite_constraint_falls_back_to_nlp() {
1388        // obj x0² (convex); constraint x0·x1 (indefinite Hessian).
1389        let obj = Expr::Binary(
1390            BinOp::Pow,
1391            Box::new(Expr::Var(0)),
1392            Box::new(Expr::Const(2.0)),
1393        );
1394        let con = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1395        let prob = qp_stub(obj, vec![con]);
1396        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1397    }
1398
1399    /// Sense guard: a PSD-Hessian quadratic constraint is convex only as an
1400    /// **upper** bound. With a finite *lower* bound (`g(x) ≥ g_l`) the
1401    /// feasible set is the nonconvex super-level set, so it must fall back to
1402    /// NLP — never be routed to the conic solver as if convex.
1403    #[test]
1404    fn classify_psd_quadratic_with_lower_bound_is_nonconvex() {
1405        let obj = Expr::Binary(
1406            BinOp::Pow,
1407            Box::new(Expr::Var(0)),
1408            Box::new(Expr::Const(2.0)),
1409        );
1410        let con = Expr::Binary(
1411            BinOp::Add,
1412            Box::new(Expr::Binary(
1413                BinOp::Pow,
1414                Box::new(Expr::Var(0)),
1415                Box::new(Expr::Const(2.0)),
1416            )),
1417            Box::new(Expr::Binary(
1418                BinOp::Pow,
1419                Box::new(Expr::Var(1)),
1420                Box::new(Expr::Const(2.0)),
1421            )),
1422        );
1423        let mut prob = qp_stub(obj, vec![con]);
1424        // g(x) ≥ 1  (finite lower, infinite upper) — convex function, but the
1425        // ≥ side is a nonconvex region.
1426        prob.g_l = vec![1.0];
1427        prob.g_u = vec![f64::INFINITY];
1428        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1429    }
1430
1431    /// Sense guard: a quadratic *equality* (`g(x) = c`) is nonconvex even
1432    /// with a PSD Hessian, so it must fall back to NLP, not ConvexQcqp.
1433    #[test]
1434    fn classify_quadratic_equality_is_nonconvex() {
1435        let obj = Expr::Const(0.0);
1436        let con = Expr::Binary(
1437            BinOp::Pow,
1438            Box::new(Expr::Var(0)),
1439            Box::new(Expr::Const(2.0)),
1440        );
1441        let mut prob = qp_stub(obj, vec![con]);
1442        prob.g_l = vec![1.0];
1443        prob.g_u = vec![1.0]; // x0² = 1 — nonconvex.
1444        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1445    }
1446
1447    /// A nonlinear objective expression whose quadratic part algebraically
1448    /// cancels has an empty Hessian ⇒ classify as `Lp`, not a spurious QP
1449    /// (which would otherwise route a linear problem to the QP IPM).
1450    #[test]
1451    fn classify_cancelling_quadratic_objective_is_lp() {
1452        // x0² − x0²  ≡ 0: the degree-2 terms cancel in the polynomial walk.
1453        let sq = || {
1454            Expr::Binary(
1455                BinOp::Pow,
1456                Box::new(Expr::Var(0)),
1457                Box::new(Expr::Const(2.0)),
1458            )
1459        };
1460        let obj = Expr::Binary(BinOp::Sub, Box::new(sq()), Box::new(sq()));
1461        let prob = qp_stub(obj, vec![Expr::Const(0.0)]);
1462        assert_eq!(classify_problem(&prob), ProblemClass::Lp);
1463    }
1464
1465    #[test]
1466    fn classify_nlp_from_transcendental_constraint() {
1467        let obj = Expr::Binary(
1468            BinOp::Pow,
1469            Box::new(Expr::Var(0)),
1470            Box::new(Expr::Const(2.0)),
1471        );
1472        let con = Expr::Unary(UnaryOp::Log, Box::new(Expr::Var(1)));
1473        let prob = qp_stub(obj, vec![con]);
1474        assert_eq!(classify_problem(&prob), ProblemClass::Nlp);
1475    }
1476
1477    /// Build a 2-var, 1-con problem stub with the given nonlinear
1478    /// objective and per-constraint nonlinear parts. Linear parts and
1479    /// bounds are filled with benign defaults.
1480    fn qp_stub(obj_nonlinear: Expr, con_nonlinear: Vec<Expr>) -> NlProblem {
1481        let m = con_nonlinear.len();
1482        NlProblem {
1483            n: 2,
1484            m,
1485            num_obj: 1,
1486            minimize: true,
1487            obj_nonlinear,
1488            obj_linear: vec![],
1489            obj_constant: 0.0,
1490            con_nonlinear,
1491            con_linear: vec![vec![]; m],
1492            x_l: vec![f64::NEG_INFINITY; 2],
1493            x_u: vec![f64::INFINITY; 2],
1494            g_l: vec![f64::NEG_INFINITY; m],
1495            g_u: vec![0.0; m],
1496            x0: vec![0.0; 2],
1497            lambda0: vec![0.0; m],
1498            suffixes: Default::default(),
1499            imported_funcs: Vec::new(),
1500            var_names: Vec::new(),
1501            con_names: Vec::new(),
1502        }
1503    }
1504
1505    // Keep parse_nl_text reachable for a future header-fast-path test
1506    // against a committed .nl fixture.
1507    #[allow(dead_code)]
1508    fn _parse(txt: &str) -> NlProblem {
1509        parse_nl_text(txt).expect("valid .nl")
1510    }
1511}