pounce_nl/nl_quadratic.rs
1//! Degree-≤2 recognition over an [`Expr`] DAG.
2//!
3//! This is the classifier's "is this row a quadratic, and which one?"
4//! question, answered once and reused. `pounce-cli`'s `dispatch` owns the
5//! *routing* decision (which `ProblemClass`, which solver); what lives here
6//! is only the algebra, so the consumers that are not the CLI —
7//! `NlTnlp`'s constant-structure evaluation, the parse-time recognizer, the
8//! `QcqpProblem` extractor — can reach it without depending on the
9//! command-line driver. It moved out of `pounce-cli/src/dispatch.rs` in
10//! Q3 of the #588 series; see
11//! `dev-notes/quadratic-structure-exploitation.md`.
12//!
13//! ## Two properties this module is built around
14//!
15//! **It is iterative.** The walk carries its own work stack rather than
16//! recursing, because the trees it is handed are not shallow: a `.nl`
17//! writer that emits `o0` (binary `+`) chains for a long sum — Pyomo does —
18//! produces a left-deep `Add` tree one level per term. The recursive
19//! predecessor aborted the process somewhere between 4 000 and 6 000 terms
20//! on a 2 MB thread (which is what a test gets, and where the crash was
21//! first reproduced) and between 16 000 and 24 000 on the CLI's 8 MB main
22//! thread. A stack overflow is an abort, not an error return, so the depth a
23//! *recognizer* survives must not depend on which thread called it.
24//!
25//! It is worth knowing what this does **not** fix: `nl_reader`'s parser
26//! recurses too, with a fatter frame — it gives out at ~6 000 on that same
27//! 8 MB thread — so a deep `.nl` file still fails to load, and it fails
28//! before reaching this module. What is fixed is every path where the tree
29//! is already built (`NlProblem::from_expressions`, a model handed across
30//! threads) and the ceiling this module used to impose on the parser's
31//! successor.
32//!
33//! **It never allocates per monomial.** The predecessor keyed monomials on
34//! `BTreeMap<Vec<usize>, f64>`, so every term cost a heap allocation and
35//! every merge cloned one (`entry(m.clone())`). A degree-≤2 form has only
36//! three shapes of term, so [`Quad2`] stores them in three fields with
37//! inline keys and the allocation disappears. The same change removes the
38//! `O(N²)` accumulation on `Add` chains: the old `add` re-scanned the whole
39//! accumulated map for zeros on *every* merge, which is quadratic down a
40//! left-deep chain. Zeros can only appear where a merge touched, so that is
41//! all this one looks at.
42
43use crate::nl_reader::{BinOp, Expr, UnaryOp};
44use std::collections::{BTreeMap, BTreeSet};
45
46/// The symmetric Hessian of a quadratic form, stored as a sparse upper-
47/// triangular (i ≤ j) map of `(i, j) -> ∂²/∂xᵢ∂xⱼ`. Empty means the
48/// expression is (at most) linear.
49pub type QuadHessian = BTreeMap<(usize, usize), f64>;
50
51/// Full quadratic read-out: `(Hessian, [(var, linear coef), …], constant)`.
52/// The linear and constant parts are the pieces AMPL/Pyomo fold into the
53/// nonlinear objective tree (see [`analyze_quadratic_full`]).
54pub type QuadForm = (QuadHessian, Vec<(usize, f64)>, f64);
55
56/// A polynomial of total degree ≤ 2 in its own shape: a constant, the
57/// linear coefficients keyed by variable, and the quadratic coefficients
58/// keyed by the (i ≤ j) variable pair.
59///
60/// This replaces a general `BTreeMap<Vec<usize>, f64>` polynomial. Degree
61/// is a property of the *type* here rather than of the data, so the
62/// "is it still quadratic?" test is three `is_empty()` calls instead of a
63/// scan, and no monomial key is ever allocated or cloned.
64///
65/// ### Zero coefficients
66///
67/// Stored coefficients are nonzero: [`add`](Quad2::add) and
68/// [`mul`](Quad2::mul) drop any entry they leave at exactly zero, which is
69/// what makes [`degree`](Quad2::degree) and [`as_constant`](Quad2::as_constant)
70/// answerable in `O(1)`. `constant` is the one exception and needs none:
71/// both `0.0` and `-0.0` in that field mean "no constant term", every
72/// consumer guards on `!= 0.0`, and [`analyze_quadratic_full`] normalizes
73/// the sign on the way out.
74///
75/// ### …and when dropping one loses something
76///
77/// Dropping is right for the *storage* question and can be wrong for the
78/// *degree* question, and [`lost_terms`](Quad2::lost_terms) is the
79/// difference (gh #683, sharpened by gh #687). A coefficient that reaches
80/// zero is a coefficient that was **summed**, and it is the arithmetic of
81/// that sum — not the drop — that decides whether anything went missing:
82///
83/// * `x − x` folds `fl(1) + fl(−1)` to `0.0`, and that add is **exact**.
84/// The term really is absent, the body really is degree 0, and the maps
85/// are not a lower bound on anything.
86/// * `2⁵³·x² + x² − 2⁵³·x²` loses the `x²` at `fl(2⁵³ + 1) = 2⁵³`, which is
87/// an **inexact** add, and only then does `2⁵³ − 2⁵³` drop the survivor.
88/// The body is `x²`; the maps say degree 0.
89///
90/// The loss happens at the inexact fold; the drop is only where it becomes
91/// visible. So the flag is set from the *fold*: a form whose arithmetic
92/// never rounded — and never flushed a live coefficient to zero — carries
93/// coefficients that are exactly what real arithmetic on the writer's own
94/// literals would give, and a term missing from its maps is a term that is
95/// genuinely absent. Flagging the drop instead refused both of the above
96/// alike, which was sound and cost reach on the first (gh #687).
97#[derive(Debug, Clone, Default, PartialEq)]
98pub struct Quad2 {
99 constant: f64,
100 linear: BTreeMap<usize, f64>,
101 quadratic: QuadHessian,
102 /// Set when a term may be **missing** from the maps — see the type
103 /// docs and [`lost_terms`](Quad2::lost_terms). Sticky: it survives
104 /// every operation the form takes part in, because a term that went
105 /// missing three additions ago is no less missing now.
106 lost_terms: bool,
107 /// Set when some arithmetic in this form's construction **rounded**:
108 /// an add that could not represent its sum, or a product that could
109 /// not represent itself. Sticky, and for the same reason.
110 ///
111 /// Clear ⇒ every stored coefficient is exactly the real-arithmetic
112 /// value of the terms folded into it, so a coefficient that reached
113 /// zero reached it because the real value is zero. That is the whole
114 /// warrant for `lost_terms` staying clear across a cancellation, and
115 /// it is why an inexact *fold* sets this bit even when the coefficient
116 /// it produced is nonzero and stays stored: what it rounded away can
117 /// be cancelled out of sight later, by an add that is itself exact.
118 inexact: bool,
119}
120
121impl Quad2 {
122 /// The degree-0 term.
123 pub fn constant(&self) -> f64 {
124 self.constant
125 }
126
127 /// The degree-1 terms, ascending by variable index.
128 pub fn linear(&self) -> &BTreeMap<usize, f64> {
129 &self.linear
130 }
131
132 /// The degree-2 terms as *polynomial* coefficients keyed `(i ≤ j)` —
133 /// the coefficient of `xᵢxⱼ`, **not** the Hessian entry (they differ by
134 /// a factor of 2 on the diagonal; [`analyze_quadratic_full`] applies it).
135 pub fn quadratic(&self) -> &QuadHessian {
136 &self.quadratic
137 }
138
139 /// Whether a term may be **missing** from this form's maps: something
140 /// was dropped on the way here *and* the arithmetic that produced it
141 /// had already rounded, or a live coefficient was flushed to zero (or
142 /// to `NaN`) outright.
143 ///
144 /// This is what turns the term maps from an answer about degree into a
145 /// lower bound on it (gh #683). When it is set, an empty
146 /// [`quadratic`](Quad2::quadratic) map is no longer evidence that the
147 /// body is affine, and a consumer asking a *degree* question — as
148 /// opposed to a storage or evaluation question — must say "not
149 /// established" instead of "affine". The two questions used to share
150 /// one predicate, which is how a genuinely quadratic row came to be
151 /// reported as proved affine and had its Jacobian frozen for a whole
152 /// solve.
153 ///
154 /// It was originally set by the **drop** alone, which conflated an
155 /// exact cancellation with a lossy one and refused both (gh #687):
156 /// `x − x` is degree 0 by an exact add, and a consumer that treats it
157 /// as "not established" gives up a proved degree, a matrix evaluation
158 /// and — once the classifier gates on this too — the convex route, for
159 /// a body that lost nothing. The gate is now the fold, so the exact
160 /// case keeps its fast paths and the lossy one is refused for the
161 /// reason it deserves. [`inexact`](Quad2::inexact) is the other half of
162 /// that answer.
163 ///
164 /// It is deliberately one bit for the whole form rather than a record
165 /// per monomial: the consumer's question is about the form, and Q3's
166 /// point was that a `Quad2` allocates nothing per term (gh #588, Q3).
167 /// Two bits, now, and still nothing per term — the cost of a per-key
168 /// provenance record is what keeps this a *conservative* answer: an
169 /// inexact fold on one monomial makes a cancellation on an unrelated
170 /// one count as lossy.
171 ///
172 /// A *linear* or constant term that goes missing sets it too, even
173 /// though losing one cannot understate an affine body's degree by
174 /// itself: it can once the form is **multiplied**, because
175 /// [`mul`](Quad2::mul)'s degree guard reads the same maps —
176 /// `(2⁵³ + 1 − 2⁵³)·x · x²` is degree 3 and folds to an apparent
177 /// degree 0. Distinguishing the two would take a third flag to buy
178 /// back reach that the corpus does not contain.
179 pub fn lost_terms(&self) -> bool {
180 self.lost_terms
181 }
182
183 /// Whether any arithmetic behind this form's coefficients **rounded**.
184 ///
185 /// Clear means every stored coefficient is exact, which is what makes a
186 /// coefficient of zero a proof of absence rather than a lower bound —
187 /// see [`lost_terms`](Quad2::lost_terms), which is this bit read at the
188 /// moment a term is dropped. Public because it is the thing a test
189 /// about the sharpened gate has to be able to see; no consumer routes
190 /// on it.
191 pub fn inexact(&self) -> bool {
192 self.inexact
193 }
194
195 pub(crate) fn of_constant(c: f64) -> Self {
196 Quad2 {
197 // `-0.0` and `0.0` alike mean "no constant term"; normalizing
198 // here keeps `Neg(Const(0.0))` from reporting `-0.0`.
199 constant: if c != 0.0 { c } else { 0.0 },
200 ..Quad2::default()
201 }
202 }
203
204 pub(crate) fn of_var(i: usize) -> Self {
205 let mut q = Quad2::default();
206 q.linear.insert(i, 1.0);
207 q
208 }
209
210 /// Total degree: 0, 1, or 2.
211 pub(crate) fn degree(&self) -> usize {
212 if !self.quadratic.is_empty() {
213 2
214 } else if !self.linear.is_empty() {
215 1
216 } else {
217 0
218 }
219 }
220
221 /// The value, when this form has no variables in it.
222 pub(crate) fn as_constant(&self) -> Option<f64> {
223 (self.degree() == 0).then_some(self.constant)
224 }
225
226 /// Number of stored (nonzero) variable terms.
227 fn width(&self) -> usize {
228 self.linear.len() + self.quadratic.len()
229 }
230
231 /// `a + b`.
232 ///
233 /// Two things here are not incidental. **Only the entries the smaller
234 /// side contributes are re-checked for zero** — the predecessor
235 /// re-scanned the whole accumulated map on every merge, which is `O(k²)`
236 /// down a `k`-term `Add` chain, and an `o0` chain is how a long sum
237 /// reaches this code. And **the smaller side is merged into the larger**,
238 /// whichever operand it is, so the same chain costs `O(k log k)` leaning
239 /// either way rather than only when it leans left. Choosing the direction
240 /// is free of arithmetic consequence: IEEE addition is commutative bit
241 /// for bit, `-0.0` included.
242 pub(crate) fn add(a: Quad2, b: Quad2) -> Quad2 {
243 let (mut acc, small) = if a.width() >= b.width() {
244 (a, b)
245 } else {
246 (b, a)
247 };
248 // Whether anything either side already knew it had rounded. Read
249 // once, before any merge, so the verdict below does not depend on
250 // the order the maps happen to iterate in.
251 let carried_inexact = acc.inexact || small.inexact;
252 let (mut dropped, mut inexact) = (false, false);
253 if small.constant != 0.0 {
254 let was = acc.constant;
255 acc.constant += small.constant;
256 // Same rule as a coefficient, for the same reason: the degree-0
257 // term is exempt from the "no stored zeros" invariant but not
258 // from arithmetic, and `mul` reads `constant == 0.0` as
259 // *annihilates*. A constant that cancelled *inexactly* is not
260 // one that was proven absent (gh #683); one that cancelled
261 // exactly is (gh #687).
262 inexact |= !add_is_exact(was, small.constant, acc.constant);
263 dropped |= was != 0.0 && acc.constant == 0.0;
264 }
265 for (i, c) in &small.linear {
266 let m = merge(&mut acc.linear, *i, *c);
267 dropped |= m.dropped;
268 inexact |= m.inexact;
269 }
270 for (k, c) in &small.quadratic {
271 let m = merge(&mut acc.quadratic, *k, *c);
272 dropped |= m.dropped;
273 inexact |= m.inexact;
274 }
275 // A term is only *lost* if something was dropped and some fold on
276 // the way here could not represent itself. Neither half is enough
277 // alone: an exact cancellation drops a term that was really zero,
278 // and an inexact fold that keeps its coefficient has hidden
279 // nothing — yet.
280 acc.lost_terms |= small.lost_terms || (dropped && (carried_inexact || inexact));
281 acc.inexact = carried_inexact || inexact;
282 acc
283 }
284
285 pub(crate) fn neg(mut self) -> Quad2 {
286 self.constant = -self.constant;
287 for c in self.linear.values_mut() {
288 *c = -*c;
289 }
290 for c in self.quadratic.values_mut() {
291 *c = -*c;
292 }
293 self
294 }
295
296 /// `self · s`, for a scalar `s`.
297 ///
298 /// The `prune` is not decoration, and neither is the flag it sets. A
299 /// nonzero coefficient times a nonzero `s` can still land on zero by
300 /// underflow — `1e-300 x0²` divided by `1e300` is reachable from a real
301 /// `.nl` body, through [`Op::Div`]'s reciprocal — so this is the
302 /// gh #683 shape again, arrived at by scaling rather than by summing.
303 /// Leaving the flushed entry stored would make an arithmetically
304 /// constant form report degree 2 to [`Quad2::degree`] and be refused as
305 /// degree 3 the moment anything multiplied it; dropping it without
306 /// recording the loss would make a genuinely degree-2 body look affine
307 /// to [`Quad2::lost_terms`]'s consumers. `prune` does the first and
308 /// this does the second. (`neg` needs neither: negation cannot reach
309 /// zero from a coefficient that was not already there, and it never
310 /// rounds.)
311 ///
312 /// A coefficient here can only reach zero by **underflow** — both
313 /// factors are nonzero — so unlike a cancelling sum there is no exact
314 /// case to spare (gh #687): `10⁻³⁰⁰ · 10⁻³⁰⁰` is a term the form can no
315 /// longer see, whatever the flags said before.
316 pub(crate) fn scale(mut self, s: f64) -> Quad2 {
317 if s == 0.0 {
318 // An exact zero annihilates every term, so this is a proof of
319 // degree 0 rather than a loss — nothing is being discarded that
320 // survived the multiplication, and `x · 0` is exact. The flags
321 // still ride along: the one route here is division by an
322 // infinity, and `0 · NaN` is not zero.
323 return Quad2 {
324 lost_terms: self.lost_terms,
325 inexact: self.inexact,
326 ..Quad2::default()
327 };
328 }
329 let was = self.constant;
330 self.constant *= s;
331 if was != 0.0 {
332 self.lost_terms |= self.constant == 0.0;
333 self.inexact |= !mul_is_exact(was, s, self.constant);
334 }
335 let mut inexact = false;
336 for c in self.linear.values_mut() {
337 let was = *c;
338 *c *= s;
339 inexact |= !mul_is_exact(was, s, *c);
340 }
341 for c in self.quadratic.values_mut() {
342 let was = *c;
343 *c *= s;
344 inexact |= !mul_is_exact(was, s, *c);
345 }
346 self.inexact |= inexact;
347 // A scale can underflow a live coefficient to zero, which is a lost
348 // term and used to be stored as a zero.
349 self.lost_terms |= self.prune();
350 self
351 }
352
353 /// Restore the "no stored zeros" invariant after an operation wrote
354 /// coefficients in bulk. Returns whether anything was dropped; what
355 /// that *means* is the caller's to say, because it depends on how the
356 /// coefficients got there (see [`scale`](Quad2::scale) and
357 /// [`mul`](Quad2::mul)).
358 fn prune(&mut self) -> bool {
359 let before = self.width();
360 self.linear.retain(|_, c| is_live(*c));
361 self.quadratic.retain(|_, c| is_live(*c));
362 self.width() != before
363 }
364
365 /// `self / d`, for a constant `d` the caller has already checked is
366 /// nonzero.
367 ///
368 /// Scales by the **reciprocal** (not `c / d`) so the arithmetic matches
369 /// what the recursive predecessor produced bit for bit; what is new is
370 /// that `fl(1/d)` is the reciprocal only when `d` is a power of two,
371 /// and `fma(r, d, −1) == 0` says so exactly. Recording it matters
372 /// because a coefficient no real arithmetic produced can still cancel
373 /// *exactly* against another one later — and that cancellation would
374 /// otherwise be read as a proof of absence (gh #687).
375 pub(crate) fn div_by_constant(self, d: f64) -> Quad2 {
376 let r = 1.0 / d;
377 let exact = r.is_normal() && d.is_normal() && r.mul_add(d, -1.0) == 0.0;
378 let mut out = self.scale(r);
379 out.inexact |= !exact;
380 out
381 }
382
383 /// Take on another form's [`lost_terms`](Quad2::lost_terms) and
384 /// [`inexact`](Quad2::inexact) history.
385 ///
386 /// The operand a `Div` or a `Pow` reads as a *constant* is a [`Quad2`]
387 /// like any other, and its arithmetic is part of this form's: dividing
388 /// by `fl(10²⁰⁰ · 10²⁰⁰)` is dividing by an infinity, and dividing by a
389 /// constant that swallowed a variable term is worse than that. The
390 /// value is read out through [`as_constant`](Quad2::as_constant), which
391 /// keeps neither fact, so the caller hands the form itself over here.
392 pub(crate) fn absorb_flags(&mut self, other: &Quad2) {
393 self.lost_terms |= other.lost_terms;
394 self.inexact |= other.inexact;
395 }
396
397 /// `self · other`, or `None` when the product would exceed total
398 /// degree 2 — past that the recognizer gives up and the caller routes
399 /// to the general NLP path.
400 pub(crate) fn mul(&self, other: &Quad2) -> Option<Quad2> {
401 if self.degree() + other.degree() > 2 {
402 return None;
403 }
404 let mut out = Quad2::default();
405 // Either operand's missing term is missing from the product too,
406 // and so is either operand's rounding.
407 let mut lost = self.lost_terms || other.lost_terms;
408 let carried_inexact = self.inexact || other.inexact;
409 let mut inexact = false;
410 // A product of two live coefficients that lands on zero underflowed
411 // — `(10⁻²⁰⁰·x)·(10⁻²⁰⁰·x)` is one monomial whose coefficient is not
412 // representable (gh #683) — and unlike a cancelling *sum* there is
413 // no exact case to spare, so `lost` is set on the spot rather than
414 // left to the cancellation rule below.
415 let product = |a: f64, b: f64, lost: &mut bool, inexact: &mut bool| -> f64 {
416 let t = a * b;
417 *lost |= !is_live(t);
418 *inexact |= !mul_is_exact(a, b, t);
419 t
420 };
421 if self.constant != 0.0 && other.constant != 0.0 {
422 // `10⁻²⁰⁰ · 10⁻²⁰⁰` is not zero, and the branches below read a
423 // zero constant as an annihilating one — which would take a
424 // degree-2 product down to nothing without a trace (gh #683).
425 out.constant = product(self.constant, other.constant, &mut lost, &mut inexact);
426 }
427 let mut dropped = false;
428 // constant × (linear, quadratic), both ways round.
429 for (a, b) in [(self, other), (other, self)] {
430 if a.constant == 0.0 {
431 continue;
432 }
433 for (i, c) in &b.linear {
434 let t = product(a.constant, *c, &mut lost, &mut inexact);
435 let m = accumulate(&mut out.linear, *i, t);
436 dropped |= m.dropped;
437 inexact |= m.inexact;
438 }
439 for (k, c) in &b.quadratic {
440 let t = product(a.constant, *c, &mut lost, &mut inexact);
441 let m = accumulate(&mut out.quadratic, *k, t);
442 dropped |= m.dropped;
443 inexact |= m.inexact;
444 }
445 }
446 // linear × linear. The degree guard above means at most one of the
447 // two operands carries quadratic terms, so this runs only when
448 // neither does and no ordering question arises.
449 for (i, a) in &self.linear {
450 for (j, b) in &other.linear {
451 let key = (*i.min(j), *i.max(j));
452 let t = product(*a, *b, &mut lost, &mut inexact);
453 let m = accumulate(&mut out.quadratic, key, t);
454 dropped |= m.dropped;
455 inexact |= m.inexact;
456 }
457 }
458 // What `prune` drops here that `accumulate` did not already see is a
459 // key whose *first* contribution was a zero — an underflowed product,
460 // and `lost` is set for it above. So the cancellation rule is the
461 // same one `add` applies: a drop counts as a loss only when some fold
462 // behind it rounded.
463 dropped |= out.prune();
464 out.lost_terms = lost || (dropped && (carried_inexact || inexact));
465 out.inexact = carried_inexact || inexact;
466 Some(out)
467 }
468}
469
470/// What folding one coefficient into a map did — the two facts
471/// [`Quad2::lost_terms`] is decided from.
472#[derive(Clone, Copy)]
473struct Merged {
474 /// The key is no longer stored: the fold reached exactly zero, or
475 /// `NaN`.
476 dropped: bool,
477 /// The fold **rounded**: what is stored (or what cancelled) is not what
478 /// exact arithmetic on the same two numbers would have left.
479 inexact: bool,
480}
481
482/// Add `c` to `map[key]`, keeping the "no stored zeros" invariant. Returns
483/// what that did, as [`Merged`].
484///
485/// Only the touched key can have become zero, which is what keeps a merge
486/// proportional to what it merged rather than to what it merged *into*.
487fn merge<K: Ord>(map: &mut BTreeMap<K, f64>, key: K, c: f64) -> Merged {
488 use std::collections::btree_map::Entry;
489 match map.entry(key) {
490 Entry::Occupied(mut e) => {
491 let a = *e.get();
492 let v = a + c;
493 let inexact = !add_is_exact(a, c, v);
494 if is_live(v) {
495 e.insert(v);
496 Merged {
497 dropped: false,
498 inexact,
499 }
500 } else {
501 e.remove();
502 Merged {
503 dropped: true,
504 inexact,
505 }
506 }
507 }
508 // Nothing was added to anything: a live `c` is stored as it stands,
509 // and a dead one is only reachable from a caller that already knows
510 // what its own zero means (`mul`'s underflowed products; `add` only
511 // ever passes stored — hence live — coefficients).
512 Entry::Vacant(e) => {
513 if is_live(c) {
514 e.insert(c);
515 Merged {
516 dropped: false,
517 inexact: false,
518 }
519 } else {
520 Merged {
521 dropped: true,
522 inexact: c.is_nan(),
523 }
524 }
525 }
526 }
527}
528
529/// Accumulate `t` into `map[key]`, the way [`Quad2::mul`] builds a product
530/// up term by term, and report what the fold did.
531///
532/// Unlike [`merge`] this leaves a zero stored — `mul` prunes once at the
533/// end — because a key it lands on twice must see the first contribution as
534/// a plain `0.0 + t`, bit for bit what the `+=` this replaced produced.
535/// Only a key that was already nonzero can be said to have *dropped*
536/// anything.
537fn accumulate<K: Ord>(map: &mut BTreeMap<K, f64>, key: K, t: f64) -> Merged {
538 let slot = map.entry(key).or_insert(0.0);
539 let was = *slot;
540 *slot = was + t;
541 Merged {
542 dropped: was != 0.0 && !is_live(*slot),
543 inexact: !add_is_exact(was, t, *slot),
544 }
545}
546
547/// The exactness predicates this recognizer's fold is decided from live in
548/// `pounce-common` since gh #673, because the `.nl` pipeline grew a second
549/// fold — `Σ 2wₖbₖbₖᵀ` in `QuadraticStructure::push_factored_form` — in a
550/// crate that cannot name this one. See [`pounce_common::exact`] for what
551/// they answer and why it is an exact question rather than a tolerance.
552use pounce_common::exact::{add_is_exact, is_live, mul_is_exact};
553
554/// One entry on the recognizer's explicit work stack.
555enum Step<'a> {
556 /// Lower this subexpression onto the value stack.
557 Visit(&'a Expr),
558 /// Combine values already on the value stack.
559 Apply(Op),
560}
561
562/// A pending combination, popped once its operands have been lowered.
563enum Op {
564 Neg,
565 Add,
566 Sub,
567 Mul,
568 Div,
569 Pow,
570 /// n-ary sum over the top `n` values.
571 Sum(usize),
572 /// Not an operation: the value now on top of the stack is the lowering
573 /// of the `Cse` body with this address, so record it before moving on.
574 CacheCse(*const Expr),
575}
576
577/// Lower an [`Expr`] to a [`Quad2`], or `None` if it contains anything the
578/// recognizer cannot prove is a degree-≤2 polynomial (transcendental ops,
579/// division by a non-constant, `Pow` with an exponent ∉ {0, 1, 2}, products
580/// of degree > 2, external calls, comparisons, `if-then-else`, `min`/`max`,
581/// …). `None` ⇒ treat as general nonlinear.
582///
583/// `Cse` nodes are inlined: a reference is mathematically its body, and
584/// every reference is an independent occurrence. A body is nevertheless
585/// lowered **once**, and its [`Quad2`] reused at every later reference
586/// (keyed on `Arc` identity). That is what makes the walk `Θ(nodes)` on a
587/// shared DAG instead of `Θ(2^depth)`; Q4 had to refuse a re-referenced
588/// body outright to bound the cost, and this is the memoization that
589/// refusal was waiting for (gh #588, Q5). It is bitwise neutral — the
590/// lowering of a body is a function of the body, so the reused value is
591/// the one a second lowering would have produced, bit for bit.
592///
593/// The walk is iterative. See the module docs for why that is a
594/// correctness property and not a style choice.
595pub fn recognize_expr(e: &Expr) -> Option<Quad2> {
596 recognize_expr_seeded(e, &|_| None)
597}
598
599/// What a caller already knows about a `Cse` body's lowering: `None` "not
600/// known, walk it", `Some(None)` "known not to lower", `Some(Some(q))` "lowers
601/// to `q`".
602pub(crate) type QuadSeed<'s> = &'s dyn Fn(*const Expr) -> Option<Option<Quad2>>;
603
604/// [`recognize_expr`], with the lowering of earlier `Cse` bodies supplied by
605/// the caller (gh#986 review). The parse-time recognizer lowers every `V`
606/// segment once as it is read; without the seed each segment re-walked the
607/// whole chain of defined variables below it, which is quadratic over a long
608/// chain. Bitwise neutral for the same reason the in-walk memo is: a body's
609/// lowering is a function of the body.
610pub(crate) fn recognize_expr_seeded(e: &Expr, seed: QuadSeed<'_>) -> Option<Quad2> {
611 let mut work: Vec<Step<'_>> = vec![Step::Visit(e)];
612 let mut vals: Vec<Quad2> = Vec::new();
613 // Lowered `Cse` bodies, by address. Only successes land here: a body
614 // that fails aborts the whole walk, so there is no negative result to
615 // remember.
616 let mut cse: std::collections::HashMap<*const Expr, Quad2> = std::collections::HashMap::new();
617
618 while let Some(step) = work.pop() {
619 match step {
620 Step::Visit(e) => match e {
621 Expr::Const(c) => vals.push(Quad2::of_constant(*c)),
622 Expr::Var(i) => vals.push(Quad2::of_var(*i)),
623 Expr::Cse(body) => {
624 let key = std::sync::Arc::as_ptr(body);
625 match cse.get(&key) {
626 Some(q) => vals.push(q.clone()),
627 None => match seed(key) {
628 Some(Some(q)) => {
629 cse.insert(key, q.clone());
630 vals.push(q);
631 }
632 Some(None) => return None,
633 None => {
634 work.push(Step::Apply(Op::CacheCse(key)));
635 work.push(Step::Visit(body));
636 }
637 },
638 }
639 }
640 Expr::Sum(items) => {
641 work.push(Step::Apply(Op::Sum(items.len())));
642 // Pushed forward, so they pop back to front and land on
643 // the value stack with item 0 on top — which puts item
644 // 0 at the *end* of the region `Op::Sum` drains, and
645 // item n-1 at its start. The fold there walks that
646 // region in reverse so the sum still accumulates front
647 // to back, the order the recursive version summed in.
648 for it in items {
649 work.push(Step::Visit(it));
650 }
651 }
652 Expr::Unary(UnaryOp::Neg, a) => {
653 work.push(Step::Apply(Op::Neg));
654 work.push(Step::Visit(a));
655 }
656 // Every other unary op is transcendental.
657 Expr::Unary(..) => return None,
658 Expr::Binary(op, a, b) => {
659 let op = match op {
660 BinOp::Add => Op::Add,
661 BinOp::Sub => Op::Sub,
662 BinOp::Mul => Op::Mul,
663 BinOp::Div => Op::Div,
664 BinOp::Pow => Op::Pow,
665 // atan2 and any other binary opcode.
666 _ => return None,
667 };
668 work.push(Step::Apply(op));
669 // `b` under `a`: `a` pops first and is lowered first.
670 work.push(Step::Visit(b));
671 work.push(Step::Visit(a));
672 }
673 // External calls are opaque; comparisons, logicals,
674 // conditionals and n-ary min/max are the control-flow `.nl`
675 // opcodes. None is provably polynomial ⇒ route to NLP.
676 _ => return None,
677 },
678 Step::Apply(Op::CacheCse(key)) => {
679 // The body's value is already on the stack and stays there;
680 // this only records it for the next reference.
681 cse.insert(key, vals.last()?.clone());
682 }
683 Step::Apply(op) => {
684 let combined = match op {
685 Op::CacheCse(_) => unreachable!("handled above"),
686 Op::Sum(n) => {
687 // The items are the top `n` values, item 0 on top,
688 // so item 0 is the *last* thing `drain` yields and
689 // item n-1 the first. Reverse it: floating-point
690 // addition is not associative, and summing back to
691 // front would disagree with the recursive
692 // predecessor — and with the AD tape — by an ulp
693 // whenever two items share a monomial key.
694 let at = vals.len().checked_sub(n)?;
695 let mut acc = Quad2::default();
696 for p in vals.drain(at..).rev() {
697 acc = Quad2::add(acc, p);
698 }
699 acc
700 }
701 Op::Neg => vals.pop()?.neg(),
702 Op::Add => {
703 let (a, b) = pop2(&mut vals)?;
704 Quad2::add(a, b)
705 }
706 Op::Sub => {
707 let (a, b) = pop2(&mut vals)?;
708 Quad2::add(a, b.neg())
709 }
710 Op::Mul => {
711 let (a, b) = pop2(&mut vals)?;
712 a.mul(&b)?
713 }
714 Op::Div => {
715 // Division is polynomial only by a nonzero constant,
716 // and scales by the reciprocal (not `c / d`) so the
717 // arithmetic matches what the recursive predecessor
718 // produced bit for bit.
719 let (a, b) = pop2(&mut vals)?;
720 let d = b.as_constant()?;
721 if d == 0.0 {
722 return None;
723 }
724 let mut out = a.div_by_constant(d);
725 // The divisor's own history comes along: `as_constant`
726 // reads a number out of a form and leaves the flags
727 // behind.
728 out.absorb_flags(&b);
729 out
730 }
731 Op::Pow => {
732 // Polynomial only for constant exponents in {0, 1, 2}.
733 let (a, b) = pop2(&mut vals)?;
734 let exp = b.as_constant()?;
735 let mut out = if exp == 0.0 {
736 Quad2::of_constant(1.0)
737 } else if exp == 1.0 {
738 a
739 } else if exp == 2.0 {
740 a.mul(&a)?
741 } else {
742 return None;
743 };
744 // Same as `Div`: the exponent is read out of a form,
745 // and one that lost a term is not the exponent it
746 // looks like.
747 out.absorb_flags(&b);
748 out
749 }
750 };
751 vals.push(combined);
752 }
753 }
754 }
755
756 debug_assert_eq!(vals.len(), 1, "one value per lowered expression");
757 vals.pop()
758}
759
760/// Pop a binary operator's two operands, left first.
761fn pop2(vals: &mut Vec<Quad2>) -> Option<(Quad2, Quad2)> {
762 let b = vals.pop()?;
763 let a = vals.pop()?;
764 Some((a, b))
765}
766
767/// Attempt to read an expression as a polynomial of total degree ≤ 2 and
768/// return its Hessian (constant, since the form is quadratic). `None` if
769/// the expression is not provably quadratic ⇒ treat as general nonlinear.
770pub fn analyze_quadratic(e: &Expr) -> Option<QuadHessian> {
771 analyze_quadratic_full(e).map(|(h, _, _)| h)
772}
773
774/// Like [`analyze_quadratic`] but also returns the degree-1 (linear)
775/// coefficients *and* the degree-0 (constant) term of the form:
776/// `(Hessian, [(var, coef), …], constant)`.
777///
778/// AMPL folds the linear part of a nonlinear term into the objective's
779/// nonlinear expression tree (the `−6·x₀` of `(x₀−3)²`, say) rather than
780/// the linear section. Callers building the QP objective vector `c` must
781/// add these in, exactly as the NLP path's `eval_f` sums the linear
782/// section *and* the nonlinear tree — otherwise the linear shift is
783/// silently dropped and the convex solve minimizes the wrong objective.
784///
785/// The **constant** is returned for the same reason: AMPL/Pyomo also fold
786/// the objective's degree-0 term into the nonlinear tree (the `+9` of
787/// `(x₀−3)²`), where it does *not* land in `NlProblem::obj_constant`. It
788/// is irrelevant to the minimizer but is part of the *reported objective
789/// value*; dropping it makes the convex solve report an objective off by
790/// that constant versus the NLP path.
791pub fn analyze_quadratic_full(e: &Expr) -> Option<QuadForm> {
792 Some(quad_form_readout(&recognize_expr(e)?))
793}
794
795/// The `(Hessian, linear, constant)` read-out of an already-recognized
796/// form — the second half of [`analyze_quadratic_full`], split out so a
797/// caller holding a [`Quad2`] the *parser* produced (gh #588, Q5) reaches
798/// the identical numbers by the identical route. There is exactly one
799/// conversion in the crate; a second one is a second thing to keep in step.
800pub fn quad_form_readout(q: &Quad2) -> QuadForm {
801 // ∂²(c·xᵢxⱼ)/∂xᵢ∂xⱼ = c for i≠j; ∂²(c·xᵢ²)/∂xᵢ² = 2c.
802 let mut h: QuadHessian = q
803 .quadratic
804 .iter()
805 .map(|(&(i, j), c)| ((i, j), if i == j { 2.0 * c } else { *c }))
806 .collect();
807 // Drop explicit zeros so `is_empty()` means "linear".
808 h.retain(|_, v| v.abs() > 0.0);
809 let lin: Vec<(usize, f64)> = q.linear.iter().map(|(i, c)| (*i, *c)).collect();
810 // `0.0 +` normalizes `-0.0`, which is how this form spells "absent".
811 (h, lin, 0.0 + q.constant)
812}
813
814/// Is this expression **already** a flat sum of monomials — that is, would
815/// reading it as `½xᵀHx + aᵀx + c` reproduce the same additions the writer
816/// wrote, rather than algebraically expanding something it did not?
817///
818/// This is the gate on the constant-structure evaluator (gh #588, Q4), and
819/// it exists because expanding a quadratic is not an accuracy-neutral
820/// rewrite. `(xᵢ − xⱼ)²` evaluated as written squares a small residual;
821/// evaluated as `xᵢ² − 2xᵢxⱼ + xⱼ²` it cancels two large numbers, and on
822/// `airport.nl` — 84 coordinates around 10³, every row a squared distance —
823/// that difference is enough to take an adaptive-μ solve from stopping at a
824/// tiny step in 16 iterations to grinding out the 300-iteration cap at the
825/// same objective. Which is precisely the gh #544 failure mode: the right
826/// answer, slowly.
827///
828/// So the rule is *exactness*, not magnitude: a form is admitted only when
829/// the recognizer's read-out does no algebra the tape would not have done.
830/// A sum of monomials qualifies — which is exactly how AMPL emits the
831/// `qcqp*` family (`o54` over `o2 n0.5 o2 o2 n<c> v<i> v<j>`), so the target
832/// of the phase is unaffected — and a `Pow` or a `Mul` over a non-atomic
833/// operand does not.
834///
835/// What this predicate is **not** is a verdict on whether a body can be
836/// evaluated from constant structure at all. It is a verdict on one
837/// *representation*. Q4 shipped as though the two were the same, and the
838/// consequence was that a model written in factored form kept its tape and
839/// gained nothing — 41 of `airport.nl`'s 42 rows, and every sum of squared
840/// residuals ever written. gh #673 is that gap, and
841/// [`recognize_factored_quadratic`] closes it by keeping the writer's own
842/// grouping instead of expanding it: same constant structure, no algebra
843/// the tape did not do. A body this predicate refuses is offered to that
844/// one before it falls back to a tape.
845///
846/// ## A re-referenced `Cse` body is *skipped*, not refused
847///
848/// This walk and [`recognize_expr`] behind it both inline a `Cse` body at
849/// **every** reference, so a DAG whose bodies are shared cost `Θ(2^depth)`
850/// rather than `Θ(nodes)` before either was memoized. `nl_reader`'s
851/// `shared_dag_walks_are_memoized_not_exponential` builds exactly that shape
852/// — 30 levels, each a `Cse` referenced twice — and taking it through this
853/// gate in Q4 measured **0.00 s → 172 s** on a model the tape path loads
854/// instantly. At depth 40 it would not have returned at all.
855///
856/// Q4 bounded that by ending the walk in `false` at the second reference,
857/// which cost reach to buy termination and was explicitly left for Q5 to
858/// replace. It is replaced here: a body reached a second time in the same
859/// mode is **skipped**, which is sound for exactly the reason
860/// `nl_reader::validate_expr` documents for the same trick — this walk
861/// aborts on the first violation, so reaching a body a second time proves
862/// the first visit found none. The verdict is unchanged for every DAG that
863/// is a tree, and a shared DAG that *is* an expanded quadratic is now
864/// admitted instead of refused. The mode is part of the key: a body is
865/// legal on the sum spine if it is a sum of monomials, and legal inside a
866/// monomial only if it is itself a monomial, so the two answers are cached
867/// apart.
868///
869/// Iterative for the same reason [`recognize_expr`] is.
870pub fn is_expanded_quadratic(e: &Expr) -> bool {
871 is_expanded_quadratic_seeded(e, &|_, _| None)
872}
873
874/// A caller's verdict on a `Cse` body, in sum-spine (`false`) or monomial
875/// (`true`) mode; `None` "not known, walk it" (gh#986 review).
876pub(crate) type ShapeSeed<'s> = &'s dyn Fn(*const Expr, bool) -> Option<bool>;
877
878/// [`is_expanded_quadratic`] with earlier `Cse` verdicts supplied by the
879/// caller; see [`recognize_expr_seeded`] for why.
880pub(crate) fn is_expanded_quadratic_seeded(e: &Expr, seed: ShapeSeed<'_>) -> bool {
881 // The sum spine: `Add`/`Sub`/`Neg`/`Sum` may nest freely, and every
882 // leaf of that spine must be a monomial.
883 let mut seen: BTreeSet<(*const Expr, bool)> = BTreeSet::new();
884 let mut spine: Vec<&Expr> = vec![e];
885 while let Some(e) = spine.pop() {
886 match e {
887 Expr::Sum(items) => spine.extend(items.iter()),
888 Expr::Binary(BinOp::Add | BinOp::Sub, a, b) => {
889 spine.push(a);
890 spine.push(b);
891 }
892 Expr::Unary(UnaryOp::Neg, a) => spine.push(a),
893 Expr::Cse(body) => {
894 let key = std::sync::Arc::as_ptr(body);
895 if seen.insert((key, false)) {
896 match seed(key, false) {
897 Some(true) => {}
898 Some(false) => return false,
899 None => spine.push(body),
900 }
901 }
902 }
903 other => {
904 if !is_monomial(other, &mut seen, seed) {
905 return false;
906 }
907 }
908 }
909 }
910 true
911}
912
913/// Is this expression a single monomial — the leaf shape
914/// [`is_expanded_quadratic`] admits on its sum spine?
915///
916/// Public because the parse-time recognizer has to answer the same
917/// question about a `V`-segment body it has already parsed, and must
918/// answer it with *this* code rather than a second copy of the rule.
919pub fn is_monomial_expr(e: &Expr) -> bool {
920 is_monomial_expr_seeded(e, &|_, _| None)
921}
922
923/// [`is_monomial_expr`] with earlier `Cse` verdicts supplied by the caller.
924pub(crate) fn is_monomial_expr_seeded(e: &Expr, seed: ShapeSeed<'_>) -> bool {
925 let mut seen: BTreeSet<(*const Expr, bool)> = BTreeSet::new();
926 is_monomial(e, &mut seen, seed)
927}
928
929/// A single product term: constants and variables multiplied together, with
930/// no addition anywhere inside it. `xᵢxⱼ`, `0.5·c·xᵢ·xⱼ`, `xᵢ²` and `xᵢ/3`
931/// all qualify; `(xᵢ − xⱼ)²` and `(xᵢ + 1)·xⱼ` do not.
932///
933/// Degree is not checked here — [`recognize_expr`] already refused anything
934/// past 2 by the time this runs, and duplicating the rule would only give
935/// the two a way to disagree.
936fn is_monomial(e: &Expr, seen: &mut BTreeSet<(*const Expr, bool)>, seed: ShapeSeed<'_>) -> bool {
937 let mut work: Vec<&Expr> = vec![e];
938 while let Some(e) = work.pop() {
939 match e {
940 Expr::Const(_) | Expr::Var(_) => {}
941 // Shared with the spine walk, and skipped on revisit for the
942 // same reason — see [`is_expanded_quadratic`]. The `true` in the
943 // key is "seen in monomial mode": a body cleared on the spine
944 // has not been cleared here.
945 Expr::Cse(body) => {
946 let key = std::sync::Arc::as_ptr(body);
947 if seen.insert((key, true)) {
948 match seed(key, true) {
949 Some(true) => {}
950 Some(false) => return false,
951 None => work.push(body),
952 }
953 }
954 }
955 Expr::Unary(UnaryOp::Neg, a) => work.push(a),
956 Expr::Binary(BinOp::Mul | BinOp::Div, a, b) => {
957 work.push(a);
958 work.push(b);
959 }
960 // `x^2` is one monomial; `(x - y)^2` is an expansion. The
961 // exponent itself may be any constant expression — the
962 // recognizer has already restricted it to {0, 1, 2}.
963 Expr::Binary(BinOp::Pow, a, b) => {
964 if !matches!(
965 a.as_ref(),
966 Expr::Const(_) | Expr::Var(_) | Expr::Unary(UnaryOp::Neg, _)
967 ) {
968 return false;
969 }
970 work.push(a);
971 work.push(b);
972 }
973 _ => return false,
974 }
975 }
976 true
977}
978
979/// True if the expression is the literal constant zero the `.nl` reader
980/// uses for "no nonlinear part".
981pub fn is_trivially_zero(e: &Expr) -> bool {
982 matches!(e, Expr::Const(c) if *c == 0.0)
983}
984
985// ---------------------------------------------------------------------
986// Factored forms — the writer's own grouping, kept
987// ---------------------------------------------------------------------
988
989/// One `w·(bᵀx + d)²` term of a [`FactoredQuadratic`].
990///
991/// `coefs` is `b`, ascending by variable index and free of stored zeros —
992/// the same convention [`Quad2::linear`] keeps, because that is where it
993/// comes from. It may be **empty**, which is a square of a constant: the
994/// term is then `w·d²`, contributes nothing to the gradient or the
995/// Hessian, and is kept rather than folded so that the value is still
996/// computed the way it was written.
997#[derive(Debug, Clone, PartialEq)]
998pub struct SquaredAffine {
999 /// The constant the square is multiplied by, sign of the sum spine
1000 /// already folded in.
1001 pub weight: f64,
1002 /// `b`, ascending by variable index.
1003 pub coefs: Vec<(usize, f64)>,
1004 /// `d`.
1005 pub constant: f64,
1006}
1007
1008/// A degree-2 form kept as `Σ wₖ(bₖᵀx + dₖ)² + aᵀx + c` — the shape the
1009/// `.nl` writer wrote, rather than its expansion about the origin.
1010///
1011/// This is the representation gh #673 asks for and
1012/// [`is_expanded_quadratic`] exists to refuse the alternative to. Reading
1013/// `(x − 500000)²` back as `x² − 10⁶x + 2.5·10¹¹` cancels five digits;
1014/// reading it back as one squared residual repeats exactly the
1015/// multiplication the tape performs, so admitting it costs no accuracy at
1016/// all.
1017///
1018/// The degree-≤1 leftovers (`linear`, `constant`) are the monomials the
1019/// writer folded into the same tree — the `+ 3y` of `(x − 1)² + 3y`. They
1020/// are summed as coefficients rather than kept term by term, which is the
1021/// same reassociation the expanded path already makes and is gated the
1022/// same way (see [`Quad2::lost_terms`]).
1023#[derive(Debug, Clone, PartialEq)]
1024pub struct FactoredQuadratic {
1025 /// The squared affine terms, in the order the spine walk met them.
1026 pub squares: Vec<SquaredAffine>,
1027 /// `a`, ascending by variable index.
1028 pub linear: Vec<(usize, f64)>,
1029 /// `c`.
1030 pub constant: f64,
1031}
1032
1033/// Recognize a degree-2 body as a sum of **squared affine forms** plus
1034/// degree-≤1 leftovers, keeping the squares factored (gh #673).
1035///
1036/// This is the second half of the gate [`is_expanded_quadratic`] opens.
1037/// That predicate admits a body whose read-out repeats the additions the
1038/// writer wrote; a factored body fails it, and used to keep its AD tape for
1039/// good reason — expanding it to the origin cancels. What this returns is
1040/// the *third* option: a read-out that is not an expansion either, because
1041/// it stores the writer's own linear forms and squares them at evaluation
1042/// time exactly as the tape does.
1043///
1044/// ## What is admitted
1045///
1046/// The sum spine (`Add`/`Sub`/`Neg`/`Sum`) may nest freely. Every leaf of
1047/// it must be one of:
1048///
1049/// * a **square**: a product of constants with exactly one `Pow(base, 2)`,
1050/// where `base` is itself an [`is_expanded_quadratic`] body of degree
1051/// ≤ 1 — `(xᵢ − xⱼ)²`, `0.5·(2x − y + 3)²`, `x²`;
1052/// * a **degree-2 monomial on the diagonal**, `c·xᵢ²`, which is the same
1053/// term wearing a different opcode and is stored as `c·(xᵢ)²`;
1054/// * a **degree-≤1 monomial**, which folds into `a` and `c`.
1055///
1056/// Anything else — a cross monomial `c·xᵢxⱼ`, a product of two *different*
1057/// affine forms, a transcendental — refuses the whole body, which then
1058/// keeps its tape exactly as it did before this function existed. The
1059/// refusal is deliberate rather than incidental: a mixed form would need
1060/// both an expanded and a factored quadratic part stored side by side, and
1061/// nothing in the corpus asks for one.
1062///
1063/// At least one square with a variable in it is required. Without that the
1064/// body is affine or already expanded, and [`is_expanded_quadratic`] is
1065/// the path that serves it.
1066///
1067/// ## Exactness
1068///
1069/// Each admitted square evaluates as `w·(d + Σbᵢxᵢ)²`. Against the tape
1070/// that is a reassociation of an affine sum and a folding of the writer's
1071/// own constants into `w` — the same latitude [`Quad2::add`] and
1072/// [`Quad2::scale`] already take on the expanded path — and *not* an
1073/// algebraic expansion. The `2.4e-5` disagreement gh #673 records for
1074/// `(x − 500000)²` comes from the expansion; there is none here. Measured
1075/// end to end: with the outer sum left naive, `airport.nl`'s 42 rows
1076/// evaluate bit-identically to the 42 tapes they replace.
1077///
1078/// The *terms* are the tape's; the sum over them is not, because
1079/// `QuadraticStructure::value` compensates it (gh #702). That makes the
1080/// read-out slightly better than the tape rather than equal to it, which is
1081/// gh #702's deliberate trade and the one line the fixture sweep moves.
1082///
1083/// The degree-≤1 leftovers are held to the same [`Quad2::lost_terms`] gate
1084/// the expanded path uses (gh #685), so a leftover that cancelled inexactly
1085/// refuses the body rather than dropping a term out of it.
1086///
1087/// ## A `Cse` on the sum spine refuses the body
1088///
1089/// [`is_expanded_quadratic`] may *skip* a re-referenced body because it
1090/// answers yes/no and the first visit already decided it. This one
1091/// accumulates terms, so skipping would silently drop them and visiting
1092/// every reference is `Θ(2^depth)` on a shared DAG — the shape
1093/// `nl_reader`'s `shared_dag_walks_are_memoized_not_exponential` builds.
1094/// So a `Cse` reached on the spine ends the walk, and the body keeps its
1095/// tape, which is what `ConHybrid` is for. Inside a square's base or a
1096/// monomial there is no such problem: [`recognize_expr`] memoizes on `Arc`
1097/// identity and the two predicates answer yes/no.
1098///
1099/// Iterative, for the reason the module docs give.
1100pub fn recognize_factored_quadratic(e: &Expr) -> Option<FactoredQuadratic> {
1101 let mut seen: BTreeSet<(*const Expr, bool)> = BTreeSet::new();
1102 // The spine, each entry carrying the sign the enclosing `Sub`/`Neg`
1103 // chain gives it. `±1` exactly, so folding it into a weight or negating
1104 // a form is not arithmetic.
1105 let mut spine: Vec<(&Expr, f64)> = vec![(e, 1.0)];
1106 let mut squares: Vec<SquaredAffine> = Vec::new();
1107 let mut rest = Quad2::default();
1108
1109 while let Some((e, sign)) = spine.pop() {
1110 match e {
1111 // Pushed back to front so the leaves *pop* in source order.
1112 // The degree-≤1 leftovers are folded into one `Quad2` by
1113 // floating-point addition, and source order is the order a
1114 // reader can reason about — not, to be exact about what this
1115 // buys, the same association `recognize_expr` uses: that folds
1116 // the *tree*, and `Quad2::add` additionally swaps its operands
1117 // by map width. So the two can still differ in the last ulp on
1118 // a leaning spine. What keeps that from mattering is
1119 // `lost_terms`, which refuses any body where the difference
1120 // could be a dropped term rather than a rounded one.
1121 Expr::Sum(items) => spine.extend(items.iter().rev().map(|it| (it, sign))),
1122 Expr::Binary(BinOp::Add, a, b) => {
1123 spine.push((b, sign));
1124 spine.push((a, sign));
1125 }
1126 Expr::Binary(BinOp::Sub, a, b) => {
1127 spine.push((b, -sign));
1128 spine.push((a, sign));
1129 }
1130 Expr::Unary(UnaryOp::Neg, a) => spine.push((a, -sign)),
1131 // See the docs: accumulating terms is what makes skipping
1132 // unsound here, and visiting is what makes it exponential.
1133 Expr::Cse(_) => return None,
1134 leaf => {
1135 if let Some((weight, base)) = peel_square(leaf) {
1136 squares.push(admit_square(sign * weight, base)?);
1137 continue;
1138 }
1139 if !is_monomial(leaf, &mut seen, &|_, _| None) {
1140 return None;
1141 }
1142 let q = recognize_expr(leaf)?;
1143 match diagonal_square(&q) {
1144 Some((i, c)) => squares.push(SquaredAffine {
1145 weight: sign * c,
1146 coefs: vec![(i, 1.0)],
1147 constant: 0.0,
1148 }),
1149 // A cross term `c·xᵢxⱼ` lands here and refuses: it is
1150 // not a square, and storing it expanded alongside the
1151 // squares is the mixed representation this refuses.
1152 None if !q.quadratic().is_empty() => return None,
1153 None => rest = Quad2::add(rest, if sign < 0.0 { q.neg() } else { q }),
1154 }
1155 }
1156 }
1157 }
1158
1159 // Nothing factored to keep ⇒ this is not our case. `is_expanded_quadratic`
1160 // serves an expanded body and an affine one needs no form at all.
1161 if !squares.iter().any(|t| !t.coefs.is_empty()) {
1162 return None;
1163 }
1164 // The leftovers are held to the expanded path's gate, for its reasons
1165 // (gh #685): a term that went missing inexactly is a term the form no
1166 // longer evaluates.
1167 if !rest.quadratic().is_empty() || rest.lost_terms() {
1168 return None;
1169 }
1170 Some(FactoredQuadratic {
1171 squares,
1172 linear: rest.linear().iter().map(|(&i, &c)| (i, c)).collect(),
1173 // `0.0 +` normalizes `-0.0`, which is how a `Quad2` spells "absent".
1174 constant: 0.0 + rest.constant(),
1175 })
1176}
1177
1178/// `c·xᵢ²` read off a recognized monomial: the one degree-2 shape that is
1179/// a square without being written as one. `None` for anything else,
1180/// including a cross term and anything carrying a linear or constant part.
1181///
1182/// The coefficient is the **polynomial** one — `Quad2::quadratic` is not
1183/// the Hessian — so `c·xᵢ²` is stored with weight `c` and not `2c`.
1184fn diagonal_square(q: &Quad2) -> Option<(usize, f64)> {
1185 if q.lost_terms() || !q.linear().is_empty() || q.constant() != 0.0 {
1186 return None;
1187 }
1188 match q.quadratic().iter().next() {
1189 Some((&(i, j), &c)) if i == j && q.quadratic().len() == 1 => Some((i, c)),
1190 _ => None,
1191 }
1192}
1193
1194/// Turn `weight · base²` into a stored term, or refuse the body.
1195///
1196/// The base has to clear both of the expanded path's gates — the shape gate
1197/// [`is_expanded_quadratic`] (so reading its coefficients back repeats the
1198/// writer's own additions) and the [`Quad2::lost_terms`] gate (so none of
1199/// them went missing) — and be degree ≤ 1, which is what makes the product
1200/// a square rather than a quartic.
1201fn admit_square(weight: f64, base: &Expr) -> Option<SquaredAffine> {
1202 if !is_expanded_quadratic(base) {
1203 return None;
1204 }
1205 let q = recognize_expr(base)?;
1206 if !q.quadratic().is_empty() || q.lost_terms() {
1207 return None;
1208 }
1209 Some(SquaredAffine {
1210 weight,
1211 coefs: q.linear().iter().map(|(&i, &c)| (i, c)).collect(),
1212 constant: 0.0 + q.constant(),
1213 })
1214}
1215
1216/// Split a leaf into `(constant weight, squared base)`, or `None` when it
1217/// is not a constant multiple of exactly one square.
1218///
1219/// The product tree may nest `Mul`, `Div` and `Neg` freely; every leaf of
1220/// it must be a constant except for the single `Pow(base, 2)`, which may
1221/// not sit under a division. Folding several constants into one `weight`
1222/// reassociates the writer's own constants and nothing else.
1223///
1224/// The order is worth stating precisely, because it is not source order:
1225/// this pops an explicit stack, so `a·(b·(c·s))` folds as `((1·c)·b)·a`.
1226/// One multiply's worth of rounding either way, on constants the writer
1227/// wrote next to each other — the same latitude `Quad2::scale` takes on the
1228/// expanded arm — but a reader reconstructing a last-ulp difference by hand
1229/// needs the real order, not the written one.
1230fn peel_square(e: &Expr) -> Option<(f64, &Expr)> {
1231 let mut work: Vec<(&Expr, bool)> = vec![(e, false)];
1232 let mut weight = 1.0f64;
1233 let mut base: Option<&Expr> = None;
1234 while let Some((e, recip)) = work.pop() {
1235 match e {
1236 Expr::Const(c) => weight = if recip { weight / c } else { weight * c },
1237 Expr::Unary(UnaryOp::Neg, a) => {
1238 weight = -weight;
1239 work.push((a, recip));
1240 }
1241 Expr::Binary(BinOp::Mul, a, b) => {
1242 work.push((a, recip));
1243 work.push((b, recip));
1244 }
1245 Expr::Binary(BinOp::Div, a, b) => {
1246 work.push((a, recip));
1247 work.push((b, !recip));
1248 }
1249 Expr::Binary(BinOp::Pow, a, b) if matches!(b.as_ref(), Expr::Const(c) if *c == 2.0) => {
1250 // A square under a division is `1/(…)²`, which is not one.
1251 // A second square makes the leaf degree 4.
1252 if recip || base.is_some() {
1253 return None;
1254 }
1255 base = Some(a);
1256 }
1257 _ => return None,
1258 }
1259 }
1260 // A weight that is not finite (`x²/0`) is not a form anyone should
1261 // evaluate from stored coefficients, and one that is exactly zero has
1262 // annihilated a term the tape still computes.
1263 let base = base?;
1264 (weight.is_finite() && weight != 0.0).then_some((weight, base))
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269 use super::*;
1270
1271 fn sq(i: usize) -> Expr {
1272 Expr::Binary(
1273 BinOp::Pow,
1274 Box::new(Expr::Var(i)),
1275 Box::new(Expr::Const(2.0)),
1276 )
1277 }
1278
1279 #[test]
1280 fn quadratic_diagonal() {
1281 // (x0 - 1)^2 => x0^2 - 2 x0 + 1
1282 let e = Expr::Binary(
1283 BinOp::Pow,
1284 Box::new(Expr::Binary(
1285 BinOp::Sub,
1286 Box::new(Expr::Var(0)),
1287 Box::new(Expr::Const(1.0)),
1288 )),
1289 Box::new(Expr::Const(2.0)),
1290 );
1291 let (h, lin, c) = analyze_quadratic_full(&e).expect("degree-2 polynomial");
1292 assert_eq!(h.get(&(0, 0)), Some(&2.0));
1293 assert_eq!(lin, vec![(0, -2.0)]);
1294 assert_eq!(c, 1.0);
1295 }
1296
1297 #[test]
1298 fn cross_term_hessian() {
1299 // x0 · x1 => H[0,1] = 1
1300 let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1301 let h = analyze_quadratic(&e).expect("degree-2");
1302 assert_eq!(h.get(&(0, 1)), Some(&1.0));
1303 }
1304
1305 #[test]
1306 fn rejects_transcendental_and_cubic() {
1307 assert!(analyze_quadratic(&Expr::Unary(UnaryOp::Sin, Box::new(Expr::Var(0)))).is_none());
1308 let cubic = Expr::Binary(
1309 BinOp::Pow,
1310 Box::new(Expr::Var(0)),
1311 Box::new(Expr::Const(3.0)),
1312 );
1313 assert!(analyze_quadratic(&cubic).is_none());
1314 // x0² · x1 — degree 3 by multiplication rather than by exponent.
1315 let deg3 = Expr::Binary(BinOp::Mul, Box::new(sq(0)), Box::new(Expr::Var(1)));
1316 assert!(analyze_quadratic(°3).is_none());
1317 }
1318
1319 #[test]
1320 fn division_by_a_constant_scales_by_the_reciprocal() {
1321 // x0² / 3 — the coefficient must be `2 · (1/3)`, which is what the
1322 // Hessian of `x0²/3` came out as before this module existed, and is
1323 // *not* bitwise `2/3`.
1324 let e = Expr::Binary(BinOp::Div, Box::new(sq(0)), Box::new(Expr::Const(3.0)));
1325 let h = analyze_quadratic(&e).expect("degree-2");
1326 assert_eq!(h.get(&(0, 0)), Some(&(2.0 * (1.0 / 3.0))));
1327 // Division by a variable is not polynomial.
1328 let e = Expr::Binary(BinOp::Div, Box::new(sq(0)), Box::new(Expr::Var(1)));
1329 assert!(analyze_quadratic(&e).is_none());
1330 }
1331
1332 #[test]
1333 fn scaling_a_coefficient_to_zero_drops_it_like_cancellation_does() {
1334 // 1e-300·x0² divided by 1e300. Neither the coefficient nor the
1335 // divisor is zero, so the whole-form `s == 0.0` shortcut does not
1336 // fire — the product underflows instead, and the entry has to go
1337 // the same way a cancelled one does.
1338 let tiny = Expr::Binary(BinOp::Mul, Box::new(Expr::Const(1e-300)), Box::new(sq(0)));
1339 let flushed = Expr::Binary(BinOp::Div, Box::new(tiny), Box::new(Expr::Const(1e300)));
1340 let h = analyze_quadratic(&flushed).expect("degree-2 at worst");
1341 assert!(
1342 h.is_empty(),
1343 "underflowed coefficient was kept as a structural nonzero: {h:?}"
1344 );
1345 // Storage and degree are the two halves of gh #683 and this route
1346 // reaches both: the entry is gone from the map, *and* the form
1347 // says so, so a consumer asking whether the body is affine gets
1348 // "not established" rather than "yes".
1349 let q = recognize_expr(&flushed).expect("degree-2 at worst");
1350 assert!(
1351 q.lost_terms(),
1352 "a coefficient that underflowed in `scale` was dropped silently"
1353 );
1354 // And the degree has to go with it for the *storage* question:
1355 // multiplying by another variable must still be recognized rather
1356 // than refused as degree 3.
1357 let times_x1 = Expr::Binary(BinOp::Mul, Box::new(flushed), Box::new(Expr::Var(1)));
1358 assert!(
1359 analyze_quadratic(×_x1).is_some(),
1360 "a form scaled to nothing was refused as degree 3"
1361 );
1362 }
1363
1364 #[test]
1365 fn cancellation_drops_the_term_and_the_degree_with_it() {
1366 // x0² − x0² is linear (empty Hessian), not a quadratic with a zero
1367 // coefficient — otherwise `x0²−x0²` times `x1` would be refused as
1368 // degree 3.
1369 let zero = Expr::Binary(BinOp::Sub, Box::new(sq(0)), Box::new(sq(0)));
1370 let h = analyze_quadratic(&zero).expect("degree-2 at worst");
1371 assert!(h.is_empty());
1372 let times_x1 = Expr::Binary(BinOp::Mul, Box::new(zero), Box::new(Expr::Var(1)));
1373 assert!(analyze_quadratic(×_x1).is_some());
1374 }
1375
1376 /// The gh #687 distinction, at the level it is made: an **exact**
1377 /// cancellation leaves the form complete, and an absorbed term does
1378 /// not. Both bodies drop a coefficient; only one of them lost anything
1379 /// doing it.
1380 #[test]
1381 fn an_exact_cancellation_is_not_a_lost_term() {
1382 // x0² − x0²: `fl(1) + fl(−1)` is exactly `0`, so the body really is
1383 // degree 0 and the maps say so with nothing held back.
1384 let zero = Expr::Binary(BinOp::Sub, Box::new(sq(0)), Box::new(sq(0)));
1385 let q = recognize_expr(&zero).expect("degree-2 at worst");
1386 assert!(q.quadratic().is_empty());
1387 assert!(!q.inexact(), "an exact fold reported rounding");
1388 assert!(
1389 !q.lost_terms(),
1390 "x0² − x0² was refused a fast path it is entitled to",
1391 );
1392
1393 // 2⁵³·x0² + x0² − 2⁵³·x0², front to back: the `x0²` is absorbed by
1394 // `fl(2⁵³ + 1) = 2⁵³` — an inexact add — and the exact `− 2⁵³` only
1395 // makes the loss visible.
1396 let big = (1u64 << 53) as f64;
1397 let scaled = |c: f64| Expr::Binary(BinOp::Mul, Box::new(Expr::Const(c)), Box::new(sq(0)));
1398 let absorbing = Expr::Sum(vec![scaled(big), sq(0), scaled(-big)]);
1399 let q = recognize_expr(&absorbing).expect("degree-2 at worst");
1400 assert!(q.quadratic().is_empty());
1401 assert!(q.inexact(), "the absorbing add was not seen to round");
1402 assert!(
1403 q.lost_terms(),
1404 "a body whose x0² was absorbed was reported complete",
1405 );
1406 }
1407
1408 /// The absorption is recorded where it happens, which is the point of
1409 /// the sharpened gate: `2⁵³·x0² + x0²` has lost the `x0²` already, and
1410 /// the form says so before anything cancels — while its coefficient is
1411 /// still stored and its degree is still 2.
1412 #[test]
1413 fn the_inexact_fold_is_flagged_before_anything_drops() {
1414 let big = (1u64 << 53) as f64;
1415 let scaled = |c: f64| Expr::Binary(BinOp::Mul, Box::new(Expr::Const(c)), Box::new(sq(0)));
1416 let e = Expr::Binary(BinOp::Add, Box::new(scaled(big)), Box::new(sq(0)));
1417 let q = recognize_expr(&e).expect("degree-2");
1418 assert_eq!(q.quadratic().get(&(0, 0)), Some(&big));
1419 assert!(q.inexact(), "fl(2⁵³ + 1) = 2⁵³ was called exact");
1420 // Nothing is missing from the maps *yet*, so the consumers keep
1421 // their fast paths: the degree is not in question here.
1422 assert!(!q.lost_terms());
1423 }
1424
1425 /// The sticky half of the same rule. The rounding and the drop are in
1426 /// different subexpressions — `(2⁵³·x0 + x0)` absorbs, `− 2⁵³·x0`
1427 /// cancels — and the verdict has to survive the trip between them.
1428 #[test]
1429 fn rounding_carried_from_a_subexpression_makes_a_later_drop_a_loss() {
1430 let big = (1u64 << 53) as f64;
1431 let scaled =
1432 |c: f64| Expr::Binary(BinOp::Mul, Box::new(Expr::Const(c)), Box::new(Expr::Var(0)));
1433 let absorbed = Expr::Binary(BinOp::Add, Box::new(scaled(big)), Box::new(Expr::Var(0)));
1434 let e = Expr::Binary(BinOp::Sub, Box::new(absorbed), Box::new(scaled(big)));
1435 let q = recognize_expr(&e).expect("degree-1 at worst");
1436 assert!(q.linear().is_empty());
1437 assert!(
1438 q.lost_terms(),
1439 "the x0 absorbed two additions ago is no less missing now",
1440 );
1441 }
1442
1443 /// Cancellation *inside a product*: `(x0 + x1)·(x0 − x1)` accumulates
1444 /// `+x0x1` and `−x0x1` onto one key and they cancel exactly. The body
1445 /// really has no cross term, so the form must not be demoted for
1446 /// noticing.
1447 #[test]
1448 fn an_exact_cancellation_in_a_product_is_not_a_lost_term() {
1449 let add = |a: Expr, b: Expr| Expr::Binary(BinOp::Add, Box::new(a), Box::new(b));
1450 let sub = |a: Expr, b: Expr| Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b));
1451 let e = Expr::Binary(
1452 BinOp::Mul,
1453 Box::new(add(Expr::Var(0), Expr::Var(1))),
1454 Box::new(sub(Expr::Var(0), Expr::Var(1))),
1455 );
1456 let q = recognize_expr(&e).expect("degree-2");
1457 assert_eq!(q.quadratic().get(&(0, 0)), Some(&1.0));
1458 assert_eq!(q.quadratic().get(&(1, 1)), Some(&-1.0));
1459 assert_eq!(q.quadratic().get(&(0, 1)), None);
1460 assert!(!q.lost_terms(), "x0² − x1² was reported incomplete");
1461 }
1462
1463 /// An underflowing **multiply** has no exact case to spare: both
1464 /// factors are nonzero and the product is a monomial the form can no
1465 /// longer see. `(10⁻²⁰⁰·x0)·(10⁻²⁰⁰·x0)` stays refused, which is gh
1466 /// #683's second reproduction and gh #687's second acceptance case.
1467 #[test]
1468 fn an_underflowing_product_is_still_a_lost_term() {
1469 let tiny = |i: usize| {
1470 Expr::Binary(
1471 BinOp::Mul,
1472 Box::new(Expr::Const(1e-200)),
1473 Box::new(Expr::Var(i)),
1474 )
1475 };
1476 let e = Expr::Binary(BinOp::Mul, Box::new(tiny(0)), Box::new(tiny(0)));
1477 let q = recognize_expr(&e).expect("degree-2 at worst");
1478 assert!(q.quadratic().is_empty());
1479 assert!(
1480 q.lost_terms(),
1481 "a coefficient that underflowed on the multiply was reported absent",
1482 );
1483 }
1484
1485 /// A constant folded away exactly is degree 0 for real — and `mul`
1486 /// reads a zero constant as annihilating, so this is the one drop whose
1487 /// verdict a *product* depends on.
1488 #[test]
1489 fn an_exactly_cancelled_constant_is_not_a_lost_term() {
1490 let e = Expr::Binary(
1491 BinOp::Sub,
1492 Box::new(Expr::Const(3.0)),
1493 Box::new(Expr::Const(3.0)),
1494 );
1495 let q = recognize_expr(&e).expect("degree 0");
1496 assert_eq!(q.as_constant(), Some(0.0));
1497 assert!(!q.lost_terms());
1498
1499 // The same shape one ulp off: `fl(2⁵³ + 1) − 2⁵³` is `0.0` where the
1500 // real value is `1`.
1501 let big = (1u64 << 53) as f64;
1502 let absorbed = Expr::Binary(
1503 BinOp::Add,
1504 Box::new(Expr::Const(big)),
1505 Box::new(Expr::Const(1.0)),
1506 );
1507 let e = Expr::Binary(BinOp::Sub, Box::new(absorbed), Box::new(Expr::Const(big)));
1508 let q = recognize_expr(&e).expect("degree 0");
1509 assert_eq!(q.as_constant(), Some(0.0));
1510 assert!(q.lost_terms(), "an absorbed constant was reported absent");
1511 }
1512
1513 /// Rounding a coefficient is recorded even when nothing is dropped,
1514 /// because that is what a later exact cancellation would be hiding:
1515 /// `x0²/3` cannot be represented, and the form has to remember it.
1516 #[test]
1517 fn a_rounded_scale_is_inexact_but_loses_nothing() {
1518 let e = Expr::Binary(BinOp::Div, Box::new(sq(0)), Box::new(Expr::Const(3.0)));
1519 let q = recognize_expr(&e).expect("degree-2");
1520 assert_eq!(q.quadratic().get(&(0, 0)), Some(&(1.0 / 3.0)));
1521 assert!(q.inexact(), "1 · (1/3) was called exact");
1522 assert!(
1523 !q.lost_terms(),
1524 "a rounded coefficient is not a missing one"
1525 );
1526
1527 // Halving is exact, so the same shape by a power of two is not.
1528 let e = Expr::Binary(BinOp::Div, Box::new(sq(0)), Box::new(Expr::Const(2.0)));
1529 let q = recognize_expr(&e).expect("degree-2");
1530 assert!(!q.inexact(), "x0²/2 rounds nothing");
1531 }
1532
1533 /// The two-sum and the two-product, against the cases the recognizer
1534 /// actually meets. Exactness is not a tolerance, so these are `==`.
1535 #[test]
1536 fn the_exactness_tests_agree_with_the_arithmetic() {
1537 let big = (1u64 << 53) as f64;
1538 assert!(add_is_exact(big, -big, big + -big));
1539 assert!(add_is_exact(1.0, 2.0, 3.0));
1540 assert!(!add_is_exact(big, 1.0, big + 1.0), "2⁵³ + 1 loses the 1");
1541 assert!(!add_is_exact(0.1, 0.2, 0.1 + 0.2));
1542 assert!(!add_is_exact(f64::INFINITY, 1.0, f64::INFINITY));
1543 assert!(!add_is_exact(f64::NAN, 1.0, f64::NAN + 1.0));
1544
1545 assert!(mul_is_exact(3.0, 1.0, 3.0), "the ±1 shortcut");
1546 assert!(mul_is_exact(3.0, 0.5, 1.5));
1547 assert!(mul_is_exact(0.1, 4.0, 0.4));
1548 assert!(!mul_is_exact(3.0, 1.0 / 3.0, 3.0 * (1.0 / 3.0)));
1549 assert!(!mul_is_exact(1e-200, 1e-200, 1e-200 * 1e-200), "underflow");
1550 assert!(!mul_is_exact(1e300, 1e300, 1e300 * 1e300), "overflow");
1551 }
1552
1553 #[test]
1554 fn cse_bodies_are_inlined_at_every_reference() {
1555 // c = x0; c · c is x0².
1556 let body = std::sync::Arc::new(Expr::Var(0));
1557 let e = Expr::Binary(
1558 BinOp::Mul,
1559 Box::new(Expr::Cse(body.clone())),
1560 Box::new(Expr::Cse(body)),
1561 );
1562 let h = analyze_quadratic(&e).expect("degree-2");
1563 assert_eq!(h.get(&(0, 0)), Some(&2.0));
1564 }
1565
1566 #[test]
1567 fn a_wide_nary_sum_does_not_recurse() {
1568 // Σ xᵢ² over 5000 terms as one `o54` node. Distinct keys, so this
1569 // says nothing about *order* — see the test below for that.
1570 const N: usize = 5000;
1571 let e = Expr::Sum((0..N).map(sq).collect());
1572 let h = analyze_quadratic(&e).expect("sum of squares is a QP");
1573 assert_eq!(h.len(), N);
1574 assert_eq!(h.get(&(N - 1, N - 1)), Some(&2.0));
1575 }
1576
1577 /// Summation order is only observable when two items land on the *same*
1578 /// monomial key with magnitudes far enough apart that the addition
1579 /// rounds. `1e16·x0² + x0² + x0²` folds to `1e16` front to back (each
1580 /// `+1` falls below the ulp) and to `1e16 + 2` back to front. The tape
1581 /// — the reference every non-recognized row is still evaluated with —
1582 /// sums front to back, so that is the answer required here.
1583 ///
1584 /// The 5000-square test above is named for this property but cannot see
1585 /// it: 5000 distinct keys never re-add anything.
1586 #[test]
1587 fn a_repeated_monomial_in_an_nary_sum_accumulates_front_to_back() {
1588 let scaled =
1589 |c: f64, i: usize| Expr::Binary(BinOp::Mul, Box::new(Expr::Const(c)), Box::new(sq(i)));
1590 let e = Expr::Sum(vec![scaled(1.0e16, 0), scaled(1.0, 0), scaled(1.0, 0)]);
1591 let h = analyze_quadratic(&e).expect("sum of squares is a QP");
1592 let got = h[&(0, 0)];
1593 assert_eq!(
1594 got.to_bits(),
1595 (2.0 * 1.0e16_f64).to_bits(),
1596 "expected the front-to-back fold, got {got:e}"
1597 );
1598 assert_ne!(got.to_bits(), (2.0 * (1.0e16_f64 + 2.0)).to_bits());
1599 }
1600
1601 /// The gate that decides whether a form may be evaluated from its
1602 /// coefficients. The two directions cost different things, so both are
1603 /// pinned: admitting a factored form loses digits (and, on
1604 /// `airport.nl`, 284 iterations), refusing an expanded one loses the
1605 /// phase's whole point on the family it was built for.
1606 #[test]
1607 fn only_already_expanded_forms_are_admitted() {
1608 // AMPL's `qcqp*` emission: `o54` over `0.5·((c·xᵢ)·xⱼ)`.
1609 let monomial = |c: f64, i: usize, j: usize| {
1610 Expr::Binary(
1611 BinOp::Mul,
1612 Box::new(Expr::Const(0.5)),
1613 Box::new(Expr::Binary(
1614 BinOp::Mul,
1615 Box::new(Expr::Binary(
1616 BinOp::Mul,
1617 Box::new(Expr::Const(c)),
1618 Box::new(Expr::Var(i)),
1619 )),
1620 Box::new(Expr::Var(j)),
1621 )),
1622 )
1623 };
1624 let row = Expr::Sum(vec![monomial(2.0, 0, 0), monomial(3.0, 0, 1)]);
1625 assert!(is_expanded_quadratic(&row));
1626 // `xᵢ²` is a single monomial, not an expansion.
1627 assert!(is_expanded_quadratic(&Expr::Sum(vec![sq(0), sq(1)])));
1628 // A left-deep `Add` chain is still a sum.
1629 let chain = Expr::Binary(BinOp::Add, Box::new(sq(0)), Box::new(sq(1)));
1630 assert!(is_expanded_quadratic(&chain));
1631 // Division by a constant, and a negated term.
1632 assert!(is_expanded_quadratic(&Expr::Binary(
1633 BinOp::Div,
1634 Box::new(sq(0)),
1635 Box::new(Expr::Const(3.0)),
1636 )));
1637 assert!(is_expanded_quadratic(&Expr::Unary(
1638 UnaryOp::Neg,
1639 Box::new(monomial(1.0, 0, 1))
1640 )));
1641
1642 // `(x₀ − x₁)²` — the `airport.nl` shape. Reading it as
1643 // `x₀² − 2x₀x₁ + x₁²` cancels, so it stays on the tape.
1644 let diff = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
1645 let factored = Expr::Binary(BinOp::Pow, Box::new(diff), Box::new(Expr::Const(2.0)));
1646 assert!(analyze_quadratic(&factored).is_some(), "it is quadratic");
1647 assert!(
1648 !is_expanded_quadratic(&factored),
1649 "but not already expanded"
1650 );
1651
1652 // `(x₀ + 1)·x₁` — expansion by multiplication rather than by power.
1653 let product = Expr::Binary(
1654 BinOp::Mul,
1655 Box::new(Expr::Binary(
1656 BinOp::Add,
1657 Box::new(Expr::Var(0)),
1658 Box::new(Expr::Const(1.0)),
1659 )),
1660 Box::new(Expr::Var(1)),
1661 );
1662 assert!(!is_expanded_quadratic(&product));
1663
1664 // One factored term anywhere in a long expanded sum disqualifies
1665 // the whole row — the cancellation is in that term, not in the sum.
1666 let mixed = Expr::Sum(vec![monomial(1.0, 0, 0), factored]);
1667 assert!(!is_expanded_quadratic(&mixed));
1668
1669 // A `Cse` referenced once is inlined and judged on its body.
1670 let once = Expr::Binary(
1671 BinOp::Mul,
1672 Box::new(Expr::Cse(std::sync::Arc::new(Expr::Var(0)))),
1673 Box::new(Expr::Var(1)),
1674 );
1675 assert!(is_expanded_quadratic(&once));
1676 }
1677
1678 /// A `Cse` body reached twice is walked once, not `2^depth` times, and
1679 /// is **admitted** rather than refused.
1680 ///
1681 /// Q4 refused it, and refused it for cost rather than algebra — this
1682 /// walk and `recognize_expr` behind it both inline a body per
1683 /// reference, so a shared DAG was `Θ(2^depth)`. Q5's memoization is
1684 /// what makes admitting it affordable, so both halves are asserted
1685 /// here: the verdict, and the fact that the test returns at all. The
1686 /// shape is `nl_reader`'s `shared_dag_walks_are_memoized_not_exponential`
1687 /// scaled up; at depth 60 an exponential walk does not finish inside
1688 /// the lifetime of this test run, and `2^60` sums do not fit in an
1689 /// `f64` count either.
1690 #[test]
1691 fn a_shared_cse_body_is_walked_once_and_admitted() {
1692 let mut e = Expr::Var(0);
1693 for _ in 0..60 {
1694 let shared = std::sync::Arc::new(e);
1695 e = Expr::Binary(
1696 BinOp::Add,
1697 Box::new(Expr::Cse(std::sync::Arc::clone(&shared))),
1698 Box::new(Expr::Cse(shared)),
1699 );
1700 }
1701 assert!(is_expanded_quadratic(&e));
1702 // And the algebra agrees: `x + x` sixty times over is `2^60 · x`.
1703 let q = recognize_expr(&e).expect("a sum of one monomial is degree 1");
1704 assert_eq!(q.linear().get(&0).copied(), Some(2.0_f64.powi(60)));
1705 std::mem::forget(e);
1706 }
1707
1708 /// The same shape inside a *monomial*, which is a different question
1709 /// with a different answer and therefore a separately keyed memo: a
1710 /// body that is a sum of monomials is legal on the spine and illegal
1711 /// under a `*`.
1712 #[test]
1713 fn a_shared_body_is_judged_per_context_not_once_and_for_all() {
1714 let sum = std::sync::Arc::new(Expr::Binary(
1715 BinOp::Add,
1716 Box::new(Expr::Var(0)),
1717 Box::new(Expr::Var(1)),
1718 ));
1719 // On the spine, twice: fine.
1720 let spine = Expr::Binary(
1721 BinOp::Add,
1722 Box::new(Expr::Cse(std::sync::Arc::clone(&sum))),
1723 Box::new(Expr::Cse(std::sync::Arc::clone(&sum))),
1724 );
1725 assert!(is_expanded_quadratic(&spine));
1726 // The same body on the spine and then under a product: the product
1727 // is `(x0 + x1) · x1`, a factored form, and the earlier clean visit
1728 // on the spine must not clear it.
1729 let mixed = Expr::Binary(
1730 BinOp::Add,
1731 Box::new(Expr::Cse(std::sync::Arc::clone(&sum))),
1732 Box::new(Expr::Binary(
1733 BinOp::Mul,
1734 Box::new(Expr::Cse(sum)),
1735 Box::new(Expr::Var(1)),
1736 )),
1737 );
1738 assert!(!is_expanded_quadratic(&mixed));
1739 }
1740
1741 /// Deep, and on a default-sized test thread, for the same reason
1742 /// [`recognize_expr`] is iterative: the gate runs on every row of every
1743 /// model, including the ones that overflow a recursive walk.
1744 #[test]
1745 fn the_expansion_gate_does_not_overflow_the_stack() {
1746 const K: usize = 250_000;
1747 let mut e = sq(0);
1748 for i in 1..K {
1749 e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(sq(i)));
1750 }
1751 assert!(is_expanded_quadratic(&e));
1752 std::mem::forget(e);
1753 }
1754
1755 /// The reason this module is iterative.
1756 ///
1757 /// A `.nl` writer that emits `o0` (binary `+`) chains for a long sum
1758 /// hands the recognizer a left-deep `Add` tree one level deep per term.
1759 /// The recursive predecessor aborted the process — a stack overflow is
1760 /// not a catchable error — somewhere under 8 000 terms on a 2 MB thread
1761 /// and under 24 000 on the CLI's 8 MB main thread. The depth below is
1762 /// far past both, and the test runs on a **default-sized test thread**
1763 /// deliberately: what a recognizer survives must not depend on who
1764 /// called it.
1765 ///
1766 /// The tree is leaked rather than dropped, because `Expr`'s derived
1767 /// `Drop` is still recursive and would overflow tearing this down.
1768 /// That is a real and separate defect (pounce#472 works around it in
1769 /// the Python bindings with a big-stack worker thread); it is not what
1770 /// this test is measuring.
1771 #[test]
1772 fn deep_add_chain_does_not_overflow_the_stack() {
1773 const K: usize = 250_000;
1774 let mut e = sq(0);
1775 for i in 1..K {
1776 e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(sq(i)));
1777 }
1778 let h = analyze_quadratic(&e).expect("a sum of squares is a QP at any depth");
1779 assert_eq!(h.len(), K, "every xᵢ² contributes one diagonal entry");
1780 assert_eq!(h.get(&(K - 1, K - 1)), Some(&2.0));
1781 std::mem::forget(e);
1782 }
1783
1784 /// Same shape, right-deep — the value stack, not the work stack, is
1785 /// what grows here. Both live on the heap.
1786 #[test]
1787 fn deep_right_leaning_chain_does_not_overflow_the_stack() {
1788 const K: usize = 250_000;
1789 let mut e = sq(K - 1);
1790 for i in (0..K - 1).rev() {
1791 e = Expr::Binary(BinOp::Add, Box::new(sq(i)), Box::new(e));
1792 }
1793 let h = analyze_quadratic(&e).expect("a sum of squares is a QP at any depth");
1794 assert_eq!(h.len(), K);
1795 std::mem::forget(e);
1796 }
1797
1798 /// A non-quadratic node deep inside a deep tree must return `None`
1799 /// rather than unwind — the bail-out path drops the work and value
1800 /// stacks, and neither is recursive.
1801 #[test]
1802 fn deep_chain_with_a_transcendental_bails_without_overflowing() {
1803 const K: usize = 250_000;
1804 let mut e = Expr::Unary(UnaryOp::Sin, Box::new(Expr::Var(0)));
1805 for i in 1..K {
1806 e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(sq(i)));
1807 }
1808 assert!(analyze_quadratic(&e).is_none());
1809 std::mem::forget(e);
1810 }
1811
1812 // -----------------------------------------------------------------
1813 // Factored forms (gh #673)
1814 // -----------------------------------------------------------------
1815
1816 /// `(base)^2`.
1817 fn sq_of(base: Expr) -> Expr {
1818 Expr::Binary(BinOp::Pow, Box::new(base), Box::new(Expr::Const(2.0)))
1819 }
1820
1821 fn var_minus(i: usize, c: f64) -> Expr {
1822 Expr::Binary(BinOp::Sub, Box::new(Expr::Var(i)), Box::new(Expr::Const(c)))
1823 }
1824
1825 /// The motivating case. `(x − 500000)²` is exactly what
1826 /// `feasible_x0_extreme_row.nl` writes, and expanding it is the 2.4e-5
1827 /// disagreement gh #673 is named after.
1828 #[test]
1829 fn a_shifted_square_is_kept_factored() {
1830 let e = sq_of(var_minus(0, 500_000.0));
1831 assert!(
1832 !is_expanded_quadratic(&e),
1833 "the expanded gate must refuse it"
1834 );
1835 let f = recognize_factored_quadratic(&e).expect("a square of an affine form");
1836 assert_eq!(f.squares.len(), 1);
1837 assert_eq!(f.squares[0].weight, 1.0);
1838 assert_eq!(f.squares[0].coefs, vec![(0, 1.0)]);
1839 assert_eq!(f.squares[0].constant, -500_000.0);
1840 assert!(f.linear.is_empty());
1841 assert_eq!(f.constant, 0.0);
1842 }
1843
1844 /// The accuracy claim, stated as a number rather than as prose: at
1845 /// `x = 500000 + 1e-4` the expansion loses five digits and the factored
1846 /// read-out loses nothing.
1847 #[test]
1848 fn the_factored_read_out_does_not_cancel_where_the_expansion_does() {
1849 let e = sq_of(var_minus(0, 500_000.0));
1850 let x = 500_000.0 + 1e-4;
1851 // What the tape computes: square the residual as written.
1852 let r = x - 500_000.0;
1853 let taped = r * r;
1854
1855 let (h, lin, c) = analyze_quadratic_full(&e).expect("degree 2");
1856 let expanded = 0.5 * h[&(0, 0)] * x * x + lin[0].1 * x + c;
1857 assert!(
1858 (expanded - taped).abs() / taped > 1e-6,
1859 "the expansion is supposed to cancel here, got {expanded} for {taped}"
1860 );
1861
1862 let f = recognize_factored_quadratic(&e).expect("a square");
1863 let t = &f.squares[0];
1864 let l = t.constant + t.coefs[0].1 * x;
1865 // Bit for bit, not within a tolerance.
1866 assert_eq!(t.weight * l * l, taped);
1867 }
1868
1869 /// `airport.nl`'s shape: a row that is a sum of squared coordinate
1870 /// differences, with the writer's grouping kept term by term.
1871 #[test]
1872 fn a_sum_of_squared_differences_is_admitted() {
1873 let diff = |i: usize, j: usize| {
1874 Expr::Binary(BinOp::Sub, Box::new(Expr::Var(i)), Box::new(Expr::Var(j)))
1875 };
1876 let e = Expr::Binary(
1877 BinOp::Add,
1878 Box::new(sq_of(diff(0, 1))),
1879 Box::new(sq_of(diff(2, 3))),
1880 );
1881 let f = recognize_factored_quadratic(&e).expect("two squares");
1882 assert_eq!(f.squares.len(), 2);
1883 let mut sup: Vec<Vec<(usize, f64)>> = f.squares.iter().map(|t| t.coefs.clone()).collect();
1884 sup.sort_by_key(|c| c[0].0);
1885 assert_eq!(
1886 sup,
1887 vec![vec![(0, 1.0), (1, -1.0)], vec![(2, 1.0), (3, -1.0)]]
1888 );
1889 assert!(
1890 f.squares
1891 .iter()
1892 .all(|t| t.weight == 1.0 && t.constant == 0.0)
1893 );
1894 }
1895
1896 /// The sign of the sum spine reaches the weight, and a constant factor
1897 /// folds into it.
1898 #[test]
1899 fn spine_signs_and_constant_factors_fold_into_the_weight() {
1900 // 3·(x₀ − 1)² − 0.5·(x₁ + 2)²
1901 let a = Expr::Binary(
1902 BinOp::Mul,
1903 Box::new(Expr::Const(3.0)),
1904 Box::new(sq_of(var_minus(0, 1.0))),
1905 );
1906 let b = Expr::Binary(
1907 BinOp::Mul,
1908 Box::new(Expr::Const(0.5)),
1909 Box::new(sq_of(Expr::Binary(
1910 BinOp::Add,
1911 Box::new(Expr::Var(1)),
1912 Box::new(Expr::Const(2.0)),
1913 ))),
1914 );
1915 let f = recognize_factored_quadratic(&Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
1916 .expect("two squares");
1917 let mut got: Vec<(f64, f64)> = f.squares.iter().map(|t| (t.weight, t.constant)).collect();
1918 got.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
1919 assert_eq!(got, vec![(-0.5, 2.0), (3.0, -1.0)]);
1920 }
1921
1922 /// A degree-≤1 leftover in the same tree is not a reason to refuse the
1923 /// row; it folds into `a`/`c` and is evaluated there.
1924 #[test]
1925 fn degree_one_leftovers_fold_into_the_linear_part() {
1926 // (x₀ − 1)² + 3·x₁ + 7
1927 let e = Expr::Sum(vec![
1928 sq_of(var_minus(0, 1.0)),
1929 Expr::Binary(
1930 BinOp::Mul,
1931 Box::new(Expr::Const(3.0)),
1932 Box::new(Expr::Var(1)),
1933 ),
1934 Expr::Const(7.0),
1935 ]);
1936 let f = recognize_factored_quadratic(&e).expect("a square plus leftovers");
1937 assert_eq!(f.squares.len(), 1);
1938 assert_eq!(f.linear, vec![(1, 3.0)]);
1939 assert_eq!(f.constant, 7.0);
1940 }
1941
1942 /// A bare `c·xᵢ²` monomial is the same term wearing a different opcode,
1943 /// and is stored as `c·(xᵢ)²` so that a row mixing the two shapes is
1944 /// still served.
1945 #[test]
1946 fn a_diagonal_monomial_is_stored_as_a_square() {
1947 let e = Expr::Binary(
1948 BinOp::Add,
1949 Box::new(sq_of(var_minus(0, 1.0))),
1950 Box::new(Expr::Binary(
1951 BinOp::Mul,
1952 Box::new(Expr::Const(4.0)),
1953 Box::new(Expr::Binary(
1954 BinOp::Mul,
1955 Box::new(Expr::Var(1)),
1956 Box::new(Expr::Var(1)),
1957 )),
1958 )),
1959 );
1960 let f = recognize_factored_quadratic(&e).expect("square + diagonal monomial");
1961 assert_eq!(f.squares.len(), 2);
1962 let diag = f
1963 .squares
1964 .iter()
1965 .find(|t| t.coefs == vec![(1, 1.0)])
1966 .unwrap();
1967 // The *polynomial* coefficient, so `4x₁²` and not `8x₁²` —
1968 // `push_factored_form` is what doubles it into a Hessian entry.
1969 assert_eq!((diag.weight, diag.constant), (4.0, 0.0));
1970 }
1971
1972 /// A cross monomial has no square to be stored as, and storing it
1973 /// expanded next to the squares is the mixed representation this
1974 /// deliberately does not have. The row keeps its tape.
1975 #[test]
1976 fn a_cross_monomial_alongside_a_square_is_refused() {
1977 let e = Expr::Binary(
1978 BinOp::Add,
1979 Box::new(sq_of(var_minus(0, 1.0))),
1980 Box::new(Expr::Binary(
1981 BinOp::Mul,
1982 Box::new(Expr::Var(1)),
1983 Box::new(Expr::Var(2)),
1984 )),
1985 );
1986 assert!(recognize_factored_quadratic(&e).is_none());
1987 }
1988
1989 /// An already-expanded body is `is_expanded_quadratic`'s to serve, and
1990 /// this must not offer a second, slower answer for it.
1991 #[test]
1992 fn an_expanded_body_is_not_claimed_here() {
1993 let e = Expr::Binary(BinOp::Add, Box::new(sq(0)), Box::new(sq(1)));
1994 assert!(is_expanded_quadratic(&e));
1995 // `x²` *is* a square, so this one is genuinely recognized — the
1996 // caller's ordering is what keeps it on the cheaper path.
1997 assert!(recognize_factored_quadratic(&e).is_some());
1998 // A body with no square at all is refused outright.
1999 let cross = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
2000 assert!(recognize_factored_quadratic(&cross).is_none());
2001 assert!(recognize_factored_quadratic(&Expr::Var(0)).is_none());
2002 }
2003
2004 /// The product of two *different* affine forms is degree 2 and is not a
2005 /// square. Admitting it would mean storing `l₁·l₂`, which this
2006 /// representation cannot express.
2007 #[test]
2008 fn a_product_of_two_different_affine_forms_is_refused() {
2009 let e = Expr::Binary(
2010 BinOp::Mul,
2011 Box::new(var_minus(0, 1.0)),
2012 Box::new(var_minus(1, 2.0)),
2013 );
2014 assert!(recognize_factored_quadratic(&e).is_none());
2015 }
2016
2017 /// A square of a *quadratic* is degree 4, and a transcendental is not a
2018 /// polynomial at all.
2019 #[test]
2020 fn quartics_and_transcendentals_are_refused() {
2021 assert!(recognize_factored_quadratic(&sq_of(sq(0))).is_none());
2022 let s = Expr::Unary(UnaryOp::Sin, Box::new(Expr::Var(0)));
2023 assert!(recognize_factored_quadratic(&sq_of(s)).is_none());
2024 }
2025
2026 /// A base that is not itself a flat sum of monomials is refused: the
2027 /// coefficients read out of `2·(x + 1)` are not the additions the
2028 /// writer wrote, which is the same rule `is_expanded_quadratic` states.
2029 #[test]
2030 fn a_base_the_expanded_gate_refuses_is_refused_here_too() {
2031 let inner = Expr::Binary(
2032 BinOp::Mul,
2033 Box::new(Expr::Const(2.0)),
2034 Box::new(Expr::Binary(
2035 BinOp::Add,
2036 Box::new(Expr::Var(0)),
2037 Box::new(Expr::Const(1.0)),
2038 )),
2039 );
2040 assert!(!is_expanded_quadratic(&inner));
2041 assert!(recognize_factored_quadratic(&sq_of(inner)).is_none());
2042 }
2043
2044 /// A leftover that lost a term inexactly refuses the body, for the
2045 /// reason gh #685 gives: the form would evaluate a row its own tape
2046 /// does not agree with.
2047 #[test]
2048 fn a_lost_leftover_refuses_the_body() {
2049 let big = 9_007_199_254_740_992.0f64; // 2⁵³
2050 let scaled = |c: f64, i: usize| {
2051 Expr::Binary(BinOp::Mul, Box::new(Expr::Const(c)), Box::new(Expr::Var(i)))
2052 };
2053 // (x₀ − 1)² + 2⁵³·x₁ + x₁ − 2⁵³·x₁ — the `x₁` is folded away.
2054 let e = Expr::Sum(vec![
2055 sq_of(var_minus(0, 1.0)),
2056 scaled(big, 1),
2057 Expr::Var(1),
2058 scaled(-big, 1),
2059 ]);
2060 assert!(recognize_factored_quadratic(&e).is_none());
2061 }
2062
2063 /// A `Cse` on the sum spine ends the walk: skipping a second reference
2064 /// would drop its terms and visiting every reference is exponential.
2065 #[test]
2066 fn a_shared_body_on_the_spine_is_refused() {
2067 let shared = std::sync::Arc::new(sq_of(var_minus(0, 1.0)));
2068 let e = Expr::Binary(
2069 BinOp::Add,
2070 Box::new(Expr::Cse(shared.clone())),
2071 Box::new(Expr::Cse(shared)),
2072 );
2073 assert!(recognize_factored_quadratic(&e).is_none());
2074 }
2075
2076 /// A weight that annihilates or overflows is not evaluated from stored
2077 /// coefficients: `0·(x−1)²` has dropped a term the tape still walks,
2078 /// and `(x−1)²/0` is not a number.
2079 #[test]
2080 fn degenerate_weights_are_refused() {
2081 let zero = Expr::Binary(
2082 BinOp::Mul,
2083 Box::new(Expr::Const(0.0)),
2084 Box::new(sq_of(var_minus(0, 1.0))),
2085 );
2086 assert!(recognize_factored_quadratic(&zero).is_none());
2087 let div0 = Expr::Binary(
2088 BinOp::Div,
2089 Box::new(sq_of(var_minus(0, 1.0))),
2090 Box::new(Expr::Const(0.0)),
2091 );
2092 assert!(recognize_factored_quadratic(&div0).is_none());
2093 }
2094
2095 /// Deep, on a default-sized test thread, for the reason every walk in
2096 /// this module is iterative: a least-squares model is a long `o0` chain
2097 /// of squared residuals, which is exactly the shape that aborts a
2098 /// recursive walk — and now exactly the shape this recognizer is for.
2099 ///
2100 /// Leaked rather than dropped: `Expr`'s derived `Drop` is still
2101 /// recursive. See `deep_add_chain_does_not_overflow_the_stack`.
2102 #[test]
2103 fn a_deep_chain_of_squares_does_not_overflow_the_stack() {
2104 const K: usize = 250_000;
2105 let mut e = sq_of(var_minus(0, 1.0));
2106 for i in 1..K {
2107 e = Expr::Binary(
2108 BinOp::Add,
2109 Box::new(e),
2110 Box::new(sq_of(var_minus(i, i as f64))),
2111 );
2112 }
2113 let f = recognize_factored_quadratic(&e).expect("K squares");
2114 assert_eq!(f.squares.len(), K);
2115 std::mem::forget(e);
2116 }
2117}