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, incl. finite var bounds)
12//! ```
13//!
14//! Mapping from the `.nl` representation:
15//! - **Objective.** `P` is the Hessian of the (degree-≤2) objective —
16//! recovered with the same `analyze_quadratic` the classifier uses, so
17//! `P` here is exactly the matrix whose definiteness was tested. `c`
18//! is the objective's linear part. A `maximize` objective is negated
19//! into a minimization.
20//! - **Constraints.** Each row has a linear part and bounds `g_l ≤ row ≤
21//! g_u`. An equality (`g_l == g_u`) becomes a row of `A`; a one- or
22//! two-sided inequality becomes one or two rows of `G` (`row ≤ g_u`
23//! and/or `−row ≤ −g_l`).
24//! - **Variable bounds.** Finite `x_l`/`x_u` become `G` rows
25//! (`−x_i ≤ −x_l`, `x_i ≤ x_u`); the `.nl` "infinity" sentinel
26//! (`|v| ≥ 1e19`) is treated as no bound.
27
28use crate::dispatch::analyze_quadratic_full;
29use crate::nl_reader::NlProblem;
30use pounce_convex::{ConeSpec, QpProblem, Triplet};
31
32/// The `.nl` infinity sentinel: AMPL writes ±1e20-ish for "no bound";
33/// upstream Ipopt treats anything with magnitude ≥ 1e19 as infinite.
34const NL_INF: f64 = 1e19;
35
36fn is_finite_bound(v: f64) -> bool {
37 v.abs() < NL_INF
38}
39
40/// Convert a classified LP/convex-QP `NlProblem` into `QpProblem`
41/// standard form. Returns `None` if the objective is not actually a
42/// degree-≤2 polynomial (should not happen for a problem the classifier
43/// routed here, but the conversion is total and falls back gracefully).
44pub fn extract_qp(prob: &NlProblem) -> Option<QpProblem> {
45 Some(extract_qp_with_map(prob)?.0) // drops con_map + reporting constant
46}
47
48/// Where each `.nl` constraint's rows landed in the standard-form QP, so
49/// the QP's multipliers can be mapped back to a per-`.nl`-constraint
50/// dual for the `.sol`. One entry per original constraint, in order.
51#[derive(Debug, Clone)]
52pub enum ConRowMap {
53 /// Equality constraint → row `a_row` of `A` (multiplier `y[a_row]`).
54 Eq { a_row: usize },
55 /// Inequality / range constraint → up to two rows of `G`: the
56 /// `row ≤ g_u` upper bound and/or the `−row ≤ −g_l` lower bound
57 /// (multipliers `z[..]`, each ≥ 0).
58 Ineq {
59 upper: Option<usize>,
60 lower: Option<usize>,
61 },
62}
63
64/// Extract the QP, the constraint→row provenance map, and the objective
65/// constant folded into the nonlinear tree (see below), together.
66///
67/// The third return value is the **degree-0 term of the nonlinear
68/// objective** (e.g. the `+9` of `(x₀−3)²` that AMPL/Pyomo emit inside the
69/// nonlinear tree rather than in `NlProblem::obj_constant`). The QP itself
70/// ignores it — it does not move the minimizer — but the caller must add
71/// it to the *reported* objective so the convex solve agrees with the NLP
72/// path. It is returned in the problem's natural (user) sense, *not*
73/// multiplied by the maximize/minimize `sign`.
74pub fn extract_qp_with_map(prob: &NlProblem) -> Option<(QpProblem, Vec<ConRowMap>, f64)> {
75 let n = prob.n;
76 let sign = if prob.minimize { 1.0 } else { -1.0 };
77
78 // --- objective Hessian P (lower triangle) + nonlinear-tree linear part
79 // + nonlinear-tree constant (degree-0 term, for reporting only) ---
80 let (hess, obj_nl_linear, obj_nl_constant) = analyze_quadratic_full(&prob.obj_nonlinear, n)?;
81 let mut p_lower: Vec<Triplet> = Vec::with_capacity(hess.len());
82 for ((i, j), v) in &hess {
83 // analyze_quadratic returns (i ≤ j) upper-ish keys; store as
84 // lower triangle (row ≥ col) for the solver.
85 let (row, col) = if i >= j { (*i, *j) } else { (*j, *i) };
86 p_lower.push(Triplet::new(row, col, sign * v));
87 }
88
89 // --- objective linear term c ---
90 // Two disjoint sources, exactly as the NLP path's eval_f sums them:
91 // the `.nl` linear section (`obj_linear`) and the degree-1 terms AMPL
92 // kept inside the nonlinear objective tree (e.g. the `−6·x₀` of
93 // `(x₀−3)²`). Dropping the latter silently solves the wrong objective.
94 let mut c = vec![0.0; n];
95 for (var, coef) in &prob.obj_linear {
96 c[*var] += sign * coef;
97 }
98 for (var, coef) in &obj_nl_linear {
99 c[*var] += sign * coef;
100 }
101
102 // --- constraints: equalities → A x = b, inequalities → G x ≤ h ---
103 let mut a: Vec<Triplet> = Vec::new();
104 let mut b: Vec<f64> = Vec::new();
105 let mut g: Vec<Triplet> = Vec::new();
106 let mut h: Vec<f64> = Vec::new();
107 let mut con_map: Vec<ConRowMap> = Vec::with_capacity(prob.con_linear.len());
108
109 for (row, lin) in prob.con_linear.iter().enumerate() {
110 let lo = prob.g_l[row];
111 let hi = prob.g_u[row];
112
113 // Combine the `.nl` linear section with any degree-≤1 terms AMPL
114 // folded into the (here empty-Hessian) nonlinear tree — the
115 // classifier admits constraint rows whose nonlinear expression
116 // reduces to degree ≤ 1 (`dispatch.rs`), e.g. cancelled
117 // quadratics or defined variables, and those linear/constant
118 // terms live in `con_nonlinear`, not `con_linear`. Dropping them
119 // silently solves the wrong constraint. The folded constant
120 // shifts the bounds: `g_l ≤ row + k ≤ g_u ⇔ g_l−k ≤ row ≤ g_u−k`.
121 // This mirrors the SOCP extractor's linear-constraint handling.
122 let (nl_lin, const_shift) = analyze_quadratic_full(&prob.con_nonlinear[row], n)
123 .map(|(_, l, k)| (l, k))
124 .unwrap_or_default();
125 let mut coef = vec![0.0; n];
126 for (var, v) in lin {
127 coef[*var] += *v;
128 }
129 for (var, v) in &nl_lin {
130 coef[*var] += *v;
131 }
132 let nonzeros = || coef.iter().enumerate().filter(|(_, v)| **v != 0.0);
133
134 if lo == hi && is_finite_bound(lo) {
135 // Equality row.
136 let eq_row = next_row(&b);
137 for (var, v) in nonzeros() {
138 a.push(Triplet::new(eq_row, var, *v));
139 }
140 b.push(lo - const_shift);
141 con_map.push(ConRowMap::Eq { a_row: eq_row });
142 } else {
143 // Upper bound: row ≤ hi.
144 let upper = if is_finite_bound(hi) {
145 let gr = next_row(&h);
146 for (var, v) in nonzeros() {
147 g.push(Triplet::new(gr, var, *v));
148 }
149 h.push(hi - const_shift);
150 Some(gr)
151 } else {
152 None
153 };
154 // Lower bound: row ≥ lo ⇔ −row ≤ −lo.
155 let lower = if is_finite_bound(lo) {
156 let gr = next_row(&h);
157 for (var, v) in nonzeros() {
158 g.push(Triplet::new(gr, var, -*v));
159 }
160 h.push(-(lo - const_shift));
161 Some(gr)
162 } else {
163 None
164 };
165 con_map.push(ConRowMap::Ineq { upper, lower });
166 }
167 }
168
169 // --- variable bounds as G rows (not part of the constraint map) ---
170 for i in 0..n {
171 let xl = prob.x_l[i];
172 let xu = prob.x_u[i];
173 if is_finite_bound(xu) {
174 let gr = next_row(&h);
175 g.push(Triplet::new(gr, i, 1.0)); // x_i ≤ xu
176 h.push(xu);
177 }
178 if is_finite_bound(xl) {
179 let gr = next_row(&h);
180 g.push(Triplet::new(gr, i, -1.0)); // −x_i ≤ −xl
181 h.push(-xl);
182 }
183 }
184
185 Some((
186 QpProblem {
187 n,
188 p_lower,
189 c,
190 a,
191 b,
192 g,
193 h,
194 // Variable bounds are currently emitted as `G` rows (see the
195 // bound-handling above), so the explicit box is left empty.
196 lb: Vec::new(),
197 ub: Vec::new(),
198 },
199 con_map,
200 obj_nl_constant,
201 ))
202}
203
204/// Map the QP solver's multipliers `(y, z)` back to a per-`.nl`-
205/// constraint dual vector (length `prob.m`), in the AMPL `.sol`
206/// convention used by POUNCE's NLP path.
207///
208/// The QP solver enforces stationarity `∇f + Aᵀy + Gᵀz = 0` with
209/// `z ≥ 0`, where each inequality `.nl` row contributes a `row ≤ g_u`
210/// (`+row`) and/or `−row ≤ −g_l` (`−row`) `G` row. The per-constraint
211/// `.nl`/Ipopt multiplier `λ` is recovered as:
212/// - equality: `λ = sign · y[a_row]`;
213/// - inequality: `λ = sign · (z_upper − z_lower)` — at most one of the
214/// two bound rows is active at a solution.
215///
216/// The inequality sign (`z_upper − z_lower`, *not* `z_lower − z_upper`)
217/// is fixed to match POUNCE's NLP path, which is the reference for what
218/// a POUNCE `.sol` carries; this is verified empirically against the NLP
219/// solve in the crate tests. `sign` undoes the maximize→minimize
220/// negation so the reported dual is in the user's original sense.
221pub fn recover_duals(prob: &NlProblem, con_map: &[ConRowMap], y: &[f64], z: &[f64]) -> Vec<f64> {
222 let sign = if prob.minimize { 1.0 } else { -1.0 };
223 con_map
224 .iter()
225 .map(|m| match m {
226 ConRowMap::Eq { a_row } => sign * y[*a_row],
227 ConRowMap::Ineq { upper, lower } => {
228 let zu = upper.map(|r| z[r]).unwrap_or(0.0);
229 let zl = lower.map(|r| z[r]).unwrap_or(0.0);
230 sign * (zu - zl)
231 }
232 })
233 .collect()
234}
235
236/// The next 0-based row index for a constraint block keyed by its RHS
237/// vector's current length.
238fn next_row(rhs: &[f64]) -> usize {
239 rhs.len()
240}
241
242// ===========================================================================
243// QCQP → SOCP extraction
244// ===========================================================================
245
246/// Where each `.nl` constraint landed in the standard-form **conic** program,
247/// so the cone multipliers can be mapped back to a per-`.nl`-constraint dual.
248/// One entry per original constraint, in order. (Analogue of [`ConRowMap`] for
249/// the SOCP path produced by [`extract_socp_with_map`].)
250#[derive(Debug, Clone)]
251pub enum ConSocpMap {
252 /// Linear equality → row `a_row` of `A` (multiplier `y[a_row]`).
253 Eq { a_row: usize },
254 /// Linear inequality / range → up to two rows of the nonnegative `G`
255 /// block (`row ≤ g_u` and/or `−row ≤ −g_l`), multipliers `z[..] ≥ 0`.
256 Ineq {
257 upper: Option<usize>,
258 lower: Option<usize>,
259 },
260 /// Convex quadratic inequality `g(x) ≤ g_u`, reformulated to one
261 /// second-order cone. The first two cone rows both carry the linear
262 /// coefficient vector `a = ∇(linear part)`, so the original constraint
263 /// multiplier is recovered as `z[r0] + z[r1]` (see
264 /// [`recover_socp_duals`]).
265 Quad { z_row0: usize, z_row1: usize },
266}
267
268/// A deferred second-order-cone block, built after the nonnegative `G` rows
269/// are known so the cones partition `G` in row order (nonneg block first,
270/// then the SOCs).
271struct SocBlock {
272 /// Index in `con_map` of the originating constraint, to patch with the
273 /// final cone-row indices once they are assigned.
274 con_idx: usize,
275 /// Linear coefficient vector `a` of the constraint (length `n`).
276 a: Vec<f64>,
277 /// `b_eff = (nonlinear constant) − g_u`, the constraint's degree-0 term
278 /// after moving the upper bound to the right: `½xᵀQx + aᵀx + b_eff ≤ 0`.
279 b_eff: f64,
280 /// Rows of the factor `F` (each length `n`) with `FᵀF = Q`; `‖Fx‖² = xᵀQx`.
281 f_rows: Vec<Vec<f64>>,
282}
283
284/// Convert a classified **convex QCQP** `NlProblem` into the conic standard
285/// form the SOCP IPM consumes:
286///
287/// ```text
288/// minimize ½ xᵀP x + cᵀx
289/// subject to A x = b
290/// h − G x ∈ K (K = nonneg orthant × second-order cones)
291/// ```
292///
293/// Returns `(QpProblem, con_map, obj_nl_constant, cones)`:
294/// - the objective `P`/`c` exactly as the LP/QP path builds them;
295/// - linear equalities in `A`/`b`; linear inequalities and finite variable
296/// bounds as a leading **nonnegative** `G` block; and each convex quadratic
297/// inequality `g(x) ≤ g_u` as one **second-order cone** block appended
298/// after it (so `cones` covers the `G` rows in order);
299/// - `con_map` mapping each original constraint to its rows for dual recovery;
300/// - `obj_nl_constant`, the objective's folded degree-0 term (added back to the
301/// reported value, exactly as in [`extract_qp_with_map`]).
302///
303/// `None` if the objective is not degree-≤2 (should not happen for a problem
304/// the classifier routed here). The reformulation of a convex quadratic
305/// `½xᵀQx + aᵀx + b_eff ≤ 0` (with `Q = FᵀF ⪰ 0`) is the standard rotated→
306/// standard SOC: writing `s = −(aᵀx + b_eff)`, the cone slack
307/// `(s+1, s−1, √2·Fx)` lies in the second-order cone iff `‖Fx‖² ≤ 2s`, i.e.
308/// iff the original constraint holds.
309pub fn extract_socp_with_map(
310 prob: &NlProblem,
311) -> Option<(QpProblem, Vec<ConSocpMap>, f64, Vec<ConeSpec>)> {
312 let n = prob.n;
313 let sign = if prob.minimize { 1.0 } else { -1.0 };
314
315 // --- objective P (lower triangle) + folded linear / constant terms ---
316 let (hess, obj_nl_linear, obj_nl_constant) = analyze_quadratic_full(&prob.obj_nonlinear, n)?;
317 let mut p_lower: Vec<Triplet> = Vec::with_capacity(hess.len());
318 for ((i, j), v) in &hess {
319 let (row, col) = if i >= j { (*i, *j) } else { (*j, *i) };
320 p_lower.push(Triplet::new(row, col, sign * v));
321 }
322 let mut c = vec![0.0; n];
323 for (var, coef) in &prob.obj_linear {
324 c[*var] += sign * coef;
325 }
326 for (var, coef) in &obj_nl_linear {
327 c[*var] += sign * coef;
328 }
329
330 // --- constraints: equalities → A; linear ineqs → nonneg G block;
331 // convex quadratics → deferred SOC blocks (added after the nonneg
332 // rows so the cones partition G in row order) ---
333 let mut a: Vec<Triplet> = Vec::new();
334 let mut b: Vec<f64> = Vec::new();
335 let mut g: Vec<Triplet> = Vec::new();
336 let mut h: Vec<f64> = Vec::new();
337 let mut con_map: Vec<ConSocpMap> = Vec::with_capacity(prob.m);
338 let mut soc_blocks: Vec<SocBlock> = Vec::new();
339
340 for (row, lin) in prob.con_linear.iter().enumerate() {
341 let lo = prob.g_l[row];
342 let hi = prob.g_u[row];
343 let nl = &prob.con_nonlinear[row];
344 let quad = analyze_quadratic_full(nl, n);
345 let is_quadratic = matches!(&quad, Some((hmap, _, _)) if !hmap.is_empty());
346
347 if is_quadratic {
348 // Convex quadratic inequality `g(x) ≤ g_u` (the classifier
349 // guarantees an upper-only bound with PSD Hessian). Build the
350 // factor F (FᵀF = Q) and defer the SOC rows.
351 let (hmap, nl_lin, nl_const) = quad.expect("checked above");
352 // Full linear coefficient vector a = linear-section + folded
353 // nonlinear-tree linear part.
354 let mut a_vec = vec![0.0; n];
355 for (var, coef) in lin {
356 a_vec[*var] += *coef;
357 }
358 for (var, coef) in &nl_lin {
359 a_vec[*var] += *coef;
360 }
361 let dense = dense_symmetric(&hmap, n);
362 let f_rows = psd_outer_factor(dense, n);
363 let con_idx = con_map.len();
364 con_map.push(ConSocpMap::Quad {
365 z_row0: 0,
366 z_row1: 0,
367 }); // patched in the SOC pass below
368 soc_blocks.push(SocBlock {
369 con_idx,
370 a: a_vec,
371 b_eff: nl_const - hi,
372 f_rows,
373 });
374 continue;
375 }
376
377 // Linear constraint. Combine the `.nl` linear section with any
378 // degree-≤1 terms AMPL folded into the (here empty-Hessian)
379 // nonlinear tree, and shift the bounds by the folded constant.
380 let (nl_lin, const_shift) = quad.map(|(_, l, k)| (l, k)).unwrap_or_default();
381 let mut coef = vec![0.0; n];
382 for (var, v) in lin {
383 coef[*var] += *v;
384 }
385 for (var, v) in &nl_lin {
386 coef[*var] += *v;
387 }
388 let nonzeros = || coef.iter().enumerate().filter(|(_, v)| **v != 0.0);
389 if lo == hi && is_finite_bound(lo) {
390 let eq_row = next_row(&b);
391 for (var, v) in nonzeros() {
392 a.push(Triplet::new(eq_row, var, *v));
393 }
394 b.push(lo - const_shift);
395 con_map.push(ConSocpMap::Eq { a_row: eq_row });
396 } else {
397 let upper = if is_finite_bound(hi) {
398 let gr = next_row(&h);
399 for (var, v) in nonzeros() {
400 g.push(Triplet::new(gr, var, *v));
401 }
402 h.push(hi - const_shift);
403 Some(gr)
404 } else {
405 None
406 };
407 let lower = if is_finite_bound(lo) {
408 let gr = next_row(&h);
409 for (var, v) in nonzeros() {
410 g.push(Triplet::new(gr, var, -*v));
411 }
412 h.push(-(lo - const_shift));
413 Some(gr)
414 } else {
415 None
416 };
417 con_map.push(ConSocpMap::Ineq { upper, lower });
418 }
419 }
420
421 // --- variable bounds as nonnegative G rows (not in the constraint map) ---
422 for i in 0..n {
423 let xl = prob.x_l[i];
424 let xu = prob.x_u[i];
425 if is_finite_bound(xu) {
426 let gr = next_row(&h);
427 g.push(Triplet::new(gr, i, 1.0));
428 h.push(xu);
429 }
430 if is_finite_bound(xl) {
431 let gr = next_row(&h);
432 g.push(Triplet::new(gr, i, -1.0));
433 h.push(-xl);
434 }
435 }
436
437 // The nonnegative block is every G row built so far. The cones list must
438 // cover G in row order: this orthant block, then one SOC per quadratic.
439 let num_nonneg = h.len();
440 let mut cones: Vec<ConeSpec> = Vec::with_capacity(1 + soc_blocks.len());
441 if num_nonneg > 0 {
442 cones.push(ConeSpec::Nonneg(num_nonneg));
443 }
444
445 // --- emit the deferred second-order cones ---
446 for blk in soc_blocks {
447 let r = blk.f_rows.len();
448 let dim = r + 2;
449 let row0 = next_row(&h);
450 // s0 = (1 − b_eff) − aᵀx → G row = a, h = 1 − b_eff.
451 for (var, &coef) in blk.a.iter().enumerate() {
452 if coef != 0.0 {
453 g.push(Triplet::new(row0, var, coef));
454 }
455 }
456 h.push(1.0 - blk.b_eff);
457 let row1 = next_row(&h);
458 // s1 = −(1 + b_eff) − aᵀx → G row = a, h = −(1 + b_eff).
459 for (var, &coef) in blk.a.iter().enumerate() {
460 if coef != 0.0 {
461 g.push(Triplet::new(row1, var, coef));
462 }
463 }
464 h.push(-(1.0 + blk.b_eff));
465 // s_{2+k} = √2·(Fx)_k → G row = −√2·F_k, h = 0.
466 let sqrt2 = std::f64::consts::SQRT_2;
467 for f in &blk.f_rows {
468 let gr = next_row(&h);
469 for (var, &fv) in f.iter().enumerate() {
470 if fv != 0.0 {
471 g.push(Triplet::new(gr, var, -sqrt2 * fv));
472 }
473 }
474 h.push(0.0);
475 }
476 cones.push(ConeSpec::SecondOrder(dim));
477 con_map[blk.con_idx] = ConSocpMap::Quad {
478 z_row0: row0,
479 z_row1: row1,
480 };
481 }
482
483 Some((
484 QpProblem {
485 n,
486 p_lower,
487 c,
488 a,
489 b,
490 g,
491 h,
492 lb: Vec::new(),
493 ub: Vec::new(),
494 },
495 con_map,
496 obj_nl_constant,
497 cones,
498 ))
499}
500
501/// Map the SOCP solver's multipliers `(y, z)` back to a per-`.nl`-constraint
502/// dual vector (length `prob.m`), in POUNCE's NLP-path `.sol` convention.
503///
504/// Linear rows reuse the QP-path recovery (`y[a_row]` for an equality;
505/// `z_upper − z_lower` for an inequality). For a convex quadratic
506/// `g(x) ≤ g_u` reformulated to a second-order cone, the constraint
507/// multiplier is recovered as the sum of the two cone duals on the rows
508/// carrying the linear coefficient vector `a`: `λ = z[r0] + z[r1]`. (At a
509/// KKT point stationarity reads `λ(∇g) = (z[r0]+z[r1])·a + …`, so this sum is
510/// the original multiplier; the cone's remaining rows reconstruct the `Qx`
511/// part.) `sign` undoes the maximize→minimize negation.
512pub fn recover_socp_duals(
513 prob: &NlProblem,
514 con_map: &[ConSocpMap],
515 y: &[f64],
516 z: &[f64],
517) -> Vec<f64> {
518 let sign = if prob.minimize { 1.0 } else { -1.0 };
519 con_map
520 .iter()
521 .map(|m| match m {
522 ConSocpMap::Eq { a_row } => sign * y[*a_row],
523 ConSocpMap::Ineq { upper, lower } => {
524 let zu = upper.map(|r| z[r]).unwrap_or(0.0);
525 let zl = lower.map(|r| z[r]).unwrap_or(0.0);
526 sign * (zu - zl)
527 }
528 ConSocpMap::Quad { z_row0, z_row1 } => sign * (z[*z_row0] + z[*z_row1]),
529 })
530 .collect()
531}
532
533/// Build a dense symmetric `n×n` matrix from a [`QuadHessian`]-style map of
534/// `(i ≤ j) → Hessian entry` (diagonal entries are the full `∂²/∂xᵢ²`, so
535/// `½xᵀHx` reproduces the quadratic form). Off-diagonals are mirrored.
536fn dense_symmetric(hmap: &std::collections::BTreeMap<(usize, usize), f64>, n: usize) -> Vec<f64> {
537 let mut dense = vec![0.0; n * n];
538 for (&(i, j), &v) in hmap {
539 dense[i * n + j] = v;
540 dense[j * n + i] = v;
541 }
542 dense
543}
544
545/// Symmetric **pivoted (rank-revealing) Cholesky** of a PSD matrix `H`
546/// (row-major `n×n`, consumed as scratch), returning the factor rows `f_k`
547/// (each length `n`) such that `Σ_k f_k f_kᵀ = H` — equivalently `FᵀF = H`
548/// with `F` the matrix whose rows are the `f_k`. The number of rows is the
549/// numerical rank, so a rank-deficient `Q` (e.g. `Q = vvᵀ`) yields the
550/// minimal cone. Complete diagonal pivoting keeps the factorization stable
551/// on the indefinite-looking-but-PSD matrices finite precision can produce.
552fn psd_outer_factor(mut a: Vec<f64>, n: usize) -> Vec<Vec<f64>> {
553 let mut rows: Vec<Vec<f64>> = Vec::new();
554 // Tolerance relative to the largest initial diagonal: pivots at or below
555 // this are treated as the zero eigenvalues of the PSD matrix.
556 let max_diag = (0..n).map(|i| a[i * n + i]).fold(0.0_f64, f64::max);
557 let tol = 1e-12 * max_diag.max(1.0);
558 for _ in 0..n {
559 // Largest remaining diagonal pivot.
560 let mut p = 0usize;
561 let mut best = f64::NEG_INFINITY;
562 for i in 0..n {
563 let d = a[i * n + i];
564 if d > best {
565 best = d;
566 p = i;
567 }
568 }
569 if best <= tol {
570 break;
571 }
572 let d = best.sqrt();
573 // f = column p of the residual, scaled by 1/d.
574 let mut f = vec![0.0; n];
575 for i in 0..n {
576 f[i] = a[i * n + p] / d;
577 }
578 // Rank-1 downdate: A ← A − f fᵀ.
579 for i in 0..n {
580 let fi = f[i];
581 if fi == 0.0 {
582 continue;
583 }
584 for j in 0..n {
585 a[i * n + j] -= fi * f[j];
586 }
587 }
588 rows.push(f);
589 }
590 rows
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::nl_reader::{BinOp, Expr};
597 use pounce_convex::{solve_qp_ipm, solve_socp_ipm, QpOptions, QpStatus};
598 use pounce_feral::FeralSolverInterface;
599 use pounce_linsol::SparseSymLinearSolverInterface;
600
601 fn backend() -> Box<dyn SparseSymLinearSolverInterface> {
602 Box::new(FeralSolverInterface::new())
603 }
604
605 fn pow2(var: usize) -> Expr {
606 Expr::Binary(
607 BinOp::Pow,
608 Box::new(Expr::Var(var)),
609 Box::new(Expr::Const(2.0)),
610 )
611 }
612
613 /// min −x0 − x1 s.t. x0² + x1² ≤ 1 → x* = (1/√2, 1/√2), f* = −√2.
614 /// Exercises the QCQP→SOCP reformulation end-to-end: a rank-2 ball
615 /// constraint becomes one second-order cone, no nonnegative block.
616 #[test]
617 fn extract_and_solve_socp_ball() {
618 let prob = NlProblem {
619 n: 2,
620 m: 1,
621 num_obj: 1,
622 minimize: true,
623 obj_nonlinear: Expr::Const(0.0),
624 obj_linear: vec![(0, -1.0), (1, -1.0)],
625 obj_constant: 0.0,
626 con_nonlinear: vec![Expr::Binary(
627 BinOp::Add,
628 Box::new(pow2(0)),
629 Box::new(pow2(1)),
630 )],
631 con_linear: vec![vec![]],
632 x_l: vec![-2e19, -2e19],
633 x_u: vec![2e19, 2e19],
634 g_l: vec![-2e19],
635 g_u: vec![1.0],
636 x0: vec![0.0, 0.0],
637 lambda0: vec![0.0],
638 suffixes: Default::default(),
639 imported_funcs: Vec::new(),
640 var_names: Vec::new(),
641 con_names: Vec::new(),
642 };
643 let (qp, con_map, obj_const, cones) = extract_socp_with_map(&prob).expect("extract");
644 assert_eq!(obj_const, 0.0);
645 // No linear inequalities / bounds → no nonneg block; one SOC of
646 // dimension rank(Q)+2 = 2+2 = 4.
647 assert_eq!(cones, vec![ConeSpec::SecondOrder(4)]);
648 assert_eq!(qp.m_ineq(), 4);
649
650 let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
651 assert_eq!(sol.status, QpStatus::Optimal);
652 let inv_sqrt2 = 1.0 / 2.0_f64.sqrt();
653 assert!((sol.x[0] - inv_sqrt2).abs() < 1e-5, "x0={}", sol.x[0]);
654 assert!((sol.x[1] - inv_sqrt2).abs() < 1e-5, "x1={}", sol.x[1]);
655 assert!(
656 (sol.obj - (-2.0_f64.sqrt())).abs() < 1e-5,
657 "obj={}",
658 sol.obj
659 );
660
661 // Analytic multiplier: c + λ·2x = 0 ⇒ λ = 1/(2x0) = √2/2 ≈ 0.7071,
662 // positive (active upper bound), matching the `.sol` sign convention.
663 let lambda = recover_socp_duals(&prob, &con_map, &sol.y, &sol.z);
664 assert_eq!(lambda.len(), 1);
665 assert!(
666 (lambda[0] - 0.5 * 2.0_f64.sqrt()).abs() < 1e-3,
667 "ball constraint dual={}",
668 lambda[0]
669 );
670 }
671
672 /// min x0 s.t. (x0−3)² ≤ 1 → feasible x0 ∈ [2, 4], optimum x0 = 2.
673 /// The constraint's linear (`−6x0`) and constant (`+9`) terms are folded
674 /// into the nonlinear tree; the reformulation must recover `b_eff = 9 − 1`
675 /// so the cone encodes `x0² − 6x0 + 8 ≤ 0`, not `x0² ≤ 1`.
676 #[test]
677 fn extract_and_solve_socp_folds_constraint_constant() {
678 let con = Expr::Binary(
679 BinOp::Pow,
680 Box::new(Expr::Binary(
681 BinOp::Sub,
682 Box::new(Expr::Var(0)),
683 Box::new(Expr::Const(3.0)),
684 )),
685 Box::new(Expr::Const(2.0)),
686 );
687 let prob = NlProblem {
688 n: 1,
689 m: 1,
690 num_obj: 1,
691 minimize: true,
692 obj_nonlinear: Expr::Const(0.0),
693 obj_linear: vec![(0, 1.0)],
694 obj_constant: 0.0,
695 con_nonlinear: vec![con],
696 con_linear: vec![vec![]],
697 x_l: vec![-2e19],
698 x_u: vec![2e19],
699 g_l: vec![-2e19],
700 g_u: vec![1.0],
701 x0: vec![0.0],
702 lambda0: vec![0.0],
703 suffixes: Default::default(),
704 imported_funcs: Vec::new(),
705 var_names: Vec::new(),
706 con_names: Vec::new(),
707 };
708 let (qp, _con_map, obj_const, cones) = extract_socp_with_map(&prob).expect("extract");
709 assert_eq!(obj_const, 0.0);
710 assert_eq!(cones, vec![ConeSpec::SecondOrder(3)]); // rank 1 + 2.
711
712 let sol = solve_socp_ipm(&qp, &cones, &QpOptions::default(), backend);
713 assert_eq!(sol.status, QpStatus::Optimal);
714 assert!((sol.x[0] - 2.0).abs() < 1e-5, "x0={}", sol.x[0]);
715 }
716
717 /// `psd_outer_factor` recovers a rank-1 `Q = vvᵀ` with a single factor row
718 /// (minimal cone), and reconstructs `Q` exactly.
719 #[test]
720 fn psd_outer_factor_is_rank_revealing() {
721 // Q = [[1,2],[2,4]] = v vᵀ with v = (1,2): rank 1.
722 let q = vec![1.0, 2.0, 2.0, 4.0];
723 let rows = psd_outer_factor(q.clone(), 2);
724 assert_eq!(rows.len(), 1, "rank-1 Q must give one factor row");
725 // Reconstruct Σ f fᵀ and compare to Q.
726 let mut recon = vec![0.0; 4];
727 for f in &rows {
728 for i in 0..2 {
729 for j in 0..2 {
730 recon[i * 2 + j] += f[i] * f[j];
731 }
732 }
733 }
734 for k in 0..4 {
735 assert!((recon[k] - q[k]).abs() < 1e-9, "recon[{k}]={}", recon[k]);
736 }
737 }
738
739 /// min (x0)^2 + (x1)^2 s.t. x0 + x1 = 2, no var bounds → (1,1), f*=2.
740 #[test]
741 fn extract_and_solve_equality_qp() {
742 let prob = NlProblem {
743 n: 2,
744 m: 1,
745 num_obj: 1,
746 minimize: true,
747 obj_nonlinear: Expr::Binary(BinOp::Add, Box::new(pow2(0)), Box::new(pow2(1))),
748 obj_linear: vec![],
749 obj_constant: 0.0,
750 con_nonlinear: vec![Expr::Const(0.0)],
751 con_linear: vec![vec![(0, 1.0), (1, 1.0)]],
752 x_l: vec![-2e19, -2e19],
753 x_u: vec![2e19, 2e19],
754 g_l: vec![2.0],
755 g_u: vec![2.0],
756 x0: vec![0.0, 0.0],
757 lambda0: vec![0.0],
758 suffixes: Default::default(),
759 imported_funcs: Vec::new(),
760 var_names: Vec::new(),
761 con_names: Vec::new(),
762 };
763 let (qp, con_map, obj_const) = extract_qp_with_map(&prob).expect("extract");
764 // No constant anywhere in this objective.
765 assert_eq!(obj_const, 0.0);
766 // P = 2I → two diagonal entries.
767 assert_eq!(qp.p_lower.len(), 2);
768 assert_eq!(qp.m_eq(), 1);
769 assert_eq!(qp.m_ineq(), 0);
770
771 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
772 assert_eq!(sol.status, QpStatus::Optimal);
773 assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
774 assert!((sol.x[1] - 1.0).abs() < 1e-6, "x1={}", sol.x[1]);
775 assert!((sol.obj - 2.0).abs() < 1e-6, "obj={}", sol.obj);
776
777 // KKT for the equality: ∇f + y·∇g = 0 → 2x_i + y = 0 at x=1 → y=−2.
778 let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
779 assert_eq!(lambda.len(), 1);
780 assert!(
781 (lambda[0] - (-2.0)).abs() < 1e-5,
782 "equality dual={}",
783 lambda[0]
784 );
785 }
786
787 /// Regression for the dropped-linear-term bug: the objective `(x0-3)²`
788 /// lives entirely in the nonlinear tree, so its linear part (`−6·x0`)
789 /// must be folded into `c`. Without it the solve minimizes `x0²`
790 /// (optimum 0) instead of `(x0-3)²` (optimum 3).
791 #[test]
792 fn extract_keeps_linear_term_from_nonlinear_tree() {
793 // (x0 - 3)^2 = x0^2 - 6 x0 + 9, all in obj_nonlinear.
794 let obj = Expr::Binary(
795 BinOp::Pow,
796 Box::new(Expr::Binary(
797 BinOp::Sub,
798 Box::new(Expr::Var(0)),
799 Box::new(Expr::Const(3.0)),
800 )),
801 Box::new(Expr::Const(2.0)),
802 );
803 let prob = NlProblem {
804 n: 1,
805 m: 0,
806 num_obj: 1,
807 minimize: true,
808 obj_nonlinear: obj,
809 obj_linear: vec![],
810 obj_constant: 0.0,
811 con_nonlinear: vec![],
812 con_linear: vec![],
813 x_l: vec![-2e19],
814 x_u: vec![2e19],
815 g_l: vec![],
816 g_u: vec![],
817 x0: vec![0.0],
818 lambda0: vec![],
819 suffixes: Default::default(),
820 imported_funcs: Vec::new(),
821 var_names: Vec::new(),
822 con_names: Vec::new(),
823 };
824 let qp = extract_qp(&prob).expect("extract");
825 assert_eq!(qp.c.len(), 1);
826 assert!(
827 (qp.c[0] - (-6.0)).abs() < 1e-12,
828 "c[0]={} — linear term from the nonlinear tree was dropped",
829 qp.c[0]
830 );
831 // P = 2 (one diagonal entry).
832 assert_eq!(qp.p_lower.len(), 1);
833
834 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
835 assert_eq!(sol.status, QpStatus::Optimal);
836 assert!(
837 (sol.x[0] - 3.0).abs() < 1e-6,
838 "x0={} (expected 3)",
839 sol.x[0]
840 );
841 }
842
843 /// Inequality dual sign/magnitude. min x0² s.t. x0 ≥ 1 (a one-sided
844 /// inequality g_l=1, g_u=+inf). Optimum x0=1, active. The expected
845 /// dual −2.0 is the value POUNCE's *NLP* path writes for this exact
846 /// problem (verified by running `solver_selection=nlp` on the same
847 /// `.nl`); recover_duals must match that reference convention.
848 #[test]
849 fn inequality_dual_recovered() {
850 let prob = NlProblem {
851 n: 1,
852 m: 1,
853 num_obj: 1,
854 minimize: true,
855 obj_nonlinear: pow2(0),
856 obj_linear: vec![],
857 obj_constant: 0.0,
858 con_nonlinear: vec![Expr::Const(0.0)],
859 con_linear: vec![vec![(0, 1.0)]], // g(x) = x0
860 x_l: vec![-2e19],
861 x_u: vec![2e19],
862 g_l: vec![1.0], // x0 ≥ 1
863 g_u: vec![2e19],
864 x0: vec![0.0],
865 lambda0: vec![0.0],
866 suffixes: Default::default(),
867 imported_funcs: Vec::new(),
868 var_names: Vec::new(),
869 con_names: Vec::new(),
870 };
871 let (qp, con_map, obj_const) = extract_qp_with_map(&prob).expect("extract");
872 // This model puts its constant in the `obj_constant` field, not the
873 // nonlinear tree, so the tree constant is 0 here.
874 assert_eq!(obj_const, 0.0);
875 // One inequality row (the lower bound row −x0 ≤ −1); no upper.
876 assert_eq!(qp.m_ineq(), 1);
877 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
878 assert_eq!(sol.status, QpStatus::Optimal);
879 assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
880 let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
881 assert!((lambda[0] - (-2.0)).abs() < 1e-5, "ineq dual={}", lambda[0]);
882 }
883
884 /// Regression (M11): a *constraint* whose linear and constant
885 /// terms are folded into the nonlinear tree (not the `con_linear`
886 /// section) must still reach `A`/`G`. AMPL/Pyomo emit this shape for
887 /// rows the classifier admits as degree-≤1 (cancelled quadratics,
888 /// defined variables): the whole `x0 − 3` lives in `con_nonlinear`
889 /// and `con_linear[0]` is empty.
890 ///
891 /// min x0 s.t. x0 − 3 ≥ 0 (body in the nonlinear tree)
892 ///
893 /// True optimum: x0 = 3. The QP extractor used to build `A`/`G` from
894 /// `con_linear` only — dropping the folded `+x0` *and* the `−3`
895 /// shift, leaving a vacuous `0 ≤ 0` row, so `min x0` came out
896 /// unbounded (or otherwise wrong) on the convex path.
897 #[test]
898 fn constraint_linear_terms_folded_in_tree_are_recovered() {
899 // con body = x0 − 3, entirely in the nonlinear tree.
900 let con = Expr::Binary(
901 BinOp::Sub,
902 Box::new(Expr::Var(0)),
903 Box::new(Expr::Const(3.0)),
904 );
905 let prob = NlProblem {
906 n: 1,
907 m: 1,
908 num_obj: 1,
909 minimize: true,
910 obj_nonlinear: Expr::Const(0.0),
911 obj_linear: vec![(0, 1.0)],
912 obj_constant: 0.0,
913 con_nonlinear: vec![con],
914 con_linear: vec![vec![]], // the `+x0` lives in the TREE
915 x_l: vec![-2e19],
916 x_u: vec![2e19],
917 g_l: vec![0.0], // x0 − 3 ≥ 0
918 g_u: vec![2e19],
919 x0: vec![0.0],
920 lambda0: vec![0.0],
921 suffixes: Default::default(),
922 imported_funcs: Vec::new(),
923 var_names: Vec::new(),
924 con_names: Vec::new(),
925 };
926 let (qp, con_map, _obj_const) = extract_qp_with_map(&prob).expect("extract");
927 // One inequality row: −x0 ≤ −3 (the lower bound, constant-shifted).
928 assert_eq!(qp.m_ineq(), 1);
929 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
930 assert_eq!(sol.status, QpStatus::Optimal);
931 assert!((sol.x[0] - 3.0).abs() < 1e-5, "x0={}", sol.x[0]);
932 // Dual is recoverable and finite (the row carries a real coef now).
933 let lambda = recover_duals(&prob, &con_map, &sol.y, &sol.z);
934 assert_eq!(lambda.len(), 1);
935 assert!(lambda[0].is_finite(), "dual={}", lambda[0]);
936 }
937
938 /// Regression: a constant folded into the *nonlinear objective tree*
939 /// (not the `obj_constant` field) must still reach the reported
940 /// objective. This is the real `.nl` shape AMPL/Pyomo emit for
941 /// `min (x0-3)^2` — the whole `x0^2 - 6 x0 + 9` lives in the nonlinear
942 /// tree and `obj_constant == 0`. The convex path used to drop the `+9`
943 /// and report an objective 9 too small (cf. HS35 in the benchmark
944 /// comparison). The minimizer is x0 = 1 (upper bound binds), where the
945 /// true objective is (1-3)^2 = 4.
946 #[test]
947 fn tree_embedded_objective_constant_is_recovered() {
948 // (x0 - 3)^2 as a single nonlinear tree: Pow(Sub(x0, 3), 2).
949 let obj = Expr::Binary(
950 BinOp::Pow,
951 Box::new(Expr::Binary(
952 BinOp::Sub,
953 Box::new(Expr::Var(0)),
954 Box::new(Expr::Const(3.0)),
955 )),
956 Box::new(Expr::Const(2.0)),
957 );
958 let prob = NlProblem {
959 n: 1,
960 m: 0,
961 num_obj: 1,
962 minimize: true,
963 obj_nonlinear: obj,
964 obj_linear: vec![],
965 obj_constant: 0.0, // the +9 is in the TREE, not here
966 con_nonlinear: vec![],
967 con_linear: vec![],
968 x_l: vec![0.0],
969 x_u: vec![1.0],
970 g_l: vec![],
971 g_u: vec![],
972 x0: vec![0.0],
973 lambda0: vec![],
974 suffixes: Default::default(),
975 imported_funcs: Vec::new(),
976 var_names: Vec::new(),
977 con_names: Vec::new(),
978 };
979 let (qp, _con_map, obj_const) = extract_qp_with_map(&prob).expect("extract");
980 // The degree-0 term of (x0-3)^2 is +9, recovered from the tree.
981 assert!((obj_const - 9.0).abs() < 1e-12, "tree constant={obj_const}");
982 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
983 assert_eq!(sol.status, QpStatus::Optimal);
984 assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
985 // Reported objective = (½xᵀPx + cᵀx) + obj_const must equal the true
986 // (1-3)^2 = 4, not the constant-dropped −5.
987 let reported = sol.obj + obj_const;
988 assert!((reported - 4.0).abs() < 1e-5, "reported obj={reported}");
989 }
990
991 /// Bound-constrained: min (x0-3)^2 = x0^2 - 6 x0 + 9, 0 ≤ x0 ≤ 1.
992 /// Optimum x0 = 1 (upper bound binds). Here the constant 9 is carried
993 /// in the `obj_constant` field (not the tree), so the extracted tree
994 /// constant is 0 (asserted inside).
995 #[test]
996 fn extract_and_solve_bounded_qp() {
997 let prob = NlProblem {
998 n: 1,
999 m: 0,
1000 num_obj: 1,
1001 minimize: true,
1002 obj_nonlinear: pow2(0),
1003 obj_linear: vec![(0, -6.0)],
1004 obj_constant: 9.0,
1005 con_nonlinear: vec![],
1006 con_linear: vec![],
1007 x_l: vec![0.0],
1008 x_u: vec![1.0],
1009 g_l: vec![],
1010 g_u: vec![],
1011 x0: vec![0.0],
1012 lambda0: vec![],
1013 suffixes: Default::default(),
1014 imported_funcs: Vec::new(),
1015 var_names: Vec::new(),
1016 con_names: Vec::new(),
1017 };
1018 let qp = extract_qp(&prob).expect("extract");
1019 // Two var-bound rows (x0 ≤ 1, −x0 ≤ 0).
1020 assert_eq!(qp.m_ineq(), 2);
1021 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1022 assert_eq!(sol.status, QpStatus::Optimal);
1023 assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
1024 }
1025
1026 /// LP: min −x0 − x1, 0 ≤ x ≤ 1 → (1,1).
1027 #[test]
1028 fn extract_and_solve_lp() {
1029 let prob = NlProblem {
1030 n: 2,
1031 m: 0,
1032 num_obj: 1,
1033 minimize: true,
1034 obj_nonlinear: Expr::Const(0.0),
1035 obj_linear: vec![(0, -1.0), (1, -1.0)],
1036 obj_constant: 0.0,
1037 con_nonlinear: vec![],
1038 con_linear: vec![],
1039 x_l: vec![0.0, 0.0],
1040 x_u: vec![1.0, 1.0],
1041 g_l: vec![],
1042 g_u: vec![],
1043 x0: vec![0.0, 0.0],
1044 lambda0: vec![],
1045 suffixes: Default::default(),
1046 imported_funcs: Vec::new(),
1047 var_names: Vec::new(),
1048 con_names: Vec::new(),
1049 };
1050 let qp = extract_qp(&prob).expect("extract");
1051 assert!(qp.p_lower.is_empty(), "LP has no Hessian");
1052 assert_eq!(qp.m_ineq(), 4); // 2 vars × (upper + lower)
1053 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1054 assert_eq!(sol.status, QpStatus::Optimal);
1055 assert!((sol.x[0] - 1.0).abs() < 1e-6);
1056 assert!((sol.x[1] - 1.0).abs() < 1e-6);
1057 }
1058
1059 /// maximize x0 s.t. 0 ≤ x0 ≤ 5 → x0 = 5. Tests sign flip on a
1060 /// maximize objective.
1061 #[test]
1062 fn extract_maximize_negates() {
1063 let prob = NlProblem {
1064 n: 1,
1065 m: 0,
1066 num_obj: 1,
1067 minimize: false,
1068 obj_nonlinear: Expr::Const(0.0),
1069 obj_linear: vec![(0, 1.0)],
1070 obj_constant: 0.0,
1071 con_nonlinear: vec![],
1072 con_linear: vec![],
1073 x_l: vec![0.0],
1074 x_u: vec![5.0],
1075 g_l: vec![],
1076 g_u: vec![],
1077 x0: vec![0.0],
1078 lambda0: vec![],
1079 suffixes: Default::default(),
1080 imported_funcs: Vec::new(),
1081 var_names: Vec::new(),
1082 con_names: Vec::new(),
1083 };
1084 let qp = extract_qp(&prob).expect("extract");
1085 // minimize −x0.
1086 assert_eq!(qp.c[0], -1.0);
1087 let sol = solve_qp_ipm(&qp, &QpOptions::default(), backend);
1088 assert_eq!(sol.status, QpStatus::Optimal);
1089 assert!((sol.x[0] - 5.0).abs() < 1e-6, "x0={}", sol.x[0]);
1090 }
1091}