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