pounce_cli/qp_extract.rs
1//! Extract a `pounce_convex::QpProblem` (standard form) from a parsed
2//! `.nl` problem, for the LP/QP dispatch path (Phase 2).
3//!
4//! The classifier (`crate::dispatch`) has already decided the problem is
5//! an LP or convex QP; this module marshals the parsed `NlProblem` into
6//! the standard form the convex IPM consumes:
7//!
8//! ```text
9//! minimize ½ xᵀP x + cᵀx
10//! subject to A x = b (equalities)
11//! G x ≤ h (inequalities)
12//! lb ≤ x ≤ ub (the variable box)
13//! ```
14//!
15//! Mapping from the `.nl` representation:
16//! - **Objective.** `P` is the Hessian of the (degree-≤2) objective —
17//! recovered with the same `analyze_quadratic` the classifier uses, so
18//! `P` here is exactly the matrix whose definiteness was tested. `c`
19//! is the objective's linear part. A `maximize` objective is negated
20//! into a minimization.
21//! - **Constraints.** Each row has a linear part and bounds `g_l ≤ row ≤
22//! g_u`. An equality (`g_l == g_u`) becomes a row of `A`; a one- or
23//! two-sided inequality becomes one or two rows of `G` (`row ≤ g_u`
24//! and/or `−row ≤ −g_l`).
25//! - **Variable bounds.** Present `x_l`/`x_u` become the solver's explicit
26//! box (see [`extract_box`] for why they are no longer emitted as `G`
27//! rows). The `.nl` "infinity" sentinel is read directionally: `x_l ≤
28//! -1e19` is no lower bound, `x_u ≥ 1e19` is no upper bound. A bound past
29//! the *opposite* sentinel (an upper bound of `-5e20`) is an ordinary
30//! bound and is kept.
31
32use crate::nl_reader::NlProblem;
33// Bound presence is read **directionally** — a lower bound is absent only at
34// or below `-1e19`, an upper bound only at or above `+1e19`. This file used a
35// symmetric `|v| < 1e19` test (gh #401): a real upper bound of `-5e20` failed
36// it and was dropped from `G` entirely, so the QP was solved over a strictly
37// larger box and reported `Optimal` at a point the model excludes.
38use pounce_common::types::{lower_bound_present, upper_bound_present};
39use pounce_convex::{ConeSpec, QpProblem, QpResiduals, QpSolution, Triplet};
40
41/// Ipopt's `bound_relax_factor` widening, as the convex extractors apply it.
42///
43/// The NLP path widens `x_L/x_U` and the inequality-row bounds `d_L/d_U`
44/// before the algorithm ever sees them (`OrigIpoptNlp::relax_bounds`, driven
45/// from `Application` with `bound_relax_factor` — Ipopt default `1e-8` —
46/// capped by `constr_viol_tol`, default `1e-4`). The convex path did not,
47/// so the *same binary* solved a materially different model depending on
48/// `solver_selection`.
49///
50/// That is not a hairline difference on a constraint-degenerate model. On
51/// `LISWET1` (gh #744) every one of the 10 000 monotonicity rows is active at
52/// the optimum and the multipliers sum to `1.6e9`, so a `1e-8` widening of the
53/// rows buys `9.0` of objective — the convex arm returned the exact optimum
54/// `36.1224` and the NLP arm (and Ipopt-MA57) the relaxed one, `27.1221`, and
55/// the 33% gap was read as a convex-solver bug. Both arms now relax, so both
56/// report `27.1221`, and `bound_relax_factor=0` gets `36.1224` from either.
57///
58/// Faithful to `relax_bounds` in three details that matter:
59/// * **Equality rows are not relaxed.** Upstream they live in `c(x) = 0`,
60/// which `relax_bounds` never touches; only `d_L/d_U` (inequality rows) and
61/// the variable box are widened.
62/// * **Rows use the scale-relative width** `min(factor, cap)·|b|` (with `|b|`
63/// read as `1` at a declared-zero bound), the gh #385 form. The variable box
64/// keeps the upstream absolute formula `min(factor·max(|b|,1), cap)`.
65/// * **Fixed variables (`x_l == x_u`) keep their bounds.** Under the default
66/// `fixed_variable_treatment=make_parameter` upstream removes them before
67/// `relax_bounds` runs, so they are never widened.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct BoundRelax {
70 /// `bound_relax_factor`. Non-positive disables the widening entirely.
71 pub factor: f64,
72 /// `constr_viol_tol` — the cap on the widening.
73 pub cap: f64,
74}
75
76impl BoundRelax {
77 /// No widening — the model exactly as declared. What the convex path did
78 /// unconditionally before gh #744, and what `bound_relax_factor=0` selects.
79 pub const NONE: Self = Self {
80 factor: 0.0,
81 cap: 0.0,
82 };
83
84 /// Whether any widening is actually applied. Public so a caller can tell
85 /// "the declared model and the solved model coincide" from "they differ",
86 /// which struct equality against [`Self::NONE`] cannot: `bound_relax_factor=0`
87 /// zeroes the factor but leaves `cap` at `constr_viol_tol`, so the pair is
88 /// inactive without being `NONE`.
89 pub fn active(self) -> bool {
90 self.factor > 0.0 && self.cap > 0.0
91 }
92
93 /// Widening of a variable bound `b`: `min(factor·max(|b|,1), cap)`.
94 fn var_delta(self, b: f64) -> f64 {
95 if !self.active() {
96 return 0.0;
97 }
98 (self.factor.abs() * b.abs().max(1.0)).min(self.cap)
99 }
100
101 /// Widening of an inequality-row bound `b`: `min(factor, cap)·|b|`, with a
102 /// declared-zero bound taking the absolute width (it has no scale).
103 /// The widening to apply to a row whose declared sides are `lo`/`hi`.
104 ///
105 /// A crossed pair (`lo > hi`) declares an *empty* feasible set — an
106 /// inconsistent model, which the NLP path rejects as
107 /// `Invalid_Problem_Definition` before `relax_bounds` is ever reached.
108 /// Widening both sides of one closes the gap whenever the crossing is
109 /// narrower than the relaxation, turning "this model has no feasible
110 /// point" into an optimal answer. A crossed row is therefore passed
111 /// through exactly as declared, so the emptiness screen still sees it
112 /// (gh #491).
113 fn for_row(self, lo: f64, hi: f64) -> Self {
114 if lower_bound_present(lo) && upper_bound_present(hi) && lo > hi {
115 Self::NONE
116 } else {
117 self
118 }
119 }
120
121 fn row_delta(self, b: f64) -> f64 {
122 if !self.active() {
123 return 0.0;
124 }
125 let scale = if b == 0.0 { 1.0 } else { b.abs() };
126 self.factor.abs().min(self.cap) * scale
127 }
128}
129
130/// Convert a classified LP/convex-QP `NlProblem` into `QpProblem`
131/// standard form. Returns `None` if the objective is not actually a
132/// degree-≤2 polynomial (should not happen for a problem the classifier
133/// routed here, but the conversion is total and falls back gracefully).
134pub fn extract_qp(prob: &NlProblem, relax: BoundRelax) -> Option<QpProblem> {
135 Some(extract_qp_with_map(prob, relax)?.0) // drops con_map + reporting constant
136}
137
138/// Where each `.nl` constraint's rows landed in the standard-form QP, so
139/// the QP's multipliers can be mapped back to a per-`.nl`-constraint
140/// dual for the `.sol`. One entry per original constraint, in order.
141#[derive(Debug, Clone)]
142pub enum ConRowMap {
143 /// Equality constraint → row `a_row` of `A` (multiplier `y[a_row]`).
144 Eq { a_row: usize },
145 /// Inequality / range constraint → up to two rows of `G`: the
146 /// `row ≤ g_u` upper bound and/or the `−row ≤ −g_l` lower bound
147 /// (multipliers `z[..]`, each ≥ 0).
148 Ineq {
149 upper: Option<usize>,
150 lower: Option<usize>,
151 },
152}
153
154/// The residuals of `sol` measured against the model **as declared**, before
155/// the [`BoundRelax`] widening.
156///
157/// [`QpSolution::kkt_residuals`] measures the problem the solver was handed,
158/// whose inequality rows and variable box are widened by
159/// `bound_relax_factor`. That is correct for the convergence test — the
160/// solver must converge on the model it is solving, and pounce-convex's own
161/// acceptance tests call it on exactly that model — and wrong for the number
162/// a caller reads as "how well does my model hold".
163///
164/// The gap is the widening itself, `min(factor, cap)·|b|` per row. On
165/// `afiro` the returned point sits `4.99e-06` outside the declared row
166/// `b = 500` — precisely `1e-8 · 500` — while the widened measurement reads
167/// `8.68e-13`, seven orders tighter, because the point does satisfy the
168/// widened row. `25fv47` reports `2.19e-11` against a declared `1.97e-05`.
169/// Neither is a solver defect: both are the widening working as designed and
170/// then being reported against the wrong model.
171///
172/// Returns `None` when no widening was applied (the two measurements
173/// coincide by construction, so the caller keeps the one it already has) or
174/// when re-extraction fails.
175pub fn declared_residuals_qp(
176 prob: &NlProblem,
177 sol: &QpSolution,
178 relax: BoundRelax,
179) -> Option<QpResiduals> {
180 if !relax.active() {
181 return None;
182 }
183 let (declared, _, _) = extract_qp_with_map(prob, BoundRelax::NONE)?;
184 // Structurally the re-extraction differs from the solved model only in
185 // the bound VALUES, so the shapes must agree; a mismatch would silently
186 // measure one model's point against another's rows.
187 debug_assert_eq!(declared.n, sol.x.len(), "declared re-extraction changed n");
188 // Only `primal_infeasibility` is consumed. The dual and complementarity
189 // terms this also computes are measured against a model whose multipliers
190 // are not the ones that produced them, so they are meaningless here and
191 // deliberately unused rather than reported.
192 Some(sol.kkt_residuals(&declared))
193}
194
195/// [`declared_residuals_qp`] for the conic arm: measures each block with its
196/// own cone, as [`QpSolution::kkt_residuals_conic`] does, so a converged SOC
197/// block is not read as infeasible (pounce#209). The cones come from the
198/// re-extraction rather than the caller, so the blocks and the bounds
199/// describe one model.
200pub fn declared_residuals_socp(
201 prob: &NlProblem,
202 sol: &QpSolution,
203 relax: BoundRelax,
204) -> Option<QpResiduals> {
205 if !relax.active() {
206 return None;
207 }
208 let (declared, _, _, cones) = extract_socp_with_map(prob, BoundRelax::NONE)?;
209 debug_assert_eq!(declared.n, sol.x.len(), "declared re-extraction changed n");
210 // As above: only `primal_infeasibility` is consumed.
211 Some(sol.kkt_residuals_conic(&declared, &cones))
212}
213
214/// Extract the QP, the constraint→row provenance map, and the objective
215/// constant folded into the nonlinear tree (see below), together.
216///
217/// The third return value is the **degree-0 term of the nonlinear
218/// objective** (e.g. the `+9` of `(x₀−3)²` that AMPL/Pyomo emit inside the
219/// nonlinear tree rather than in `NlProblem::obj_constant`). The QP itself
220/// ignores it — it does not move the minimizer — but the caller must add
221/// it to the *reported* objective so the convex solve agrees with the NLP
222/// path. It is returned in the problem's natural (user) sense, *not*
223/// multiplied by the maximize/minimize `sign`.
224pub fn extract_qp_with_map(
225 prob: &NlProblem,
226 relax: BoundRelax,
227) -> Option<(QpProblem, Vec<ConRowMap>, f64)> {
228 let n = prob.n;
229 let sign = if prob.minimize { 1.0 } else { -1.0 };
230
231 // --- objective Hessian P (lower triangle) + nonlinear-tree linear part
232 // + nonlinear-tree constant (degree-0 term, for reporting only) ---
233 let (hess, obj_nl_linear, obj_nl_constant) = prob.obj_nonlinear.analyze_quadratic_full()?;
234 let mut p_lower: Vec<Triplet> = Vec::with_capacity(hess.len());
235 for ((i, j), v) in &hess {
236 // analyze_quadratic returns (i ≤ j) upper-ish keys; store as
237 // lower triangle (row ≥ col) for the solver.
238 let (row, col) = if i >= j { (*i, *j) } else { (*j, *i) };
239 p_lower.push(Triplet::new(row, col, sign * v));
240 }
241
242 // --- objective linear term c ---
243 // Two disjoint sources, exactly as the NLP path's eval_f sums them:
244 // the `.nl` linear section (`obj_linear`) and the degree-1 terms AMPL
245 // kept inside the nonlinear objective tree (e.g. the `−6·x₀` of
246 // `(x₀−3)²`). Dropping the latter silently solves the wrong objective.
247 let mut c = vec![0.0; n];
248 for (var, coef) in &prob.obj_linear {
249 c[*var] += sign * coef;
250 }
251 for (var, coef) in &obj_nl_linear {
252 c[*var] += sign * coef;
253 }
254
255 // --- constraints: equalities → A x = b, inequalities → G x ≤ h ---
256 let mut a: Vec<Triplet> = Vec::new();
257 let mut b: Vec<f64> = Vec::new();
258 let mut g: Vec<Triplet> = Vec::new();
259 let mut h: Vec<f64> = Vec::new();
260 let mut con_map: Vec<ConRowMap> = Vec::with_capacity(prob.con_linear.len());
261
262 for (row, lin) in prob.con_linear.iter().enumerate() {
263 let lo = prob.g_l[row];
264 let hi = prob.g_u[row];
265
266 // Combine the `.nl` linear section with any degree-≤1 terms AMPL
267 // folded into the (here empty-Hessian) nonlinear tree — the
268 // classifier admits constraint rows whose nonlinear expression
269 // reduces to degree ≤ 1 (`dispatch.rs`), e.g. defined variables
270 // or a quadratic the writer wrote out and cancelled exactly, and
271 // those linear/constant terms live in `con_nonlinear`, not
272 // `con_linear`. Dropping them silently solves the wrong
273 // constraint. (A row whose coefficients cancelled *in the
274 // recognizer's own arithmetic* does not reach here at all: it
275 // never classifies LP/QP — gh #685.) The folded constant
276 // shifts the bounds: `g_l ≤ row + k ≤ g_u ⇔ g_l−k ≤ row ≤ g_u−k`.
277 // This mirrors the SOCP extractor's linear-constraint handling.
278 let (nl_lin, const_shift) = prob.con_nonlinear[row]
279 .analyze_quadratic_full()
280 .map(|(_, l, k)| (l, k))
281 .unwrap_or_default();
282 let mut coef = vec![0.0; n];
283 for (var, v) in lin {
284 coef[*var] += *v;
285 }
286 for (var, v) in &nl_lin {
287 coef[*var] += *v;
288 }
289 let nonzeros = || coef.iter().enumerate().filter(|(_, v)| **v != 0.0);
290
291 if lo == hi && lower_bound_present(lo) && upper_bound_present(hi) {
292 // Equality row.
293 let eq_row = next_row(&b);
294 for (var, v) in nonzeros() {
295 a.push(Triplet::new(eq_row, var, *v));
296 }
297 b.push(lo - const_shift);
298 con_map.push(ConRowMap::Eq { a_row: eq_row });
299 } else {
300 // Inequality row. Both sides carry the `bound_relax_factor`
301 // widening the NLP path applies to `d_L/d_U` (see [`BoundRelax`]);
302 // it is zero when the caller passed `BoundRelax::NONE`, and on a
303 // crossed row, which must stay crossed.
304 let relax = relax.for_row(lo, hi);
305 // Upper bound: row ≤ hi.
306 let upper = if upper_bound_present(hi) {
307 let gr = next_row(&h);
308 for (var, v) in nonzeros() {
309 g.push(Triplet::new(gr, var, *v));
310 }
311 h.push(hi + relax.row_delta(hi) - const_shift);
312 Some(gr)
313 } else {
314 None
315 };
316 // Lower bound: row ≥ lo ⇔ −row ≤ −lo.
317 let lower = if lower_bound_present(lo) {
318 let gr = next_row(&h);
319 for (var, v) in nonzeros() {
320 g.push(Triplet::new(gr, var, -*v));
321 }
322 h.push(-(lo - relax.row_delta(lo) - const_shift));
323 Some(gr)
324 } else {
325 None
326 };
327 con_map.push(ConRowMap::Ineq { upper, lower });
328 }
329 }
330
331 // --- variable bounds as the explicit box (not as `G` rows) ---
332 let (lb, ub) = extract_box(prob, relax);
333
334 Some((
335 QpProblem {
336 n,
337 p_lower,
338 c,
339 a,
340 b,
341 g,
342 h,
343 lb,
344 ub,
345 },
346 con_map,
347 obj_nl_constant,
348 ))
349}
350
351/// The `.nl` variable bounds as `pounce-convex`'s explicit box, with an
352/// absent bound spelled `∓∞`.
353///
354/// Both extractors used to emit each finite bound as a `G` row (`x_i ≤ x_u`,
355/// `−x_i ≤ −x_l`) and leave `lb`/`ub` empty. That was never wrong — the IPM
356/// re-expands finite bounds into exactly those rows internally — but it threw
357/// away the one thing the solvers can only get from the box: **that these
358/// rows are a box**. Three consequences, all real:
359///
360/// * The empty-box screen ([`pounce_convex`]'s `screen_variable_box`, gh #491)
361/// reads `lb`/`ub`, so a model with a reversed bound arrived as a pair of
362/// contradictory rows instead — an infeasibility that has to be *certified*
363/// numerically rather than seen. The interior-point method managed that at
364/// most widths but returned `NumericalFailure` at a `NaN` iterate for
365/// crossings around `1e-8`.
366/// * The active-set engine handles a box with bound *statuses*, not with
367/// constraint rows; feeding it `2n` extra rows made every `.nl` QP that much
368/// larger in the one dimension an active-set method pays combinatorially
369/// for.
370/// * Presolve reasons about `tlb`/`tub` directly, so bounds hidden in rows had
371/// to be rediscovered by activity-based tightening before any box reduction
372/// could fire.
373///
374/// The bound *multipliers* now come back in the solution's `z_lb`/`z_ub`
375/// rather than being decoded out of `z` by row position.
376fn extract_box(prob: &NlProblem, relax: BoundRelax) -> (Vec<f64>, Vec<f64>) {
377 // Two declared boxes are passed through untouched.
378 //
379 // A variable pinned by `x_l == x_u` is fixed, and upstream's default
380 // `fixed_variable_treatment=make_parameter` lifts it out of the problem
381 // before `relax_bounds` runs — so it is never widened. Keep it pinned
382 // here too; widening it would hand the solver two decision variables'
383 // worth of slack that the NLP path does not have.
384 //
385 // A *crossed* box (`x_l > x_u`) is an empty set, and the NLP path rejects
386 // it as `Invalid_Problem_Definition` before relaxation. Widening it by
387 // more than the crossing would close the gap and return an optimal point
388 // for a model with no feasible one — gh #491's `1e-8` fixture crosses by
389 // less than the default `2 × 1e-8` widening. Leave it crossed so the
390 // empty-box screen downstream still sees it.
391 let as_declared = |i: usize| {
392 let (l, u) = (prob.x_l[i], prob.x_u[i]);
393 lower_bound_present(l) && upper_bound_present(u) && l >= u
394 };
395 let lb = (0..prob.n)
396 .map(|i| {
397 let v = prob.x_l[i];
398 if !lower_bound_present(v) {
399 f64::NEG_INFINITY
400 } else if as_declared(i) {
401 v
402 } else {
403 v - relax.var_delta(v)
404 }
405 })
406 .collect();
407 let ub = (0..prob.n)
408 .map(|i| {
409 let v = prob.x_u[i];
410 if !upper_bound_present(v) {
411 f64::INFINITY
412 } else if as_declared(i) {
413 v
414 } else {
415 v + relax.var_delta(v)
416 }
417 })
418 .collect();
419 (lb, ub)
420}
421
422/// Map the QP solver's multipliers `(y, z)` back to a per-`.nl`-
423/// constraint dual vector (length `prob.m`), in the AMPL `.sol`
424/// convention used by POUNCE's NLP path.
425///
426/// The QP solver enforces stationarity `∇f + Aᵀy + Gᵀz = 0` with
427/// `z ≥ 0`, where each inequality `.nl` row contributes a `row ≤ g_u`
428/// (`+row`) and/or `−row ≤ −g_l` (`−row`) `G` row. The per-constraint
429/// `.nl`/Ipopt multiplier `λ` is recovered as:
430/// - equality: `λ = sign · y[a_row]`;
431/// - inequality: `λ = sign · (z_upper − z_lower)` — at most one of the
432/// two bound rows is active at a solution.
433///
434/// The inequality sign (`z_upper − z_lower`, *not* `z_lower − z_upper`)
435/// is fixed to match POUNCE's NLP path, which is the reference for what
436/// a POUNCE `.sol` carries; this is verified empirically against the NLP
437/// solve in the crate tests. `sign` undoes the maximize→minimize
438/// negation so the reported dual is in the user's original sense.
439pub fn recover_duals(prob: &NlProblem, con_map: &[ConRowMap], y: &[f64], z: &[f64]) -> Vec<f64> {
440 let sign = if prob.minimize { 1.0 } else { -1.0 };
441 con_map
442 .iter()
443 .map(|m| match m {
444 ConRowMap::Eq { a_row } => sign * y[*a_row],
445 ConRowMap::Ineq { upper, lower } => {
446 let zu = upper.map(|r| z[r]).unwrap_or(0.0);
447 let zl = lower.map(|r| z[r]).unwrap_or(0.0);
448 sign * (zu - zl)
449 }
450 })
451 .collect()
452}
453
454/// The next 0-based row index for a constraint block keyed by its RHS
455/// vector's current length.
456fn next_row(rhs: &[f64]) -> usize {
457 rhs.len()
458}
459
460/// Recover the per-variable **bound multipliers** from a solved QP or SOCP.
461///
462/// Both extractors put the `.nl` variable bounds in the explicit box
463/// ([`extract_box`]), so the solver returns their multipliers directly in
464/// `z_lb`/`z_ub` and this is a length-normalizing read rather than the
465/// row-position decode it used to be. Variables are 1:1 with the `.nl`
466/// variables in both extractors, so no index remap is needed; a slot without
467/// a finite bound stays `0.0` because no bound was active there.
468///
469/// The returned `z_lb` / `z_ub` are the raw non-negative multipliers of the
470/// *internal minimize* problem (a maximize objective was negated during
471/// extraction); the caller applies the maximize `sign` and the Ipopt
472/// `ipopt_zL_out = +z_l`, `ipopt_zU_out = −z_u` output convention.
473pub fn recover_bound_mults(prob: &NlProblem, sol: &QpSolution) -> (Vec<f64>, Vec<f64>) {
474 let read = |v: &[f64]| -> Vec<f64> {
475 (0..prob.n)
476 .map(|i| v.get(i).copied().unwrap_or(0.0))
477 .collect()
478 };
479 (read(&sol.z_lb), read(&sol.z_ub))
480}
481
482// ===========================================================================
483// QCQP → SOCP extraction
484// ===========================================================================
485
486/// Where each `.nl` constraint landed in the standard-form **conic** program,
487/// so the cone multipliers can be mapped back to a per-`.nl`-constraint dual.
488/// One entry per original constraint, in order. (Analogue of [`ConRowMap`] for
489/// the SOCP path produced by [`extract_socp_with_map`].)
490#[derive(Debug, Clone)]
491pub enum ConSocpMap {
492 /// Linear equality → row `a_row` of `A` (multiplier `y[a_row]`).
493 Eq { a_row: usize },
494 /// Linear inequality / range → up to two rows of the nonnegative `G`
495 /// block (`row ≤ g_u` and/or `−row ≤ −g_l`), multipliers `z[..] ≥ 0`.
496 Ineq {
497 upper: Option<usize>,
498 lower: Option<usize>,
499 },
500 /// Convex quadratic inequality `g(x) ≤ g_u`, reformulated to one
501 /// second-order cone. The first two cone rows both carry the linear
502 /// coefficient vector `a/κ`, so the original constraint multiplier is
503 /// recovered as `(z[r0] + z[r1])/κ` (see [`recover_socp_duals`]; `κ` is
504 /// the rotated cone's balancing factor, `1` unless `|b| > 1`).
505 Quad {
506 z_row0: usize,
507 z_row1: usize,
508 kappa: f64,
509 },
510}
511
512/// A deferred second-order-cone block, built after the nonnegative `G` rows
513/// are known so the cones partition `G` in row order (nonneg block first,
514/// then the SOCs).
515/// Everything here is sized by the row's own **support** `k` — the variables
516/// that actually appear in it — never by the problem width `n`. A QCQP row
517/// typically touches a handful of variables out of `n` in the hundreds of
518/// thousands (`nql180`: `k = 2`, `n = 129 601`), so an `n`-sized structure per
519/// row is the difference between kilobytes and tens of gigabytes.
520struct SocBlock {
521 /// Index in `con_map` of the originating constraint, to patch with the
522 /// final cone-row indices once they are assigned.
523 con_idx: usize,
524 /// Linear coefficients of the constraint as `(variable, coefficient)`,
525 /// ascending by variable and with zeros dropped.
526 a: Vec<(usize, f64)>,
527 /// `b_eff = (nonlinear constant) − g_u`, the constraint's degree-0 term
528 /// after moving the upper bound to the right: `½xᵀQx + aᵀx + b_eff ≤ 0`.
529 b_eff: f64,
530 /// Rows of the factor `F` with `FᵀF = Q`, each a sparse
531 /// `(variable, coefficient)` list in the problem's own indexing.
532 ///
533 /// Sparse rather than length-`n` (or even length-`k`) dense: a diagonal
534 /// `Q` — the `qssp180`/`nql180` regime — has rank `k` and one nonzero per
535 /// factor row, so a dense factor would be `k²` to hold a `k`-nonzero
536 /// object.
537 f_rows: Vec<Vec<(usize, f64)>>,
538}
539
540/// Convert a classified **convex QCQP** `NlProblem` into the conic standard
541/// form the SOCP IPM consumes:
542///
543/// ```text
544/// minimize ½ xᵀP x + cᵀx
545/// subject to A x = b
546/// h − G x ∈ K (K = nonneg orthant × second-order cones)
547/// ```
548///
549/// Returns `(QpProblem, con_map, obj_nl_constant, cones)`:
550/// - the objective `P`/`c` exactly as the LP/QP path builds them;
551/// - linear equalities in `A`/`b`; linear inequalities and finite variable
552/// bounds as a leading **nonnegative** `G` block; and each convex quadratic
553/// inequality `g(x) ≤ g_u` as one **second-order cone** block appended
554/// after it (so `cones` covers the `G` rows in order);
555/// - `con_map` mapping each original constraint to its rows for dual recovery;
556/// - `obj_nl_constant`, the objective's folded degree-0 term (added back to the
557/// reported value, exactly as in [`extract_qp_with_map`]).
558///
559/// `None` if the objective is not degree-≤2 (should not happen for a problem
560/// the classifier routed here). The reformulation of a convex quadratic
561/// `½xᵀQx + aᵀx + b_eff ≤ 0` (with `Q = FᵀF ⪰ 0`) is the standard rotated→
562/// standard SOC: writing `s = −(aᵀx + b_eff)`, the cone slack
563/// `(s+1, s−1, √2·Fx)` lies in the second-order cone iff `‖Fx‖² ≤ 2s`, i.e.
564/// iff the original constraint holds.
565///
566/// The cone is **centred** first (gh #1041): the part of `a` in the range of
567/// `Fᵀ` is absorbed by completing the square, `Fᵀw = a_∥`, so the constraint
568/// becomes `½‖Fx + w‖² + a_⊥ᵀx + (b_eff − ½‖w‖²) ≤ 0` and the cone slack is
569/// `(s+1, s−1, √2·(Fx + w))` with `s = −(a_⊥ᵀx + b_eff − ½‖w‖²)`. See
570/// [`centre_soc`] for why the uncentred form fails on a ball far from the
571/// origin.
572pub fn extract_socp_with_map(
573 prob: &NlProblem,
574 relax: BoundRelax,
575) -> Option<(QpProblem, Vec<ConSocpMap>, f64, Vec<ConeSpec>)> {
576 let n = prob.n;
577 let sign = if prob.minimize { 1.0 } else { -1.0 };
578
579 // --- objective P (lower triangle) + folded linear / constant terms ---
580 let (hess, obj_nl_linear, obj_nl_constant) = prob.obj_nonlinear.analyze_quadratic_full()?;
581 let mut p_lower: Vec<Triplet> = Vec::with_capacity(hess.len());
582 for ((i, j), v) in &hess {
583 let (row, col) = if i >= j { (*i, *j) } else { (*j, *i) };
584 p_lower.push(Triplet::new(row, col, sign * v));
585 }
586 let mut c = vec![0.0; n];
587 for (var, coef) in &prob.obj_linear {
588 c[*var] += sign * coef;
589 }
590 for (var, coef) in &obj_nl_linear {
591 c[*var] += sign * coef;
592 }
593
594 // --- constraints: equalities → A; linear ineqs → nonneg G block;
595 // convex quadratics → deferred SOC blocks (added after the nonneg
596 // rows so the cones partition G in row order) ---
597 let mut a: Vec<Triplet> = Vec::new();
598 let mut b: Vec<f64> = Vec::new();
599 let mut g: Vec<Triplet> = Vec::new();
600 let mut h: Vec<f64> = Vec::new();
601 let mut con_map: Vec<ConSocpMap> = Vec::with_capacity(prob.m);
602 let mut soc_blocks: Vec<SocBlock> = Vec::new();
603
604 for (row, lin) in prob.con_linear.iter().enumerate() {
605 let lo = prob.g_l[row];
606 let hi = prob.g_u[row];
607 let nl = &prob.con_nonlinear[row];
608 let quad = nl.analyze_quadratic_full();
609 let is_quadratic = matches!(&quad, Some((hmap, _, _)) if !hmap.is_empty());
610
611 if is_quadratic {
612 // Convex quadratic inequality `g(x) ≤ g_u` (the classifier
613 // guarantees an upper-only bound with PSD Hessian). Build the
614 // factor F (FᵀF = Q) and defer the SOC rows.
615 let (hmap, nl_lin, nl_const) = quad.expect("checked above");
616 // Linear coefficients a = linear-section + folded nonlinear-tree
617 // linear part, accumulated sparsely: a QCQP row's linear part is
618 // as narrow as its quadratic part, and `n` here can be six digits.
619 let mut a_map: std::collections::BTreeMap<usize, f64> =
620 std::collections::BTreeMap::new();
621 for (var, coef) in lin {
622 *a_map.entry(*var).or_insert(0.0) += *coef;
623 }
624 for (var, coef) in &nl_lin {
625 *a_map.entry(*var).or_insert(0.0) += *coef;
626 }
627 let a_vec: Vec<(usize, f64)> = a_map.into_iter().filter(|&(_, c)| c != 0.0).collect();
628
629 let f_rows = socp_factor_rows(&hmap);
630 let con_idx = con_map.len();
631 con_map.push(ConSocpMap::Quad {
632 z_row0: 0,
633 z_row1: 0,
634 kappa: 1.0,
635 }); // patched in the SOC pass below
636 soc_blocks.push(SocBlock {
637 con_idx,
638 a: a_vec,
639 b_eff: nl_const - (hi + relax.row_delta(hi)),
640 f_rows,
641 });
642 continue;
643 }
644
645 // Linear constraint. Combine the `.nl` linear section with any
646 // degree-≤1 terms AMPL folded into the (here empty-Hessian)
647 // nonlinear tree, and shift the bounds by the folded constant.
648 let (nl_lin, const_shift) = quad.map(|(_, l, k)| (l, k)).unwrap_or_default();
649 let mut coef = vec![0.0; n];
650 for (var, v) in lin {
651 coef[*var] += *v;
652 }
653 for (var, v) in &nl_lin {
654 coef[*var] += *v;
655 }
656 let nonzeros = || coef.iter().enumerate().filter(|(_, v)| **v != 0.0);
657 if lo == hi && lower_bound_present(lo) && upper_bound_present(hi) {
658 let eq_row = next_row(&b);
659 for (var, v) in nonzeros() {
660 a.push(Triplet::new(eq_row, var, *v));
661 }
662 b.push(lo - const_shift);
663 con_map.push(ConSocpMap::Eq { a_row: eq_row });
664 } else {
665 let relax = relax.for_row(lo, hi);
666 let upper = if upper_bound_present(hi) {
667 let gr = next_row(&h);
668 for (var, v) in nonzeros() {
669 g.push(Triplet::new(gr, var, *v));
670 }
671 h.push(hi + relax.row_delta(hi) - const_shift);
672 Some(gr)
673 } else {
674 None
675 };
676 let lower = if lower_bound_present(lo) {
677 let gr = next_row(&h);
678 for (var, v) in nonzeros() {
679 g.push(Triplet::new(gr, var, -*v));
680 }
681 h.push(-(lo - relax.row_delta(lo) - const_shift));
682 Some(gr)
683 } else {
684 None
685 };
686 con_map.push(ConSocpMap::Ineq { upper, lower });
687 }
688 }
689
690 // Variable bounds go in the explicit box, not into this orthant block —
691 // see [`extract_box`]. `solve_socp_ipm` appends them as a trailing
692 // nonnegative block of its own, *after* the cones, so they stay outside
693 // the partition `cones` has to cover.
694 let (lb, ub) = extract_box(prob, relax);
695
696 // The nonnegative block is every G row built so far. The cones list must
697 // cover G in row order: this orthant block, then one SOC per quadratic.
698 let num_nonneg = h.len();
699 let mut cones: Vec<ConeSpec> = Vec::with_capacity(1 + soc_blocks.len());
700 if num_nonneg > 0 {
701 cones.push(ConeSpec::Nonneg(num_nonneg));
702 }
703
704 // --- emit the deferred second-order cones ---
705 for blk in soc_blocks {
706 let r = blk.f_rows.len();
707 let dim = r + 2;
708 // Complete the square: `a = Fᵀw + a_⊥`, `b = b_eff − ½‖w‖²`.
709 let (a_perp, w) = centre_soc(&blk.f_rows, &blk.a);
710 let b_c = blk.b_eff - 0.5 * w.iter().map(|v| v * v).sum::<f64>();
711 // Balance the rotated cone's two factors (gh #1056): see
712 // [`rotated_cone_balance`]. `κ = 1` is the historical `(s+1, s−1)`.
713 let kappa = rotated_cone_balance(b_c);
714 let row0 = next_row(&h);
715 // s0 = s/κ + κ, s = −b − a_⊥ᵀx → G row = a_⊥/κ, h = −b/κ + κ.
716 for &(var, coef) in &a_perp {
717 g.push(Triplet::new(row0, var, coef / kappa));
718 }
719 h.push(-b_c / kappa + kappa);
720 let row1 = next_row(&h);
721 // s1 = s/κ − κ → G row = a_⊥/κ, h = −b/κ − κ.
722 for &(var, coef) in &a_perp {
723 g.push(Triplet::new(row1, var, coef / kappa));
724 }
725 h.push(-b_c / kappa - kappa);
726 // s_{2+k} = √2·(Fx + w)_k → G row = −√2·F_k, h = √2·w_k.
727 let sqrt2 = std::f64::consts::SQRT_2;
728 for (f, &wk) in blk.f_rows.iter().zip(&w) {
729 let gr = next_row(&h);
730 for &(var, fv) in f {
731 g.push(Triplet::new(gr, var, -sqrt2 * fv));
732 }
733 h.push(sqrt2 * wk);
734 }
735 cones.push(ConeSpec::SecondOrder(dim));
736 con_map[blk.con_idx] = ConSocpMap::Quad {
737 z_row0: row0,
738 z_row1: row1,
739 kappa,
740 };
741 }
742
743 Some((
744 QpProblem {
745 n,
746 p_lower,
747 c,
748 a,
749 b,
750 g,
751 h,
752 lb,
753 ub,
754 },
755 con_map,
756 obj_nl_constant,
757 cones,
758 ))
759}
760
761/// Map the SOCP solver's multipliers `(y, z)` back to a per-`.nl`-constraint
762/// dual vector (length `prob.m`), in POUNCE's NLP-path `.sol` convention.
763///
764/// Linear rows reuse the QP-path recovery (`y[a_row]` for an equality;
765/// `z_upper − z_lower` for an inequality). For a convex quadratic
766/// `g(x) ≤ g_u` reformulated to a second-order cone, the constraint
767/// multiplier is recovered as the sum of the two cone duals on the rows
768/// carrying the linear coefficient vector `a`: `λ = z[r0] + z[r1]`. (At a
769/// KKT point stationarity reads `λ(∇g) = (z[r0]+z[r1])·a + …`, so this sum is
770/// the original multiplier; the cone's remaining rows reconstruct the `Qx`
771/// part.) `sign` undoes the maximize→minimize negation.
772pub fn recover_socp_duals(
773 prob: &NlProblem,
774 con_map: &[ConSocpMap],
775 y: &[f64],
776 z: &[f64],
777) -> Vec<f64> {
778 let sign = if prob.minimize { 1.0 } else { -1.0 };
779 con_map
780 .iter()
781 .map(|m| match m {
782 ConSocpMap::Eq { a_row } => sign * y[*a_row],
783 ConSocpMap::Ineq { upper, lower } => {
784 let zu = upper.map(|r| z[r]).unwrap_or(0.0);
785 let zl = lower.map(|r| z[r]).unwrap_or(0.0);
786 sign * (zu - zl)
787 }
788 ConSocpMap::Quad {
789 z_row0,
790 z_row1,
791 kappa,
792 } => sign * (z[*z_row0] + z[*z_row1]) / kappa,
793 })
794 .collect()
795}
796
797/// The balancing factor `κ` for a rotated cone whose constant is `b`
798/// (gh #1056).
799///
800/// `½‖y‖² ≤ s` is the rotated cone `2·u·v ≥ ‖y‖²` with `u·v = s`, emitted as
801/// the standard cone `(u + v, u − v, √2·y)`. Taking `v = 1` — the historical
802/// `(s + 1, s − 1)` — puts the whole magnitude of `s` in one factor, and in
803/// doubles `1e20 ± 1` are both `1e20`: on `x₀² + x₁² ≤ 1e20` the row was
804/// `(1e20, 1e20, 2x₀, 2x₁)`, whose determinant is `−4‖x‖²`, so the declared
805/// cone was empty but for `x = 0` and the conic IPM ended `Numerical
806/// failure`. Well before the `1` vanishes the imbalance costs: the same
807/// inactive ball takes 159 iterations at `1e6`, and an *active* ball of
808/// radius `1e4` already failed.
809///
810/// `u = s/κ`, `v = κ` keeps `4uv = 4s`, so cone membership is unchanged, and
811/// with `κ ≈ √|b|` the two factors are the same size wherever `s` is near its
812/// constant. A power of two, so `s/κ` and `a/κ` are exact; `1` when
813/// `|b| ≤ 1`, which leaves every such row exactly as it was. The multiplier
814/// maps back as `(z₀ + z₁)/κ` ([`recover_socp_duals`]).
815pub(crate) fn rotated_cone_balance(b: f64) -> f64 {
816 if !(b.abs() > 1.0) || !b.is_finite() {
817 return 1.0;
818 }
819 let k = 2.0_f64.powi((0.5 * b.abs().log2()).round() as i32);
820 if k.is_finite() && k > 0.0 { k } else { 1.0 }
821}
822
823/// Split a quadratic row's linear part `a` into `Fᵀw + a_⊥` (gh #1041),
824/// returning `(a_⊥, w)` with `w` indexed like `f_rows`.
825///
826/// Without this the rotated cone carries `‖Fx‖²` and `2s = −2(aᵀx + b_eff)`
827/// as two separate, huge quantities whose *difference* is the constraint: on
828/// `(x − 1000)² ≤ 1` both are ~2·10⁶ at the optimum while the ball's radius is
829/// 1, so the cone is pinned to its boundary to six digits and the conic IPM
830/// stalls at an infeasible point (x ≈ 1003.4). Centred, the same row is
831/// `(2, 0, 2(x − 1000))` — the explicit SOC `pounce.solve_socp` solves
832/// exactly. The cancellation still happens, but once, in the scalar
833/// `b_eff − ½‖w‖²`, not inside every iterate.
834///
835/// `w` is the least-squares solution of `Fᵀw = a` (normal equations
836/// `FFᵀw = Fa`, positive definite because the factor rows are linearly
837/// independent by construction). A diagonal factor — one variable per row —
838/// takes the closed form `w_k = a_i / f_k` and leaves `a_⊥` exactly zero on
839/// the factored variables. Otherwise a residual entry at roundoff level of the
840/// two terms it is the difference of is set to zero, so a ball whose centre
841/// is in range does not keep a spurious `1e-16`-scale linear term.
842fn centre_soc(f_rows: &[Vec<(usize, f64)>], a: &[(usize, f64)]) -> (Vec<(usize, f64)>, Vec<f64>) {
843 use std::collections::BTreeMap;
844 let r = f_rows.len();
845 if r == 0 {
846 return (a.to_vec(), Vec::new());
847 }
848 let a_map: BTreeMap<usize, f64> = a.iter().copied().collect();
849 let a_at = |var: usize| a_map.get(&var).copied().unwrap_or(0.0);
850
851 let diagonal = f_rows.iter().all(|f| f.len() == 1) && {
852 let mut vars: Vec<usize> = f_rows.iter().map(|f| f[0].0).collect();
853 vars.sort_unstable();
854 vars.windows(2).all(|p| p[0] != p[1])
855 };
856 if diagonal {
857 let w: Vec<f64> = f_rows.iter().map(|f| a_at(f[0].0) / f[0].1).collect();
858 let covered: std::collections::BTreeSet<usize> = f_rows.iter().map(|f| f[0].0).collect();
859 let a_perp = a
860 .iter()
861 .copied()
862 .filter(|(v, _)| !covered.contains(v))
863 .collect();
864 return (a_perp, w);
865 }
866
867 // Normal equations `M w = F a`, `M = F Fᵀ` (r×r, SPD).
868 let dense_rows: Vec<BTreeMap<usize, f64>> =
869 f_rows.iter().map(|f| f.iter().copied().collect()).collect();
870 let dot = |x: &BTreeMap<usize, f64>, y: &[(usize, f64)]| -> f64 {
871 y.iter()
872 .map(|(v, c)| c * x.get(v).copied().unwrap_or(0.0))
873 .sum()
874 };
875 let mut m = vec![0.0; r * r];
876 for i in 0..r {
877 for j in 0..=i {
878 let v = dot(&dense_rows[i], &f_rows[j]);
879 m[i * r + j] = v;
880 m[j * r + i] = v;
881 }
882 }
883 let mut w: Vec<f64> = f_rows
884 .iter()
885 .map(|f| f.iter().map(|&(v, c)| c * a_at(v)).sum())
886 .collect();
887 // Cholesky M = L Lᵀ in place (lower triangle), then two triangular solves.
888 for j in 0..r {
889 let diag = m[j * r + j];
890 let mut d = diag;
891 for k in 0..j {
892 d -= m[j * r + k] * m[j * r + k];
893 }
894 // A dependent row's pivot is the difference of `diag` and what the
895 // earlier rows explain of it, so it lands at roundoff of `diag` —
896 // often just *above* zero — not at zero. Testing `d <= 0` alone let
897 // that through and solved with a pivot of pure noise.
898 if d.is_nan() || d <= 16.0 * f64::EPSILON * diag {
899 // Numerically rank-deficient factor: leave the cone uncentred,
900 // which is exactly the pre-gh#1041 reformulation. Traced, because
901 // an offset ball that lands here is back to the conditioning
902 // gh#1041 fixed and would otherwise fail with no clue why.
903 tracing::debug!(
904 target: "pounce::socp",
905 rank = r, pivot = j, value = d,
906 "centre_soc: FFᵀ not positive definite; SOC block left uncentred"
907 );
908 return (a.to_vec(), vec![0.0; r]);
909 }
910 let d = d.sqrt();
911 m[j * r + j] = d;
912 for i in (j + 1)..r {
913 let mut v = m[i * r + j];
914 for k in 0..j {
915 v -= m[i * r + k] * m[j * r + k];
916 }
917 m[i * r + j] = v / d;
918 }
919 }
920 for i in 0..r {
921 for k in 0..i {
922 w[i] -= m[i * r + k] * w[k];
923 }
924 w[i] /= m[i * r + i];
925 }
926 for i in (0..r).rev() {
927 for k in (i + 1)..r {
928 w[i] -= m[k * r + i] * w[k];
929 }
930 w[i] /= m[i * r + i];
931 }
932
933 // a_⊥ = a − Fᵀw over the union of a's and F's supports.
934 let mut ftw: BTreeMap<usize, f64> = BTreeMap::new();
935 for (f, &wk) in f_rows.iter().zip(&w) {
936 for &(v, c) in f {
937 *ftw.entry(v).or_insert(0.0) += c * wk;
938 }
939 }
940 let mut vars: Vec<usize> = a_map.keys().chain(ftw.keys()).copied().collect();
941 vars.sort_unstable();
942 vars.dedup();
943 let a_perp = vars
944 .into_iter()
945 .filter_map(|v| {
946 let (av, fv) = (a_at(v), ftw.get(&v).copied().unwrap_or(0.0));
947 let res = av - fv;
948 let roundoff = 16.0 * f64::EPSILON * av.abs().max(fv.abs());
949 (res.abs() > roundoff).then_some((v, res))
950 })
951 .collect();
952 (a_perp, w)
953}
954
955/// Factor one quadratic row's Hessian `Q` into sparse rows `f_k` (in the
956/// problem's variable indexing) with `Σ_k f_k f_kᵀ = Q`.
957///
958/// Two paths, and the cheap one is the common one:
959///
960/// * **Diagonal `Q`** — `f` is one row per positive diagonal entry, each with a
961/// single nonzero `√d_i`. `O(k)` time and `O(k)` space in the row's support.
962/// This is the `qssp180`/`nql180` regime, where the general path's `O(k³)`
963/// factorization and `k²` factor would both be ruinous for no benefit.
964/// * **Otherwise** — pivoted Cholesky on a dense `k×k` over the row's support,
965/// then scatter the rows back to problem indices, dropping zeros. Sized by
966/// `k`, never by `n`.
967fn socp_factor_rows(
968 hmap: &std::collections::BTreeMap<(usize, usize), f64>,
969) -> Vec<Vec<(usize, f64)>> {
970 let support = quad_support(hmap);
971 let k = support.len();
972
973 if !hmap.keys().any(|&(i, j)| i != j) {
974 // Diagonal: one factor row per variable, holding that entry's square
975 // root. Nonpositive diagonal entries are the zero eigenvalues of a PSD
976 // diagonal matrix (convexity is already established before we get
977 // here), and contribute no row.
978 //
979 // Two details make this a *shortcut* rather than an approximation, so
980 // the fast path and the general path emit bit-identical factors and a
981 // problem's trajectory cannot depend on which one ran:
982 //
983 // * the tolerance is the same expression `psd_outer_factor` uses, so
984 // both drop exactly the same entries and agree on the cone's
985 // dimension. Since gh #703 that expression is **relative to the
986 // entry's own magnitude**, not to the largest diagonal: on a
987 // diagonal `Q` no downdate ever touches another pivot, so every
988 // positive entry is a genuine eigenvalue whatever its size, and the
989 // filter here is simply `v > 0`. Cutting at `1e-12 · max_diag`
990 // instead discarded real directions on a column-scaled model — see
991 // `psd_outer_factor`;
992 // * the value is `d / √d`, not `√d`. They differ by an ulp — `√2` is
993 // `0x1.6a09e667f3bcdp+0` and `2/√2` is `0x1.6a09e667f3bccp+0` — and
994 // `d / √d` is what the general path's `a[i][p] / d_pivot` computes.
995 // Reproducing it is free; not reproducing it moved `qcqp_ball` from
996 // 17 conic iterations to 12 on a 2-ulp perturbation of one `G`
997 // entry, which is precisely the kind of invisible trajectory change
998 // `scripts/sweep-fixtures.sh` exists to catch.
999 // * the rows come out in **pivot order**, largest diagonal first. On a
1000 // diagonal matrix the rank-1 downdate only zeros the pivot, so the
1001 // general path's complete pivoting visits entries in descending
1002 // order with ties going to the lower index — which is what a
1003 // *stable* sort of the (ascending-by-index) map entries gives.
1004 // `‖Fx‖` does not care about row order, but `G` does: the rows land
1005 // in the KKT matrix and the ordering feeds the fill-reducing
1006 // permutation.
1007 let mut diag: Vec<(usize, f64)> = hmap
1008 .iter()
1009 .filter(|&(_, &v)| v > 0.0)
1010 .map(|(&(i, _), &v)| (i, v))
1011 .collect();
1012 diag.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("PSD diagonal is finite"));
1013 return diag
1014 .into_iter()
1015 .map(|(i, v)| vec![(i, v / v.sqrt())])
1016 .collect();
1017 }
1018
1019 let dense = dense_symmetric_on_support(hmap, &support);
1020 psd_outer_factor(dense, k)
1021 .into_iter()
1022 .map(|f| {
1023 f.into_iter()
1024 .enumerate()
1025 .filter(|&(_, fv)| fv != 0.0)
1026 .map(|(loc, fv)| (support[loc], fv))
1027 .collect()
1028 })
1029 .collect()
1030}
1031
1032/// The variables a quadratic row touches, ascending and deduplicated.
1033///
1034/// This is the row's *support*, and it is what every downstream structure is
1035/// sized by. `hmap` stores only `i ≤ j`, so both coordinates must be collected.
1036fn quad_support(hmap: &std::collections::BTreeMap<(usize, usize), f64>) -> Vec<usize> {
1037 let mut s: Vec<usize> = hmap.keys().flat_map(|&(i, j)| [i, j]).collect();
1038 s.sort_unstable();
1039 s.dedup();
1040 s
1041}
1042
1043/// Build a dense symmetric `k×k` matrix over a row's `support` from a
1044/// [`QuadHessian`]-style map of `(i ≤ j) → Hessian entry` (diagonal entries are
1045/// the full `∂²/∂xᵢ²`, so `½xᵀHx` reproduces the quadratic form). Off-diagonals
1046/// are mirrored.
1047///
1048/// Sized by `k`, never by `n`: the previous `n×n` version asked for 134 GB on a
1049/// two-variable row of `nql180`.
1050fn dense_symmetric_on_support(
1051 hmap: &std::collections::BTreeMap<(usize, usize), f64>,
1052 support: &[usize],
1053) -> Vec<f64> {
1054 let k = support.len();
1055 // `support` is sorted, so a binary search is the local index.
1056 let loc = |v: usize| support.binary_search(&v).expect("key came from support");
1057 let mut dense = vec![0.0; k * k];
1058 for (&(i, j), &v) in hmap {
1059 let (li, lj) = (loc(i), loc(j));
1060 dense[li * k + lj] = v;
1061 dense[lj * k + li] = v;
1062 }
1063 dense
1064}
1065
1066/// Symmetric **pivoted (rank-revealing) Cholesky** of a PSD matrix `H`
1067/// (row-major `n×n`, consumed as scratch), returning the factor rows `f_k`
1068/// (each length `n`) such that `Σ_k f_k f_kᵀ = H` — equivalently `FᵀF = H`
1069/// with `F` the matrix whose rows are the `f_k`.
1070///
1071/// Callers pass a **row's support size** `k` here, not the problem width: this
1072/// is `O(n³)` in whatever it is handed, so the distinction is what makes a wide
1073/// QCQP extractable at all.
1074///
1075/// The number of rows is the
1076/// numerical rank, so a rank-deficient `Q` (e.g. `Q = vvᵀ`) yields the
1077/// minimal cone. Complete diagonal pivoting keeps the factorization stable
1078/// on the indefinite-looking-but-PSD matrices finite precision can produce.
1079///
1080/// # The rank test is relative to each pivot's own starting magnitude (gh #703)
1081///
1082/// This used to cut at `1e-12 · max_diag` — a *global* threshold — and that
1083/// is not a rank test, it is a units test. Rank deficiency is what the
1084/// rank-1 downdate reveals: a direction already spanned by the pivots taken
1085/// so far has its residual diagonal driven from `Q_pp` to (numerically)
1086/// zero. A direction that is merely *small in the coordinates the model was
1087/// written in* has its residual diagonal stay a healthy fraction of `Q_pp`,
1088/// and is a genuine eigenvalue however far below `max_diag` it sits.
1089///
1090/// The global cut confused the two, and silently. On
1091/// `qcqp_columns_illcond.nl` — the well-conditioned fixture under the exact
1092/// substitution `x_j → x_j / c_j`, so a matrix of provably identical rank —
1093/// the diagonal spans `[1.5e-7, 4.3e9]`, `1e-12 · max_diag ≈ 4.3e-3`
1094/// discarded **7 of 24** directions, and the cone `‖Fx‖ ≤ t` stopped
1095/// constraining them. The conic solver then satisfied *its* cone to
1096/// `2.66e-15` and reported `SolveSucceeded` at an objective 10% away from
1097/// the true optimum, on a point that violates the original quadratic row by
1098/// `4.948e+01` — 38% of its right-hand side. A relative residual check
1099/// would not have caught it either: measured against `‖Q‖ = 4.3e9` the
1100/// reconstruction error is `5.4e-13`. The dropped rank is the only signal.
1101///
1102/// `a[p][p] > 1e-12 · Q_pp` is that test, and it is invariant under the
1103/// diagonal congruence `Q → CQC` that provoked the bug, since both sides
1104/// scale by `c_p²`. It changes nothing about the pivot *order* (still the
1105/// largest remaining diagonal), so a well-scaled matrix factors bit for bit
1106/// as before, and it keeps the diagonal shortcut above interchangeable with
1107/// this path: on a diagonal `Q` no downdate touches a pivot, so both keep
1108/// exactly the positive entries.
1109fn psd_outer_factor(mut a: Vec<f64>, n: usize) -> Vec<Vec<f64>> {
1110 let mut rows: Vec<Vec<f64>> = Vec::new();
1111 // Each pivot's *initial* diagonal, so the rank test below can ask how far
1112 // the downdate has moved it rather than how it compares to the model's
1113 // units. Clamped at zero: a PSD matrix has `Q_ii ≥ 0`, and an entry that
1114 // finite precision has pushed slightly negative must not produce a
1115 // negative threshold that admits it.
1116 let d0: Vec<f64> = (0..n).map(|i| a[i * n + i].max(0.0)).collect();
1117 // Columns already decided — either factored out, or ruled a zero
1118 // eigenvalue. A pivoted Cholesky never revisits a pivot, and the residual
1119 // it leaves on that diagonal is roundoff, not a candidate.
1120 let mut settled = vec![false; n];
1121 for _ in 0..n {
1122 // Largest undecided diagonal pivot.
1123 let mut p = usize::MAX;
1124 let mut best = f64::NEG_INFINITY;
1125 for i in 0..n {
1126 if settled[i] {
1127 continue;
1128 }
1129 let d = a[i * n + i];
1130 if d > best {
1131 best = d;
1132 p = i;
1133 }
1134 }
1135 if p == usize::MAX {
1136 break;
1137 }
1138 // Rule it a zero eigenvalue when the downdate has reduced it to a
1139 // negligible fraction of where it started — that is the residual
1140 // saying the direction is already spanned. `best <= 0` (including a
1141 // pivot whose `d0` is zero, where the threshold is zero) rules it out
1142 // too.
1143 //
1144 // `continue`, not `break`: with an *absolute* threshold the pivot
1145 // order and the rank order were the same order, so the first failure
1146 // ended it. A relative threshold decouples them — a column whose `d0`
1147 // is `1e-20` is still live at a residual of `1e-20`, while a spent
1148 // column with `d0 = 2` is dead at the `4e-16` of roundoff it carries,
1149 // and the dead one sorts first. Breaking there would drop the live
1150 // column and make the rank depend on the model's units again, which
1151 // is the whole defect this test was rewritten to fix.
1152 if best <= 1e-12 * d0[p] || best <= 0.0 {
1153 settled[p] = true;
1154 continue;
1155 }
1156 settled[p] = true;
1157 let d = best.sqrt();
1158 // f = column p of the residual, scaled by 1/d.
1159 let mut f = vec![0.0; n];
1160 for i in 0..n {
1161 f[i] = a[i * n + p] / d;
1162 }
1163 // Rank-1 downdate: A ← A − f fᵀ.
1164 for i in 0..n {
1165 let fi = f[i];
1166 if fi == 0.0 {
1167 continue;
1168 }
1169 for j in 0..n {
1170 a[i * n + j] -= fi * f[j];
1171 }
1172 }
1173 rows.push(f);
1174 }
1175 rows
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181 use crate::nl_reader::NlBody;
1182 use crate::nl_reader::{BinOp, Expr};
1183 use pounce_convex::{QpOptions, QpStatus, solve_qp_ipm, solve_socp_ipm};
1184 use pounce_feral::FeralSolverInterface;
1185 use pounce_linsol::SparseSymLinearSolverInterface;
1186
1187 fn backend() -> Box<dyn SparseSymLinearSolverInterface> {
1188 Box::new(FeralSolverInterface::new())
1189 }
1190
1191 fn pow2(var: usize) -> Expr {
1192 Expr::Binary(
1193 BinOp::Pow,
1194 Box::new(Expr::Var(var)),
1195 Box::new(Expr::Const(2.0)),
1196 )
1197 }
1198
1199 /// min −x0 − x1 s.t. x0² + x1² ≤ 1 → x* = (1/√2, 1/√2), f* = −√2.
1200 /// Exercises the QCQP→SOCP reformulation end-to-end: a rank-2 ball
1201 /// constraint becomes one second-order cone, no nonnegative block.
1202 #[test]
1203 fn extract_and_solve_socp_ball() {
1204 let prob = NlProblem {
1205 src: None,
1206 cse_bodies: Vec::new(),
1207 complementarity: Vec::new(),
1208 n: 2,
1209 m: 1,
1210 num_obj: 1,
1211 minimize: true,
1212 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1213 obj_linear: vec![(0, -1.0), (1, -1.0)],
1214 obj_constant: 0.0,
1215 con_nonlinear: vec![NlBody::Tree(Expr::Binary(
1216 BinOp::Add,
1217 Box::new(pow2(0)),
1218 Box::new(pow2(1)),
1219 ))],
1220 con_linear: vec![vec![]],
1221 x_l: vec![-2e19, -2e19],
1222 x_u: vec![2e19, 2e19],
1223 g_l: vec![-2e19],
1224 g_u: vec![1.0],
1225 x0: vec![0.0, 0.0],
1226 lambda0: vec![0.0],
1227 suffixes: Default::default(),
1228 imported_funcs: Vec::new(),
1229 ampl_options: Vec::new(),
1230 nl_counts: None,
1231 n_discrete: None,
1232 var_names: Vec::new(),
1233 con_names: Vec::new(),
1234 };
1235 let (qp, con_map, obj_const, cones) =
1236 extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1237 assert_eq!(obj_const, 0.0);
1238 // No linear inequalities / bounds → no nonneg block; one SOC of
1239 // dimension rank(Q)+2 = 2+2 = 4.
1240 assert_eq!(cones, vec![ConeSpec::SecondOrder(4)]);
1241 assert_eq!(qp.m_ineq(), 4);
1242
1243 let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
1244 assert_eq!(sol.status, QpStatus::Optimal);
1245 let inv_sqrt2 = 1.0 / 2.0_f64.sqrt();
1246 assert!((sol.x[0] - inv_sqrt2).abs() < 1e-5, "x0={}", sol.x[0]);
1247 assert!((sol.x[1] - inv_sqrt2).abs() < 1e-5, "x1={}", sol.x[1]);
1248 assert!(
1249 (sol.obj - (-2.0_f64.sqrt())).abs() < 1e-5,
1250 "obj={}",
1251 sol.obj
1252 );
1253
1254 // Analytic multiplier: c + λ·2x = 0 ⇒ λ = 1/(2x0) = √2/2 ≈ 0.7071,
1255 // positive (active upper bound), matching the `.sol` sign convention.
1256 let lambda = recover_socp_duals(&prob, &con_map, &sol.y, &sol.z);
1257 assert_eq!(lambda.len(), 1);
1258 assert!(
1259 (lambda[0] - 0.5 * 2.0_f64.sqrt()).abs() < 1e-3,
1260 "ball constraint dual={}",
1261 lambda[0]
1262 );
1263 }
1264
1265 /// min x0 s.t. (x0−3)² ≤ 1 → feasible x0 ∈ [2, 4], optimum x0 = 2.
1266 /// The constraint's linear (`−6x0`) and constant (`+9`) terms are folded
1267 /// into the nonlinear tree; the reformulation must recover `b_eff = 9 − 1`
1268 /// so the cone encodes `x0² − 6x0 + 8 ≤ 0`, not `x0² ≤ 1`.
1269 #[test]
1270 fn extract_and_solve_socp_folds_constraint_constant() {
1271 let con = Expr::Binary(
1272 BinOp::Pow,
1273 Box::new(Expr::Binary(
1274 BinOp::Sub,
1275 Box::new(Expr::Var(0)),
1276 Box::new(Expr::Const(3.0)),
1277 )),
1278 Box::new(Expr::Const(2.0)),
1279 );
1280 let prob = NlProblem {
1281 src: None,
1282 cse_bodies: Vec::new(),
1283 complementarity: Vec::new(),
1284 n: 1,
1285 m: 1,
1286 num_obj: 1,
1287 minimize: true,
1288 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1289 obj_linear: vec![(0, 1.0)],
1290 obj_constant: 0.0,
1291 con_nonlinear: vec![NlBody::Tree(con)],
1292 con_linear: vec![vec![]],
1293 x_l: vec![-2e19],
1294 x_u: vec![2e19],
1295 g_l: vec![-2e19],
1296 g_u: vec![1.0],
1297 x0: vec![0.0],
1298 lambda0: vec![0.0],
1299 suffixes: Default::default(),
1300 imported_funcs: Vec::new(),
1301 ampl_options: Vec::new(),
1302 nl_counts: None,
1303 n_discrete: None,
1304 var_names: Vec::new(),
1305 con_names: Vec::new(),
1306 };
1307 let (qp, _con_map, obj_const, cones) =
1308 extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1309 assert_eq!(obj_const, 0.0);
1310 assert_eq!(cones, vec![ConeSpec::SecondOrder(3)]); // rank 1 + 2.
1311
1312 let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
1313 assert_eq!(sol.status, QpStatus::Optimal);
1314 assert!((sol.x[0] - 2.0).abs() < 1e-5, "x0={}", sol.x[0]);
1315 }
1316
1317 /// Build `min −x_i − x_j s.t. x_i² + x_j² ≤ 1` over `n` variables, where
1318 /// `i` and `j` are neither low-numbered nor adjacent.
1319 fn wide_ball(n: usize, i: usize, j: usize) -> NlProblem {
1320 let sq = |v: usize| {
1321 Expr::Binary(
1322 BinOp::Pow,
1323 Box::new(Expr::Var(v)),
1324 Box::new(Expr::Const(2.0)),
1325 )
1326 };
1327 NlProblem {
1328 src: None,
1329 cse_bodies: Vec::new(),
1330 complementarity: Vec::new(),
1331 n,
1332 m: 1,
1333 num_obj: 1,
1334 minimize: true,
1335 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1336 obj_linear: vec![(i, -1.0), (j, -1.0)],
1337 obj_constant: 0.0,
1338 con_nonlinear: vec![NlBody::Tree(Expr::Binary(
1339 BinOp::Add,
1340 Box::new(sq(i)),
1341 Box::new(sq(j)),
1342 ))],
1343 con_linear: vec![vec![]],
1344 x_l: vec![-10.0; n],
1345 x_u: vec![10.0; n],
1346 g_l: vec![-2e19],
1347 g_u: vec![1.0],
1348 x0: vec![0.0; n],
1349 lambda0: vec![0.0],
1350 suffixes: Default::default(),
1351 imported_funcs: Vec::new(),
1352 ampl_options: Vec::new(),
1353 nl_counts: None,
1354 n_discrete: None,
1355 var_names: Vec::new(),
1356 con_names: Vec::new(),
1357 }
1358 }
1359
1360 /// The cone factor is built on the row's **support**, so its columns are
1361 /// support-local and must be scattered back through `support` on emission.
1362 /// If that scatter is dropped, a row touching `x11`/`x37` silently
1363 /// constrains `x0`/`x1` instead — a wrong answer, not a crash.
1364 #[test]
1365 fn socp_factor_columns_scatter_back_to_original_variables() {
1366 let prob = wide_ball(40, 11, 37);
1367 let (qp, _con_map, _obj_const, cones) =
1368 extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1369 assert_eq!(cones, vec![ConeSpec::SecondOrder(4)]); // rank 2 + 2.
1370
1371 // Every G entry in the two factor rows must sit in column 11 or 37.
1372 // Rows 0 and 1 are the `a`-rows (here empty: no linear part).
1373 let factor_cols: std::collections::BTreeSet<usize> =
1374 qp.g.iter().filter(|t| t.row >= 2).map(|t| t.col).collect();
1375 assert_eq!(
1376 factor_cols,
1377 [11usize, 37].into_iter().collect(),
1378 "factor rows must reference the row's own variables, got {factor_cols:?}"
1379 );
1380 }
1381
1382 /// The extractor must be sized by a row's support, not by the problem
1383 /// width. A two-variable quadratic row in a 50 000-variable problem needs
1384 /// kilobytes; sizing it `n×n` would ask for 20 GB and abort the process.
1385 #[test]
1386 fn socp_extraction_is_sized_by_support_not_problem_width() {
1387 let n = 50_000;
1388 let prob = wide_ball(n, 7, n - 3);
1389 let (qp, _con_map, _obj_const, cones) =
1390 extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1391 assert_eq!(cones, vec![ConeSpec::SecondOrder(4)]);
1392 // The cone contributes exactly two nonzeros per factor row.
1393 assert_eq!(qp.g.iter().filter(|t| t.row >= 2).count(), 2);
1394 }
1395
1396 /// The same scatter, but through the **dense** path: a cross term makes `Q`
1397 /// non-diagonal, so the row goes through the pivoted Cholesky and its
1398 /// support-local columns must be mapped back. Rank 1, so one factor row.
1399 #[test]
1400 fn socp_dense_factor_columns_scatter_back_to_original_variables() {
1401 // (x11 + x37)² ≤ 1 — off-diagonal Q, rank 1.
1402 let con = Expr::Binary(
1403 BinOp::Pow,
1404 Box::new(Expr::Binary(
1405 BinOp::Add,
1406 Box::new(Expr::Var(11)),
1407 Box::new(Expr::Var(37)),
1408 )),
1409 Box::new(Expr::Const(2.0)),
1410 );
1411 let mut prob = wide_ball(40, 11, 37);
1412 prob.con_nonlinear = vec![NlBody::Tree(con)];
1413
1414 let (qp, _con_map, _obj_const, cones) =
1415 extract_socp_with_map(&prob, BoundRelax::NONE).expect("extract");
1416 assert_eq!(cones, vec![ConeSpec::SecondOrder(3)], "rank 1 + 2");
1417 let factor_cols: std::collections::BTreeSet<usize> =
1418 qp.g.iter().filter(|t| t.row >= 2).map(|t| t.col).collect();
1419 assert_eq!(factor_cols, [11usize, 37].into_iter().collect());
1420 }
1421
1422 /// A diagonal `Q` must not be densified. `socp_factor_rows` returns one
1423 /// single-nonzero row per positive diagonal entry, so the factor is `O(k)`
1424 /// — this is what makes the very large diagonal QCQPs (`qssp180`,
1425 /// `nql180`) representable at all.
1426 #[test]
1427 fn diagonal_hessian_factors_in_linear_space() {
1428 let mut h = std::collections::BTreeMap::new();
1429 for i in 0..1000usize {
1430 h.insert((i, i), 4.0);
1431 }
1432 let rows = socp_factor_rows(&h);
1433 assert_eq!(rows.len(), 1000);
1434 assert!(
1435 rows.iter().all(|r| r.len() == 1),
1436 "a diagonal Q must give one nonzero per factor row"
1437 );
1438 for (k, r) in rows.iter().enumerate() {
1439 assert_eq!(r[0].0, k);
1440 assert!((r[0].1 - 2.0).abs() < 1e-12, "√4 = 2, got {}", r[0].1);
1441 }
1442 }
1443
1444 /// Both factor paths must satisfy the same contract, `Σ_k f_k f_kᵀ = Q`,
1445 /// and must agree on the rank. There are now two of them — an `O(k)`
1446 /// diagonal shortcut and the general pivoted Cholesky — so the contract is
1447 /// asserted directly rather than inferred from the shortcut's derivation.
1448 ///
1449 /// The near-zero diagonal entry is the rank agreement. Since gh #703 the
1450 /// rank test is relative to each pivot's *own* starting magnitude rather
1451 /// than to `max_diag`, so a diagonal matrix has no zero eigenvalues at
1452 /// all: `1e-20` on its own row is a genuine, tiny eigenvalue and both
1453 /// paths must **keep** it. What must not differ is which of them thinks
1454 /// so — a disagreement would build cones of different dimension for the
1455 /// same constraint.
1456 #[test]
1457 fn both_factor_paths_reconstruct_q_and_agree_on_rank() {
1458 let recon = |rows: &[Vec<(usize, f64)>]| {
1459 let mut q: std::collections::BTreeMap<(usize, usize), f64> = Default::default();
1460 for r in rows {
1461 for &(i, fi) in r {
1462 for &(j, fj) in r {
1463 *q.entry((i, j)).or_insert(0.0) += fi * fj;
1464 }
1465 }
1466 }
1467 q.retain(|_, v| v.abs() > 1e-12);
1468 q
1469 };
1470
1471 // Diagonal path, with one entry twenty orders of magnitude below the
1472 // largest. Deliberately *not* descending by index: the largest entry
1473 // sits on the highest variable, so a shortcut that emitted in index
1474 // order would produce the right cone with the rows in the wrong order.
1475 let diag: std::collections::BTreeMap<(usize, usize), f64> =
1476 [((3, 3), 2.0), ((8, 8), 9.0), ((9, 9), 1e-20)]
1477 .into_iter()
1478 .collect();
1479 let drows = socp_factor_rows(&diag);
1480 assert_eq!(
1481 drows.len(),
1482 3,
1483 "1e-20 on its own row is a small eigenvalue, not a missing one"
1484 );
1485
1486 // The shortcut must be *bit*-identical to the general path, not merely
1487 // close: a 2-ulp difference in one `G` entry visibly moved `qcqp_ball`'s
1488 // conic trajectory (17 → 12 iterations). Run the same matrix through
1489 // `psd_outer_factor` and compare the raw bits.
1490 let support = quad_support(&diag);
1491 let general = psd_outer_factor(dense_symmetric_on_support(&diag, &support), support.len());
1492 let general_vals: Vec<f64> = general
1493 .iter()
1494 .map(|f| f.iter().copied().find(|v| *v != 0.0).expect("one nonzero"))
1495 .collect();
1496 let short_vals: Vec<f64> = drows.iter().map(|r| r[0].1).collect();
1497 assert_eq!(
1498 short_vals.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1499 general_vals.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1500 "diagonal shortcut must reproduce psd_outer_factor bit for bit \
1501 (got {short_vals:?} vs {general_vals:?})"
1502 );
1503 assert_eq!(drows[0][0].0, 8, "largest diagonal pivots first");
1504 assert_eq!(drows[1][0].0, 3);
1505 assert_eq!(drows[2][0].0, 9, "then the smallest, still emitted");
1506 assert!(
1507 (drows[2][0].1 - 1e-10).abs() < 1e-22,
1508 "√1e-20 = 1e-10, got {}",
1509 drows[2][0].1
1510 );
1511 let dq = recon(&drows);
1512 // Two entries, not three: `recon` drops anything under `1e-12`, and
1513 // `(1e-10)² = 1e-20` is under it. The row is in the factor; its
1514 // contribution to `Q` is genuinely below what a reconstruction can
1515 // see, which is the whole reason the *pivot* test cannot be absolute.
1516 assert_eq!(dq.len(), 2);
1517 assert!((dq[&(3, 3)] - 2.0).abs() < 1e-12);
1518 assert!((dq[&(8, 8)] - 9.0).abs() < 1e-12);
1519
1520 // General path: same matrix plus a cross term, so the diagonal
1521 // shortcut no longer applies. Q = [[2,1],[1,9]] on {3,8} is positive
1522 // definite, so full rank 2.
1523 let mut coupled = diag.clone();
1524 coupled.insert((3, 8), 1.0);
1525 let grows = socp_factor_rows(&coupled);
1526 assert_eq!(grows.len(), 3, "2 from the coupled block, 1 from `1e-20`");
1527 let gq = recon(&grows);
1528 assert!((gq[&(3, 3)] - 2.0).abs() < 1e-10);
1529 assert!((gq[&(8, 8)] - 9.0).abs() < 1e-10);
1530 assert!((gq[&(3, 8)] - 1.0).abs() < 1e-10);
1531 assert!((gq[&(8, 3)] - 1.0).abs() < 1e-10);
1532 assert!(
1533 !gq.contains_key(&(9, 9)),
1534 "1e-20 is below what the reconstruction resolves"
1535 );
1536 }
1537
1538 /// `psd_outer_factor` recovers a rank-1 `Q = vvᵀ` with a single factor row
1539 /// (minimal cone), and reconstructs `Q` exactly.
1540 #[test]
1541 fn psd_outer_factor_is_rank_revealing() {
1542 // Q = [[1,2],[2,4]] = v vᵀ with v = (1,2): rank 1.
1543 let q = vec![1.0, 2.0, 2.0, 4.0];
1544 let rows = psd_outer_factor(q.clone(), 2);
1545 assert_eq!(rows.len(), 1, "rank-1 Q must give one factor row");
1546 // Reconstruct Σ f fᵀ and compare to Q.
1547 let mut recon = vec![0.0; 4];
1548 for f in &rows {
1549 for i in 0..2 {
1550 for j in 0..2 {
1551 recon[i * 2 + j] += f[i] * f[j];
1552 }
1553 }
1554 }
1555 for k in 0..4 {
1556 assert!((recon[k] - q[k]).abs() < 1e-9, "recon[{k}]={}", recon[k]);
1557 }
1558 }
1559
1560 /// **The rank of `Q` is a property of `Q`, not of the units its variables
1561 /// are measured in.** `psd_outer_factor` decides the dimension of the cone
1562 /// a QCQP row becomes, so if a change of units can change that number, the
1563 /// solver builds a different — smaller — feasible set for the same model
1564 /// and reports success on the answer to a different problem.
1565 ///
1566 /// That is exactly what gh #703 hit. The rank test used to be
1567 /// `1e-12 · max_diag`, one absolute cut for the whole matrix, which asks
1568 /// how a pivot compares to the *largest* entry rather than how far its own
1569 /// downdate has moved it. Rescaling the columns of `qcqp_columns` by
1570 /// `10^{-4}…10^{4}` spread `max_diag` over nineteen orders of magnitude and
1571 /// took the rank of a full-rank 24×24 row from 24 to **17** — seven real
1572 /// directions dropped, `SolveSucceeded`, a self-reported violation of
1573 /// `2.66e-15` against an actual one of `4.948e+01`, and an objective 10%
1574 /// off its well-conditioned twin.
1575 ///
1576 /// Diagonal congruence `Q → C Q C` with `C ≻ 0` diagonal is precisely a
1577 /// change of units, and it preserves rank exactly (Sylvester). So the test
1578 /// is: factor the same `Q` under a spread of column scalings and require
1579 /// the row count never to move.
1580 #[test]
1581 fn rank_does_not_depend_on_the_units_the_columns_are_measured_in() {
1582 // A 4×4 PSD matrix of exact rank 3: `Q = Σ_{k<3} v_k v_kᵀ` over three
1583 // independent vectors, so one direction is genuinely absent and the
1584 // factorization must find that too — an invariance test that only ever
1585 // returned `n` would be satisfied by a rank test that never fires.
1586 let vs = [
1587 [1.0, 2.0, 0.0, -1.0],
1588 [0.0, 1.0, 3.0, 1.0],
1589 [2.0, 0.0, 1.0, 4.0],
1590 ];
1591 let n = 4;
1592 let mut q = vec![0.0; n * n];
1593 for v in &vs {
1594 for i in 0..n {
1595 for j in 0..n {
1596 q[i * n + j] += v[i] * v[j];
1597 }
1598 }
1599 }
1600 assert_eq!(psd_outer_factor(q.clone(), n).len(), 3, "unscaled rank");
1601
1602 // `C = diag(10^e)`. The exponents run over the same range the
1603 // `qcqp_columns` fixtures use, and are deliberately *not* uniform: a
1604 // uniform scaling is a scalar multiple, which even an absolute
1605 // threshold survives.
1606 for spread in [1i32, 2, 3, 4, 6] {
1607 for sign in [1i32, -1] {
1608 let c: Vec<f64> = (0..n)
1609 .map(|i| 10f64.powi(sign * spread * (i as i32 - 1)))
1610 .collect();
1611 let mut scaled = vec![0.0; n * n];
1612 for i in 0..n {
1613 for j in 0..n {
1614 scaled[i * n + j] = c[i] * q[i * n + j] * c[j];
1615 }
1616 }
1617 let rank = psd_outer_factor(scaled, n).len();
1618 assert_eq!(
1619 rank, 3,
1620 "C Q C with C = diag(10^({sign}·{spread}·(i−1))) has the \
1621 same rank as Q; got {rank}"
1622 );
1623 }
1624 }
1625 }
1626
1627 /// The companion property, on the other side of the same threshold: a
1628 /// direction that *is* spanned must still be dropped, however the columns
1629 /// are scaled. Rank invariance alone would be satisfied by never cutting
1630 /// anything, which would hand every row a full-dimensional cone and cost
1631 /// the `qssp180`-class models their whole reason for taking the conic
1632 /// route.
1633 #[test]
1634 fn a_spanned_direction_is_dropped_at_every_column_scaling() {
1635 // Exactly rank 1: `Q = v vᵀ`, so three of four directions are spanned.
1636 let v = [1.0, 2.0, -3.0, 0.5];
1637 let n = 4;
1638 let mut q = vec![0.0; n * n];
1639 for i in 0..n {
1640 for j in 0..n {
1641 q[i * n + j] = v[i] * v[j];
1642 }
1643 }
1644 for e in [-8i32, -4, 0, 4, 8] {
1645 let c: Vec<f64> = (0..n).map(|i| 10f64.powi(e * (i as i32 - 1))).collect();
1646 let mut scaled = vec![0.0; n * n];
1647 for i in 0..n {
1648 for j in 0..n {
1649 scaled[i * n + j] = c[i] * q[i * n + j] * c[j];
1650 }
1651 }
1652 assert_eq!(
1653 psd_outer_factor(scaled, n).len(),
1654 1,
1655 "rank-1 Q stays rank 1 under diag(10^({e}·(i−1)))"
1656 );
1657 }
1658 }
1659
1660 /// A pivot the downdate has *not* spent must survive even when a spent one
1661 /// sorts above it. This is the case that made the fix more than a change of
1662 /// threshold: `√2 · √2 ≠ 2` in binary, so a factored-out column of size 2
1663 /// carries `4.4e-16` of roundoff on its diagonal afterwards, which is
1664 /// larger than a genuine `1e-20` eigenvalue sitting untouched on another
1665 /// column. Complete pivoting picks the roundoff first. With an absolute
1666 /// threshold that did not matter — both failed the same cut. With a
1667 /// relative one they disagree, so the loop has to *settle* the failing
1668 /// pivot and keep looking rather than stop at the first failure.
1669 #[test]
1670 fn a_live_pivot_below_a_spent_ones_roundoff_is_still_found() {
1671 let n = 2;
1672 // diag(2, 1e-20), the smaller entry twenty orders down.
1673 let rows = psd_outer_factor(vec![2.0, 0.0, 0.0, 1e-20], n);
1674 assert_eq!(
1675 rows.len(),
1676 2,
1677 "both diagonal entries are eigenvalues; got {rows:?}"
1678 );
1679 assert!(
1680 (rows[1][1] - 1e-10).abs() < 1e-22,
1681 "the surviving row is √1e-20, got {}",
1682 rows[1][1]
1683 );
1684 }
1685
1686 /// gh #1056: the rotated cone's two leading rows are `h₀ = −b/κ + κ` and
1687 /// `h₁ = −b/κ − κ`, and their whole job is `h₀² − h₁² = −4b`. With the
1688 /// historical `κ = 1`, at `b = −1e20` both round to `1e20` and the
1689 /// difference is `0` — the declared cone is empty. Balanced, it is exact,
1690 /// and `κ` is a power of two (so `a/κ` is exact) and `1` for `|b| ≤ 1`.
1691 #[test]
1692 fn the_rotated_cone_keeps_its_constant_at_any_magnitude() {
1693 for b in [-0.5, -1.0, 1.0, -4.0, -1e6, -1e20, -1e100, 3e40] {
1694 let k = super::rotated_cone_balance(b);
1695 assert!(k > 0.0 && k.log2().fract() == 0.0, "b = {b:e}: κ = {k}");
1696 if b.abs() <= 1.0 {
1697 assert_eq!(k, 1.0, "b = {b:e}");
1698 }
1699 let (h0, h1) = (-b / k + k, -b / k - k);
1700 let got = (h0 - h1) * (h0 + h1);
1701 assert!(
1702 ((got + 4.0 * b) / (4.0 * b)).abs() < 1e-12,
1703 "b = {b:e}, κ = {k}: h₀² − h₁² = {got:e}, want {:e}",
1704 -4.0 * b
1705 );
1706 }
1707 // What κ = 1 did.
1708 assert_eq!(1.0 - -1e20, -(1.0 + -1e20));
1709 }
1710
1711 /// gh #1041: `centre_soc` must split `a` into `Fᵀw + a_⊥` exactly, on
1712 /// both of its branches — the diagonal closed form and the normal
1713 /// equations — and leave `a_⊥` zero where `a` is in range.
1714 #[test]
1715 fn centre_soc_splits_a_into_range_and_complement() {
1716 let check = |f_rows: &[Vec<(usize, f64)>], a: &[(usize, f64)], n: usize| {
1717 let (a_perp, w) = centre_soc(f_rows, a);
1718 let mut back = vec![0.0; n];
1719 for (f, wk) in f_rows.iter().zip(&w) {
1720 for &(v, c) in f {
1721 back[v] += c * wk;
1722 }
1723 }
1724 for &(v, c) in &a_perp {
1725 back[v] += c;
1726 }
1727 let mut want = vec![0.0; n];
1728 for &(v, c) in a {
1729 want[v] += c;
1730 }
1731 for i in 0..n {
1732 assert!(
1733 (back[i] - want[i]).abs() <= 1e-9 * want[i].abs().max(1.0),
1734 "Fᵀw + a_⊥ ≠ a at {i}: {back:?} vs {want:?}"
1735 );
1736 }
1737 a_perp
1738 };
1739 // Diagonal: (x0 − 1000)² + (x1 − 5)² with a linear term in x2.
1740 let s2 = std::f64::consts::SQRT_2;
1741 let a_perp = check(
1742 &[vec![(0, s2)], vec![(1, s2)]],
1743 &[(0, -2000.0), (1, -10.0), (2, 3.0)],
1744 3,
1745 );
1746 assert_eq!(a_perp, vec![(2, 3.0)], "in-range part must leave exactly");
1747 // Coupled: Q = [[8, −4], [−4, 8]] (from (x+y)² + 3(x−y)², doubled),
1748 // a = Q·(1000, 1000) − e₂ so the residual is the x2 term only.
1749 let f = psd_outer_factor(vec![8.0, -4.0, -4.0, 8.0], 2)
1750 .into_iter()
1751 .map(|r| {
1752 r.into_iter()
1753 .enumerate()
1754 .filter(|&(_, v)| v != 0.0)
1755 .collect()
1756 })
1757 .collect::<Vec<Vec<(usize, f64)>>>();
1758 let a_perp = check(&f, &[(0, -4000.0), (1, -4000.0), (2, -1.0)], 3);
1759 assert_eq!(a_perp, vec![(2, -1.0)], "roundoff in range must be dropped");
1760 }
1761
1762 /// The fallback branch of `centre_soc`: factor rows that are linearly
1763 /// dependent make `FFᵀ` singular, and the block must come back exactly
1764 /// uncentred (`a_⊥ = a`, `w = 0`). Before the pivot test was relative,
1765 /// this case's second pivot came out at roundoff *above* zero and the
1766 /// solve returned `w = (48, −2048)` from a pivot of pure noise.
1767 #[test]
1768 fn centre_soc_falls_back_to_uncentred_on_a_singular_gram() {
1769 let row = vec![(0, 1.0), (1, 1.0)];
1770 let a = [(0, -2000.0), (1, -2000.0)];
1771 let (a_perp, w) = centre_soc(&[row.clone(), row], &a);
1772 assert_eq!(a_perp, a.to_vec());
1773 assert_eq!(w, vec![0.0, 0.0]);
1774 }
1775
1776 /// min (x0)^2 + (x1)^2 s.t. x0 + x1 = 2, no var bounds → (1,1), f*=2.
1777 #[test]
1778 fn extract_and_solve_equality_qp() {
1779 let prob = NlProblem {
1780 src: None,
1781 cse_bodies: Vec::new(),
1782 complementarity: Vec::new(),
1783 n: 2,
1784 m: 1,
1785 num_obj: 1,
1786 minimize: true,
1787 obj_nonlinear: NlBody::Tree(Expr::Binary(
1788 BinOp::Add,
1789 Box::new(pow2(0)),
1790 Box::new(pow2(1)),
1791 )),
1792 obj_linear: vec![],
1793 obj_constant: 0.0,
1794 con_nonlinear: vec![NlBody::Tree(Expr::Const(0.0))],
1795 con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
1796 x_l: vec![-2e19, -2e19],
1797 x_u: vec![2e19, 2e19],
1798 g_l: vec![2.0],
1799 g_u: vec![2.0],
1800 x0: vec![0.0, 0.0],
1801 lambda0: vec![0.0],
1802 suffixes: Default::default(),
1803 imported_funcs: Vec::new(),
1804 ampl_options: Vec::new(),
1805 nl_counts: None,
1806 n_discrete: None,
1807 var_names: Vec::new(),
1808 con_names: Vec::new(),
1809 };
1810 let (qp, con_map, obj_const) =
1811 extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
1812 // No constant anywhere in this objective.
1813 assert_eq!(obj_const, 0.0);
1814 // P = 2I → two diagonal entries.
1815 assert_eq!(qp.p_lower.len(), 2);
1816 assert_eq!(qp.m_eq(), 1);
1817 assert_eq!(qp.m_ineq(), 0);
1818
1819 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1820 assert_eq!(sol.status, QpStatus::Optimal);
1821 assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
1822 assert!((sol.x[1] - 1.0).abs() < 1e-6, "x1={}", sol.x[1]);
1823 assert!((sol.obj - 2.0).abs() < 1e-6, "obj={}", sol.obj);
1824
1825 // KKT for the equality: ∇f + y·∇g = 0 → 2x_i + y = 0 at x=1 → y=−2.
1826 let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
1827 assert_eq!(lambda.len(), 1);
1828 assert!(
1829 (lambda[0] - (-2.0)).abs() < 1e-5,
1830 "equality dual={}",
1831 lambda[0]
1832 );
1833 }
1834
1835 /// Regression for the dropped-linear-term bug: the objective `(x0-3)²`
1836 /// lives entirely in the nonlinear tree, so its linear part (`−6·x0`)
1837 /// must be folded into `c`. Without it the solve minimizes `x0²`
1838 /// (optimum 0) instead of `(x0-3)²` (optimum 3).
1839 #[test]
1840 fn extract_keeps_linear_term_from_nonlinear_tree() {
1841 // (x0 - 3)^2 = x0^2 - 6 x0 + 9, all in obj_nonlinear.
1842 let obj = Expr::Binary(
1843 BinOp::Pow,
1844 Box::new(Expr::Binary(
1845 BinOp::Sub,
1846 Box::new(Expr::Var(0)),
1847 Box::new(Expr::Const(3.0)),
1848 )),
1849 Box::new(Expr::Const(2.0)),
1850 );
1851 let prob = NlProblem {
1852 src: None,
1853 cse_bodies: Vec::new(),
1854 complementarity: Vec::new(),
1855 n: 1,
1856 m: 0,
1857 num_obj: 1,
1858 minimize: true,
1859 obj_nonlinear: NlBody::Tree(obj),
1860 obj_linear: vec![],
1861 obj_constant: 0.0,
1862 con_nonlinear: vec![],
1863 con_linear: vec![],
1864 x_l: vec![-2e19],
1865 x_u: vec![2e19],
1866 g_l: vec![],
1867 g_u: vec![],
1868 x0: vec![0.0],
1869 lambda0: vec![],
1870 suffixes: Default::default(),
1871 imported_funcs: Vec::new(),
1872 ampl_options: Vec::new(),
1873 nl_counts: None,
1874 n_discrete: None,
1875 var_names: Vec::new(),
1876 con_names: Vec::new(),
1877 };
1878 let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
1879 assert_eq!(qp.c.len(), 1);
1880 assert!(
1881 (qp.c[0] - (-6.0)).abs() < 1e-12,
1882 "c[0]={} — linear term from the nonlinear tree was dropped",
1883 qp.c[0]
1884 );
1885 // P = 2 (one diagonal entry).
1886 assert_eq!(qp.p_lower.len(), 1);
1887
1888 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1889 assert_eq!(sol.status, QpStatus::Optimal);
1890 assert!(
1891 (sol.x[0] - 3.0).abs() < 1e-6,
1892 "x0={} (expected 3)",
1893 sol.x[0]
1894 );
1895 }
1896
1897 /// Inequality dual sign/magnitude. min x0² s.t. x0 ≥ 1 (a one-sided
1898 /// inequality g_l=1, g_u=+inf). Optimum x0=1, active. The expected
1899 /// dual −2.0 is the value POUNCE's *NLP* path writes for this exact
1900 /// problem (verified by running `solver_selection=nlp` on the same
1901 /// `.nl`); recover_duals must match that reference convention.
1902 #[test]
1903 fn inequality_dual_recovered() {
1904 let prob = NlProblem {
1905 src: None,
1906 cse_bodies: Vec::new(),
1907 complementarity: Vec::new(),
1908 n: 1,
1909 m: 1,
1910 num_obj: 1,
1911 minimize: true,
1912 obj_nonlinear: NlBody::Tree(pow2(0)),
1913 obj_linear: vec![],
1914 obj_constant: 0.0,
1915 con_nonlinear: vec![NlBody::Tree(Expr::Const(0.0))],
1916 con_linear: vec![vec![(0, 1.0)]], // g(x) = x0
1917 x_l: vec![-2e19],
1918 x_u: vec![2e19],
1919 g_l: vec![1.0], // x0 ≥ 1
1920 g_u: vec![2e19],
1921 x0: vec![0.0],
1922 lambda0: vec![0.0],
1923 suffixes: Default::default(),
1924 imported_funcs: Vec::new(),
1925 ampl_options: Vec::new(),
1926 nl_counts: None,
1927 n_discrete: None,
1928 var_names: Vec::new(),
1929 con_names: Vec::new(),
1930 };
1931 let (qp, con_map, obj_const) =
1932 extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
1933 // This model puts its constant in the `obj_constant` field, not the
1934 // nonlinear tree, so the tree constant is 0 here.
1935 assert_eq!(obj_const, 0.0);
1936 // One inequality row (the lower bound row −x0 ≤ −1); no upper.
1937 assert_eq!(qp.m_ineq(), 1);
1938 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1939 assert_eq!(sol.status, QpStatus::Optimal);
1940 assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
1941 let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
1942 assert!((lambda[0] - (-2.0)).abs() < 1e-5, "ineq dual={}", lambda[0]);
1943 }
1944
1945 /// Regression (M11): a *constraint* whose linear and constant
1946 /// terms are folded into the nonlinear tree (not the `con_linear`
1947 /// section) must still reach `A`/`G`. AMPL/Pyomo emit this shape for
1948 /// rows the classifier admits as degree-≤1 (cancelled quadratics,
1949 /// defined variables): the whole `x0 − 3` lives in `con_nonlinear`
1950 /// and `con_linear[0]` is empty.
1951 ///
1952 /// min x0 s.t. x0 − 3 ≥ 0 (body in the nonlinear tree)
1953 ///
1954 /// True optimum: x0 = 3. The QP extractor used to build `A`/`G` from
1955 /// `con_linear` only — dropping the folded `+x0` *and* the `−3`
1956 /// shift, leaving a vacuous `0 ≤ 0` row, so `min x0` came out
1957 /// unbounded (or otherwise wrong) on the convex path.
1958 #[test]
1959 fn constraint_linear_terms_folded_in_tree_are_recovered() {
1960 // con body = x0 − 3, entirely in the nonlinear tree.
1961 let con = Expr::Binary(
1962 BinOp::Sub,
1963 Box::new(Expr::Var(0)),
1964 Box::new(Expr::Const(3.0)),
1965 );
1966 let prob = NlProblem {
1967 src: None,
1968 cse_bodies: Vec::new(),
1969 complementarity: Vec::new(),
1970 n: 1,
1971 m: 1,
1972 num_obj: 1,
1973 minimize: true,
1974 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
1975 obj_linear: vec![(0, 1.0)],
1976 obj_constant: 0.0,
1977 con_nonlinear: vec![NlBody::Tree(con)],
1978 con_linear: vec![vec![]], // the `+x0` lives in the TREE
1979 x_l: vec![-2e19],
1980 x_u: vec![2e19],
1981 g_l: vec![0.0], // x0 − 3 ≥ 0
1982 g_u: vec![2e19],
1983 x0: vec![0.0],
1984 lambda0: vec![0.0],
1985 suffixes: Default::default(),
1986 imported_funcs: Vec::new(),
1987 ampl_options: Vec::new(),
1988 nl_counts: None,
1989 n_discrete: None,
1990 var_names: Vec::new(),
1991 con_names: Vec::new(),
1992 };
1993 let (qp, con_map, _obj_const) =
1994 extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
1995 // One inequality row: −x0 ≤ −3 (the lower bound, constant-shifted).
1996 assert_eq!(qp.m_ineq(), 1);
1997 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1998 assert_eq!(sol.status, QpStatus::Optimal);
1999 assert!((sol.x[0] - 3.0).abs() < 1e-5, "x0={}", sol.x[0]);
2000 // Dual is recoverable and finite (the row carries a real coef now).
2001 let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
2002 assert_eq!(lambda.len(), 1);
2003 assert!(lambda[0].is_finite(), "dual={}", lambda[0]);
2004 }
2005
2006 /// Regression: a constant folded into the *nonlinear objective tree*
2007 /// (not the `obj_constant` field) must still reach the reported
2008 /// objective. This is the real `.nl` shape AMPL/Pyomo emit for
2009 /// `min (x0-3)^2` — the whole `x0^2 - 6 x0 + 9` lives in the nonlinear
2010 /// tree and `obj_constant == 0`. The convex path used to drop the `+9`
2011 /// and report an objective 9 too small (cf. HS35 in the benchmark
2012 /// comparison). The minimizer is x0 = 1 (upper bound binds), where the
2013 /// true objective is (1-3)^2 = 4.
2014 #[test]
2015 fn tree_embedded_objective_constant_is_recovered() {
2016 // (x0 - 3)^2 as a single nonlinear tree: Pow(Sub(x0, 3), 2).
2017 let obj = Expr::Binary(
2018 BinOp::Pow,
2019 Box::new(Expr::Binary(
2020 BinOp::Sub,
2021 Box::new(Expr::Var(0)),
2022 Box::new(Expr::Const(3.0)),
2023 )),
2024 Box::new(Expr::Const(2.0)),
2025 );
2026 let prob = NlProblem {
2027 src: None,
2028 cse_bodies: Vec::new(),
2029 complementarity: Vec::new(),
2030 n: 1,
2031 m: 0,
2032 num_obj: 1,
2033 minimize: true,
2034 obj_nonlinear: NlBody::Tree(obj),
2035 obj_linear: vec![],
2036 obj_constant: 0.0, // the +9 is in the TREE, not here
2037 con_nonlinear: vec![],
2038 con_linear: vec![],
2039 x_l: vec![0.0],
2040 x_u: vec![1.0],
2041 g_l: vec![],
2042 g_u: vec![],
2043 x0: vec![0.0],
2044 lambda0: vec![],
2045 suffixes: Default::default(),
2046 imported_funcs: Vec::new(),
2047 ampl_options: Vec::new(),
2048 nl_counts: None,
2049 n_discrete: None,
2050 var_names: Vec::new(),
2051 con_names: Vec::new(),
2052 };
2053 let (qp, _con_map, obj_const) =
2054 extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
2055 // The degree-0 term of (x0-3)^2 is +9, recovered from the tree.
2056 assert!((obj_const - 9.0).abs() < 1e-12, "tree constant={obj_const}");
2057 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
2058 assert_eq!(sol.status, QpStatus::Optimal);
2059 assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
2060 // Reported objective = (½xᵀPx + cᵀx) + obj_const must equal the true
2061 // (1-3)^2 = 4, not the constant-dropped −5.
2062 let reported = sol.obj + obj_const;
2063 assert!((reported - 4.0).abs() < 1e-5, "reported obj={reported}");
2064 }
2065
2066 /// Bound-constrained: min (x0-3)^2 = x0^2 - 6 x0 + 9, 0 ≤ x0 ≤ 1.
2067 /// Optimum x0 = 1 (upper bound binds). Here the constant 9 is carried
2068 /// in the `obj_constant` field (not the tree), so the extracted tree
2069 /// constant is 0 (asserted inside).
2070 #[test]
2071 fn extract_and_solve_bounded_qp() {
2072 let prob = NlProblem {
2073 src: None,
2074 cse_bodies: Vec::new(),
2075 complementarity: Vec::new(),
2076 n: 1,
2077 m: 0,
2078 num_obj: 1,
2079 minimize: true,
2080 obj_nonlinear: NlBody::Tree(pow2(0)),
2081 obj_linear: vec![(0, -6.0)],
2082 obj_constant: 9.0,
2083 con_nonlinear: vec![],
2084 con_linear: vec![],
2085 x_l: vec![0.0],
2086 x_u: vec![1.0],
2087 g_l: vec![],
2088 g_u: vec![],
2089 x0: vec![0.0],
2090 lambda0: vec![],
2091 suffixes: Default::default(),
2092 imported_funcs: Vec::new(),
2093 ampl_options: Vec::new(),
2094 nl_counts: None,
2095 n_discrete: None,
2096 var_names: Vec::new(),
2097 con_names: Vec::new(),
2098 };
2099 let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
2100 // The bounds are the box, not `G` rows.
2101 assert_eq!(qp.m_ineq(), 0);
2102 assert_eq!((qp.lb[0], qp.ub[0]), (0.0, 1.0));
2103 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
2104 assert_eq!(sol.status, QpStatus::Optimal);
2105 assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
2106 }
2107
2108 /// LP: min −x0 − x1, 0 ≤ x ≤ 1 → (1,1).
2109 #[test]
2110 fn extract_and_solve_lp() {
2111 let prob = NlProblem {
2112 src: None,
2113 cse_bodies: Vec::new(),
2114 complementarity: Vec::new(),
2115 n: 2,
2116 m: 0,
2117 num_obj: 1,
2118 minimize: true,
2119 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2120 obj_linear: vec![(0, -1.0), (1, -1.0)],
2121 obj_constant: 0.0,
2122 con_nonlinear: vec![],
2123 con_linear: vec![],
2124 x_l: vec![0.0, 0.0],
2125 x_u: vec![1.0, 1.0],
2126 g_l: vec![],
2127 g_u: vec![],
2128 x0: vec![0.0, 0.0],
2129 lambda0: vec![],
2130 suffixes: Default::default(),
2131 imported_funcs: Vec::new(),
2132 ampl_options: Vec::new(),
2133 nl_counts: None,
2134 n_discrete: None,
2135 var_names: Vec::new(),
2136 con_names: Vec::new(),
2137 };
2138 let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
2139 assert!(qp.p_lower.is_empty(), "LP has no Hessian");
2140 assert_eq!(qp.m_ineq(), 0, "bounds are the box, not `G` rows");
2141 assert_eq!(qp.lb, vec![0.0, 0.0]);
2142 assert_eq!(qp.ub, vec![1.0, 1.0]);
2143 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
2144 assert_eq!(sol.status, QpStatus::Optimal);
2145 assert!((sol.x[0] - 1.0).abs() < 1e-6);
2146 assert!((sol.x[1] - 1.0).abs() < 1e-6);
2147 }
2148
2149 /// maximize x0 s.t. 0 ≤ x0 ≤ 5 → x0 = 5. Tests sign flip on a
2150 /// maximize objective.
2151 #[test]
2152 fn extract_maximize_negates() {
2153 let prob = NlProblem {
2154 src: None,
2155 cse_bodies: Vec::new(),
2156 complementarity: Vec::new(),
2157 n: 1,
2158 m: 0,
2159 num_obj: 1,
2160 minimize: false,
2161 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2162 obj_linear: vec![(0, 1.0)],
2163 obj_constant: 0.0,
2164 con_nonlinear: vec![],
2165 con_linear: vec![],
2166 x_l: vec![0.0],
2167 x_u: vec![5.0],
2168 g_l: vec![],
2169 g_u: vec![],
2170 x0: vec![0.0],
2171 lambda0: vec![],
2172 suffixes: Default::default(),
2173 imported_funcs: Vec::new(),
2174 ampl_options: Vec::new(),
2175 nl_counts: None,
2176 n_discrete: None,
2177 var_names: Vec::new(),
2178 con_names: Vec::new(),
2179 };
2180 let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
2181 // minimize −x0.
2182 assert_eq!(qp.c[0], -1.0);
2183 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
2184 assert_eq!(sol.status, QpStatus::Optimal);
2185 assert!((sol.x[0] - 5.0).abs() < 1e-6, "x0={}", sol.x[0]);
2186 }
2187
2188 /// **gh #401.** A real bound past the *opposite* absent-bound sentinel is
2189 /// an ordinary bound, and must survive into `G`.
2190 ///
2191 /// `is_finite_bound` was `|v| < 1e19`, a symmetric magnitude test. An upper
2192 /// bound of `-5e20` failed it and the row `x_0 <= -5e20` never entered `G`,
2193 /// so the QP was solved over a strictly larger box — `min x_0` subject to
2194 /// nothing, which the IPM answers `Optimal` at a point the model excludes.
2195 #[test]
2196 fn variable_bound_past_the_opposite_sentinel_is_kept() {
2197 let prob = NlProblem {
2198 src: None,
2199 cse_bodies: Vec::new(),
2200 complementarity: Vec::new(),
2201 n: 1,
2202 m: 0,
2203 num_obj: 1,
2204 minimize: false, // maximize x0, so the -5e20 upper bound binds
2205 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2206 obj_linear: vec![(0, 1.0)],
2207 obj_constant: 0.0,
2208 con_nonlinear: vec![],
2209 con_linear: vec![],
2210 // No lower bound (`-1e21` is past the lower sentinel, so absent);
2211 // a real upper bound of `-5e20`, which is *not*.
2212 x_l: vec![-1e21],
2213 x_u: vec![-5e20],
2214 g_l: vec![],
2215 g_u: vec![],
2216 x0: vec![-7e20],
2217 lambda0: vec![],
2218 suffixes: Default::default(),
2219 imported_funcs: Vec::new(),
2220 ampl_options: Vec::new(),
2221 nl_counts: None,
2222 n_discrete: None,
2223 var_names: Vec::new(),
2224 con_names: Vec::new(),
2225 };
2226 let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
2227 assert_eq!(
2228 qp.ub[0], -5e20,
2229 "`x0 <= -5e20` is a real bound and must reach the box; the \
2230 symmetric |v| < 1e19 test dropped it, leaving an \
2231 unbounded-above box the model does not declare"
2232 );
2233 }
2234
2235 /// **gh #401.** A row with equal bounds past the sentinel used to vanish
2236 /// from the problem *entirely* — contributing nothing to `A` and nothing
2237 /// to `G`.
2238 ///
2239 /// Directionally, `g_l = g_u = -5e20` is not an equality at all: the lower
2240 /// bound is absent (it is past `-1e19`) and the upper bound is real, so the
2241 /// row is the one-sided `x0 + x1 <= -5e20`. The old code got there by a
2242 /// different route and lost it: `lo == hi && is_finite_bound(lo)` failed, so
2243 /// the row fell into the inequality branch — where `is_finite_bound(hi)` and
2244 /// `is_finite_bound(lo)` were false too, leaving `upper` and `lower` both
2245 /// `None`. Silently deleted.
2246 ///
2247 /// (Note there is no such thing as an equality row outside `±1e19` under
2248 /// this convention: an equality needs both bounds present, and the two
2249 /// presence tests only overlap inside the band.)
2250 #[test]
2251 fn a_row_with_equal_bounds_past_the_sentinel_does_not_vanish() {
2252 let prob = NlProblem {
2253 src: None,
2254 cse_bodies: Vec::new(),
2255 complementarity: Vec::new(),
2256 n: 2,
2257 m: 1,
2258 num_obj: 1,
2259 minimize: true,
2260 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2261 obj_linear: vec![(0, 1.0)],
2262 obj_constant: 0.0,
2263 con_nonlinear: vec![NlBody::Tree(Expr::Const(0.0))],
2264 con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
2265 x_l: vec![-2e19, -2e19],
2266 x_u: vec![2e19, 2e19],
2267 g_l: vec![-5e20],
2268 g_u: vec![-5e20],
2269 x0: vec![0.0, 0.0],
2270 lambda0: vec![0.0],
2271 suffixes: Default::default(),
2272 imported_funcs: Vec::new(),
2273 ampl_options: Vec::new(),
2274 nl_counts: None,
2275 n_discrete: None,
2276 var_names: Vec::new(),
2277 con_names: Vec::new(),
2278 };
2279 let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
2280 assert_eq!(
2281 qp.m_eq(),
2282 0,
2283 "the lower bound is absent, so this is no equality"
2284 );
2285 assert_eq!(
2286 qp.m_ineq(),
2287 1,
2288 "`x0 + x1 <= -5e20` is a real constraint and must reach G; it used \
2289 to disappear from the problem entirely"
2290 );
2291 assert_eq!(qp.h[0], -5e20);
2292 }
2293
2294 /// **gh #401.** The box is built with the *directional* presence test, so
2295 /// a bound past the opposite sentinel survives while a genuinely absent
2296 /// one becomes `∓∞`. Pins the case only the directional reading admits.
2297 ///
2298 /// This used to assert instead that `recover_bound_mults` walked the same
2299 /// `G`-row layout the builder emitted — a real hazard when the two agreed
2300 /// only by construction, and one that no longer exists: bounds are the
2301 /// box, and their multipliers come back in `z_lb`/`z_ub` with no layout
2302 /// to keep in step.
2303 #[test]
2304 fn the_box_is_built_with_the_directional_bound_test() {
2305 let prob = NlProblem {
2306 src: None,
2307 cse_bodies: Vec::new(),
2308 complementarity: Vec::new(),
2309 n: 2,
2310 m: 0,
2311 num_obj: 1,
2312 minimize: true,
2313 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2314 obj_linear: vec![(0, 1.0), (1, 1.0)],
2315 obj_constant: 0.0,
2316 con_nonlinear: vec![],
2317 con_linear: vec![],
2318 // x0: upper bound past the *lower* sentinel, no lower bound.
2319 // x1: an ordinary two-sided box.
2320 x_l: vec![-2e19, 0.0],
2321 x_u: vec![-5e20, 1.0],
2322 g_l: vec![],
2323 g_u: vec![],
2324 x0: vec![-6e20, 0.5],
2325 lambda0: vec![],
2326 suffixes: Default::default(),
2327 imported_funcs: Vec::new(),
2328 ampl_options: Vec::new(),
2329 nl_counts: None,
2330 n_discrete: None,
2331 var_names: Vec::new(),
2332 con_names: Vec::new(),
2333 };
2334 let qp = extract_qp(&prob, BoundRelax::NONE).expect("extract");
2335 assert_eq!(qp.m_ineq(), 0, "bounds are the box, not `G` rows");
2336 // x0: no lower bound; a real upper bound past the *lower* sentinel.
2337 assert_eq!(qp.lb[0], f64::NEG_INFINITY);
2338 assert_eq!(qp.ub[0], -5e20);
2339 // x1: an ordinary two-sided box, carried through unchanged.
2340 assert_eq!(qp.lb[1], 0.0);
2341 assert_eq!(qp.ub[1], 1.0);
2342 }
2343
2344 /// The bound multipliers a solve produces are handed back per variable,
2345 /// not decoded from a row layout. A short `z_lb`/`z_ub` (a driver that
2346 /// returned early without them) reads as "no bound active" rather than
2347 /// panicking on the index.
2348 #[test]
2349 fn bound_multipliers_come_back_per_variable() {
2350 let prob = NlProblem {
2351 src: None,
2352 cse_bodies: Vec::new(),
2353 complementarity: Vec::new(),
2354 n: 2,
2355 m: 0,
2356 num_obj: 1,
2357 minimize: true,
2358 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2359 obj_linear: vec![(0, 1.0), (1, 1.0)],
2360 obj_constant: 0.0,
2361 con_nonlinear: vec![],
2362 con_linear: vec![],
2363 x_l: vec![0.0, 0.0],
2364 x_u: vec![1.0, 1.0],
2365 g_l: vec![],
2366 g_u: vec![],
2367 x0: vec![0.5, 0.5],
2368 lambda0: vec![],
2369 suffixes: Default::default(),
2370 imported_funcs: Vec::new(),
2371 ampl_options: Vec::new(),
2372 nl_counts: None,
2373 n_discrete: None,
2374 var_names: Vec::new(),
2375 con_names: Vec::new(),
2376 };
2377 let sol = QpSolution {
2378 status: pounce_convex::QpStatus::Optimal,
2379 x: vec![0.0, 1.0],
2380 y: vec![],
2381 z: vec![],
2382 z_lb: vec![7.0, 0.0],
2383 z_ub: vec![0.0, 9.0],
2384 obj: 0.0,
2385 iters: 0,
2386 iterates: Vec::new(),
2387 };
2388 let (z_lb, z_ub) = recover_bound_mults(&prob, &sol);
2389 assert_eq!(z_lb, vec![7.0, 0.0]);
2390 assert_eq!(z_ub, vec![0.0, 9.0]);
2391
2392 let empty = QpSolution {
2393 z_lb: Vec::new(),
2394 z_ub: Vec::new(),
2395 ..sol
2396 };
2397 let (z_lb, z_ub) = recover_bound_mults(&prob, &empty);
2398 assert_eq!(z_lb, vec![0.0, 0.0]);
2399 assert_eq!(z_ub, vec![0.0, 0.0]);
2400 }
2401
2402 /// gh #744/#745: `bound_relax_factor` reaches the extracted model.
2403 ///
2404 /// One inequality row (`x0 + x1 >= 2`), one two-sided range row, one
2405 /// equality row, a bounded variable, a fixed variable, and a free
2406 /// variable — so every case the widening treats differently is present.
2407 fn relax_fixture() -> NlProblem {
2408 NlProblem {
2409 src: None,
2410 cse_bodies: Vec::new(),
2411 complementarity: Vec::new(),
2412 n: 3,
2413 m: 3,
2414 num_obj: 1,
2415 minimize: true,
2416 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
2417 obj_linear: vec![(0, 1.0)],
2418 obj_constant: 0.0,
2419 con_nonlinear: vec![
2420 NlBody::Tree(Expr::Const(0.0)),
2421 NlBody::Tree(Expr::Const(0.0)),
2422 NlBody::Tree(Expr::Const(0.0)),
2423 ],
2424 con_linear: vec![
2425 vec![(0, 1.0), (1, 1.0)],
2426 vec![(1, 1.0), (2, 1.0)],
2427 vec![(0, 1.0), (2, 1.0)],
2428 ],
2429 // x0 bounded above and below, x1 fixed, x2 free.
2430 x_l: vec![-4.0, 5.0, -2e19],
2431 x_u: vec![8.0, 5.0, 2e19],
2432 // row 0: >= 2 (lower only); row 1: -3 <= . <= 6 (range);
2433 // row 2: == 7 (equality).
2434 g_l: vec![2.0, -3.0, 7.0],
2435 g_u: vec![2e19, 6.0, 7.0],
2436 x0: vec![0.0, 0.0, 0.0],
2437 lambda0: vec![0.0, 0.0, 0.0],
2438 suffixes: Default::default(),
2439 imported_funcs: Vec::new(),
2440 ampl_options: Vec::new(),
2441 nl_counts: None,
2442 n_discrete: None,
2443 var_names: Vec::new(),
2444 con_names: Vec::new(),
2445 }
2446 }
2447
2448 #[test]
2449 fn bound_relax_none_leaves_the_declared_model_alone() {
2450 let prob = relax_fixture();
2451 let (qp, _, _) = extract_qp_with_map(&prob, BoundRelax::NONE).expect("extract");
2452 assert_eq!(qp.lb, vec![-4.0, 5.0, f64::NEG_INFINITY]);
2453 assert_eq!(qp.ub, vec![8.0, 5.0, f64::INFINITY]);
2454 assert_eq!(qp.b, vec![7.0]);
2455 // Rows, in emission order: row0's `>= 2` as `-x0-x1 <= -2`;
2456 // row1's `<= 6` then its `>= -3` as `<= 3`.
2457 assert_eq!(qp.h, vec![-2.0, 6.0, 3.0]);
2458 }
2459
2460 #[test]
2461 fn bound_relax_widens_inequality_rows_and_the_free_box_only() {
2462 let prob = relax_fixture();
2463 let relax = BoundRelax {
2464 factor: 1e-8,
2465 cap: 1e-4,
2466 };
2467 let (qp, _, _) = extract_qp_with_map(&prob, relax).expect("extract");
2468
2469 // Variable box: upstream's absolute formula `min(f*max(|b|,1), cap)`.
2470 // x0's bounds widen outward; x1 is *fixed* and must not move (upstream
2471 // removes fixed variables before `relax_bounds` runs); x2 is free.
2472 assert!((qp.lb[0] - (-4.0 - 4e-8)).abs() < 1e-18);
2473 assert!((qp.ub[0] - (8.0 + 8e-8)).abs() < 1e-18);
2474 assert_eq!(qp.lb[1], 5.0);
2475 assert_eq!(qp.ub[1], 5.0);
2476 assert_eq!(qp.lb[2], f64::NEG_INFINITY);
2477 assert_eq!(qp.ub[2], f64::INFINITY);
2478
2479 // Equality rows are never relaxed — upstream keeps them in `c(x) = 0`,
2480 // which `relax_bounds` does not touch.
2481 assert_eq!(qp.b, vec![7.0]);
2482
2483 // Inequality rows use the scale-relative width `min(f, cap)*|b|`.
2484 // `x0+x1 >= 2` → `-x0-x1 <= -(2 - 2e-8)`.
2485 assert!((qp.h[0] - -(2.0 - 2e-8)).abs() < 1e-18);
2486 // `. <= 6` → `<= 6 + 6e-8`; `. >= -3` → `<= 3 + 3e-8`.
2487 assert!((qp.h[1] - (6.0 + 6e-8)).abs() < 1e-18);
2488 assert!((qp.h[2] - (3.0 + 3e-8)).abs() < 1e-18);
2489 }
2490
2491 #[test]
2492 fn bound_relax_caps_the_widening_and_floors_a_zero_row_bound() {
2493 let mut prob = relax_fixture();
2494 // A huge row bound: the relative width `min(f, cap)*|b|` would be
2495 // enormous without the `min` against `cap` in the *factor*.
2496 prob.g_l[0] = 0.0; // declared-zero bound: no scale, absolute width.
2497 let relax = BoundRelax {
2498 factor: 1e-2,
2499 cap: 1e-4,
2500 };
2501 let (qp, _, _) = extract_qp_with_map(&prob, relax).expect("extract");
2502 // Zero bound → width is `min(1e-2, 1e-4) * 1 = 1e-4`.
2503 assert!((qp.h[0] - 1e-4).abs() < 1e-18, "{}", qp.h[0]);
2504 // Variable box is capped by `cap` outright: `1e-2*max(4,1) = 4e-2`,
2505 // capped to `1e-4`.
2506 assert!((qp.lb[0] - (-4.0 - 1e-4)).abs() < 1e-18);
2507 }
2508
2509 /// An empty declared set must survive extraction empty. Relaxation runs
2510 /// *after* upstream's consistency check, and the emptiness screens on the
2511 /// convex side read the extracted `lb`/`ub` and row pairs — so widening a
2512 /// crossing narrower than the relaxation would silently make an
2513 /// inconsistent model solvable (gh #491, gh #744).
2514 #[test]
2515 fn bound_relax_does_not_close_a_crossed_box_or_a_crossed_row() {
2516 let mut prob = relax_fixture();
2517 // x0's box crossed by 1e-8, narrower than the 2*4e-8 it would widen by.
2518 prob.x_l[0] = 0.0;
2519 prob.x_u[0] = -1e-8;
2520 // Row 1 crossed by 1e-8 too: `1e-8 <= x1 + x2 <= 0`.
2521 prob.g_l[1] = 1e-8;
2522 prob.g_u[1] = 0.0;
2523 let relax = BoundRelax {
2524 factor: 1e-8,
2525 cap: 1e-4,
2526 };
2527 let (qp, _, _) = extract_qp_with_map(&prob, relax).expect("extract");
2528 assert_eq!(qp.lb[0], 0.0);
2529 assert_eq!(qp.ub[0], -1e-8);
2530 // Row 1's pair: `<= 0` and `>= 1e-8` (as `<= -1e-8`), both verbatim.
2531 assert_eq!(qp.h[1], 0.0);
2532 assert_eq!(qp.h[2], -1e-8);
2533 // The uncrossed row 0 is still widened.
2534 assert!((qp.h[0] - -(2.0 - 2e-8)).abs() < 1e-18);
2535 }
2536}