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}