pounce_nl/nl_scaling.rs
1//! Curvature-based scaling for quadratically-constrained models (gh #703).
2//!
3//! # What this is for
4//!
5//! POUNCE's default `nlp_scaling_method=gradient-based` is a **point
6//! sample**: it reads `∇f` and the Jacobian once, at x₀, and sets row `i`'s
7//! factor from `‖∇gᵢ(x₀)‖_∞`. That is a fine estimator of a row's magnitude
8//! when the row's derivative at x₀ is representative of its derivative
9//! elsewhere, and no estimator at all when it is not.
10//!
11//! A row written `½xᵀQᵢx ≤ bᵢ` about the origin has `∇gᵢ(0) = 0`. Started
12//! from `x₀ = 0` — the default for a model with free variables and no
13//! initial guess, which is how AMPL emits the Mittelmann `qcqp*` family —
14//! the sample reads nothing and the row is assigned factor **1.0** however
15//! far `Qᵢ` and `bᵢ` disagree in magnitude. No cutoff reaches it: `100/0`
16//! and `1e-6/0` both clamp to 1. Measured across POUNCE's own CLI fixture
17//! corpus, that is 196 of 196 quadratic rows left unscaled.
18//!
19//! This module computes the factors from the model's **coefficients**
20//! instead of from a derivative sample, so a row's scale does not depend on
21//! where the modeller happened to start. It is the scheme worked out in
22//! `dev-notes/quadratic-structure-exploitation.md` §8, in two stages.
23//!
24//! # Stage 1 — one `D` for the whole family
25//!
26//! Ruiz per `Qᵢ` individually is wrong: each would demand its own column
27//! scaling and there is only one `x`. The matrix that gets factored is
28//! `H(λ) = Q₀ + Σλᵢ Qᵢ`, so `D` must equilibrate all of them **jointly**.
29//! §8's device is a pair of λ-independent magnitude surrogates — the
30//! ∞-norm envelope of every `H(λ)` that can arise —
31//!
32//! ```text
33//! P̂[j,k] = max( |Q₀[j,k]| , maxᵢ |Qᵢ[j,k]| )
34//! Ĵ[i,j] = max( |aᵢ[j]| , maxₖ |Qᵢ[j,k]| )
35//! ```
36//!
37//! which are then Ruiz-equilibrated as the symmetric augmented matrix
38//! `K̂ = [[P̂, Ĵᵀ], [Ĵ, 0]]`, exactly as
39//! `pounce_convex::equilibrate` sweeps the LP/QP one. `D` is the variable
40//! block of the result.
41//!
42//! **Extension beyond §8, stated.** §8 was written for a driver whose only
43//! rows are quadratic. A model on the NLP path has linear rows too, and they
44//! constrain the same `x`; leaving them out of `Ĵ` would balance the
45//! quadratic rows by unbalancing the linear ones. So every constraint row
46//! contributes a row to `Ĵ` — a linear row through `|aᵢ[j]|` alone, which is
47//! what the formula reduces to when `Qᵢ = 0`.
48//!
49//! # Stage 2 — the per-row scale
50//!
51//! After `D` is fixed,
52//!
53//! ```text
54//! eᵢ = 1 / max( ‖D Qᵢ D‖_∞ , ‖D aᵢ‖_∞ , |bᵢ| )
55//! ```
56//!
57//! This is the term that carries the measured win. On a QCQP shaped like
58//! `qcqp1000-2c` the right-hand sides run ~50× above `‖Qᵢ‖_∞`, so the `max`
59//! *is* the right-hand side — and an unnormalized `bᵢ` biases the slack
60//! `sᵢ = −gᵢ(x)` and with it the `−sᵢ/λᵢ` diagonal of the KKT system.
61//!
62//! # What is deliberately **not** scaled
63//!
64//! The objective. `pounce_convex::equilibrate` learned this the hard way and
65//! its comment is the authority: the Ruiz pass already normalizes the `P`
66//! block against the constraint blocks, and a `σ < 1` applied to a problem
67//! that *has* a Hessian shrinks it below the constraint scale, degrades the
68//! scaled problem's strong convexity and diverges the dual iterates. Every
69//! model this method accepts has a quadratic objective or none, so `σ` would
70//! never be the LP case where that module found it necessary. `obj_scaling`
71//! is left at 1.0.
72//!
73//! # Scope, and why it declines
74//!
75//! Both surrogates are `λ`-independent only because each `Qᵢ` is a
76//! *constant* matrix. A row with a genuine nonlinearity has no such `Qᵢ`,
77//! the envelope does not exist, and building `D` from that row's linear
78//! section alone would silently equilibrate against a fiction. So
79//! [`curvature_scaling`] **declines** (returns `None`) on any model whose
80//! objective or rows are not all degree ≤ 2, and the caller turns that into
81//! an error naming the option rather than falling back to something else —
82//! a scaling option that is accepted and then quietly not applied is gh #483
83//! all over again.
84
85use std::collections::BTreeMap;
86
87use crate::nl_reader::NlProblem;
88use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
89
90/// Ruiz sweeps over `K̂`. Matches `pounce_convex::equilibrate`'s
91/// `RUIZ_SWEEPS`: Ruiz converges geometrically and a handful of passes
92/// brings the row/column ∞-norms within a few percent of 1.
93const RUIZ_SWEEPS: usize = 10;
94
95/// Clamp on every emitted factor, matching the `[1e-8, 1e8]` bracket
96/// `pounce_convex::equilibrate` puts on `σ`, so degenerate data cannot
97/// itself create an extreme scaling. The lower end coincides with
98/// `nlp_scaling_min_value`'s default.
99const SCALE_LO: Number = 1e-8;
100const SCALE_HI: Number = 1e8;
101
102/// Point-free coefficient magnitudes of one quadratic constraint row.
103///
104/// `curvature` is `‖Q‖_∞`, the largest absolute row sum of the row's
105/// Hessian — Gershgorin's bound on `λ_max(Q)`, and the exact quantity
106/// stage 2's `eᵢ` is built from. It is an upper bound on the curvature,
107/// not the curvature, so a mismatch measured against it **understates**
108/// the one measured against `λ_max`.
109#[derive(Debug, Clone)]
110pub struct QuadRowCoef {
111 pub index: usize,
112 /// `‖Q‖_∞` — see the type docs.
113 pub curvature: Number,
114 /// `‖a‖_∞` over the `.nl` linear section plus the degree-1 terms the
115 /// writer folded into the nonlinear tree.
116 pub linear: Number,
117 /// `|b|` — the finite bound the row is written against, shifted by the
118 /// folded constant. A range row reports the larger magnitude.
119 pub rhs: Number,
120}
121
122/// Read every constraint row's quadratic coefficients out of an
123/// [`NlProblem`].
124///
125/// Uses [`crate::nl_reader::NlBody::analyze_quadratic_full`] — the same
126/// read-out the LP/QP dispatch classifies with — so a row counted here is
127/// a row the recognizer agrees is quadratic. Rows it refuses (a genuine
128/// nonlinearity, or a quadratic whose recognition lost a term) are simply
129/// absent, which is why a caller reporting this census must report the
130/// count alongside `m` rather than implying it covers the model.
131///
132/// `O(nnz)` in the stored Hessian entries and no evaluation: this is a
133/// property of the file, not of a point.
134pub fn quad_row_coefs(prob: &NlProblem) -> Vec<QuadRowCoef> {
135 let mut out = Vec::new();
136 for i in 0..prob.m {
137 let Some((hess, nl_lin, nl_const)) = prob.con_nonlinear[i].analyze_quadratic_full() else {
138 continue;
139 };
140 if hess.is_empty() {
141 continue; // degree ≤ 1: a linear row, not this census's business
142 }
143 // `hess` is the upper triangle (i ≤ j) of a symmetric matrix, so an
144 // off-diagonal entry contributes its magnitude to two row sums.
145 let mut row_sum: BTreeMap<usize, Number> = BTreeMap::new();
146 for (&(r, c), v) in &hess {
147 let a = v.abs();
148 *row_sum.entry(r).or_insert(0.0) += a;
149 if r != c {
150 *row_sum.entry(c).or_insert(0.0) += a;
151 }
152 }
153 let curvature = row_sum.values().fold(0.0_f64, |m, &v| m.max(v));
154 let linear = row_linear(prob, i, &nl_lin)
155 .values()
156 .fold(0.0_f64, |m, &v| m.max(v.abs()));
157 out.push(QuadRowCoef {
158 index: i,
159 curvature,
160 linear,
161 rhs: row_rhs(prob, i, nl_const),
162 });
163 }
164 out
165}
166
167/// A row's full linear part: the `.nl` linear section plus the degree-1
168/// terms AMPL folded into the nonlinear tree. They can land on the same
169/// variable, so they are accumulated rather than concatenated.
170fn row_linear(prob: &NlProblem, i: usize, nl_lin: &[(usize, Number)]) -> BTreeMap<usize, Number> {
171 let mut lin: BTreeMap<usize, Number> = BTreeMap::new();
172 for (var, coef) in &prob.con_linear[i] {
173 *lin.entry(*var).or_insert(0.0) += *coef;
174 }
175 for (var, coef) in nl_lin {
176 *lin.entry(*var).or_insert(0.0) += *coef;
177 }
178 lin
179}
180
181/// `|bᵢ|` — the finite bound the row is written against, shifted by the
182/// constant the writer folded into the tree (the row is
183/// `½xᵀQx + aᵀx ≤ g_u − c`). A range row reports the larger magnitude,
184/// since one scale has to serve both sides.
185fn row_rhs(prob: &NlProblem, i: usize, nl_const: Number) -> Number {
186 let (lo, hi) = (prob.g_l[i], prob.g_u[i]);
187 let mut rhs = 0.0_f64;
188 if lower_bound_present(lo) {
189 rhs = rhs.max((lo - nl_const).abs());
190 }
191 if upper_bound_present(hi) {
192 rhs = rhs.max((hi - nl_const).abs());
193 }
194 rhs
195}
196
197/// The factors [`curvature_scaling`] produces, in the conventions
198/// [`crate::nl_reader::NlTnlp::get_scaling_parameters`] hands back.
199#[derive(Debug, Clone)]
200pub struct CurvatureScaling {
201 /// Per-variable factors in **`ScalingTnlp`'s** convention, `x̃ = d ⊙ x`
202 /// — so `d = D⁻¹` for §8's `x = D x̂`. Emitting `D` here instead would
203 /// apply the scaling backwards, which is the one sign error in this
204 /// module that no test of `D` alone would catch; `x_factors_invert_d`
205 /// pins it.
206 pub x: Vec<Number>,
207 /// Per-row factors `eᵢ`, multiplying the row exactly as
208 /// gradient-based scaling's `c_scale` / `d_scale` do.
209 pub g: Vec<Number>,
210 /// Whether the model this was computed from actually carries a nonzero
211 /// second-order coefficient — in the objective's `P` or in some row's
212 /// `Qᵢ`.
213 ///
214 /// A model of degree ≤ 2 need not have any: an LP is degree ≤ 2 with
215 /// every `Q` empty, and the scheme still returns factors for it, but
216 /// with `‖D Qᵢ D‖_∞ = 0` throughout, stage 2 collapses to
217 /// `eᵢ = 1/max(‖D aᵢ‖_∞, |bᵢ|)` and stage 1's `K̂` loses its `P̂`
218 /// block. What is left is plain Ruiz equilibration of `[A b]` — a
219 /// perfectly good scaling, but not one that read any curvature, because
220 /// there was none to read. The caller needs to know the difference: it
221 /// is the whole justification for spending the convex fast path on this
222 /// option (gh #703, gh#483).
223 pub quadratic: bool,
224}
225
226/// One row's degree-≤2 read-out, as [`curvature_scaling`] needs it: the
227/// Hessian's stored triangle, the accumulated linear part, and `|bᵢ|`.
228struct QuadRow {
229 hess: BTreeMap<(usize, usize), Number>,
230 lin: BTreeMap<usize, Number>,
231 rhs: Number,
232}
233
234/// Compute §8's two-stage scaling for `prob`.
235///
236/// Returns `None` when the model is not one this scheme is defined for —
237/// the objective or some row is not degree ≤ 2, so no constant `Qᵢ` exists
238/// and the magnitude envelope of `H(λ)` is not λ-independent. See the
239/// module docs on why that is a refusal and not a fallback.
240pub fn curvature_scaling(prob: &NlProblem) -> Option<CurvatureScaling> {
241 let n = prob.n;
242 let m = prob.m;
243
244 // ---- read the model's constant structure, or decline ----
245 let (obj_hess, _obj_lin, _obj_const) = prob.obj_nonlinear.analyze_quadratic_full()?;
246 let mut rows: Vec<QuadRow> = Vec::with_capacity(m);
247 for i in 0..m {
248 let (hess, nl_lin, nl_const) = prob.con_nonlinear[i].analyze_quadratic_full()?;
249 rows.push(QuadRow {
250 hess,
251 lin: row_linear(prob, i, &nl_lin),
252 rhs: row_rhs(prob, i, nl_const),
253 });
254 }
255
256 // ---- stage 1: the two λ-independent magnitude surrogates ----
257 //
258 // `P̂` is sparse over the union of the objective's and every row's
259 // Hessian support. Materializing it dense would be `n²` on a model
260 // whose whole point is that `n` is four digits.
261 let mut p_hat: BTreeMap<(usize, usize), Number> = BTreeMap::new();
262 let bump = |map: &mut BTreeMap<(usize, usize), Number>, key, v: Number| {
263 let slot = map.entry(key).or_insert(0.0);
264 if v > *slot {
265 *slot = v;
266 }
267 };
268 for (&k, v) in &obj_hess {
269 bump(&mut p_hat, k, v.abs());
270 }
271 // `Ĵ[i, j] = max(|aᵢ[j]|, maxₖ |Qᵢ[j, k]|)`, sparse per row over the
272 // row's own support.
273 let mut j_hat: Vec<BTreeMap<usize, Number>> = Vec::with_capacity(m);
274 for QuadRow { hess, lin, .. } in &rows {
275 for (&k, v) in hess {
276 bump(&mut p_hat, k, v.abs());
277 }
278 let mut row: BTreeMap<usize, Number> = BTreeMap::new();
279 for (&(r, c), v) in hess {
280 let a = v.abs();
281 let slot = row.entry(r).or_insert(0.0);
282 if a > *slot {
283 *slot = a;
284 }
285 let slot = row.entry(c).or_insert(0.0);
286 if a > *slot {
287 *slot = a;
288 }
289 }
290 for (&j, v) in lin {
291 let a = v.abs();
292 let slot = row.entry(j).or_insert(0.0);
293 if a > *slot {
294 *slot = a;
295 }
296 }
297 j_hat.push(row);
298 }
299
300 // ---- Ruiz on `K̂ = [[P̂, Ĵᵀ], [Ĵ, 0]]` ----
301 //
302 // `K̂` is symmetric, so one scale vector serves rows and columns; the
303 // layout is [0, n) variables then [n, n+m) rows, matching
304 // `pounce_convex::equilibrate`.
305 let dim = n + m;
306 let mut s = vec![1.0_f64; dim];
307 let mut rownorm = vec![0.0_f64; dim];
308 for _ in 0..RUIZ_SWEEPS {
309 rownorm.iter_mut().for_each(|v| *v = 0.0);
310 for (&(r, c), v) in &p_hat {
311 let x = (s[r] * v * s[c]).abs();
312 if x > rownorm[r] {
313 rownorm[r] = x;
314 }
315 if r != c && x > rownorm[c] {
316 rownorm[c] = x;
317 }
318 }
319 for (i, row) in j_hat.iter().enumerate() {
320 let ri = n + i;
321 for (&j, v) in row {
322 let x = (s[ri] * v * s[j]).abs();
323 if x > rownorm[ri] {
324 rownorm[ri] = x;
325 }
326 if x > rownorm[j] {
327 rownorm[j] = x;
328 }
329 }
330 }
331 // Ruiz update. An all-zero row — an empty column, or a row with no
332 // entries at all — is left unscaled rather than divided by zero.
333 for i in 0..dim {
334 if rownorm[i] > 0.0 {
335 s[i] /= rownorm[i].sqrt();
336 }
337 }
338 }
339 let d: Vec<Number> = s[..n].iter().map(|v| v.clamp(SCALE_LO, SCALE_HI)).collect();
340
341 // ---- stage 2: eᵢ = 1 / max(‖D Qᵢ D‖_∞, ‖D aᵢ‖_∞, |bᵢ|) ----
342 let mut g = vec![1.0_f64; m];
343 for (i, QuadRow { hess, lin, rhs }) in rows.iter().enumerate() {
344 // `‖D Qᵢ D‖_∞` is the largest absolute row sum of the *full*
345 // symmetric `D Qᵢ D`; `hess` holds one triangle, so an
346 // off-diagonal entry lands in two row sums.
347 let mut row_sum: BTreeMap<usize, Number> = BTreeMap::new();
348 for (&(r, c), v) in hess {
349 let scaled = (d[r] * v * d[c]).abs();
350 *row_sum.entry(r).or_insert(0.0) += scaled;
351 if r != c {
352 *row_sum.entry(c).or_insert(0.0) += scaled;
353 }
354 }
355 let q_norm = row_sum.values().fold(0.0_f64, |m, &v| m.max(v));
356 let a_norm = lin
357 .iter()
358 .fold(0.0_f64, |m, (&j, v)| m.max((v * d[j]).abs()));
359 let scale = q_norm.max(a_norm).max(*rhs);
360 // An empty row constrains nothing and normalizing it is a division
361 // by zero; leave it alone.
362 if scale > 0.0 {
363 g[i] = (1.0 / scale).clamp(SCALE_LO, SCALE_HI);
364 }
365 }
366
367 // `ScalingTnlp` substitutes `x̃ = d ⊙ x` while §8 writes `x = D x̂`, so
368 // the factor handed over is the reciprocal. See `CurvatureScaling::x`.
369 Some(CurvatureScaling {
370 x: d.iter().map(|v| 1.0 / v).collect(),
371 g,
372 // Nonzero, not merely present: `analyze_quadratic_full` reports the
373 // support it found, and a stored explicit zero is not curvature.
374 quadratic: obj_hess
375 .values()
376 .chain(rows.iter().flat_map(|r| r.hess.values()))
377 .any(|v| *v != 0.0),
378 })
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::nl_reader::parse_nl_text;
385
386 /// `min ½(2x₀² + 2e-8·x₁²) − x₀ − x₁ s.t. ½(4x₀² + 2e-8·x₁²) ≤ 1e5`,
387 /// both variables free.
388 ///
389 /// **The spread is in the objective too, and that is the point.**
390 /// Stage 1 equilibrates the ∞-norm envelope of `Q₀ + Σλᵢ Qᵢ`, so a
391 /// tiny coefficient in one row that the objective's own entry masks is
392 /// correctly left to stage 2's `eᵢ` — a column rescale would be wrong
393 /// there, because the matrix that actually gets factored is *not*
394 /// ill-scaled in that direction. Putting the spread in both makes the
395 /// envelope itself span eight orders, which is when `D` is the right
396 /// tool and the reciprocal convention below can be read off it.
397 const SPREAD_NL: &str = "\
398g3 0 1 0
399 2 1 1 0 0
400 1 1
401 0 0
402 2 2 2
403 0 0 0 1
404 0 0 0 0 0
405 2 2
406 0 0
407 0 0 0 0 0
408b
4093
4103
411r
4121 100000
413C0
414o54
4152
416o2
417n0.5
418o2
419o2
420n4.0
421v0
422v0
423o2
424n0.5
425o2
426o2
427n2e-8
428v1
429v1
430O0 0
431o54
4322
433o2
434n0.5
435o2
436o2
437n2.0
438v0
439v0
440o2
441n0.5
442o2
443o2
444n2e-8
445v1
446v1
447k1
4481
449J0 2
4500 0
4511 0
452G0 2
4530 -1.0
4541 -1.0
455";
456
457 /// `min x₀ s.t. exp(x₀) ≤ 2` — degree > 2, so no constant `Q`.
458 const NONLINEAR_NL: &str = "\
459g3 0 1 0
460 1 1 1 0 0
461 1 0
462 0 0
463 1 1 1
464 0 0 0 1
465 0 0 0 0 0
466 1 1
467 0 0
468 0 0 0 0 0
469b
4703
471r
4721 2
473C0
474o44
475v0
476O0 0
477n0
478k0
479J0 1
4800 0
481G0 1
4820 1.0
483";
484
485 #[test]
486 fn a_genuine_nonlinearity_is_declined_not_approximated() {
487 let prob = parse_nl_text(NONLINEAR_NL).expect("parse");
488 assert!(
489 curvature_scaling(&prob).is_none(),
490 "exp(x) has no constant Hessian; approximating it from the \
491 linear section would equilibrate against a fiction"
492 );
493 }
494
495 #[test]
496 fn a_quadratic_model_is_accepted() {
497 let prob = parse_nl_text(SPREAD_NL).expect("parse");
498 let sc = curvature_scaling(&prob).expect("degree ≤ 2 everywhere");
499 assert_eq!(sc.x.len(), 2);
500 assert_eq!(sc.g.len(), 1);
501 assert!(sc.x.iter().all(|v| v.is_finite() && *v > 0.0));
502 assert!(sc.g.iter().all(|v| v.is_finite() && *v > 0.0));
503 }
504
505 /// The factor handed back is `D⁻¹`, not `D`: `ScalingTnlp` substitutes
506 /// `x̃ = d ⊙ x` while §8 writes `x = D x̂`. Emitting `D` would apply
507 /// the whole scheme backwards — doubling the imbalance instead of
508 /// removing it — and every test of `D`'s *magnitudes* would still pass.
509 ///
510 /// `x₁`'s curvature is 2e-8 against `x₀`'s 4 throughout the pencil, so
511 /// `x₁` must be ~10⁴ times larger than `x₀` for either form to be
512 /// O(1); the scaled variable `x̃₁ = d₁·x₁` is only O(1) if `d₁ ≪ d₀`.
513 #[test]
514 fn x_factors_invert_d() {
515 let prob = parse_nl_text(SPREAD_NL).expect("parse");
516 let sc = curvature_scaling(&prob).expect("quadratic");
517 assert!(
518 sc.x[1] < sc.x[0],
519 "the small-coefficient variable must be shrunk, not grown: \
520 d = {:?}",
521 sc.x
522 );
523 // Eight orders in the coefficients is four in the variable, and
524 // the ratio should be that big rather than a rounding away from 1.
525 assert!(
526 sc.x[0] / sc.x[1] > 1e2,
527 "expected a large ratio, got {:?}",
528 sc.x
529 );
530 }
531
532 /// Stage 2's whole content: after the row is scaled, the largest of
533 /// its three magnitudes is 1. Checked against the same three terms the
534 /// formula is built from, recomputed here from the model rather than
535 /// carried over from the implementation.
536 #[test]
537 fn the_row_scale_normalizes_the_row() {
538 let prob = parse_nl_text(SPREAD_NL).expect("parse");
539 let sc = curvature_scaling(&prob).expect("quadratic");
540 // Recover D from the emitted reciprocal.
541 let d: Vec<f64> = sc.x.iter().map(|v| 1.0 / v).collect();
542 let (hess, nl_lin, nl_const) = prob.con_nonlinear[0]
543 .analyze_quadratic_full()
544 .expect("quadratic row");
545 let mut row_sum: BTreeMap<usize, f64> = BTreeMap::new();
546 for (&(r, c), v) in &hess {
547 let s = (d[r] * v * d[c]).abs();
548 *row_sum.entry(r).or_insert(0.0) += s;
549 if r != c {
550 *row_sum.entry(c).or_insert(0.0) += s;
551 }
552 }
553 let q = row_sum.values().fold(0.0_f64, |m, &v| m.max(v));
554 let a = row_linear(&prob, 0, &nl_lin)
555 .iter()
556 .fold(0.0_f64, |m, (&j, v)| m.max((v * d[j]).abs()));
557 let b = row_rhs(&prob, 0, nl_const);
558 let scaled_max = q.max(a).max(b) * sc.g[0];
559 assert!(
560 (scaled_max - 1.0).abs() < 1e-12,
561 "scaled row magnitude should be exactly 1, got {scaled_max}"
562 );
563 }
564
565 /// `quad_row_coefs` reads the coefficients, not a point: `Q = diag(4,
566 /// 2e-8)` gives `‖Q‖_∞ = 4`, the row carries no linear part, and the
567 /// right-hand side is the one written in the file.
568 #[test]
569 fn quad_row_coefs_reads_the_file() {
570 let prob = parse_nl_text(SPREAD_NL).expect("parse");
571 let rows = quad_row_coefs(&prob);
572 assert_eq!(rows.len(), 1);
573 let r = &rows[0];
574 assert_eq!(r.index, 0);
575 assert!((r.curvature - 4.0).abs() < 1e-12);
576 assert_eq!(r.linear, 0.0);
577 assert!((r.rhs - 1.0e5).abs() < 1e-9);
578 }
579
580 /// gh #703 / gh#483: the `quadratic` flag distinguishes a model the
581 /// scheme *read curvature from* from one that merely satisfies its
582 /// degree-≤2 precondition. An LP is degree ≤ 2 with every `Qᵢ` empty,
583 /// so `curvature_scaling` returns factors — good ones, but the ones
584 /// plain Ruiz equilibration of `[A b]` would give, because there was no
585 /// second-order coefficient anywhere to read. The CLI spends the convex
586 /// fast path on this option only when the answer here is `true`; see
587 /// `decline_convex_for_curvature_scaling` in `pounce-cli`.
588 #[test]
589 fn quadratic_is_false_exactly_when_no_second_order_coefficient_exists() {
590 // A pure LP: `min x0 + x1` s.t. `x0 + x1 >= 1`, `x0, x1 >= 0`.
591 let lp = "\
592g3 0 1 0
593 2 1 1 0 0
594 0 0
595 0 0
596 0 0 0
597 0 0 0 1
598 0 0 0 0 0
599 2 2
600 0 0
601 0 0 0 0 0
602C0
603n0
604O0 0
605n0
606x2
6070 0
6081 0
609r
6102 1
611b
6122 0
6132 0
614k1
6152
616J0 2
6170 1
6181 1
619G0 2
6200 1
6211 1
622";
623 let prob = crate::nl_reader::parse_nl_text(lp).expect("parse LP");
624 let sc = curvature_scaling(&prob).expect("an LP is degree <= 2");
625 assert!(
626 !sc.quadratic,
627 "an LP has no `Q` to read; factors {:?} / {:?}",
628 sc.x, sc.g
629 );
630
631 // The same model with a quadratic objective bolted on is the other
632 // side of the same test: identical rows, one nonzero second-order
633 // coefficient, and the flag flips.
634 let qp = lp.replace(
635 "O0 0
636n0",
637 "O0 0
638o5
639v0
640n2",
641 );
642 let prob = crate::nl_reader::parse_nl_text(&qp).expect("parse QP");
643 let sc = curvature_scaling(&prob).expect("x0^2 is degree 2");
644 assert!(sc.quadratic, "`x0^2` is a second-order coefficient");
645 }
646}