Skip to main content

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}