Skip to main content

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(&deg3).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(&times_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(&times_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}