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