Skip to main content

pounce_nl/
nl_reader.rs

1//! Minimal AMPL `.nl` ASCII-format reader.
2//!
3//! Implements the `g`-header text dialect for problems whose constraint
4//! and objective expressions are restricted to a polynomial-friendly
5//! subset of opcodes. This is **not** a full `.nl` reader — it is the
6//! smallest piece that lets `pounce --nl-file foo.nl` solve a real
7//! AMPL-emitted unconstrained problem.
8//!
9//! Supported:
10//! * Text header (`g…`).
11//! * Constraint and objective expression segments using opcodes
12//!   `o0` (add), `o1` (sub), `o2` (mul), `o3` (div), `o5` (pow),
13//!   `o16` (unary minus), `o39` (sqrt), `o42` (log10), `o43` (log),
14//!   `o44` (exp), `o15` (abs), `o41` (sin), `o46` (cos), `o38` (tan),
15//!   `o49` (atan), `o53` (acos), plus
16//!   `n<num>` constants and `v<idx>` variables.
17//! * Linear-Jacobian (`J`) and linear-objective (`G`) segments.
18//! * Variable bounds (`b`) and constraint bounds (`r`).
19//! * Optional initial primal (`x`) segment and initial dual (`d`)
20//!   segment. Both are parsed (into `x0` / `lambda0`) and returned by
21//!   `get_starting_point`; the duals feed a `warm_start_init_point` solve.
22//! * Multiple objectives (we use only the first; per AMPL convention).
23//!
24//! Not supported (will return an error explaining what's missing):
25//! * Network / piecewise-linear constructs.
26//! * Complementarity rows.
27//! * Binary-format `.nl` files (`b…` header).
28//!
29//! References:
30//! * <https://ampl.com/REFS/hooking2.pdf> — "Hooking Your Solver to
31//!   AMPL" (David M. Gay), the canonical `.nl` spec.
32//! * `ref/Ipopt/test/mytoy.nl` — annotated example used for the unit
33//!   tests in this module.
34
35use crate::nl_quadratic::{
36    FactoredQuadratic, Quad2, QuadForm, QuadHessian, is_expanded_quadratic, is_trivially_zero,
37    quad_form_readout, recognize_expr, recognize_factored_quadratic,
38};
39use crate::nl_tape::{HybridTape, Tape, hybrid_supported};
40use pounce_common::types::{Index, Number, lower_bound_present, upper_bound_present};
41use pounce_nlp::constant_derivatives::{DerivativeProof, DerivativeProofs};
42use pounce_nlp::quadratic::{QuadraticStructure, SquareTerm};
43use pounce_nlp::tnlp::{
44    BoundsInfo, IDX_NAMES, IndexStyle, IpoptCq, IpoptData, Linearity, MetaData, NlpInfo,
45    ScalingRequest, Solution, SparsityRequest, StartingPoint, TNLP,
46};
47use std::cell::RefCell;
48use std::collections::{BTreeMap, BTreeSet};
49use std::path::Path;
50use std::rc::Rc;
51use std::sync::Arc;
52
53#[derive(Debug, Clone)]
54pub enum Expr {
55    /// Numeric constant.
56    Const(Number),
57    /// Variable reference (0-based index into `x`).
58    Var(usize),
59    /// Binary op: `args = [lhs, rhs]`.
60    Binary(BinOp, Box<Expr>, Box<Expr>),
61    /// Unary op.
62    Unary(UnaryOp, Box<Expr>),
63    /// n-ary sum (opcode `o54` — variadic; we may emit it from `o0`
64    /// folding optimization, but the parser treats `o0` as binary).
65    Sum(Vec<Expr>),
66    /// Reference to a common subexpression (`.nl` `V` segment). The
67    /// payload is a shared body; many references to the same CSE share
68    /// one `Arc`, so the parsed problem is a DAG. Walking through `Cse`
69    /// is mathematically equivalent to inlining the body at each
70    /// occurrence (every reference is an independent occurrence in the
71    /// chain rule), so eval/grad/collect_vars just recurse into the
72    /// inner `Expr`. The pointer is atomically refcounted (`Arc`, not
73    /// `Rc`) so a parsed problem — and the `NlTnlp` built from it —
74    /// is `Send` and can move to a rayon worker for batched solving
75    /// (pounce#126); sharing is still read-only after parse.
76    Cse(Arc<Expr>),
77    /// AMPL imported (external) function call. `id` matches an entry in
78    /// `NlProblem.imported_funcs`; resolution to a live shared library
79    /// happens when the tape is built (see `nl_external::ExternalResolver`).
80    Funcall { id: usize, args: Vec<FuncallArg> },
81    /// Relational comparison (`o22`/`o23`/`o24`/`o28`/`o29`/`o30`).
82    /// Evaluates to `1.0` when the comparison holds, else `0.0`. The
83    /// result is piecewise-constant, so it has zero derivative
84    /// everywhere (the kink at equality is ignored — standard
85    /// subgradient-free treatment, matching ASL).
86    Compare(CmpOp, Box<Expr>, Box<Expr>),
87    /// Logical AND (`o21`). `1.0` iff both operands are nonzero.
88    /// Zero derivative (piecewise constant).
89    And(Box<Expr>, Box<Expr>),
90    /// Logical OR (`o20`). `1.0` iff either operand is nonzero.
91    /// Zero derivative (piecewise constant).
92    Or(Box<Expr>, Box<Expr>),
93    /// Logical NOT (`o34`). `1.0` iff the operand is zero.
94    /// Zero derivative (piecewise constant).
95    Not(Box<Expr>),
96    /// `if-then-else` (`o35` OPIFnl). Evaluates `cond`; when it is
97    /// nonzero the value and all derivatives flow through `then_`,
98    /// otherwise through `else_`. The branch switch is a non-smooth
99    /// event the derivative ignores (it differentiates only the
100    /// active branch), exactly as ASL/IPOPT does for `if`.
101    Cond {
102        cond: Box<Expr>,
103        then_: Box<Expr>,
104        else_: Box<Expr>,
105    },
106    /// n-ary minimum (`o11` MINLIST). Value is the smallest operand.
107    /// Piecewise linear: the derivative flows through whichever operand
108    /// is currently smallest (a subgradient; ties resolve to the first
109    /// such operand), and the second derivative is identically zero —
110    /// the standard AD treatment for min/max, matching ASL/IPOPT.
111    MinList(Vec<Expr>),
112    /// n-ary maximum (`o12` MAXLIST). Value is the largest operand;
113    /// derivative routing mirrors [`Expr::MinList`].
114    MaxList(Vec<Expr>),
115}
116
117/// Relational operator carried by [`Expr::Compare`]. The variants map
118/// 1:1 onto AMPL opcodes `o22 LT`, `o23 LE`, `o24 EQ`, `o28 GE`,
119/// `o29 GT`, `o30 NE`.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum CmpOp {
122    Lt,
123    Le,
124    Eq,
125    Ge,
126    Gt,
127    Ne,
128}
129
130/// One positional argument to an AMPL imported function call. AMPL splits
131/// arguments into reals (carried by `ra[]`) and strings (carried by `sa[]`);
132/// `FuncallArg` mirrors that split. Real args are arbitrary expressions.
133#[derive(Debug, Clone)]
134pub enum FuncallArg {
135    Real(Expr),
136    Str(String),
137}
138
139/// An AMPL imported (external) function declaration from a top-level
140/// `F<id> <type> <nargs> <name>` segment.
141#[derive(Debug, Clone)]
142pub struct ImportedFunc {
143    pub id: usize,
144    /// 0 = real-valued, 1 = string-args (per AMPL's funcadd ABI).
145    pub kind: usize,
146    /// Declared arg count. >=0 exact arity; <=-1 means at least `-(nargs+1)`.
147    pub nargs: i64,
148    pub name: String,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum BinOp {
153    Add,
154    Sub,
155    Mul,
156    Div,
157    Pow,
158    /// Two-argument arctangent `atan2(a, b)` with operands `(y, x)`.
159    Atan2,
160    /// `a·ln(a/b)` — GAMS `centropy`. No `.nl` opcode; in-memory `Expr` only.
161    ///
162    /// Fused for the same reason as [`UnaryOp::XLogX`], plus one of its own:
163    /// `∂²/∂b²` is `a/b²`, and `b²` overflows for `|b| > 1.3e154` while
164    /// `a/b²` itself stays comfortably in range. The fused rule evaluates it
165    /// as `q/b` with `q = a/b` and never squares anything.
166    CEntropy,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum UnaryOp {
171    Neg,
172    Sqrt,
173    Log,
174    Exp,
175    Abs,
176    Sin,
177    Cos,
178    Log10,
179    Tan,
180    Atan,
181    Acos,
182    Sinh,
183    Cosh,
184    Tanh,
185    Asin,
186    Acosh,
187    Asinh,
188    Atanh,
189    /// Gauss error function. No `.nl` opcode maps here — AMPL has no `erf`,
190    /// so the parser never emits it — but the in-memory builder (issue #469)
191    /// does, which is the whole point: a frontend that constructs an `Expr`
192    /// directly is not limited to what `.nl` can spell.
193    Erf,
194    /// `a·ln(a)` — GAMS `entropy`. Like [`UnaryOp::Erf`], no `.nl` opcode maps
195    /// here; it is reachable only from an in-memory `Expr`.
196    ///
197    /// Fused rather than lowered to `Mul(a, Log(a))` because the chain rule
198    /// *cannot* produce its second derivative. `(a·ln a)'' = 1/a` is finite
199    /// wherever `a > 0` — at `a = 1e-299` it is `1e299` — but every
200    /// decomposition routes through `ln''(a) = -1/a² = -1e598`, which exceeds
201    /// `f64::MAX`. A composite that is in range, built from a factor that is
202    /// not, is unreachable by any chain rule however carefully written, so the
203    /// fusion is a correctness requirement rather than an optimization.
204    XLogX,
205}
206
207/// The `.nl` header's nonlinearity census — lines 3 and 5 of Gay's header
208/// table (*Hooking Your Solver to AMPL*, §D and Table 1).
209///
210/// AMPL has already done this analysis when it writes the file, so these are
211/// facts about the model that cost nothing to keep and would otherwise have
212/// to be recovered by walking every expression tree in it.
213///
214/// ```text
215///  55 1        # nonlinear constraints, objectives          -> nl_cons, nl_objs
216///  100 110 100 # nonlinear vars in constraints, objectives, both
217/// ```
218///
219/// Two properties of the format make these usable rather than merely
220/// informative, and both are asserted against the fixture corpus in
221/// `crates/pounce-cli/tests/nl_header_counts.rs`:
222///
223/// 1. **Nonlinear rows come first.** Constraints `0..nl_cons` are the ones
224///    with a nonlinear body; objectives `0..nl_objs` likewise.
225/// 2. **Nonlinear variables come first.** The `.nl` variable order is
226///    "nonlinear in both, then constraints-only, then objectives-only, then
227///    everything linear", so the variables that appear nonlinearly occupy a
228///    prefix of length [`NlCounts::nonlinear_vars`].
229///
230/// The counts are what the *writer* asserted, which is not always what the
231/// parsed trees say: `parse_nl_text` folds a variable-free `C` body into the
232/// row bounds (`gh #492`), so a row counted in `nl_cons` can arrive here with
233/// a nonlinear part of `Const(0.0)`. The discrepancy is one-directional —
234/// the header only ever over-states nonlinearity relative to the trees — and
235/// every consumer below is written to be sound under exactly that direction.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct NlCounts {
238    /// `nlc`: constraints with a nonlinear body.
239    pub nl_cons: usize,
240    /// `nlo`: objectives with a nonlinear body.
241    pub nl_objs: usize,
242    /// `nlvc`: variables appearing nonlinearly in constraints. **Includes**
243    /// the [`Self::nl_vars_both`] variables that are also nonlinear in an
244    /// objective.
245    pub nl_vars_cons: usize,
246    /// `nlvo`: variables appearing nonlinearly in objectives. Also includes
247    /// [`Self::nl_vars_both`].
248    pub nl_vars_objs: usize,
249    /// `nlvb`: variables appearing nonlinearly in *both* a constraint and an
250    /// objective. Counted a second and third time in the two fields above,
251    /// which is why the total is an inclusion–exclusion and not a sum.
252    pub nl_vars_both: usize,
253}
254
255impl NlCounts {
256    /// Number of distinct variables that appear nonlinearly anywhere:
257    /// `nlvc + nlvo − nlvb`, because `nlvb` is double-counted by the other
258    /// two. Saturating, so a malformed header cannot underflow.
259    ///
260    /// This is *not* `max(nlvc, nlvo)`: for `min x₀² s.t. x₁² ≤ 1` the counts
261    /// are `nlvc = nlvo = 1`, `nlvb = 0` and there are two nonlinear
262    /// variables, not one.
263    pub fn nonlinear_vars(&self) -> usize {
264        self.nl_vars_cons
265            .saturating_add(self.nl_vars_objs)
266            .saturating_sub(self.nl_vars_both)
267    }
268}
269
270/// The nonlinear body of the objective, or of one constraint row, as the
271/// parser left it.
272///
273/// Before gh #588 Q5 this was always an [`Expr`], and for most bodies it
274/// still is. The second variant exists because the `Expr` DAG is what sets
275/// peak RSS on a quadratic model — 2.32 M nodes for a ten-row
276/// `qcqp500-3c` — and every consumer of a *recognized* body wants the
277/// degree-≤2 coefficients rather than the tree they would have to walk to
278/// recover them. So the parser recognizes those bodies from the token
279/// stream and never builds the tree at all.
280///
281/// The distinction is deliberately impossible to ignore. Nine consumers
282/// read these bodies (§5.3 of `dev-notes/quadratic-structure-exploitation.md`
283/// enumerates them) and several would read a *missing* tree as "this row is
284/// linear" — a silent wrong answer. An enum makes every one of them a
285/// compile error until it says which reading it wants.
286#[derive(Debug, Clone)]
287pub enum NlBody {
288    /// The expression tree.
289    Tree(Expr),
290    /// A degree-2 form recognized while the token stream was consumed. The
291    /// tree was never built; [`NlProblem::con_expr`] rebuilds it on demand,
292    /// byte for byte, by re-parsing the same bytes with the same parser.
293    Quad(Box<QuadBody>),
294}
295
296/// A body the parser recognized as an already-expanded quadratic.
297#[derive(Debug, Clone)]
298pub struct QuadBody {
299    /// The recognized form. Bit-for-bit what
300    /// [`crate::nl_quadratic::recognize_expr`] returns for the tree these
301    /// same bytes parse to — asserted directly, over the whole corpus, by
302    /// `pounce-cli/tests/quad_parse_differential.rs`.
303    pub form: Quad2,
304    /// Every variable the body's token stream mentions, ascending and
305    /// deduplicated — exactly the set [`collect_vars`] reports for the tree,
306    /// **including** variables whose coefficient cancelled to zero (which
307    /// `form` necessarily drops). Structural consumers want this one; the
308    /// linearity contract is over-stating-is-safe, and a support taken from
309    /// `form` would under-state.
310    pub vars: Vec<u32>,
311    /// Byte range of this body's token stream inside [`NlProblem::src`].
312    pub src: std::ops::Range<usize>,
313    /// Nesting depth of the tree these tokens would have built, on the same
314    /// convention as a leaf counting 1.
315    ///
316    /// Recorded because the streaming recognizer is iterative and the tree
317    /// parser is not: a body deep enough to overflow the parser's stack now
318    /// *loads*, and the depth guard that used to be enforced implicitly by
319    /// the parse failing has to be enforced by something. `pounce-py`'s
320    /// `checked_depth` reads it (pounce #472). Rebuilding such a body with
321    /// [`NlProblem::con_expr`] would still recurse, which is the same
322    /// ceiling Q3 recorded and Q5 does not lift.
323    pub depth: u32,
324}
325
326impl NlBody {
327    /// The identity zero — "this row has no nonlinear part". A recognized
328    /// body is degree 2 by construction, so it is never trivially zero;
329    /// the question still has to be asked through here rather than by
330    /// matching on a tree that may not exist.
331    pub fn is_trivially_zero(&self) -> bool {
332        match self {
333            NlBody::Tree(e) => matches!(e, Expr::Const(c) if *c == 0.0),
334            NlBody::Quad(_) => false,
335        }
336    }
337
338    /// The recognized degree-2 form, when the parser produced one.
339    pub fn quad(&self) -> Option<&Quad2> {
340        match self {
341            NlBody::Tree(_) => None,
342            NlBody::Quad(q) => Some(&q.form),
343        }
344    }
345
346    /// The tree, when there is one resident. `None` for a recognized body —
347    /// use [`NlProblem::con_expr`] / [`NlProblem::obj_expr`] to rebuild it.
348    pub fn tree(&self) -> Option<&Expr> {
349        match self {
350            NlBody::Tree(e) => Some(e),
351            NlBody::Quad(_) => None,
352        }
353    }
354
355    /// This body as a degree-≤2 Hessian, or `None` if it is not provably
356    /// quadratic — [`crate::nl_quadratic::analyze_quadratic`] for a tree,
357    /// and the form the parser already proved for a recognized body. The
358    /// two answers are the same by construction and asserted to be so bit
359    /// for bit; this is the accessor that keeps the corpus from being
360    /// re-recognized once per consumer.
361    ///
362    /// `None` also when a term was dropped getting to the form — see
363    /// [`Self::analyze_quadratic_full`].
364    pub fn analyze_quadratic(&self) -> Option<QuadHessian> {
365        self.analyze_quadratic_full().map(|(h, _, _)| h)
366    }
367
368    /// [`Self::analyze_quadratic`] with the linear and constant parts.
369    ///
370    /// ## A form that dropped a term is not this body
371    ///
372    /// Everything downstream of here *reads coefficients out*: the
373    /// classifier decides a problem class from the Hessian, and
374    /// `qp_extract` builds `P`, `c`, `A` and `G` from all three parts. So
375    /// this accessor owes its callers a form that is the whole body, and
376    /// a recognized form is only that when nothing was dropped reaching
377    /// it. `2⁵³·x₀² + x₀² − 2⁵³·x₀²` folds to `x₀²` and stores nothing;
378    /// `(10⁻²⁰⁰·x₀)·(10⁻²⁰⁰·x₀)` underflows the same way (gh #683).
379    ///
380    /// Handing that form out is what routed the reproduction in gh #685
381    /// to the **LP** fast path: with the row's only quadratic term gone
382    /// the classifier saw a linear row, `qp_extract` folded an empty
383    /// linear part into `G`, and the constraint left the model
384    /// altogether — `min −x₀` subject to a vanished row walks `x₀` to its
385    /// `10⁶` bound and reports `Optimal`. A wrong answer, on the default
386    /// route, with no option set (gh #685 part 2).
387    ///
388    /// The gate is [`Quad2::lost_terms`] and not an emptiness test, for
389    /// the reason spelled out on [`Self::admitted_quad_form`]: partial
390    /// cancellation leaves a non-empty map that is still short a term.
391    /// Refusing costs reach only — the row falls back to the AD tape,
392    /// and the model to the NLP path, which solves it soundly.
393    ///
394    /// `lost_terms` is the *inexact fold* and not the drop it leads to
395    /// (gh #687), so the reach given up here is only the reach that has
396    /// to be. `x − x` cancels exactly — nothing was lost, the form is
397    /// the body, and it is still handed out; `2⁵³·x + x − 2⁵³·x` loses
398    /// the `x` at `fl(2⁵³ + 1)`, and that is what this refuses.
399    ///
400    /// Use [`Self::quad_terms_dropped`] to tell the two `None`s apart.
401    pub fn analyze_quadratic_full(&self) -> Option<QuadForm> {
402        match self {
403            NlBody::Tree(e) => {
404                let form = recognize_expr(e)?;
405                (!form.lost_terms()).then(|| quad_form_readout(&form))
406            }
407            NlBody::Quad(q) => (!q.form.lost_terms()).then(|| quad_form_readout(&q.form)),
408        }
409    }
410
411    /// Whether the recognizer reached a degree-≤2 form for this body but
412    /// lost at least one term getting there — the case
413    /// [`Self::analyze_quadratic_full`] refuses.
414    ///
415    /// `false` both for a body that recognized cleanly and for one that
416    /// did not recognize at all, so this separates the two reasons that
417    /// accessor answers `None`; it is not a nonlinearity test. Meant for
418    /// the *refusal* path (the classifier naming its reason), not the hot
419    /// one: on a tree it re-runs the recognizer.
420    pub fn quad_terms_dropped(&self) -> bool {
421        match self {
422            NlBody::Tree(e) => recognize_expr(e).is_some_and(|f| f.lost_terms()),
423            NlBody::Quad(q) => q.form.lost_terms(),
424        }
425    }
426
427    /// The form the constant-structure evaluator is allowed to use — i.e.
428    /// [`Self::analyze_quadratic_full`] behind the *exactness* gate.
429    ///
430    /// A recognized body has already passed that gate: the parser admits
431    /// only a flat sum of monomials, which is the same rule
432    /// [`crate::nl_quadratic::is_expanded_quadratic`] applies to a tree.
433    /// Reading a factored form out of stored coefficients cancels — five
434    /// digits on `(x − 500000)²` — so the gate is on both arms or on
435    /// neither (gh #588, Q4).
436    ///
437    /// A body this refuses for that reason is not out of reach, only out of
438    /// *this* representation: [`Self::admitted_factored_form`] serves it by
439    /// keeping the squares factored (gh #673), and only a body neither can
440    /// express keeps its tape.
441    ///
442    /// ## The second gate: a term that was *lost* is a term that is missing
443    ///
444    /// `is_expanded_quadratic` is a gate on the *shape* the coefficients
445    /// were derived from. It says nothing about whether the derivation kept
446    /// them. A flat sum of monomials passes it and still folds to a form
447    /// with an entry missing, because the fold is floating-point addition:
448    /// `2⁵³·x₀² + x₀² − 2⁵³·x₀²` is `x₀²` and stores nothing, and
449    /// `(10⁻²⁰⁰·x₀)·(10⁻²⁰⁰·x₀)` underflows the same way (gh #683).
450    ///
451    /// Evaluating **that** form is not a five-digit cancellation, it is a
452    /// missing term. At `x₀ = 3` the row reads `0` where its own tape reads
453    /// `16`, and `∂g/∂x` reads `[0, 0]` where the tape reads `[8, 0]` — so
454    /// the `≤` the row sits under stops constraining anything at all. In the
455    /// reproduction (`issue_685_cancelled_quadratic_evaluation`) the solve
456    /// then walks the objective variable to its `-10⁶` floor and reports
457    /// `Optimal`, where the same bytes down the tape stop at `-0.281`. On
458    /// the default route, with no option set. So the form is admitted only
459    /// when [`Quad2::lost_terms`] is clear (gh #685 part 1).
460    ///
461    /// It has to be that flag and not an emptiness test. Partial
462    /// cancellation is the same defect wearing a different face:
463    /// `2⁵³·x₀² + x₀² − 2⁵³·x₀² + x₁²` keeps `x₁²`, so the map is not empty
464    /// and [`Self::provably_affine`] answers `Some(false)` quite correctly —
465    /// while the read-out is still short an entire `x₀²`. A gate that looked
466    /// at emptiness would pass this and stay wrong.
467    ///
468    /// And it is the *loss*, not the drop. `x₀² − x₀²` folds through
469    /// `fl(1) + fl(−1) = 0` with nothing rounded away, so its read-out is
470    /// the whole body and it keeps this fast path; gating on the drop
471    /// refused it alongside the absorbing row above, for arithmetic that
472    /// lost nothing (gh #687).
473    ///
474    /// The cost is reach, not correctness: a row that lost a term goes back
475    /// to the AD tape, which is where it was before Q4.
476    pub fn admitted_quad_form(&self) -> Option<QuadForm> {
477        match self {
478            NlBody::Tree(e) => {
479                if is_trivially_zero(e) || !is_expanded_quadratic(e) {
480                    return None;
481                }
482                let form = recognize_expr(e)?;
483                (!form.lost_terms()).then(|| quad_form_readout(&form))
484            }
485            NlBody::Quad(q) => (!q.form.lost_terms()).then(|| quad_form_readout(&q.form)),
486        }
487    }
488
489    /// The **factored** form the constant-structure evaluator may use when
490    /// [`Self::admitted_quad_form`] refuses (gh #673).
491    ///
492    /// That accessor's gate is `is_expanded_quadratic`, and what it refuses
493    /// is a body whose read-out would be an algebraic *expansion* of what
494    /// the writer wrote — `(x − 500000)²` read back as
495    /// `x² − 10⁶x + 2.5·10¹¹`, five digits gone. The refusal was never
496    /// about the body being unsuitable for constant-structure evaluation;
497    /// it was about the *representation*. So a body written as a sum of
498    /// squared residuals — which is every least-squares model, and 41 of
499    /// `airport.nl`'s 42 rows — is served here instead, by keeping the
500    /// writer's own grouping and squaring it at evaluation time exactly as
501    /// the tape does. See
502    /// [`recognize_factored_quadratic`](crate::nl_quadratic::recognize_factored_quadratic)
503    /// for what is admitted and why it costs no accuracy.
504    ///
505    /// Answers `None` for a body the parser recognized: a
506    /// [`NlBody::Quad`] is an already-flat sum of monomials by
507    /// construction, so it has no factoring left to keep and
508    /// [`Self::admitted_quad_form`] has already served it.
509    ///
510    /// ## This arm answers for the bodies refused on *shape*, and only those
511    ///
512    /// [`Self::admitted_quad_form`] says `None` for two different reasons,
513    /// and only one of them is this arm's. A body refused because its shape
514    /// is factored is what this serves. A body refused because a term went
515    /// **missing** in the fold ([`Quad2::lost_terms`], gh #685) keeps its
516    /// tape, and the explicit `is_expanded_quadratic` test here is what
517    /// keeps it there: those bodies are flat sums of monomials, so the
518    /// square-shaped ones among them — `2⁵³x₀² + x₀² − 2⁵³x₀²` is three —
519    /// would otherwise be admitted here by the back door.
520    ///
521    /// What breaks when they are is worth stating, because it is *not* that
522    /// the fast path becomes less accurate. Measured with this test
523    /// removed: the row whose tape answers `16.0` at `x₀ = 3` is answered
524    /// `9.0` by the factored arm — and `9.0` is the mathematically right
525    /// value of `x₀²`, which the compensated outer sum (gh #702) recovers
526    /// and the tape's naive fold does not. End to end the reproduction
527    /// moves from `−1.812` to `−2.236`, which is `−√5`, the true optimum of
528    /// the model those bytes describe.
529    ///
530    /// It is still a defect, for the reason this file's own doc comment
531    /// gives: the tape is the reference because it is what the row means
532    /// *to this solver*, not because it is exact. Two routes over the same
533    /// bytes that answer `9` and `16` are a `POUNCE_DBG_NO_QUAD`-shaped
534    /// divergence whichever one is closer to the algebra. (The Hessian
535    /// **pattern** diverges too — `Σ 2wₖbₖbₖᵀ` folds that row's `(0, 0)` to
536    /// exactly `0.0`, `2⁵⁴ + 2` tying back to `2⁵⁴`, and a zero entry is
537    /// not stored where the tape declares one: `nnz_h` 2 → 1.)
538    ///
539    /// Pinned by
540    /// `a_row_that_dropped_a_term_is_not_admitted_as_a_factored_form_either`.
541    ///
542    /// Callers must still try [`Self::admitted_quad_form`] first: both can
543    /// answer for the same body (a bare `x²` is a monomial and a square),
544    /// and the expanded arm is the cheaper evaluation — a matvec over a
545    /// merged row rather than one squaring per term.
546    pub fn admitted_factored_form(&self) -> Option<FactoredQuadratic> {
547        match self {
548            NlBody::Tree(e) => {
549                if is_trivially_zero(e) || is_expanded_quadratic(e) {
550                    return None;
551                }
552                recognize_factored_quadratic(e)
553            }
554            NlBody::Quad(_) => None,
555        }
556    }
557
558    /// Whether this body is provably **affine** — degree ≤ 1 — as a
559    /// three-valued answer: `Some(true)` proved affine, `Some(false)`
560    /// proved to have a nonzero second derivative, `None` no proof
561    /// either way.
562    ///
563    /// This is the degree question *without* the exactness gate
564    /// [`Self::admitted_quad_form`] applies, and the difference is the
565    /// point (gh #588, Q6). That gate exists because reading a value out
566    /// of stored coefficients cancels for a factored form; nothing is
567    /// read out here. The answer is used to decide whether a derivative
568    /// may be **reused** across iterates — a question about the
569    /// *degree* of the body, which a factored `(x − a)²` answers just as
570    /// well as an expanded one.
571    ///
572    /// `None` is not evidence of nonlinearity: the recognizer refuses
573    /// `2·(x + 1)`, which is affine. Consumers must treat it as "not
574    /// established".
575    ///
576    /// ## What the exactness argument above still does not buy
577    ///
578    /// It is true that nothing is *evaluated* from the coefficients here.
579    /// What the argument missed is that the **degree answer is itself
580    /// computed by the coefficient arithmetic**: the recognizer sums a
581    /// row's quadratic coefficients in floating point and drops the ones
582    /// that reach exactly zero, so `2⁵³·x² + x² − 2⁵³·x²` — and
583    /// `(10⁻²⁰⁰·x)·(10⁻²⁰⁰·x)`, by underflow — folded to an empty
584    /// quadratic map and were reported *proved affine*. Q6's consumer then
585    /// froze those rows' Jacobians for the whole solve (gh #683).
586    ///
587    /// So an empty quadratic map is a proof of degree ≤ 1 only when no term
588    /// went missing getting there, which is what
589    /// [`Quad2::lost_terms`](crate::nl_quadratic::Quad2::lost_terms)
590    /// records. When one did, this answers `None` — the state the contract
591    /// already reserved for "not established", which is why the fix needs
592    /// nothing of its consumer.
593    ///
594    /// A term that cancelled **exactly** did not go missing, and gh #687 is
595    /// where that stopped costing a proof: `x₀² − x₀²` is degree 0 by an
596    /// add that rounded nothing, its tape holds `∂g/∂x` at zero for every
597    /// `x`, and answering `None` for it gave up a whole solve of frozen
598    /// Jacobian to be safe from arithmetic that never happened.
599    ///
600    /// Deliberately answers from the term maps rather than from
601    /// [`Self::analyze_quadratic_full`]'s triplets: on `qssp180` that is
602    /// 65 341 recognized rows, and materializing a `QuadHessian` per row
603    /// to ask whether it is empty is the allocation-per-object cost Q3
604    /// removed from the recognizer in the first place.
605    pub fn provably_affine(&self) -> Option<bool> {
606        match self {
607            NlBody::Tree(e) => {
608                if is_trivially_zero(e) {
609                    return Some(true);
610                }
611                affine_from_form(&recognize_expr(e)?)
612            }
613            NlBody::Quad(q) => affine_from_form(&q.form),
614        }
615    }
616
617    /// Add this body's structural variable support to `out`.
618    pub fn collect_vars(&self, out: &mut BTreeSet<usize>) {
619        match self {
620            NlBody::Tree(e) => collect_vars(e, out),
621            NlBody::Quad(q) => out.extend(q.vars.iter().map(|&v| v as usize)),
622        }
623    }
624}
625
626/// Give one body a constant-structure form, if either representation will
627/// take it, and return its id.
628///
629/// The order is the contract [`NlBody::admitted_factored_form`] states:
630/// the expanded read-out first, because it is the cheaper evaluation and
631/// because it is what the `qcqp*` family — the target of gh #588's Q4 —
632/// arrives as; the factored one only for the bodies that gate refuses
633/// (gh #673). A body neither admits keeps its AD tape, which is where every
634/// body was before Q4.
635fn push_body_form(quad: &mut QuadraticStructure, body: &NlBody) -> Option<u32> {
636    if let Some((h, lin, c)) = body.admitted_quad_form() {
637        return Some(quad.push_form(&h, &lin, c));
638    }
639    let fq = body.admitted_factored_form()?;
640    let terms: Vec<SquareTerm<'_>> = fq
641        .squares
642        .iter()
643        .map(|t| SquareTerm {
644            weight: t.weight,
645            coefs: &t.coefs,
646            constant: t.constant,
647        })
648        .collect();
649    // `None` here is the gh #685 gate on this arm: the body is factored,
650    // but assembling `Σ 2wₖbₖbₖᵀ` dropped an entry the tape will declare.
651    // Falls through to the tape, like any other refusal.
652    quad.push_factored_form(&terms, &fq.linear, fq.constant)
653}
654
655/// The degree read-out [`NlBody::provably_affine`] makes of a recognized
656/// form, in one place so both arms answer it the same way.
657///
658/// A stored quadratic coefficient is a witness that the body is degree 2.
659/// An *absent* one is only a witness of the opposite when nothing went
660/// missing on the way — see [`Quad2::lost_terms`], gh #683 and gh #687.
661fn affine_from_form(q: &Quad2) -> Option<bool> {
662    if q.quadratic().is_empty() {
663        (!q.lost_terms()).then_some(true)
664    } else {
665        Some(false)
666    }
667}
668
669impl From<Expr> for NlBody {
670    fn from(e: Expr) -> Self {
671        NlBody::Tree(e)
672    }
673}
674
675/// Parsed `.nl` problem in the form needed by `NlTnlp`.
676#[derive(Debug, Clone)]
677pub struct NlProblem {
678    pub n: usize,
679    pub m: usize,
680    pub num_obj: usize,
681    pub minimize: bool,
682    pub obj_nonlinear: NlBody,
683    pub obj_linear: Vec<(usize, Number)>,
684    pub obj_constant: Number,
685    /// Per-constraint nonlinear part (length m).
686    pub con_nonlinear: Vec<NlBody>,
687    /// Per-constraint linear part (length m), each a list of (var, coef).
688    pub con_linear: Vec<Vec<(usize, Number)>>,
689    pub x_l: Vec<Number>,
690    pub x_u: Vec<Number>,
691    pub g_l: Vec<Number>,
692    pub g_u: Vec<Number>,
693    pub x0: Vec<Number>,
694    /// Initial constraint multipliers from the `d` segment, in the engine's
695    /// sign (`L = f + lambda' g`, `f` negated for a maximize model):
696    /// `-sigma * dual` for the AMPL duals the file carries, the inverse of
697    /// the CLI's `.sol` writer (gh#1033).
698    pub lambda0: Vec<Number>,
699    /// AMPL suffix dictionaries. Variable / constraint / objective
700    /// suffixes are stored as dense vectors (length n / m / num_obj)
701    /// with the sparse `.nl` `S`-segment entries scattered in, default
702    /// zero. The integer / real split matches the `S`-segment header's
703    /// kind bit (`0x4` ⇒ real, else integer). See
704    /// <https://ampl.com/REFS/hooking2.pdf> §6 and the upstream `.nl`
705    /// reader in `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`.
706    pub suffixes: NlSuffixes,
707    /// The model's own AMPL option words, taken verbatim from `.nl`
708    /// header line 0 (`g<count> <opt0> <opt1> ...`). A solver echoes
709    /// these back in the `.sol` `Options` block rather than interpreting
710    /// them — see [`crate::sol_writer::format_sol_with_options`]. Empty
711    /// for problems not built from a `.nl` file.
712    pub ampl_options: Vec<i64>,
713    /// The header's nonlinearity census, when the problem came from a `.nl`
714    /// file whose header parsed cleanly. `None` for a model built in memory
715    /// ([`NlProblem::from_expressions`]) — there is no header to read, and
716    /// inventing one would let a consumer trust a count nobody computed.
717    pub nl_counts: Option<NlCounts>,
718    /// Header line 7's discrete-variable census: declared binary
719    /// and general-integer variables (gh#987). `None` when there was no header
720    /// or it did not parse. pounce solves the continuous relaxation, so a
721    /// nonzero count means the answer is a bound, not a MIP solution.
722    pub n_discrete: Option<DiscreteCensus>,
723    /// AMPL imported (external) functions declared via top-level `F` segments.
724    /// Empty unless the `.nl` file calls compiled-C user functions (typically
725    /// emitted by IDAES property packages — see issue #49).
726    pub imported_funcs: Vec<ImportedFunc>,
727    /// Variable names from the sibling `.col` file, index-aligned to `x`
728    /// (one name per line, column order). Empty when no `.col` file was
729    /// found — AMPL only emits it under `option auxfiles rc;`.
730    ///
731    /// Carrying names lets diagnostics report `flow_balance` / `T_reactor`
732    /// instead of `c[3]` / `x[132]`. Lee et al. (2024) identify the gap
733    /// between detecting an issue and tracing it to a *named* equation as a
734    /// central roadblock for equation-oriented model debugging; threading
735    /// names through to the solver/debugger is the prerequisite for closing
736    /// it. See <https://doi.org/10.69997/sct.147875>.
737    pub var_names: Vec<String>,
738    /// Constraint names from the sibling `.row` file, index-aligned to `g`
739    /// (one name per line, row order). Empty when no `.row` file was found.
740    /// See [`NlProblem::var_names`] for why names are captured.
741    pub con_names: Vec<String>,
742    /// The `.nl` text this problem was parsed from, kept only when some
743    /// body was recognized as a quadratic and therefore has no tree of its
744    /// own. It is what makes [`NlProblem::con_expr`] able to hand back the
745    /// *exact* tree rather than a re-derived one: same bytes, same parser,
746    /// same `Expr`. `None` for [`NlProblem::from_expressions`] models and
747    /// for a parse that recognized nothing.
748    ///
749    /// The text is resident during parsing either way, so keeping it costs
750    /// nothing at the peak this is all in aid of — see
751    /// `dev-notes/quadratic-structure-exploitation.md` §0f.
752    pub src: Option<Arc<String>>,
753    /// The `V`-segment common subexpressions, by CSE-local index. Held so a
754    /// re-parse resolves `v<i>` (`i >= n`) to the *same* `Arc` the original
755    /// parse did — `HybridTape::build_multi` keys sharing on pointer
756    /// identity, so a rebuilt body that allocated fresh bodies would look
757    /// unshared.
758    pub cse_bodies: Vec<Arc<Expr>>,
759}
760
761/// The pieces of a model built in memory, as handed to
762/// [`NlProblem::from_expressions`].
763///
764/// Everything is expressed as [`Expr`] trees — there is no linear/nonlinear
765/// split to fill in, because the AD tape treats a linear term exactly like
766/// any other subexpression (`.nl`'s `J`/`G` segments are a file-format
767/// optimization, not an evaluator requirement). `n` is taken from the length
768/// of `x_l`; `m` from the length of `constraints`.
769///
770/// One cost to that simplification, in *metadata* rather than values: with
771/// `con_linear` empty, `get_constraints_linearity` tags a row `Linear` only
772/// when its expression is literally `Const(0.0)`, so a genuinely linear row
773/// built here reports `NonLinear`. Presolve consumes that tag, and the
774/// direction is the safe one — it loses tightening it could have done, and
775/// never asserts linearity that does not hold — but a frontend that cares
776/// about presolve strength on linear rows should know the tag is
777/// pessimistic on this path.
778#[derive(Debug, Clone)]
779pub struct NlProblemParts {
780    /// `true` to minimize `objective`, `false` to maximize it. Matches
781    /// [`NlProblem::minimize`]: the evaluator negates a maximize objective
782    /// so callers always see the minimization form.
783    pub minimize: bool,
784    /// Objective expression.
785    pub objective: Expr,
786    /// Constant offset added to the objective.
787    pub obj_constant: Number,
788    /// One expression per constraint row; row `i` is bounded by
789    /// `g_l[i] <= constraints[i](x) <= g_u[i]`.
790    pub constraints: Vec<Expr>,
791    /// Variable bounds and starting point, each length `n`. Use `±1e19`
792    /// for "unbounded", the same sentinel the `.nl` reader emits.
793    pub x_l: Vec<Number>,
794    pub x_u: Vec<Number>,
795    pub x0: Vec<Number>,
796    /// Constraint bounds, each length `m`. `g_l[i] == g_u[i]` is an
797    /// equality row.
798    pub g_l: Vec<Number>,
799    pub g_u: Vec<Number>,
800    /// Optional names, index-aligned to `x` / `g`. Empty is fine — every
801    /// consumer falls back to indices (see [`NlProblem::var_names`]).
802    pub var_names: Vec<String>,
803    pub con_names: Vec<String>,
804}
805
806impl NlProblem {
807    /// Assemble a problem from expression trees, with no `.nl` file
808    /// anywhere in the loop (issue #469).
809    ///
810    /// A modeling frontend that already has its own expression DAG should
811    /// come in here rather than serialize to `.nl` and re-parse: the round
812    /// trip is not only slower, it is *lossy*, because `.nl` writers
813    /// routinely refuse operators this tape supports natively (`atan2`,
814    /// `min`/`max`, and — with no `.nl` opcode at all — [`UnaryOp::Erf`]).
815    ///
816    /// The result is an ordinary [`NlProblem`], so it feeds
817    /// [`NlTnlp::try_new`] and gets exactly the evaluators a parsed model
818    /// does: objective, gradient, constraints, Jacobian + structure,
819    /// Lagrangian Hessian + structure, and
820    /// [`NlTnlp::hessian_vector_product`].
821    ///
822    /// Errors on a length mismatch or on a `Var(i)` index at or beyond `n`
823    /// — the latter would otherwise be an out-of-bounds read in the tape's
824    /// forward sweep, so it must be caught while it is still a diagnosable
825    /// user error.
826    pub fn from_expressions(parts: NlProblemParts) -> Result<NlProblem, String> {
827        let NlProblemParts {
828            minimize,
829            objective,
830            obj_constant,
831            constraints,
832            x_l,
833            x_u,
834            x0,
835            g_l,
836            g_u,
837            var_names,
838            con_names,
839        } = parts;
840
841        let n = x_l.len();
842        let m = constraints.len();
843        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
844            if got == want {
845                Ok(())
846            } else {
847                Err(format!(
848                    "from_expressions: {name} has length {got}, expected {want}"
849                ))
850            }
851        };
852        check("x_u", x_u.len(), n)?;
853        check("x0", x0.len(), n)?;
854        check("g_l", g_l.len(), m)?;
855        check("g_u", g_u.len(), m)?;
856        if !var_names.is_empty() {
857            check("var_names", var_names.len(), n)?;
858        }
859        if !con_names.is_empty() {
860            check("con_names", con_names.len(), m)?;
861        }
862
863        // Numeric validation, the same screen the `.nl` reader applies
864        // (gh #847). `lower_bound_present` / `upper_bound_present` are
865        // `is_finite() && ...`, so a caller that builds a model here and hands
866        // in a non-finite bound gets it silently dropped -- read as "no bound
867        // declared" -- exactly as a file containing `1e400` was. `-inf` on a
868        // lower side and `+inf` on an upper one are the sentinel said a
869        // different way and are normalized; everything else, `NaN` included,
870        // is refused.
871        let mut x_l = x_l;
872        let mut x_u = x_u;
873        let mut g_l = g_l;
874        let mut g_u = g_u;
875        for (i, v) in x_l.iter_mut().enumerate() {
876            *v = finite_bound_or_err(&format!("x_l[{i}]"), *v, true)
877                .map_err(|e| format!("from_expressions: {e}"))?;
878        }
879        for (i, v) in x_u.iter_mut().enumerate() {
880            *v = finite_bound_or_err(&format!("x_u[{i}]"), *v, false)
881                .map_err(|e| format!("from_expressions: {e}"))?;
882        }
883        for (i, v) in g_l.iter_mut().enumerate() {
884            *v = finite_bound_or_err(&format!("g_l[{i}]"), *v, true)
885                .map_err(|e| format!("from_expressions: {e}"))?;
886        }
887        for (i, v) in g_u.iter_mut().enumerate() {
888            *v = finite_bound_or_err(&format!("g_u[{i}]"), *v, false)
889                .map_err(|e| format!("from_expressions: {e}"))?;
890        }
891        for (i, v) in x0.iter().enumerate() {
892            finite_or_err(&format!("x0[{i}]"), *v).map_err(|e| format!("from_expressions: {e}"))?;
893        }
894        finite_or_err("obj_constant", obj_constant)
895            .map_err(|e| format!("from_expressions: {e}"))?;
896
897        // Structural validation. Memoized on `Cse` pointer identity so a
898        // heavily-shared DAG costs O(nodes) rather than O(inlined tree).
899        let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
900        validate_expr(&objective, n, &mut seen).map_err(|e| format!("objective {e}"))?;
901        for (i, c) in constraints.iter().enumerate() {
902            validate_expr(c, n, &mut seen).map_err(|e| format!("constraint {i} {e}"))?;
903        }
904
905        Ok(NlProblem {
906            n,
907            m,
908            num_obj: 1,
909            minimize,
910            obj_nonlinear: NlBody::Tree(objective),
911            obj_linear: Vec::new(),
912            obj_constant,
913            con_nonlinear: constraints.into_iter().map(NlBody::Tree).collect(),
914            con_linear: vec![Vec::new(); m],
915            x_l,
916            x_u,
917            g_l,
918            g_u,
919            x0,
920            lambda0: vec![0.0; m],
921            suffixes: NlSuffixes::default(),
922            imported_funcs: Vec::new(),
923            ampl_options: Vec::new(),
924            // No header was read, so there is no census to report. Consumers
925            // fall back to walking the trees, which is what they would have
926            // to do here anyway.
927            nl_counts: None,
928            n_discrete: None,
929            var_names,
930            con_names,
931            // Built from trees, so every body has one and there is nothing
932            // to rebuild from.
933            src: None,
934            cse_bodies: Vec::new(),
935        })
936    }
937
938    /// The objective's nonlinear body as an [`Expr`].
939    ///
940    /// Borrowed when the tree is resident, rebuilt when the parser
941    /// recognized the body and skipped building it. The rebuild re-parses
942    /// the body's own bytes with the same parser that produced the rest of
943    /// the model, so the result is the tree a non-recognizing parse would
944    /// have produced — structurally identical, coefficient bit patterns
945    /// included, and sharing the same `Cse` allocations.
946    ///
947    /// It is not free: it allocates the nodes the recognizer avoided. Reach
948    /// for [`NlBody::quad`] first if the coefficients are what you want.
949    pub fn obj_expr(&self) -> std::borrow::Cow<'_, Expr> {
950        self.body_expr(&self.obj_nonlinear, "objective")
951    }
952
953    /// Row `k`'s nonlinear body as an [`Expr`]. See [`Self::obj_expr`].
954    ///
955    /// # Panics
956    ///
957    /// Panics if `k >= m`, or if re-parsing a recognized body fails — the
958    /// latter cannot happen for a problem this crate produced (the bytes
959    /// parsed once already, by this code) and means the `NlProblem` was
960    /// assembled by hand with a `src` that does not match its bodies.
961    pub fn con_expr(&self, k: usize) -> std::borrow::Cow<'_, Expr> {
962        self.body_expr(&self.con_nonlinear[k], "constraint")
963    }
964
965    fn body_expr<'a>(&'a self, body: &'a NlBody, what: &str) -> std::borrow::Cow<'a, Expr> {
966        match body {
967            NlBody::Tree(e) => std::borrow::Cow::Borrowed(e),
968            NlBody::Quad(q) => {
969                let src = self
970                    .src
971                    .as_deref()
972                    .unwrap_or_else(|| panic!("{what} body was recognized but no source is kept"));
973                std::borrow::Cow::Owned(
974                    parse_body_fragment(&src[q.src.clone()], self.n, &self.cse_bodies)
975                        .unwrap_or_else(|e| panic!("re-parsing a recognized {what} body: {e}")),
976                )
977            }
978        }
979    }
980}
981
982/// Structural check on an expression bound for [`NlProblem::from_expressions`]:
983/// every `Var(i)` must satisfy `i < n`, and no `Expr::Funcall` may appear.
984///
985/// Both are things the tape cannot recover from later. An out-of-range
986/// `Var` is an out-of-bounds read in the forward sweep. A `Funcall` is
987/// worse-looking than it is fatal: `from_expressions` has nowhere to put
988/// the `F`-segment declarations an AMPL imported function needs
989/// (`NlProblemParts` has no field for them, and the built problem's
990/// `imported_funcs` is necessarily empty), so *any* funcall on this path is
991/// unresolvable. Accepting it would surface as "AMPLFUNC is not set" —
992/// advice the user cannot act on, because setting `AMPLFUNC` just moves the
993/// failure to "funcall id N has no F<N> declaration". Rejecting it here
994/// says the true thing: this door does not carry external functions; go
995/// through `read_nl` / `parse_nl_text` for a model that needs them.
996///
997/// `seen` memoizes `Cse` bodies by pointer identity across calls, so
998/// passing one set through a whole problem keeps the walk linear in
999/// distinct DAG nodes rather than exponential in sharing depth. Skipping a
1000/// repeat visit is sound because the caller aborts on the first violation:
1001/// reaching a body a second time proves the first visit found none.
1002fn validate_expr(
1003    e: &Expr,
1004    n: usize,
1005    seen: &mut std::collections::HashSet<*const Expr>,
1006) -> Result<(), String> {
1007    match e {
1008        Expr::Const(_) => Ok(()),
1009        Expr::Var(i) => {
1010            if *i < n {
1011                Ok(())
1012            } else {
1013                Err(format!("references Var({i}) but n = {n}"))
1014            }
1015        }
1016        Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
1017            validate_expr(a, n, seen)?;
1018            validate_expr(b, n, seen)
1019        }
1020        Expr::Unary(_, a) | Expr::Not(a) => validate_expr(a, n, seen),
1021        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
1022            for a in args {
1023                validate_expr(a, n, seen)?;
1024            }
1025            Ok(())
1026        }
1027        Expr::Cond { cond, then_, else_ } => {
1028            validate_expr(cond, n, seen)?;
1029            validate_expr(then_, n, seen)?;
1030            validate_expr(else_, n, seen)
1031        }
1032        Expr::Cse(body) => {
1033            if seen.insert(Arc::as_ptr(body)) {
1034                validate_expr(body, n, seen)
1035            } else {
1036                Ok(())
1037            }
1038        }
1039        Expr::Funcall { id, .. } => Err(format!(
1040            "references AMPL imported function id {id}, which this path cannot \
1041             resolve: a problem built from expressions has no F-segment \
1042             declarations to bind it to. Load such a model with read_nl or \
1043             parse_nl_text instead."
1044        )),
1045    }
1046}
1047
1048/// Suffix data parsed out of `S`-segments. Sparse entries are scattered
1049/// into dense vectors at problem load time so callers can index by
1050/// variable / constraint number directly. Empty maps when the `.nl`
1051/// file declared no suffixes.
1052#[derive(Debug, Clone, Default)]
1053pub struct NlSuffixes {
1054    /// Variable-level integer suffixes (kind = 0). Each vector has
1055    /// length `n_full` (problem variables).
1056    pub var_int: BTreeMap<String, Vec<Index>>,
1057    /// Constraint-level integer suffixes (kind = 1). Length `m_full`.
1058    pub con_int: BTreeMap<String, Vec<Index>>,
1059    /// Objective-level integer suffixes (kind = 2). Length `num_obj`.
1060    pub obj_int: BTreeMap<String, Vec<Index>>,
1061    /// Problem-level integer suffixes (kind = 3). Single value per name.
1062    pub problem_int: BTreeMap<String, Index>,
1063    /// Variable-level real suffixes (kind = 4). Length `n_full`.
1064    pub var_real: BTreeMap<String, Vec<Number>>,
1065    /// Constraint-level real suffixes (kind = 5). Length `m_full`.
1066    pub con_real: BTreeMap<String, Vec<Number>>,
1067    /// Objective-level real suffixes (kind = 6). Length `num_obj`.
1068    pub obj_real: BTreeMap<String, Vec<Number>>,
1069    /// Problem-level real suffixes (kind = 7). Single value per name.
1070    pub problem_real: BTreeMap<String, Number>,
1071}
1072
1073/// Deepest expression nesting the reader will build (gh#986). On a thread
1074/// whose stack is declared with [`set_parse_stack_budget`] the limit is
1075/// lowered to what that stack holds at the worst-case frame size; the CLI
1076/// runs on a 1 GiB thread and `pounce-py` on a 1 GiB worker (each falling
1077/// back to a smaller reservation, and a correspondingly lower limit, when the
1078/// OS refuses). A left-deep `o0` sum is read flat and does not count.
1079pub const MAX_PARSE_DEPTH: u32 = 40_000;
1080
1081thread_local! {
1082    /// Stack bytes the current thread offers the recursive reader
1083    /// (gh#986), set by whoever spawned it with a known stack. `None` (the
1084    /// default) means "unknown": [`DEFAULT_ASSUMED_STACK`] is assumed.
1085    static PARSE_STACK_BUDGET: std::cell::Cell<Option<usize>> =
1086        const { std::cell::Cell::new(None) };
1087}
1088
1089/// Tell the reader how much stack this thread has, so its depth guard fires
1090/// before the stack does at *any* build profile (gh#986). Call it first thing
1091/// on a thread created with an explicit stack size (the CLI's main thread, the
1092/// Python worker); `None` clears it.
1093pub fn set_parse_stack_budget(bytes: Option<usize>) {
1094    PARSE_STACK_BUDGET.with(|b| b.set(bytes));
1095}
1096
1097/// Stack the recursive reader (parse, tape build, drop glue) needs per
1098/// nesting level, as a worst case over build profiles. Measured on a
1099/// negation chain 39 900 deep: an unoptimised build (`opt-level = 0`, what
1100/// `cargo build` / `maturin develop` produce) fails at 512 MiB and passes at
1101/// 1 GiB, i.e. 13–26 KiB per level; optimised builds use well under 2 KiB.
1102const PARSE_LEVEL_BYTES: usize = if cfg!(debug_assertions) {
1103    32 * 1024
1104} else {
1105    4 * 1024
1106};
1107
1108/// Stack a thread is assumed to have when nobody declared one with
1109/// [`set_parse_stack_budget`] (gh#986 review). The first pass gave such a
1110/// thread the full [`MAX_PARSE_DEPTH`], sized for the CLI's 1 GiB thread, so a
1111/// library caller of [`read_nl_file`] on an ordinary thread, or the wasm
1112/// build, aborted on a stack overflow where the guard was meant to refuse.
1113/// The assumption is the smallest stack such a caller normally runs on: Rust's
1114/// default for a spawned thread (2 MiB, also what the test harness uses) on
1115/// native targets, and the 1 MiB `rustc` gives a wasm32 module.
1116const DEFAULT_ASSUMED_STACK: usize = if cfg!(target_arch = "wasm32") {
1117    1 << 20
1118} else {
1119    2 << 20
1120};
1121
1122/// The depth limit for the current thread: [`MAX_PARSE_DEPTH`], lowered to
1123/// what the thread's declared stack holds -- or, undeclared, what
1124/// [`DEFAULT_ASSUMED_STACK`] holds.
1125fn parse_depth_limit() -> u32 {
1126    let bytes = PARSE_STACK_BUDGET
1127        .with(|b| b.get())
1128        .unwrap_or(DEFAULT_ASSUMED_STACK);
1129    ((bytes / PARSE_LEVEL_BYTES).min(MAX_PARSE_DEPTH as usize)) as u32
1130}
1131
1132/// A run of binary `o0` longer than this is read as one n-ary `Sum`
1133/// (gh#986); shorter runs keep their binary tree.
1134const SUM_CHAIN_FLATTEN_MIN: usize = 64;
1135
1136/// Parse an `.nl` file from disk.
1137///
1138/// After parsing the `.nl` body, this also looks for AMPL's optional
1139/// sibling name files — `stub.col` (variable names) and `stub.row`
1140/// (constraint names), emitted only when the modeler sets
1141/// `option auxfiles rc;`. When present and well-formed they populate
1142/// [`NlProblem::var_names`] / [`NlProblem::con_names`]; when absent or
1143/// malformed the names stay empty and every downstream consumer falls
1144/// back to indices. Names are a diagnostic nicety, never load-blocking
1145/// (cf. Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
1146pub fn read_nl_file(path: &Path) -> Result<NlProblem, String> {
1147    // AMPL invokes a solver with an extensionless *stub* — e.g.
1148    // `pounce mymodel -AMPL` — and expects `mymodel.nl` to be read (and
1149    // the `.col`/`.row`/`.sol` siblings named off the same stem). If the
1150    // path as given is missing but appending `.nl` names an existing file,
1151    // resolve to that. This only ever *adds* a fallback: an existing path
1152    // is read verbatim, so nothing changes for callers that already pass a
1153    // full `.nl` path (Pyomo, `--nl-file`, the second-positional form).
1154    let resolved = if path.exists() {
1155        path.to_path_buf()
1156    } else {
1157        let with_nl = append_extension(path, "nl");
1158        if with_nl.exists() {
1159            with_nl
1160        } else {
1161            path.to_path_buf()
1162        }
1163    };
1164    let txt = std::fs::read_to_string(&resolved)
1165        .map_err(|e| format!("could not read {}: {}", resolved.display(), e))?;
1166    // By value: a recognized body keeps a byte range into this text rather
1167    // than a tree, so it is moved into the problem instead of copied.
1168    let mut prob = parse_nl_string(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())?;
1169    prob.var_names = read_name_file(&resolved.with_extension("col"), prob.n);
1170    prob.con_names = read_name_file(&resolved.with_extension("row"), prob.m);
1171    Ok(prob)
1172}
1173
1174/// Append `.ext` to `path`'s full file name (AMPL stub convention:
1175/// `mymodel` → `mymodel.nl`), as opposed to [`Path::with_extension`],
1176/// which would *replace* an existing extension. A stub that itself
1177/// contains a dot (`my.model` → `my.model.nl`) is therefore handled the
1178/// way AMPL names it.
1179fn append_extension(path: &Path, ext: &str) -> std::path::PathBuf {
1180    let mut name = path.as_os_str().to_os_string();
1181    name.push(".");
1182    name.push(ext);
1183    std::path::PathBuf::from(name)
1184}
1185
1186/// Read an AMPL name file (`.col` / `.row`): one name per line, in index
1187/// order. Returns the first `expected` names, or an empty vector when the
1188/// file is missing, unreadable, or has fewer than `expected` lines.
1189///
1190/// Returning empty (rather than erroring) on any mismatch is deliberate:
1191/// names are an optional diagnostic aid, so a missing or truncated file
1192/// must never block a solve. The `.take(expected)` also drops AMPL's
1193/// convention of appending the objective name after the constraint names
1194/// in `.row`, keeping the result aligned 1:1 with `g`.
1195fn read_name_file(path: &Path, expected: usize) -> Vec<String> {
1196    let Ok(txt) = std::fs::read_to_string(path) else {
1197        return Vec::new();
1198    };
1199    let names: Vec<String> = txt.lines().take(expected).map(str::to_owned).collect();
1200    if names.len() == expected {
1201        names
1202    } else {
1203        Vec::new()
1204    }
1205}
1206
1207/// The value of a constraint's nonlinear part when that part is a
1208/// constant, else `None`. Drives the constant-row-body fold in
1209/// [`parse_nl_text`].
1210///
1211/// "Constant" is decided by *evaluation*, not by syntax: a literal
1212/// `Expr::Const` is the common case, but `o0 n1 n2` is just as constant
1213/// and is folded too.
1214///
1215/// Declines in two cases, both of which would make the fold unsound:
1216/// * The expression calls an AMPL imported function. Its value depends on
1217///   a shared library resolved much later (`nl_external::ExternalResolver`),
1218///   so it is not a parse-time constant even with constant arguments — and
1219///   [`eval_expr`] panics on `Funcall` rather than guess.
1220/// * The value is not finite (`n0 / n0`, `log(-1)`, an overflow). Pushing
1221///   a NaN or infinity into a bound would corrupt a row that is merely
1222///   infeasible; leaving the expression in place keeps it a solver-time
1223///   fact.
1224fn row_constant_value(e: &Expr) -> Option<Number> {
1225    // The identity zero the parser preallocates for every untouched row is
1226    // by far the most common input; settle it without walking anything.
1227    if let Expr::Const(c) = e {
1228        return c.is_finite().then_some(*c);
1229    }
1230    let mut vars: BTreeSet<usize> = BTreeSet::new();
1231    collect_vars(e, &mut vars);
1232    if !vars.is_empty() {
1233        return None;
1234    }
1235    let mut funcs: BTreeSet<usize> = BTreeSet::new();
1236    crate::nl_external::collect_funcall_ids(e, &mut funcs);
1237    if !funcs.is_empty() {
1238        return None;
1239    }
1240    // Variable-free, so no `Expr::Var` can index into the (empty) point.
1241    let v = eval_expr(e, &[]);
1242    v.is_finite().then_some(v)
1243}
1244
1245/// Parse `.nl` text content. Public so tests can use string literals.
1246///
1247/// Degree-2 bodies are recognized as the token stream is read, so their
1248/// `Expr` trees are never built (gh #588, Q5); `POUNCE_DBG_NO_QUAD=1`
1249/// turns that off and restores the pre-Q5 parse exactly, which is what the
1250/// A/B reference and gh #540's guard test need. See
1251/// [`parse_nl_text_with_quadratic`] for the same switch as a parameter.
1252pub fn parse_nl_text(txt: &str) -> Result<NlProblem, String> {
1253    parse_nl_text_with_quadratic(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
1254}
1255
1256/// [`parse_nl_text`] with parse-time quadratic recognition explicitly on
1257/// or off.
1258///
1259/// The env var the plain entry point reads is process-global, which is
1260/// exactly wrong for a differential test that has to parse the *same* text
1261/// both ways in one process and compare the results — so the knob is a
1262/// parameter here, mirroring [`NlTnlp::try_new_with_quadratic`].
1263pub fn parse_nl_text_with_quadratic(txt: &str, use_quadratic: bool) -> Result<NlProblem, String> {
1264    parse_nl_string(txt.to_string(), use_quadratic)
1265}
1266
1267/// [`parse_nl_text_with_quadratic`], taking the text by value.
1268///
1269/// A recognized body keeps a byte range into the source rather than a tree,
1270/// so the source outlives the parse. Taking ownership means the file
1271/// [`read_nl_file`] already read is *moved* into the problem instead of
1272/// copied: the text is resident during parsing either way, so this costs
1273/// nothing at the peak the phase is about.
1274pub fn parse_nl_string(txt: String, use_quadratic: bool) -> Result<NlProblem, String> {
1275    let src = Arc::new(txt);
1276    let mut p = Parser::new(&src, use_quadratic);
1277    p.parse_header()?;
1278    let n = p.n;
1279    let m = p.m;
1280    let num_obj = p.num_obj;
1281
1282    let mut con_nonlinear: Vec<NlBody> = (0..m).map(|_| NlBody::Tree(Expr::Const(0.0))).collect();
1283    let mut obj_nonlinear = NlBody::Tree(Expr::Const(0.0));
1284    let mut minimize = true;
1285    let mut obj_linear: Vec<(usize, Number)> = Vec::new();
1286    let mut con_linear: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
1287    let mut x_l = vec![-1e19; n];
1288    let mut x_u = vec![1e19; n];
1289    let mut g_l = vec![-1e19; m];
1290    let mut g_u = vec![1e19; m];
1291    let mut x0 = vec![0.0; n];
1292    let mut lambda0 = vec![0.0; m];
1293    let mut suffixes = NlSuffixes::default();
1294    let mut imported_funcs: Vec<ImportedFunc> = Vec::new();
1295    // Segment presence, for the truncation check after the loop (gh#785).
1296    let mut saw_r = false;
1297    let mut saw_b = false;
1298
1299    while let Some(line) = p.peek_segment_line() {
1300        let tag = line
1301            .trim_start()
1302            .chars()
1303            .next()
1304            .ok_or("unexpected blank segment header")?;
1305        match tag {
1306            'C' => {
1307                let (_hdr, rest) = p.eat_segment_header()?;
1308                let _ = rest;
1309                let idx = parse_segment_index(_hdr, 'C')?;
1310                if idx >= m {
1311                    return Err(format!("C{idx} out of range; m={m}"));
1312                }
1313                con_nonlinear[idx] = p.parse_body()?;
1314            }
1315            'O' => {
1316                let (hdr, _rest) = p.eat_segment_header()?;
1317                let parts: Vec<&str> = hdr.split_whitespace().collect();
1318                if parts.len() < 2 {
1319                    return Err(format!("malformed O-segment header: {hdr}"));
1320                }
1321                let idx = parse_segment_index(parts[0], 'O')?;
1322                let kind: i32 = parts[1].parse().map_err(|e| format!("O kind: {e}"))?;
1323                if idx == 0 {
1324                    minimize = kind == 0;
1325                    obj_nonlinear = p.parse_body()?;
1326                } else {
1327                    // Extra objectives are read but ignored.
1328                    let _ = p.parse_expr()?;
1329                }
1330            }
1331            'r' => {
1332                p.eat_segment_header()?;
1333                saw_r = true;
1334                for i in 0..m {
1335                    let line = p.next_data_line()?;
1336                    let (lo, hi) = parse_bound_line(line)?;
1337                    g_l[i] = lo;
1338                    g_u[i] = hi;
1339                }
1340            }
1341            'b' => {
1342                p.eat_segment_header()?;
1343                saw_b = true;
1344                for i in 0..n {
1345                    let line = p.next_data_line()?;
1346                    let (lo, hi) = parse_bound_line(line)?;
1347                    x_l[i] = lo;
1348                    x_u[i] = hi;
1349                }
1350            }
1351            'k' => {
1352                // Column counts in the Jacobian; we don't need their
1353                // values for evaluation (the J segments give explicit
1354                // lists), but we must consume exactly as many data lines
1355                // as follow or the segment stream desyncs. The `.nl`
1356                // format writes that line count in the header itself
1357                // (`k<count>`), and the standard value is `n-1`. Read the
1358                // declared count rather than assuming it: a file with a
1359                // nonstandard count would otherwise leave us reading the
1360                // wrong number of lines, swallowing a later segment header
1361                // (or stopping short) and failing with a confusing,
1362                // far-removed error. Validate against the expected `n-1`
1363                // so a mismatch surfaces here, clearly, at its source.
1364                let (hdr, _) = p.eat_segment_header()?;
1365                let declared = parse_segment_index(hdr, 'k')?;
1366                let expected = if n == 0 { 0 } else { n - 1 };
1367                if declared != expected {
1368                    return Err(format!(
1369                        "k-segment declares {declared} column-count lines but \
1370                         the standard count for n={n} variables is {expected}"
1371                    ));
1372                }
1373                for _ in 0..declared {
1374                    p.next_data_line()?;
1375                }
1376            }
1377            'J' => {
1378                let (hdr, _) = p.eat_segment_header()?;
1379                let parts: Vec<&str> = hdr.split_whitespace().collect();
1380                if parts.len() < 2 {
1381                    return Err(format!("malformed J-segment header: {hdr}"));
1382                }
1383                let row = parse_segment_index(parts[0], 'J')?;
1384                let nz: usize = parts[1].parse().map_err(|e| format!("J nz: {e}"))?;
1385                if row >= m {
1386                    return Err(format!("J{row} out of range"));
1387                }
1388                for _ in 0..nz {
1389                    let line = p.next_data_line()?;
1390                    let (var, coef) = parse_var_coef(line)?;
1391                    // Validate the column index here: an out-of-range `var`
1392                    // would otherwise be stored and panic as a slice OOB
1393                    // (`x[var]`) during constraint evaluation. Mirror the
1394                    // clean parse error used for the row index above.
1395                    if var >= n {
1396                        return Err(format!(
1397                            "J{row} entry variable index {var} out of range (n={n})"
1398                        ));
1399                    }
1400                    con_linear[row].push((var, coef));
1401                }
1402            }
1403            'G' => {
1404                let (hdr, _) = p.eat_segment_header()?;
1405                let parts: Vec<&str> = hdr.split_whitespace().collect();
1406                if parts.len() < 2 {
1407                    return Err(format!("malformed G-segment header: {hdr}"));
1408                }
1409                let idx = parse_segment_index(parts[0], 'G')?;
1410                let nz: usize = parts[1].parse().map_err(|e| format!("G nz: {e}"))?;
1411                let mut acc = Vec::with_capacity(nz);
1412                for _ in 0..nz {
1413                    let line = p.next_data_line()?;
1414                    let (var, coef) = parse_var_coef(line)?;
1415                    // Same as J: reject an out-of-range gradient column index
1416                    // up front rather than letting it panic on `x[var]` later.
1417                    if var >= n {
1418                        return Err(format!(
1419                            "G{idx} entry variable index {var} out of range (n={n})"
1420                        ));
1421                    }
1422                    acc.push((var, coef));
1423                }
1424                if idx == 0 {
1425                    obj_linear = acc;
1426                }
1427            }
1428            'x' => {
1429                let (hdr, _) = p.eat_segment_header()?;
1430                let parts: Vec<&str> = hdr.split_whitespace().collect();
1431                let nx: usize = parts
1432                    .first()
1433                    .and_then(|s| s.trim_start_matches('x').parse().ok())
1434                    .ok_or_else(|| format!("malformed x-segment header: {hdr}"))?;
1435                for _ in 0..nx {
1436                    let line = p.next_data_line()?;
1437                    let (idx, val) = parse_var_coef(line)?;
1438                    // Reject out-of-range indices as a parse error, matching
1439                    // J/G strictness, rather than silently dropping the entry
1440                    // (which hides a corrupt initial-primal segment).
1441                    if idx >= n {
1442                        return Err(format!(
1443                            "x-segment variable index {idx} out of range (n={n})"
1444                        ));
1445                    }
1446                    x0[idx] = val;
1447                }
1448            }
1449            'd' => {
1450                let (hdr, _) = p.eat_segment_header()?;
1451                let parts: Vec<&str> = hdr.split_whitespace().collect();
1452                let nd: usize = parts
1453                    .first()
1454                    .and_then(|s| s.trim_start_matches('d').parse().ok())
1455                    .ok_or_else(|| format!("malformed d-segment header: {hdr}"))?;
1456                for _ in 0..nd {
1457                    let line = p.next_data_line()?;
1458                    let (idx, val) = parse_var_coef(line)?;
1459                    // Reject out-of-range indices as a parse error, matching
1460                    // J/G strictness, rather than silently dropping the entry
1461                    // (which hides a corrupt initial-dual segment).
1462                    if idx >= m {
1463                        return Err(format!(
1464                            "d-segment constraint index {idx} out of range (m={m})"
1465                        ));
1466                    }
1467                    // A `d` entry is in AMPL's dual sign (`d obj / d b`, the
1468                    // sign a `.sol` dual block carries), and pounce's
1469                    // multiplier is that of `L = f + lambda' g`, i.e. `-dual`.
1470                    // `sol_writer` negates on the way out; negate on the way
1471                    // in so a `.nl` start and a `.sol` result share one sign
1472                    // (gh#1033; matches Ipopt's AMPL driver).
1473                    lambda0[idx] = -val;
1474                }
1475            }
1476            'V' => p.parse_v_segment()?,
1477            'S' => {
1478                parse_suffix_segment(&mut p, n, m, num_obj, &mut suffixes)?;
1479            }
1480            'F' => {
1481                // AMPL imported (external) function declaration:
1482                // `F<k> <type> <nargs> <name>`.
1483                let (hdr, _rest) = p.eat_segment_header()?;
1484                let parts: Vec<&str> = hdr.split_whitespace().collect();
1485                if parts.is_empty() {
1486                    return Err(format!("malformed F-segment header: '{hdr}'"));
1487                }
1488                let id = parse_segment_index(parts[0], 'F')?;
1489                let kind: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1490                let nargs: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
1491                let name = parts.get(3).copied().unwrap_or("").to_string();
1492                imported_funcs.push(ImportedFunc {
1493                    id,
1494                    kind,
1495                    nargs,
1496                    name,
1497                });
1498            }
1499            other => return Err(format!("unknown .nl segment tag '{other}'")),
1500        }
1501    }
1502
1503    // A `.nl` file that ends early is not a smaller model — it is a corrupt
1504    // one, and every segment the truncation ate is a piece of the problem
1505    // that silently reverts to a default: a dropped `r` leaves every row at
1506    // ±1e19 (i.e. unconstrained), a dropped `b` leaves every variable free,
1507    // a dropped `J` leaves every row's linear part empty. The segment loop
1508    // cannot tell those defaults from a legitimately absent segment, so the
1509    // truncated model solves, and reports `SolveSucceeded` with a
1510    // confidently wrong objective — which is strictly worse than the parse
1511    // error every other malformed input already gets (gh#785). The header
1512    // declares enough to tell the two apart; check it here, once, against
1513    // what the segments actually delivered.
1514    //
1515    // `r` and `b` are unconditional in the format: AMPL writes both whenever
1516    // the model has rows / columns at all, free rows and free variables
1517    // included — those get the `3` ("no bounds") code, not an omitted line.
1518    if m > 0 && !saw_r {
1519        return Err(format!(
1520            "missing `r` (constraint-bounds) segment for a model declaring {m} \
1521             constraint(s): the .nl file is truncated or corrupt"
1522        ));
1523    }
1524    if n > 0 && !saw_b {
1525        return Err(format!(
1526            "missing `b` (variable-bounds) segment for a model declaring {n} \
1527             variable(s): the .nl file is truncated or corrupt"
1528        ));
1529    }
1530    // The `J` segments must deliver exactly the Jacobian nonzero count the
1531    // header declares — `nzc` is the sum of their lengths by construction,
1532    // nonlinear-only columns included (they are written with a zero
1533    // coefficient, not omitted). This is the check that catches a truncation
1534    // landing *after* `r` and `b`, where the bounds are all present and only
1535    // the coefficients are gone.
1536    if let Some(declared) = p.declared_jac_nnz {
1537        let parsed: usize = con_linear.iter().map(Vec::len).sum();
1538        if parsed != declared {
1539            return Err(format!(
1540                "header declares {declared} Jacobian nonzero(s) but the J \
1541                 segments supply {parsed}: the .nl file is truncated or corrupt"
1542            ));
1543        }
1544    }
1545
1546    // Normalize constant row bodies into the row bounds, so that
1547    // "the nonlinear part is the identity zero" and "this row's body is
1548    // Σaⱼxⱼ" mean the same thing for every downstream consumer.
1549    //
1550    // A `C<i>` segment holding a variable-free expression (`n3.0`, or
1551    // anything that evaluates to a constant such as `o0 n1 n2`) is an
1552    // affine row — but it arrives with a non-zero `con_nonlinear[i]`,
1553    // which every consumer reads as "nonlinear": the linearity predicate
1554    // (`get_constraints_linearity`), which is what makes presolve's
1555    // linear-equality reduction decline the row; the CLI problem
1556    // classifier (`is_trivially_zero` in `dispatch.rs`, whose fallback
1557    // polynomial walk absorbs a bare literal but not a constant it has to
1558    // compute — so an otherwise plain LP carrying a `sqrt(9)` classified
1559    // NLP and never reached the convex path); and the FBBT translator.
1560    // Folding here fixes all of them at once, with no per-consumer audit.
1561    //
1562    // The shift is exact and invisible from outside: the body drops by `c`
1563    // and each bound drops by `c` with it, so the feasible set, the active
1564    // set, and the duals are unchanged (`gh #492`). This is the same
1565    // normalization `qp_extract::analyze_quadratic_full` already performs
1566    // ad hoc via `const_shift`, promoted to the parse boundary.
1567    for i in 0..m {
1568        // A recognized body is degree 2, so it is not a constant row and
1569        // this fold has nothing to do with it.
1570        let Some(tree) = con_nonlinear[i].tree() else {
1571            continue;
1572        };
1573        let Some(c) = row_constant_value(tree) else {
1574            continue;
1575        };
1576        // Presence is directional (gh #401): shifting an *absent* bound
1577        // would turn the ±1e19 sentinel into a real bound for `c < 0`
1578        // (lower) or `c > 0` (upper), inventing a constraint. Leave the
1579        // sentinels alone.
1580        if lower_bound_present(g_l[i]) {
1581            g_l[i] -= c;
1582        }
1583        if upper_bound_present(g_u[i]) {
1584            g_u[i] -= c;
1585        }
1586        con_nonlinear[i] = NlBody::Tree(Expr::Const(0.0));
1587    }
1588
1589    // The source is kept only when something in it has no tree of its own.
1590    // A model with nothing recognized is byte-for-byte the pre-Q5 problem,
1591    // extra field included.
1592    let any_recognized =
1593        obj_nonlinear.quad().is_some() || con_nonlinear.iter().any(|b| b.quad().is_some());
1594    let kept_src = any_recognized.then(|| Arc::clone(&src));
1595
1596    // A `d` entry was stored as `-dual` above, which is the engine's
1597    // multiplier only for a minimize model. A maximize model is solved as
1598    // `min -f`, and the CLI writes its `.sol` dual as `-sigma * lambda`
1599    // (sigma = -1, gh#959), so the inverse is `lambda = -sigma * dual`.
1600    // Applied here because the `O` segment fixing the sense may follow `d`.
1601    if !minimize {
1602        for v in lambda0.iter_mut() {
1603            *v = -*v;
1604        }
1605    }
1606    Ok(NlProblem {
1607        n,
1608        m,
1609        num_obj,
1610        minimize,
1611        obj_nonlinear,
1612        obj_linear,
1613        obj_constant: 0.0,
1614        con_nonlinear,
1615        con_linear,
1616        x_l,
1617        x_u,
1618        g_l,
1619        g_u,
1620        x0,
1621        lambda0,
1622        suffixes,
1623        ampl_options: p.ampl_options.clone(),
1624        nl_counts: p.nl_counts,
1625        n_discrete: p.n_discrete,
1626        imported_funcs,
1627        // `.nl` text carries no names; `read_nl_file` fills these from the
1628        // sibling `.col`/`.row` files when present.
1629        var_names: Vec::new(),
1630        con_names: Vec::new(),
1631        src: kept_src,
1632        cse_bodies: p.cses.clone(),
1633    })
1634}
1635
1636/// Parse a single `S`-segment. Format (Gay 2005, "Hooking Your Solver
1637/// to AMPL", §6, and `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`):
1638///
1639/// ```text
1640/// S<kind> <nentries> <suffix_name>
1641/// <idx> <value>      ... nentries lines
1642/// ```
1643///
1644/// `<kind>` is a 3-bit encoding:
1645/// * Bits 0-1 select the suffix target: 0 = variables, 1 = constraints,
1646///   2 = objectives, 3 = problem-level.
1647/// * Bit 2 (`0x4`) selects the value type: 0 = integer, 1 = real.
1648///
1649/// Sparse entries scatter into a freshly-allocated dense vector (zero
1650/// default), sized for the target dimension. Problem-level suffixes
1651/// (kind = 3 / 7) carry a single value.
1652fn parse_suffix_segment(
1653    p: &mut Parser,
1654    n: usize,
1655    m: usize,
1656    num_obj: usize,
1657    out: &mut NlSuffixes,
1658) -> Result<(), String> {
1659    let (hdr, _) = p.eat_segment_header()?;
1660    let parts: Vec<&str> = hdr.split_whitespace().collect();
1661    if parts.len() < 3 {
1662        return Err(format!(
1663            "malformed S-segment header: '{hdr}' (expected `S<kind> <n> <name>`)"
1664        ));
1665    }
1666    let kind_str = parts[0].trim_start_matches('S');
1667    let kind: u32 = kind_str
1668        .parse()
1669        .map_err(|e| format!("S kind '{kind_str}': {e}"))?;
1670    let nentries: usize = parts[1].parse().map_err(|e| format!("S nentries: {e}"))?;
1671    let name = parts[2].to_string();
1672
1673    let is_real = (kind & 0x4) != 0;
1674    let target = kind & 0x3;
1675    let target_dim = match target {
1676        0 => n,
1677        1 => m,
1678        2 => num_obj,
1679        3 => 0, // problem-level — entries are single-valued (idx=0)
1680        _ => unreachable!("kind & 0x3 is in 0..=3"),
1681    };
1682
1683    // Pre-allocate dense buffers (default zero). Problem-level kinds
1684    // (3 / 7) hold a single scalar — we still read the (idx, value)
1685    // pairs but only the value field is meaningful.
1686    let mut int_buf: Vec<Index> = if !is_real && target != 3 {
1687        vec![0; target_dim]
1688    } else {
1689        Vec::new()
1690    };
1691    let mut real_buf: Vec<Number> = if is_real && target != 3 {
1692        vec![0.0; target_dim]
1693    } else {
1694        Vec::new()
1695    };
1696    let mut problem_int: Index = 0;
1697    let mut problem_real: Number = 0.0;
1698
1699    for _ in 0..nentries {
1700        let line = p.next_data_line()?;
1701        let parts: Vec<&str> = line.split_whitespace().collect();
1702        if parts.len() < 2 {
1703            return Err(format!(
1704                "malformed S-segment entry '{line}' (expected `<idx> <value>`)"
1705            ));
1706        }
1707        let idx: usize = parts[0]
1708            .parse()
1709            .map_err(|e| format!("S entry idx '{}': {e}", parts[0]))?;
1710        if target != 3 && idx >= target_dim {
1711            return Err(format!(
1712                "S-suffix '{name}' index {idx} out of range for target dim {target_dim}"
1713            ));
1714        }
1715        if is_real {
1716            let v: Number = parts[1]
1717                .parse()
1718                .map_err(|e| format!("S real entry value '{}': {e}", parts[1]))?;
1719            if target == 3 {
1720                problem_real = v;
1721            } else {
1722                real_buf[idx] = v;
1723            }
1724        } else {
1725            let v: Index = parts[1]
1726                .parse()
1727                .map_err(|e| format!("S int entry value '{}': {e}", parts[1]))?;
1728            if target == 3 {
1729                problem_int = v;
1730            } else {
1731                int_buf[idx] = v;
1732            }
1733        }
1734    }
1735
1736    match (target, is_real) {
1737        (0, false) => {
1738            out.var_int.insert(name, int_buf);
1739        }
1740        (1, false) => {
1741            out.con_int.insert(name, int_buf);
1742        }
1743        (2, false) => {
1744            out.obj_int.insert(name, int_buf);
1745        }
1746        (3, false) => {
1747            out.problem_int.insert(name, problem_int);
1748        }
1749        (0, true) => {
1750            out.var_real.insert(name, real_buf);
1751        }
1752        (1, true) => {
1753            out.con_real.insert(name, real_buf);
1754        }
1755        (2, true) => {
1756            out.obj_real.insert(name, real_buf);
1757        }
1758        (3, true) => {
1759            out.problem_real.insert(name, problem_real);
1760        }
1761        _ => unreachable!(),
1762    }
1763    Ok(())
1764}
1765
1766fn parse_segment_index(s: &str, tag: char) -> Result<usize, String> {
1767    let trimmed = s.trim_start_matches(tag);
1768    trimmed
1769        .parse()
1770        .map_err(|e| format!("malformed {tag}-segment index '{s}': {e}"))
1771}
1772
1773// `parse_bound_line` and `parse_var_coef` run once per `r` / `b` / `J` /
1774// `G` / `x` / `d` data line, so between them they see every Jacobian and
1775// gradient nonzero in the file. Both walk the whitespace iterator
1776// directly instead of collecting a `Vec<&str>` first — that collect was
1777// a heap allocation per line on top of the one the reader used to make
1778// handing the line over.
1779/// Refuse a non-finite number read out of a `.nl` file (gh #847).
1780///
1781/// `str::parse::<f64>()` accepts `inf`, `-inf` and `nan`, and it also *returns*
1782/// `inf` for any literal that overflows the type — `1e400` is a plausible thing
1783/// for a model generator to write. Nothing downstream treats such a value as an
1784/// error, and in one place it is actively misread: `lower_bound_present` /
1785/// `upper_bound_present` are `is_finite() && ...`, so a non-finite bound is
1786/// indistinguishable from a bound that was never declared, and is silently
1787/// dropped. On a model that a lower bound of `1e300` makes infeasible, the same
1788/// bound written `1e400` returned `EXIT: Optimal Solution Found.` with exit code
1789/// 0. A `nan` is worse: it propagates into the answer, and the solve reports
1790/// `Objective: nan` under `Solve_Succeeded`.
1791///
1792/// Ipopt refuses this input ("Invalid number"), POUNCE's own NLP arm refuses it,
1793/// and `pounce.solve_qp` refuses a non-finite bound with a bespoke `ValueError`.
1794/// There is no reading on which `Optimal` is the intended answer, so the reader
1795/// refuses it too.
1796fn finite_or_err(what: &str, v: Number) -> Result<Number, String> {
1797    if v.is_finite() {
1798        Ok(v)
1799    } else {
1800        Err(format!(
1801            "invalid number: {what} is {v}, which is not finite"
1802        ))
1803    }
1804}
1805
1806/// The same screen for a *bound* slot, where one non-finite value has an
1807/// unambiguous meaning and is normalized instead of refused.
1808///
1809/// `.nl` states "no bound" with a bound **kind** (1 = upper only, 2 = lower
1810/// only, 3 = free), so a non-finite number in a bound slot is a corrupt value
1811/// rather than a notation — with one exception per side. `-inf` in a *lower*
1812/// slot and `+inf` in an *upper* slot say precisely what the `±1e19` sentinel
1813/// says, and a writer that emits them means it, so they map to the sentinel.
1814///
1815/// Everything else is refused, and the asymmetry is the whole point: `+inf` as
1816/// a *lower* bound is the gh #847 case. It is not "unbounded below" — it is an
1817/// empty box, and reading it as "absent" is what turned an infeasible model
1818/// into an `Optimal` one. `NaN` is refused on either side, having no meaning at
1819/// all.
1820fn finite_bound_or_err(what: &str, v: Number, lower: bool) -> Result<Number, String> {
1821    if v.is_finite() {
1822        return Ok(v);
1823    }
1824    if lower && v == Number::NEG_INFINITY {
1825        return Ok(-1e19);
1826    }
1827    if !lower && v == Number::INFINITY {
1828        return Ok(1e19);
1829    }
1830    Err(format!(
1831        "invalid number: {what} is {v}, which is not finite (a `.nl` file \
1832         states an absent bound with a bound kind of 1, 2 or 3, not with a \
1833         non-finite value)"
1834    ))
1835}
1836
1837fn parse_bound_line(line: &str) -> Result<(Number, Number), String> {
1838    let mut parts = line.split_whitespace();
1839    let kind: i32 = parts
1840        .next()
1841        .ok_or("empty bound line")?
1842        .parse()
1843        .map_err(|e| format!("bound kind: {e}"))?;
1844    let lo;
1845    let hi;
1846    match kind {
1847        0 => {
1848            // 0  lo  hi
1849            let (l, h) = (parts.next(), parts.next());
1850            let (Some(l), Some(h)) = (l, h) else {
1851                return Err(format!("bound kind 0 needs 2 values: '{line}'"));
1852            };
1853            lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1854            hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1855        }
1856        1 => {
1857            // 1  hi
1858            let Some(h) = parts.next() else {
1859                return Err(format!("bound kind 1 needs 1 value: '{line}'"));
1860            };
1861            lo = -1e19;
1862            hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1863        }
1864        2 => {
1865            // 2  lo
1866            let Some(l) = parts.next() else {
1867                return Err(format!("bound kind 2 needs 1 value: '{line}'"));
1868            };
1869            lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1870            hi = 1e19;
1871        }
1872        3 => {
1873            // 3  (free)
1874            lo = -1e19;
1875            hi = 1e19;
1876        }
1877        4 => {
1878            // 4  eq
1879            let Some(v) = parts.next() else {
1880                return Err(format!("bound kind 4 needs 1 value: '{line}'"));
1881            };
1882            // An equality has no "absent" side, so neither infinity is a
1883            // notation here and both are refused.
1884            let v: Number = finite_or_err("eq bound", v.parse().map_err(|e| format!("eq: {e}"))?)?;
1885            lo = v;
1886            hi = v;
1887        }
1888        5 => return Err("complementarity (kind 5) bounds are not supported".into()),
1889        other => return Err(format!("unknown bound kind {other}")),
1890    }
1891    Ok((lo, hi))
1892}
1893
1894fn parse_var_coef(line: &str) -> Result<(usize, Number), String> {
1895    let mut parts = line.split_whitespace();
1896    let (Some(v), Some(c)) = (parts.next(), parts.next()) else {
1897        return Err(format!("malformed var/coef line: '{line}'"));
1898    };
1899    let v: usize = v.parse().map_err(|e| format!("var idx: {e}"))?;
1900    let c: Number = finite_or_err("coefficient", c.parse().map_err(|e| format!("coef: {e}"))?)?;
1901    Ok((v, c))
1902}
1903
1904/// Build an [`NlCounts`] from `.nl` header lines 3 (`nlc nlo`) and 5
1905/// (`nlvc nlvo nlvb`).
1906///
1907/// Returns `None` unless both lines carry the full complement of
1908/// non-negative integers, so a truncated or non-conforming header reads as
1909/// "unknown" rather than as zeros — "no nonlinear variables" is a claim, and
1910/// a header that failed to parse has not made it.
1911fn parse_nl_counts(line3: &str, line5: &str) -> Option<NlCounts> {
1912    let nums = |line: &str, want: usize| -> Option<Vec<usize>> {
1913        let v: Vec<usize> = line
1914            .split_whitespace()
1915            .take(want)
1916            .map(str::parse)
1917            .collect::<Result<_, _>>()
1918            .ok()?;
1919        (v.len() == want).then_some(v)
1920    };
1921    let cons_objs = nums(line3, 2)?;
1922    let vars = nums(line5, 3)?;
1923    Some(NlCounts {
1924        nl_cons: cons_objs[0],
1925        nl_objs: cons_objs[1],
1926        nl_vars_cons: vars[0],
1927        nl_vars_objs: vars[1],
1928        nl_vars_both: vars[2],
1929    })
1930}
1931
1932/// Header line 7: `nbv niv nlvbi nlvci nlvoi` (gh#987).
1933///
1934/// In AMPL's convention `nbv` and `niv` count only the discrete variables
1935/// that appear **linearly**; a discrete variable that appears nonlinearly is
1936/// counted in `nlvbi` / `nlvci` / `nlvoi` instead, *not* in `nbv + niv`. So a
1937/// MINLP whose integers all appear nonlinearly has `nbv = niv = 0` — reading
1938/// only those two fields reports it as continuous.
1939#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1940pub struct DiscreteCensus {
1941    /// `nbv`: binary variables appearing only linearly.
1942    pub binary: usize,
1943    /// `niv`: other integer variables appearing only linearly.
1944    pub integer: usize,
1945    /// `nlvbi`: integer variables nonlinear in both constraints and objectives.
1946    pub nl_both: usize,
1947    /// `nlvci`: integer variables nonlinear in constraints only.
1948    pub nl_cons: usize,
1949    /// `nlvoi`: integer variables nonlinear in objectives only.
1950    pub nl_objs: usize,
1951}
1952
1953impl DiscreteCensus {
1954    /// Total declared discrete variables, linear and nonlinear
1955    /// (`nbv + niv + nlvbi + nlvci + nlvoi`).
1956    pub fn total(&self) -> usize {
1957        self.binary + self.integer + self.nonlinear_discrete()
1958    }
1959
1960    /// Discrete variables that appear nonlinearly (`nlvbi + nlvci + nlvoi`).
1961    pub fn nonlinear_discrete(&self) -> usize {
1962        self.nl_both + self.nl_cons + self.nl_objs
1963    }
1964
1965    /// The 0-based columns of every discrete variable, from Gay's variable
1966    /// ordering ("Writing .nl Files", table 4):
1967    ///
1968    /// 1. nonlinear in both constraints and objectives — `nlvb` columns, the
1969    ///    last `nlvbi` of them integer;
1970    /// 2. nonlinear in constraints only — `nlvc − nlvb` columns, the last
1971    ///    `nlvci` integer;
1972    /// 3. nonlinear in objectives only — `nlvo − nlvc` columns (when
1973    ///    positive), the last `nlvoi` integer;
1974    /// 4. linear variables, ending in `nbv` binary then `niv` integer, which
1975    ///    are therefore the last `nbv + niv` columns of the model.
1976    ///
1977    /// `None` when the columns cannot be identified: a nonlinear integer
1978    /// count is non-zero but the header's line-5 census is missing, or the
1979    /// counts are inconsistent with the block sizes. Callers must report that
1980    /// as *not checked*, never as passed.
1981    pub fn integer_columns(&self, n: usize, counts: Option<NlCounts>) -> Option<Vec<usize>> {
1982        let lin = self.binary + self.integer;
1983        if lin > n {
1984            return None;
1985        }
1986        let mut cols = Vec::with_capacity(self.total());
1987        if self.nonlinear_discrete() > 0 {
1988            let c = counts?;
1989            let nlvb = c.nl_vars_both;
1990            let nlvc = c.nl_vars_cons;
1991            let nlvo = c.nl_vars_objs;
1992            if nlvc < nlvb {
1993                return None;
1994            }
1995            let b3 = nlvo.saturating_sub(nlvc);
1996            let blocks = [
1997                (0, nlvb, self.nl_both),
1998                (nlvb, nlvc - nlvb, self.nl_cons),
1999                (nlvc, b3, self.nl_objs),
2000            ];
2001            for (start, len, ints) in blocks {
2002                if ints > len || start + len > n - lin {
2003                    return None;
2004                }
2005                cols.extend(start + len - ints..start + len);
2006            }
2007        }
2008        cols.extend(n - lin..n);
2009        Some(cols)
2010    }
2011}
2012
2013struct Parser<'a> {
2014    lines: Vec<&'a str>,
2015    /// Start address and length of the source text. A recognized body
2016    /// records the byte *range* it consumed so it can be re-parsed later
2017    /// (see [`QuadBody::src`]), and every line borrows from this buffer, so
2018    /// one subtraction per line recovers its offset.
2019    ///
2020    /// Deliberately not a per-line offset table: on a 119 MB generated
2021    /// `qcqp500-3c` the file is ~17 M lines, and a `Vec<usize>` beside the
2022    /// existing `Vec<&str>` would add 136 MB to the peak this phase exists
2023    /// to reduce.
2024    txt_base: usize,
2025    txt_len: usize,
2026    pos: usize,
2027    n: usize,
2028    m: usize,
2029    num_obj: usize,
2030    /// Number of AMPL imported (external) functions declared in the header.
2031    n_funcs: usize,
2032    /// Header lines 3 and 5, when both parsed. See [`NlCounts`].
2033    nl_counts: Option<NlCounts>,
2034    /// `(nbv, niv)` from header line 7.
2035    n_discrete: Option<DiscreteCensus>,
2036    /// `nzc` from header line 8: the number of Jacobian nonzeros the file
2037    /// *declares*. [`parse_nl_string`] cross-checks it against the number the
2038    /// `J` segments actually deliver, which is how a file truncated before
2039    /// them is told from a model that genuinely has none (gh#785). `None`
2040    /// when the header does not carry it in the documented shape.
2041    declared_jac_nnz: Option<usize>,
2042    ampl_options: Vec<i64>,
2043    /// Common subexpressions (`V` segments). Index in this vec is the
2044    /// CSE-local index, i.e. the global `.nl` index minus `n`.
2045    cses: Vec<Arc<Expr>>,
2046    /// Recognize degree-2 bodies from the token stream instead of building
2047    /// their trees (gh #588, Q5). Off restores the pre-Q5 parse exactly.
2048    quad_enabled: bool,
2049    /// Per-CSE answers the streaming recognizer needs about a `V` body it
2050    /// may be handed a reference to, all keyed by CSE-local index: the
2051    /// body's own degree-≤2 form, whether it is legal on a sum spine,
2052    /// whether it is legal *inside* a monomial, and its variable support.
2053    ///
2054    /// These are computed from the built `V` body with the same functions
2055    /// `NlTnlp` would apply to a whole row, so a reference costs a lookup
2056    /// and cannot drift from what the tree walk would have said. `V` bodies
2057    /// keep their trees regardless — they are shared, so the memory is
2058    /// amortized over every reference, and dropping them would mean
2059    /// rebuilding them for the rows that are *not* recognized.
2060    cse_quad: Vec<Option<Quad2>>,
2061    cse_sum_ok: Vec<bool>,
2062    cse_mono_ok: Vec<bool>,
2063    cse_vars: Vec<Vec<u32>>,
2064    cse_depth: Vec<u32>,
2065    /// gh#986 review: the depth of every `V` body *through* the defined
2066    /// variables it references (a `Cse` counting as its body), keyed by the
2067    /// body's `Arc` pointer. Always kept, whatever `quad_enabled` says: it is
2068    /// what bounds the recursion of every later pass that walks through a
2069    /// `Cse` (tape build, `collect_vars`, evaluation, drop glue), and it makes
2070    /// [`expr_tree_depth`] linear in the body rather than in the whole chain.
2071    cse_depth_memo: std::collections::HashMap<*const Expr, u32>,
2072    /// `V` body pointer -> its index in `cses` (and in the `cse_*` caches),
2073    /// so the parse-time passes can read an earlier body's verdict instead of
2074    /// re-walking it (gh#986 review).
2075    cse_index: std::collections::HashMap<*const Expr, usize>,
2076    /// Current recursion depth of `parse_expr` (gh#986).
2077    parse_depth: u32,
2078    /// Depth at which `parse_expr` refuses (gh#986); see [`parse_depth_limit`].
2079    max_depth: u32,
2080}
2081
2082/// One pending operator on the streaming recognizer's stack.
2083struct QFrame {
2084    op: QOp,
2085    /// Operands still to be read.
2086    remaining: usize,
2087    /// Read this frame's operands in monomial mode — no `+`/`-` may appear
2088    /// below it. See [`crate::nl_quadratic::is_expanded_quadratic`].
2089    mono: bool,
2090}
2091
2092/// The operators the streaming recognizer accepts. One variant per shape
2093/// that [`crate::nl_quadratic::recognize_expr`] handles; everything else
2094/// makes it bail.
2095enum QOp {
2096    Neg,
2097    Add,
2098    Sub,
2099    Mul,
2100    Div,
2101    /// `o5`/`o81`/`o83`: base and exponent both read.
2102    Pow,
2103    /// `o82`: square, exponent implicit.
2104    Square,
2105    Sum(usize),
2106}
2107
2108impl<'a> Parser<'a> {
2109    fn new(txt: &'a str, quad_enabled: bool) -> Self {
2110        let lines: Vec<&str> = txt.lines().collect();
2111        Self {
2112            lines,
2113            txt_base: txt.as_ptr() as usize,
2114            txt_len: txt.len(),
2115            pos: 0,
2116            n: 0,
2117            m: 0,
2118            num_obj: 0,
2119            n_funcs: 0,
2120            nl_counts: None,
2121            n_discrete: None,
2122            declared_jac_nnz: None,
2123            ampl_options: Vec::new(),
2124            cses: Vec::new(),
2125            quad_enabled,
2126            cse_quad: Vec::new(),
2127            cse_sum_ok: Vec::new(),
2128            cse_mono_ok: Vec::new(),
2129            cse_vars: Vec::new(),
2130            cse_depth: Vec::new(),
2131            cse_depth_memo: std::collections::HashMap::new(),
2132            cse_index: std::collections::HashMap::new(),
2133            parse_depth: 0,
2134            max_depth: parse_depth_limit(),
2135        }
2136    }
2137
2138    /// Byte offset in the source text of the start of line `line`, or the
2139    /// end of the text when the cursor has run off it.
2140    ///
2141    /// Pointer arithmetic against the same allocation, never a deref: every
2142    /// line in `lines` borrows from the source buffer, so the difference is
2143    /// its offset.
2144    fn byte_at(&self, line: usize) -> usize {
2145        match self.lines.get(line) {
2146            Some(l) => l.as_ptr() as usize - self.txt_base,
2147            None => self.txt_len,
2148        }
2149    }
2150
2151    fn next_line(&mut self) -> Option<&'a str> {
2152        while self.pos < self.lines.len() {
2153            let l = self.lines[self.pos];
2154            self.pos += 1;
2155            // Strip comment after '#' for header / data lines (but
2156            // leave the segment-tag tokens untouched — they are the
2157            // first token on the line).
2158            let trimmed = strip_comment(l).trim();
2159            if !trimmed.is_empty() {
2160                return Some(l);
2161            }
2162        }
2163        None
2164    }
2165
2166    /// Next non-blank line, comment stripped and trimmed.
2167    ///
2168    /// Borrows from the source text rather than allocating: the result
2169    /// is `&'a str`, tied to the `.nl` buffer and not to `self`, so it
2170    /// outlives the `&mut self` this took. A large `.nl` is mostly data
2171    /// lines — 620k of them for a 20k-variable model — and returning an
2172    /// owned `String` put one heap allocation on every single one.
2173    fn next_data_line(&mut self) -> Result<&'a str, String> {
2174        while self.pos < self.lines.len() {
2175            let l = self.lines[self.pos];
2176            self.pos += 1;
2177            let trimmed = strip_comment(l).trim();
2178            if !trimmed.is_empty() {
2179                return Ok(trimmed);
2180            }
2181        }
2182        Err("unexpected end of file in data line".to_string())
2183    }
2184
2185    fn parse_header(&mut self) -> Result<(), String> {
2186        let line0 = self.next_line().ok_or("empty .nl file")?;
2187        let trimmed = strip_comment(line0).trim();
2188        let first = trimmed.chars().next().ok_or("empty header line")?;
2189        if first != 'g' {
2190            return Err(format!(
2191                "only ASCII (g-) .nl files supported; got header '{trimmed}'"
2192            ));
2193        }
2194        // Line 0 is `g<count> <opt0> <opt1> ...`: the digits glued to the
2195        // `g` say how many AMPL option words follow on the same line.
2196        // A solver echoes them back in the `.sol` `Options` block, so
2197        // keep them verbatim. Malformed or truncated option lists are not
2198        // fatal — the writer falls back to a generic block.
2199        let mut words = trimmed.split_whitespace();
2200        let n_opts: usize = words.next().and_then(|w| w[1..].parse().ok()).unwrap_or(0);
2201        let opts: Vec<i64> = words.filter_map(|w| w.parse().ok()).collect();
2202        if opts.len() >= n_opts {
2203            self.ampl_options = opts[..n_opts].to_vec();
2204        }
2205
2206        // Header line 2: n_vars n_cons n_objs ranges eqns
2207        let l2 = self.next_data_line()?;
2208        let nums: Vec<&str> = l2.split_whitespace().collect();
2209        if nums.len() < 3 {
2210            return Err(format!("malformed line 2: '{l2}'"));
2211        }
2212        self.n = nums[0].parse().map_err(|e| format!("n: {e}"))?;
2213        self.m = nums[1].parse().map_err(|e| format!("m: {e}"))?;
2214        self.num_obj = nums[2].parse().map_err(|e| format!("num_obj: {e}"))?;
2215
2216        // Header line 3: `nlc nlo`. Line 4: the network-constraint census,
2217        // which pounce has no use for. Line 5: `nlvc nlvo nlvb`. Together
2218        // these are the model's nonlinearity census — see [`NlCounts`].
2219        //
2220        // A header that does not carry them in the documented shape leaves
2221        // `nl_counts` at `None` rather than at a guess: every consumer has a
2222        // walk-the-trees fallback, and a fabricated count is worse than an
2223        // absent one. The rest of the header stays tolerant in the same way
2224        // the `nfunc` read below is.
2225        let l3 = self.next_data_line()?;
2226        let _l4_network = self.next_data_line()?;
2227        let l5 = self.next_data_line()?;
2228        self.nl_counts = parse_nl_counts(l3, l5);
2229        // Line 5 (0-indexed from `g`-header): `nwv nfunc arith flags`
2230        let l6 = self.next_data_line()?;
2231        let nums5: Vec<&str> = l6.split_whitespace().collect();
2232        self.n_funcs = nums5.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
2233        // Lines 6..10 are metadata we mostly don't need. Line 7 is the
2234        // discrete-variable census; line 8 is `nzc nzo` — the Jacobian and
2235        // objective-gradient nonzero counts, kept because `nzc` is what
2236        // catches a file truncated before its `J` segments (gh#785); lines
2237        // 9 and 10 are the maximum name lengths and the common-expression
2238        // census.
2239        let l7_discrete = self.next_data_line()?;
2240        {
2241            let v: Vec<usize> = l7_discrete
2242                .split_whitespace()
2243                .map_while(|w| w.parse::<usize>().ok())
2244                .collect();
2245            if v.len() >= 2 {
2246                self.n_discrete = Some(DiscreteCensus {
2247                    binary: v[0],
2248                    integer: v[1],
2249                    // `nlvbi nlvci nlvoi`; absent in a short header.
2250                    nl_both: v.get(2).copied().unwrap_or(0),
2251                    nl_cons: v.get(3).copied().unwrap_or(0),
2252                    nl_objs: v.get(4).copied().unwrap_or(0),
2253                });
2254            }
2255        }
2256        let l8 = self.next_data_line()?;
2257        // Tolerant like the `nfunc` read above: a header that does not
2258        // carry the count in the documented shape leaves it `None`, and
2259        // the cross-check that reads it is skipped rather than fabricated.
2260        self.declared_jac_nnz = l8.split_whitespace().next().and_then(|s| s.parse().ok());
2261        let _l9_name_lens = self.next_data_line()?;
2262        let _l10_common_exprs = self.next_data_line()?;
2263        Ok(())
2264    }
2265
2266    fn peek_segment_line(&mut self) -> Option<&'a str> {
2267        let saved = self.pos;
2268        let l = self.next_line()?;
2269        self.pos = saved;
2270        Some(l)
2271    }
2272
2273    /// Eat the next non-blank line as a segment header. Returns the
2274    /// whole header (after stripping comments) and the comment text.
2275    fn eat_segment_header(&mut self) -> Result<(&'a str, &'a str), String> {
2276        let raw = self
2277            .next_line()
2278            .ok_or_else(|| "expected segment header".to_string())?;
2279        let (hdr, comment) = split_comment(raw);
2280        Ok((hdr.trim(), comment.trim()))
2281    }
2282
2283    /// Parse one `C`/`O` body, recognizing it from the token stream when
2284    /// that is possible instead of building its tree (gh #588, Q5).
2285    ///
2286    /// The cursor is the whole mechanism. `Parser` is line-based, so a
2287    /// failed recognition costs one assignment to rewind — the same trick
2288    /// `parse_funcall_arg` already uses to route a Hollerith literal — and
2289    /// the fallback then produces exactly the tree this file has always
2290    /// produced. Nothing downstream can tell which arm ran except by
2291    /// measuring memory.
2292    ///
2293    /// Only **degree-2** forms are kept. A body that recognizes as constant
2294    /// or linear is re-parsed as a tree and stored as one: the constant-row
2295    /// fold (gh #492), the linearity contract and the classifier's LP fast
2296    /// path all key on the identity zero and on trees, they are cheap
2297    /// already, and the memory that motivates this phase is entirely in
2298    /// degree-2 rows. Refusing to touch them keeps the change confined to
2299    /// the rows it is for.
2300    fn parse_body(&mut self) -> Result<NlBody, String> {
2301        if !self.quad_enabled {
2302            let e = self.parse_expr()?;
2303            self.check_effective_depth(&e)?;
2304            return Ok(NlBody::Tree(e));
2305        }
2306        let saved = self.pos;
2307        if let Some((form, vars, depth)) = self.parse_expr_quadratic() {
2308            if !form.quadratic().is_empty() {
2309                let src = self.byte_at(saved)..self.byte_at(self.pos);
2310                return Ok(NlBody::Quad(Box::new(QuadBody {
2311                    form,
2312                    vars,
2313                    src,
2314                    depth,
2315                })));
2316            }
2317        }
2318        self.pos = saved;
2319        let e = self.parse_expr()?;
2320        self.check_effective_depth(&e)?;
2321        Ok(NlBody::Tree(e))
2322    }
2323
2324    /// gh#986 review: refuse a tree whose depth *through the defined
2325    /// variables it references* exceeds the guard. `parse_expr` counts only
2326    /// the syntactic nesting of the text it reads, and a `v<i>` reference to a
2327    /// `V` segment is one token there; a chain of `V` segments each referencing
2328    /// the previous is shallow to the parser and as deep as the chain to every
2329    /// pass that walks through a `Cse` (the tape builder, `collect_vars`, drop
2330    /// glue), so the stack the guard exists to protect was unguarded.
2331    fn check_effective_depth(&self, e: &Expr) -> Result<u32, String> {
2332        let d = expr_tree_depth(e, &self.cse_depth_memo);
2333        if d > self.max_depth {
2334            return Err(format!(
2335                "the model nests an expression {d} levels deep counting the defined \
2336                 variables (V segments) it references, more than the {} levels the \
2337                 recursive reader accepts; a chain of defined variables each \
2338                 referencing the previous counts once per link",
2339                self.max_depth
2340            ));
2341        }
2342        Ok(d)
2343    }
2344
2345    /// Read one expression off the token stream as a degree-≤2 form,
2346    /// **without building it**, or give up.
2347    ///
2348    /// This is the same computation as
2349    /// `is_expanded_quadratic(e) && recognize_expr(e)` run on the tree these
2350    /// tokens parse to — the accuracy gate and the algebra, interleaved so
2351    /// neither needs the tree. That equivalence is the phase's correctness
2352    /// claim and is asserted directly, bit for bit, over every body of every
2353    /// `.nl` file in the repository by
2354    /// `pounce-cli/tests/quad_parse_differential.rs`.
2355    ///
2356    /// Two things it must reproduce and not merely approximate:
2357    ///
2358    /// * **The exactness rule.** `is_expanded_quadratic` admits a body only
2359    ///   when reading it as `½xᵀHx + aᵀx + c` repeats the additions the
2360    ///   writer already wrote — expanding a *factored* form cancels, which
2361    ///   is what took `airport.nl` from 16 to 300 iterations in Q4. Here
2362    ///   that rule is structural rather than a separate pass: `+`/`-` are
2363    ///   the spine, everything under a `*`, `/` or `^` is read in monomial
2364    ///   mode, and a `+` seen in monomial mode gives up.
2365    /// * **The association.** `recognize_expr` folds an `o54` sumlist from
2366    ///   the *first* operand to the last, which is also the order the AD
2367    ///   tape sums in, so this folds the same way — operands arrive here in
2368    ///   file order and are folded as they arrive. Summation order is not
2369    ///   observable on distinct monomials and is exactly observable on
2370    ///   repeated ones.
2371    ///
2372    /// On `None` the cursor is left wherever it stopped; the caller rewinds.
2373    fn parse_expr_quadratic(&mut self) -> Option<(Quad2, Vec<u32>, u32)> {
2374        let mut frames: Vec<QFrame> = Vec::new();
2375        let mut vals: Vec<Quad2> = Vec::new();
2376        let mut vars: Vec<u32> = Vec::new();
2377        // Deepest node of the tree these tokens describe. A leaf sits under
2378        // `frames.len()` operators, and a CSE reference carries its body's
2379        // depth under it — the same convention `pounce-py`'s `expr_depth`
2380        // uses, because that is who reads the answer.
2381        let mut depth: u32 = 0;
2382        let note_leaf = |frames: &[QFrame], depth: &mut u32, below: u32| {
2383            let d = u32::try_from(frames.len()).unwrap_or(u32::MAX);
2384            *depth = (*depth).max(d.saturating_add(1).saturating_add(below));
2385        };
2386
2387        loop {
2388            let mono = frames.last().is_some_and(|f| f.mono);
2389            // A `^` base must be atomic — `Const`, `Var` or a `Neg` — or the
2390            // body is a factored form dressed as a monomial. `is_monomial`
2391            // applies the same test to `Pow`'s left operand.
2392            let pow_base = matches!(
2393                frames.last(),
2394                Some(QFrame {
2395                    op: QOp::Pow,
2396                    remaining: 2,
2397                    ..
2398                }) | Some(QFrame {
2399                    op: QOp::Square,
2400                    remaining: 1,
2401                    ..
2402                })
2403            );
2404
2405            let raw = self.next_line()?;
2406            let tok = strip_comment(raw).trim();
2407            let first = tok.chars().next()?;
2408            match first {
2409                'n' => {
2410                    // A constant base is atomic; `is_monomial` accepts it.
2411                    // A non-finite literal bails out of the fast path so the
2412                    // general parser reaches it and reports the error rather
2413                    // than folding it into a quadratic (gh #847).
2414                    let v: Number = tok[1..]
2415                        .trim()
2416                        .parse()
2417                        .ok()
2418                        .filter(|v: &Number| v.is_finite())?;
2419                    note_leaf(&frames, &mut depth, 0);
2420                    vals.push(Quad2::of_constant(v));
2421                }
2422                'v' => {
2423                    let i: usize = tok[1..].trim().parse().ok()?;
2424                    if i < self.n {
2425                        note_leaf(&frames, &mut depth, 0);
2426                        vars.push(u32::try_from(i).ok()?);
2427                        vals.push(Quad2::of_var(i));
2428                    } else {
2429                        // A CSE reference lowers to `Expr::Cse`, which is
2430                        // *not* one of the shapes `is_monomial` accepts as a
2431                        // power's base.
2432                        if pow_base {
2433                            return None;
2434                        }
2435                        let local = i.checked_sub(self.n)?;
2436                        let ok = if mono {
2437                            *self.cse_mono_ok.get(local)?
2438                        } else {
2439                            *self.cse_sum_ok.get(local)?
2440                        };
2441                        if !ok {
2442                            return None;
2443                        }
2444                        let form = self.cse_quad.get(local)?.clone()?;
2445                        note_leaf(&frames, &mut depth, *self.cse_depth.get(local)?);
2446                        vars.extend_from_slice(self.cse_vars.get(local)?);
2447                        vals.push(form);
2448                    }
2449                }
2450                'o' => {
2451                    let code: i32 = tok[1..].trim().parse().ok()?;
2452                    if pow_base && code != 16 {
2453                        return None;
2454                    }
2455                    let (op, arity, child_mono) = match code {
2456                        // The sum spine. Inside a monomial there is no such
2457                        // thing, and the body is a factored form.
2458                        0 if !mono => (QOp::Add, 2, false),
2459                        1 if !mono => (QOp::Sub, 2, false),
2460                        16 => (QOp::Neg, 1, mono),
2461                        54 if !mono => {
2462                            let count_line = self.next_data_line().ok()?;
2463                            let count: usize =
2464                                count_line.split_whitespace().next()?.parse().ok()?;
2465                            (QOp::Sum(count), count, false)
2466                        }
2467                        // Monomial operators: everything below them is read
2468                        // in monomial mode.
2469                        2 => (QOp::Mul, 2, true),
2470                        3 => (QOp::Div, 2, true),
2471                        5 | 81 | 83 => (QOp::Pow, 2, true),
2472                        82 => (QOp::Square, 1, true),
2473                        // Transcendentals, comparisons, conditionals,
2474                        // min/max lists, and `+`/`-` under a monomial.
2475                        _ => return None,
2476                    };
2477                    if arity == 0 {
2478                        // An empty `o54` is the empty sum: zero.
2479                        note_leaf(&frames, &mut depth, 0);
2480                        vals.push(Quad2::default());
2481                    } else {
2482                        frames.push(QFrame {
2483                            op,
2484                            remaining: arity,
2485                            mono: child_mono,
2486                        });
2487                        continue;
2488                    }
2489                }
2490                // `f` (imported function call), `h`, and anything else.
2491                _ => return None,
2492            }
2493
2494            // A value has just been produced. Close every frame it completes.
2495            while let Some(f) = frames.last_mut() {
2496                f.remaining -= 1;
2497                if f.remaining > 0 {
2498                    break;
2499                }
2500                let f = frames.pop()?;
2501                let combined = apply_quad_op(f.op, &mut vals)?;
2502                vals.push(combined);
2503            }
2504            if frames.is_empty() {
2505                break;
2506            }
2507        }
2508
2509        if vals.len() != 1 {
2510            return None;
2511        }
2512        vars.sort_unstable();
2513        vars.dedup();
2514        Some((vals.pop()?, vars, depth))
2515    }
2516
2517    fn parse_expr(&mut self) -> Result<Expr, String> {
2518        // gh#986: a recursion guard that fires before the stack does. A
2519        // stack overflow is an abort, not an error return, so the reader
2520        // refuses past `MAX_PARSE_DEPTH` levels with a clean message at any
2521        // input depth instead of relying on the caller's stack size.
2522        if self.parse_depth >= self.max_depth {
2523            return Err(format!(
2524                "the model nests an expression more than {} levels deep, which the \
2525                 recursive reader refuses rather than overflow the stack (a long \
2526                 left-deep `o0` sum is read flat and does not count; deep unary or \
2527                 `o2`/`o3` nesting does)",
2528                self.max_depth
2529            ));
2530        }
2531        self.parse_depth += 1;
2532        let r = self.parse_expr_inner();
2533        self.parse_depth -= 1;
2534        r
2535    }
2536
2537    fn parse_expr_inner(&mut self) -> Result<Expr, String> {
2538        let raw = self
2539            .next_line()
2540            .ok_or_else(|| "expected expression token".to_string())?;
2541        // Borrowed, not owned: this runs once per node of every
2542        // expression tree in the file, so an owned `String` here is one
2543        // heap allocation per tape op in the whole model.
2544        let tok = strip_comment(raw).trim();
2545        if tok.is_empty() {
2546            return Err("empty expression token".into());
2547        }
2548        let first = tok.chars().next().ok_or("empty expression token")?;
2549        match first {
2550            'n' => {
2551                let v: Number = tok[1..]
2552                    .trim()
2553                    .parse()
2554                    .map_err(|e| format!("n value: {e}"))?;
2555                // A `nan` literal in the objective body reached the answer
2556                // itself: `Objective: nan` under `Solve_Succeeded` and exit
2557                // code 0 (gh #847).
2558                Ok(Expr::Const(finite_or_err("numeric literal", v)?))
2559            }
2560            'v' => {
2561                let i: usize = tok[1..]
2562                    .trim()
2563                    .parse()
2564                    .map_err(|e| format!("v index: {e}"))?;
2565                Ok(self.var_or_cse(i)?)
2566            }
2567            'o' => {
2568                let code: i32 = tok[1..]
2569                    .trim()
2570                    .parse()
2571                    .map_err(|e| format!("opcode: {e}"))?;
2572                self.parse_opcode(code)
2573            }
2574            'f' => {
2575                // AMPL imported (external) function call: `f<id> <nargs>`
2576                // followed by nargs child expressions (or string literals).
2577                let rest = &tok[1..];
2578                let mut parts = rest.split_whitespace();
2579                let id_str = parts
2580                    .next()
2581                    .ok_or_else(|| format!("missing function id in '{tok}'"))?;
2582                let nargs_str = parts
2583                    .next()
2584                    .ok_or_else(|| format!("missing nargs in '{tok}'"))?;
2585                let id: usize = id_str
2586                    .parse()
2587                    .map_err(|e| format!("bad function id '{id_str}': {e}"))?;
2588                let nargs: usize = nargs_str
2589                    .parse()
2590                    .map_err(|e| format!("bad funcall nargs '{nargs_str}': {e}"))?;
2591                let mut args: Vec<FuncallArg> = Vec::with_capacity(nargs);
2592                for _ in 0..nargs {
2593                    args.push(self.parse_funcall_arg()?);
2594                }
2595                Ok(Expr::Funcall { id, args })
2596            }
2597            't' | 'u' => Err(format!("unsupported expression token '{tok}'")),
2598            other => Err(format!(
2599                "unexpected expression token start '{other}': '{tok}'"
2600            )),
2601        }
2602    }
2603
2604    /// Parse one argument to an AMPL imported function. An argument
2605    /// is either a normal expression (real-valued) or a string literal
2606    /// in the form `h<len>:<chars>`. AMPL emits string args only when the
2607    /// function was declared `FUNCADD_STRING_ARGS` (e.g. component name
2608    /// or a parameters-directory path for IDAES Helmholtz functions).
2609    fn parse_funcall_arg(&mut self) -> Result<FuncallArg, String> {
2610        // Peek the next non-blank line so we can route `h...` differently.
2611        let saved = self.pos;
2612        let raw = self
2613            .next_line()
2614            .ok_or_else(|| "expected funcall argument".to_string())?;
2615        // A string arg is a Hollerith literal `h<len>:<chars>` where the
2616        // chars are *exactly* `<len>` bytes and may legitimately contain
2617        // '#'. We must NOT strip a trailing comment before extracting the
2618        // content (that would truncate e.g. a path like `a#b`), and we
2619        // honor the declared length rather than splitting loosely on ':'.
2620        // Detect the form from the leading non-blank char of the raw line;
2621        // no expression opcode (`o`/`v`/`n`/`f`) begins with 'h'.
2622        let lead = raw.trim_start();
2623        if let Some(after_h) = lead.strip_prefix('h') {
2624            let colon = after_h
2625                .find(':')
2626                .ok_or_else(|| format!("malformed Hollerith string arg (no ':'): {lead:?}"))?;
2627            let len: usize = after_h[..colon]
2628                .trim()
2629                .parse()
2630                .map_err(|e| format!("Hollerith length in {lead:?}: {e}"))?;
2631            let chars = &after_h[colon + 1..];
2632            if chars.len() < len {
2633                return Err(format!(
2634                    "Hollerith string shorter than declared length {len}: {chars:?}"
2635                ));
2636            }
2637            // Take exactly `len` bytes; anything past it (trailing
2638            // whitespace, a real comment) is not part of the string.
2639            if !chars.is_char_boundary(len) {
2640                return Err(format!(
2641                    "Hollerith length {len} splits a multibyte char in {chars:?}"
2642                ));
2643            }
2644            Ok(FuncallArg::Str(chars[..len].to_string()))
2645        } else {
2646            // Rewind: parse_expr re-consumes the line we just peeked.
2647            self.pos = saved;
2648            Ok(FuncallArg::Real(self.parse_expr()?))
2649        }
2650    }
2651
2652    fn parse_opcode(&mut self, code: i32) -> Result<Expr, String> {
2653        match code {
2654            0 => {
2655                // gh#986: a left-deep run of binary `o0` — `o0 o0 o0 … a b c d`,
2656                // what AMPL and discopt write for a long sum — is read as one
2657                // n-ary `Sum`, so the tree is one level deep however many terms
2658                // there are. Taken only past `SUM_CHAIN_FLATTEN_MIN` links, so
2659                // an ordinary model keeps exactly the tree it always had.
2660                // Each `o0`'s first child is the next line, so the `k + 1`
2661                // operands that follow the run are, in order, the leaves of
2662                // `((a + b) + c) + d`; `Sum` adds them left to right, the same
2663                // order of operations.
2664                let after_first = self.pos;
2665                let mut k = 1usize;
2666                loop {
2667                    let before = self.pos;
2668                    match self.next_line() {
2669                        Some(l) if strip_comment(l).trim() == "o0" => k += 1,
2670                        _ => {
2671                            self.pos = before;
2672                            break;
2673                        }
2674                    }
2675                }
2676                if k > SUM_CHAIN_FLATTEN_MIN {
2677                    let mut terms = Vec::with_capacity(k + 1);
2678                    for _ in 0..=k {
2679                        terms.push(self.parse_expr()?);
2680                    }
2681                    return Ok(Expr::Sum(terms));
2682                }
2683                self.pos = after_first;
2684                let a = self.parse_expr()?;
2685                let b = self.parse_expr()?;
2686                Ok(Expr::Binary(BinOp::Add, Box::new(a), Box::new(b)))
2687            }
2688            1 => {
2689                let a = self.parse_expr()?;
2690                let b = self.parse_expr()?;
2691                Ok(Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
2692            }
2693            2 => {
2694                let a = self.parse_expr()?;
2695                let b = self.parse_expr()?;
2696                Ok(merge_abs_pow_product(a, b))
2697            }
2698            3 => {
2699                let a = self.parse_expr()?;
2700                let b = self.parse_expr()?;
2701                Ok(Expr::Binary(BinOp::Div, Box::new(a), Box::new(b)))
2702            }
2703            5 => {
2704                let a = self.parse_expr()?;
2705                let b = self.parse_expr()?;
2706                Ok(Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b)))
2707            }
2708            15 => Ok(Expr::Unary(UnaryOp::Abs, Box::new(self.parse_expr()?))),
2709            16 => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.parse_expr()?))),
2710            39 => Ok(Expr::Unary(UnaryOp::Sqrt, Box::new(self.parse_expr()?))),
2711            41 => Ok(Expr::Unary(UnaryOp::Sin, Box::new(self.parse_expr()?))),
2712            42 => Ok(Expr::Unary(UnaryOp::Log10, Box::new(self.parse_expr()?))),
2713            43 => Ok(Expr::Unary(UnaryOp::Log, Box::new(self.parse_expr()?))),
2714            44 => Ok(Expr::Unary(UnaryOp::Exp, Box::new(self.parse_expr()?))),
2715            46 => Ok(Expr::Unary(UnaryOp::Cos, Box::new(self.parse_expr()?))),
2716            38 => Ok(Expr::Unary(UnaryOp::Tan, Box::new(self.parse_expr()?))),
2717            49 => Ok(Expr::Unary(UnaryOp::Atan, Box::new(self.parse_expr()?))),
2718            53 => Ok(Expr::Unary(UnaryOp::Acos, Box::new(self.parse_expr()?))),
2719            40 => Ok(Expr::Unary(UnaryOp::Sinh, Box::new(self.parse_expr()?))),
2720            45 => Ok(Expr::Unary(UnaryOp::Cosh, Box::new(self.parse_expr()?))),
2721            37 => Ok(Expr::Unary(UnaryOp::Tanh, Box::new(self.parse_expr()?))),
2722            51 => Ok(Expr::Unary(UnaryOp::Asin, Box::new(self.parse_expr()?))),
2723            52 => Ok(Expr::Unary(UnaryOp::Acosh, Box::new(self.parse_expr()?))),
2724            50 => Ok(Expr::Unary(UnaryOp::Asinh, Box::new(self.parse_expr()?))),
2725            47 => Ok(Expr::Unary(UnaryOp::Atanh, Box::new(self.parse_expr()?))),
2726            // atan2(y, x): binary, operand order `y` then `x`.
2727            48 => {
2728                let a = self.parse_expr()?;
2729                let b = self.parse_expr()?;
2730                Ok(Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b)))
2731            }
2732            // Relational comparisons (binary). Operand order is
2733            // `left OP right`.
2734            22 => self.parse_compare(CmpOp::Lt),
2735            23 => self.parse_compare(CmpOp::Le),
2736            24 => self.parse_compare(CmpOp::Eq),
2737            28 => self.parse_compare(CmpOp::Ge),
2738            29 => self.parse_compare(CmpOp::Gt),
2739            30 => self.parse_compare(CmpOp::Ne),
2740            // Logical connectives.
2741            20 => {
2742                let a = self.parse_expr()?;
2743                let b = self.parse_expr()?;
2744                Ok(Expr::Or(Box::new(a), Box::new(b)))
2745            }
2746            21 => {
2747                let a = self.parse_expr()?;
2748                let b = self.parse_expr()?;
2749                Ok(Expr::And(Box::new(a), Box::new(b)))
2750            }
2751            34 => Ok(Expr::Not(Box::new(self.parse_expr()?))),
2752            // if-then-else: condition, then-value, else-value.
2753            35 => {
2754                let cond = self.parse_expr()?;
2755                let then_ = self.parse_expr()?;
2756                let else_ = self.parse_expr()?;
2757                Ok(Expr::Cond {
2758                    cond: Box::new(cond),
2759                    then_: Box::new(then_),
2760                    else_: Box::new(else_),
2761                })
2762            }
2763            54 => {
2764                // Variadic sum: next data line gives the count.
2765                let count_line = self.next_data_line()?;
2766                let count: usize = count_line
2767                    .split_whitespace()
2768                    .next()
2769                    .ok_or_else(|| "missing variadic count".to_string())?
2770                    .parse()
2771                    .map_err(|e| format!("variadic count: {e}"))?;
2772                let mut args = Vec::with_capacity(count);
2773                for _ in 0..count {
2774                    args.push(self.parse_expr()?);
2775                }
2776                Ok(Expr::Sum(args))
2777            }
2778            // Variadic min (o11 MINLIST) / max (o12 MAXLIST): like o54,
2779            // a count data line followed by that many operands.
2780            11 | 12 => {
2781                let count_line = self.next_data_line()?;
2782                let count: usize = count_line
2783                    .split_whitespace()
2784                    .next()
2785                    .ok_or_else(|| "missing min/max list count".to_string())?
2786                    .parse()
2787                    .map_err(|e| format!("min/max list count: {e}"))?;
2788                let mut args = Vec::with_capacity(count);
2789                for _ in 0..count {
2790                    args.push(self.parse_expr()?);
2791                }
2792                if code == 11 {
2793                    Ok(Expr::MinList(args))
2794                } else {
2795                    Ok(Expr::MaxList(args))
2796                }
2797            }
2798            // AMPL power specializations (ASL `opcode.hd` 81/82/83). AMPL
2799            // emits these in place of the general `o5` (OPPOW) as a hint that
2800            // one operand is constant. The distinction exists because an
2801            // integer / half-integer constant power is evaluated by a
2802            // mul/sqrt chain that stays real for a negative base, whereas the
2803            // general `pow` (via `exp(c·ln x)`) returns NaN there. Structurally
2804            // they read exactly like `o5`, so they lower to the same `Pow` AST
2805            // and reuse the existing constant-power tape lowering (see
2806            // `nl_tape::try_emit_const_pow`). Arity/operand order confirmed
2807            // against the ASL reader and the `ampl/mp` opcode table:
2808            // POW_CONST_EXP / POW_CONST_BASE are binary `base, exp`; POW2 is
2809            // unary with an implicit exponent of 2.
2810            //
2811            // o81 OP1POW: `base ^ (const exponent)` — binary, operands
2812            // `base` then `exp` (the exponent is a numeric node here).
2813            81 => {
2814                let base = self.parse_expr()?;
2815                let exp = self.parse_expr()?;
2816                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2817            }
2818            // o82 OP2POW: square — unary, single operand; exponent 2 implicit.
2819            82 => {
2820                let base = self.parse_expr()?;
2821                Ok(Expr::Binary(
2822                    BinOp::Pow,
2823                    Box::new(base),
2824                    Box::new(Expr::Const(2.0)),
2825                ))
2826            }
2827            // o83 OPCPOW: `(const base) ^ exponent` — binary, operands `base`
2828            // (the numeric node) then `exp`.
2829            83 => {
2830                let base = self.parse_expr()?;
2831                let exp = self.parse_expr()?;
2832                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2833            }
2834            other => Err(format!("unsupported opcode o{other}")),
2835        }
2836    }
2837
2838    /// Parse the two operands of a relational opcode into an
2839    /// [`Expr::Compare`]. Operand order is `left OP right`.
2840    fn parse_compare(&mut self, op: CmpOp) -> Result<Expr, String> {
2841        let a = self.parse_expr()?;
2842        let b = self.parse_expr()?;
2843        Ok(Expr::Compare(op, Box::new(a), Box::new(b)))
2844    }
2845
2846    /// Resolve a `v<i>` token into either a plain variable reference
2847    /// (`i < n`) or a shared CSE reference (`i >= n`).
2848    fn var_or_cse(&self, i: usize) -> Result<Expr, String> {
2849        if i < self.n {
2850            Ok(Expr::Var(i))
2851        } else {
2852            let local = i - self.n;
2853            self.cses
2854                .get(local)
2855                .map(|rc| Expr::Cse(rc.clone()))
2856                .ok_or_else(|| {
2857                    format!(
2858                        "v{i} references CSE {local} but only {} have been defined",
2859                        self.cses.len()
2860                    )
2861                })
2862        }
2863    }
2864
2865    /// Parse a `V<k> <nlin> <type>` common-subexpression segment. The
2866    /// CSE evaluates to `nonlinear_expr + sum_i coef_i * v_{var_i}`.
2867    /// CSEs are numbered starting at `n` and must appear in order.
2868    fn parse_v_segment(&mut self) -> Result<(), String> {
2869        let (hdr, _) = self.eat_segment_header()?;
2870        let parts: Vec<&str> = hdr.split_whitespace().collect();
2871        if parts.len() < 2 {
2872            return Err(format!("malformed V-segment header: {hdr}"));
2873        }
2874        let cse_idx = parse_segment_index(parts[0], 'V')?;
2875        let nlin: usize = parts[1].parse().map_err(|e| format!("V nlin: {e}"))?;
2876        // parts[2] (type) is ignored; values >0 just mark special-purpose CSEs.
2877        let mut linear: Vec<(usize, Number)> = Vec::with_capacity(nlin);
2878        for _ in 0..nlin {
2879            let line = self.next_data_line()?;
2880            let (var, coef) = parse_var_coef(line)?;
2881            linear.push((var, coef));
2882        }
2883        let nonlin = self.parse_expr()?;
2884        // Build `nonlin + sum coef_i * v_{var_i}`. Linear terms can
2885        // reference earlier CSEs as well as plain variables.
2886        let mut combined = nonlin;
2887        for (var, coef) in linear {
2888            let v_expr = self.var_or_cse(var)?;
2889            let term = if coef == 1.0 {
2890                v_expr
2891            } else {
2892                Expr::Binary(BinOp::Mul, Box::new(Expr::Const(coef)), Box::new(v_expr))
2893            };
2894            combined = Expr::Binary(BinOp::Add, Box::new(combined), Box::new(term));
2895        }
2896        if cse_idx < self.n {
2897            return Err(format!("V{cse_idx} below n={}", self.n));
2898        }
2899        let local = cse_idx - self.n;
2900        if local != self.cses.len() {
2901            return Err(format!(
2902                "V-segment index V{cse_idx} out of order; expected V{}",
2903                self.n + self.cses.len()
2904            ));
2905        }
2906        // What a reference to this body would mean to the streaming
2907        // recognizer, answered once here rather than per reference. The
2908        // `V` tree exists at this point, so these are the *same* functions
2909        // `NlTnlp` applies to a whole row — the parse-time recognizer never
2910        // gets a second opinion about a CSE.
2911        // gh#986 review: bound the chain before any pass walks through it.
2912        let depth = self.check_effective_depth(&combined)?;
2913        if self.quad_enabled {
2914            // gh#986 review: every earlier body's verdicts are already
2915            // known, so each pass reads them instead of re-walking the chain
2916            // of defined variables below this one (quadratic over a long
2917            // chain otherwise).
2918            let idx = &self.cse_index;
2919            let (quad, sum_ok, mono_ok) = (&self.cse_quad, &self.cse_sum_ok, &self.cse_mono_ok);
2920            let quad_seed = |k: *const Expr| idx.get(&k).map(|&i| quad[i].clone());
2921            let shape_seed = |k: *const Expr, mono: bool| {
2922                idx.get(&k)
2923                    .map(|&i| if mono { mono_ok[i] } else { sum_ok[i] })
2924            };
2925            let q = crate::nl_quadratic::recognize_expr_seeded(&combined, &quad_seed);
2926            let s_ok = crate::nl_quadratic::is_expanded_quadratic_seeded(&combined, &shape_seed);
2927            let m_ok = crate::nl_quadratic::is_monomial_expr_seeded(&combined, &shape_seed);
2928            let mut vars: BTreeSet<usize> = BTreeSet::new();
2929            collect_vars_seeded(&combined, &mut vars, &self.cse_index, &self.cse_vars);
2930            self.cse_quad.push(q);
2931            self.cse_sum_ok.push(s_ok);
2932            self.cse_mono_ok.push(m_ok);
2933            self.cse_vars
2934                .push(vars.into_iter().map(|v| v as u32).collect());
2935            self.cse_depth.push(depth);
2936        }
2937        let body = Arc::new(combined);
2938        self.cse_index.insert(Arc::as_ptr(&body), self.cses.len());
2939        self.cse_depth_memo.insert(Arc::as_ptr(&body), depth);
2940        self.cses.push(body);
2941        Ok(())
2942    }
2943}
2944
2945/// Combine the operands of one recognized operator, mirroring
2946/// [`crate::nl_quadratic::recognize_expr`]'s `Apply` arm term for term.
2947///
2948/// Every difference from that function is a difference in the coefficients
2949/// this parser stores, so there is nothing here that is "equivalent but
2950/// tidier": the division scales by the reciprocal because that is what the
2951/// tree walk does, and the sumlist folds back to front for the same reason.
2952/// Nesting depth of a `V`-segment body, leaf = 1, a `Cse` reference
2953/// counting one level above its body — the convention `pounce-py`'s
2954/// `expr_depth` uses, since that is the guard the answer feeds.
2955///
2956/// Recursive, and safe to be: it recurses only through the syntax of `e`,
2957/// which [`Parser::parse_expr`] has just built *recursively* on this same
2958/// stack, so a frame that fits the parser fits this. A `Cse` reference is
2959/// read from `memo` (every `V` body is memoized when its segment is parsed,
2960/// and a body can only reference earlier ones), so the walk never descends a
2961/// chain of defined variables: linear in `e`, where it used to be linear in
2962/// the whole chain on every `V` segment -- quadratic over a long chain, and
2963/// as deep as it (gh#986 review).
2964fn expr_tree_depth(e: &Expr, memo: &std::collections::HashMap<*const Expr, u32>) -> u32 {
2965    let deepest = |kids: &mut dyn Iterator<Item = &Expr>| {
2966        kids.fold(0u32, |acc, k| acc.max(expr_tree_depth(k, memo)))
2967    };
2968    1 + match e {
2969        Expr::Const(_) | Expr::Var(_) => 0,
2970        Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
2971            deepest(&mut [&**a, &**b].into_iter())
2972        }
2973        Expr::Unary(_, a) | Expr::Not(a) => expr_tree_depth(a, memo),
2974        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => deepest(&mut args.iter()),
2975        Expr::Cond { cond, then_, else_ } => {
2976            deepest(&mut [&**cond, &**then_, &**else_].into_iter())
2977        }
2978        Expr::Funcall { args, .. } => deepest(&mut args.iter().filter_map(|a| match a {
2979            FuncallArg::Real(inner) => Some(inner),
2980            FuncallArg::Str(_) => None,
2981        })),
2982        Expr::Cse(body) => match memo.get(&Arc::as_ptr(body)) {
2983            Some(&d) => d,
2984            None => expr_tree_depth(body, memo),
2985        },
2986    }
2987}
2988
2989fn apply_quad_op(op: QOp, vals: &mut Vec<Quad2>) -> Option<Quad2> {
2990    let pop2 = |vals: &mut Vec<Quad2>| -> Option<(Quad2, Quad2)> {
2991        let b = vals.pop()?;
2992        let a = vals.pop()?;
2993        Some((a, b))
2994    };
2995    Some(match op {
2996        QOp::Sum(n) => {
2997            let at = vals.len().checked_sub(n)?;
2998            let mut acc = Quad2::default();
2999            // Operands arrive in file order, so `drain` already yields them
3000            // front to back — the order `recognize_expr` folds in, and the
3001            // order the AD tape sums in. Do not reverse this: floating-point
3002            // addition is not associative, and on repeated monomials the two
3003            // orders do not agree bit for bit.
3004            for p in vals.drain(at..) {
3005                acc = Quad2::add(acc, p);
3006            }
3007            acc
3008        }
3009        QOp::Neg => vals.pop()?.neg(),
3010        QOp::Add => {
3011            let (a, b) = pop2(vals)?;
3012            Quad2::add(a, b)
3013        }
3014        QOp::Sub => {
3015            let (a, b) = pop2(vals)?;
3016            Quad2::add(a, b.neg())
3017        }
3018        QOp::Mul => {
3019            let (a, b) = pop2(vals)?;
3020            a.mul(&b)?
3021        }
3022        QOp::Div => {
3023            let (a, b) = pop2(vals)?;
3024            let d = b.as_constant()?;
3025            if d == 0.0 {
3026                return None;
3027            }
3028            let mut out = a.div_by_constant(d);
3029            out.absorb_flags(&b);
3030            out
3031        }
3032        QOp::Pow => {
3033            let (a, b) = pop2(vals)?;
3034            let exp = b.as_constant()?;
3035            let mut out = if exp == 0.0 {
3036                Quad2::of_constant(1.0)
3037            } else if exp == 1.0 {
3038                a
3039            } else if exp == 2.0 {
3040                a.mul(&a)?
3041            } else {
3042                return None;
3043            };
3044            // The exponent is read out of a form with `as_constant`, which
3045            // leaves its flags behind; `Div` above does the same.
3046            out.absorb_flags(&b);
3047            out
3048        }
3049        // `o82` is `Pow(base, Const(2.0))` with the exponent left implicit,
3050        // so it takes the `exp == 2.0` branch above.
3051        QOp::Square => {
3052            let a = vals.pop()?;
3053            a.mul(&a)?
3054        }
3055    })
3056}
3057
3058/// Re-parse one recognized body from the bytes it was recognized from.
3059///
3060/// The point is that this is the *same* function the original parse ran, on
3061/// the *same* bytes, with the same `n` and the same `V`-segment bodies — so
3062/// the tree it returns is the tree that parse would have built, down to the
3063/// `Arc` identities that `HybridTape::build_multi` keys CSE sharing on.
3064/// Deriving an equivalent tree from the stored coefficients instead would
3065/// be a different tree, evaluated by a different tape, and this phase would
3066/// stop being invisible from outside.
3067fn parse_body_fragment(txt: &str, n: usize, cses: &[Arc<Expr>]) -> Result<Expr, String> {
3068    let mut p = Parser::new(txt, false);
3069    p.n = n;
3070    p.cses = cses.to_vec();
3071    p.parse_expr()
3072}
3073
3074/// `a·b` for an `o2` node, with one rewrite: when a factor `|e|^p` on the
3075/// left meets a factor `|e|^q` on the right (same operand `e`, constant
3076/// `p, q > 0`), the two become the single factor `|e|^(p+q)` (gh#1042 R3).
3077///
3078/// **Why.** Vanderbei's `dallas*` models write a head-loss term as
3079/// `(k·|x|^2)·|x|^0.85`. Differentiated as written, the product rule meets
3080/// `|x|^0.85` at `x = 0`, where its slope `0.85·|x|^-0.15` is `∞` and its
3081/// second derivative `-0.1275·|x|^-1.15` is `-∞`, against co-factors
3082/// `k·|x|^2 = 0` and `2k·|x| = 0`: the Hessian of the term is `0·∞ = NaN`
3083/// (the gradient survives, measured `0`), though the term is `k·|x|^2.85`,
3084/// whose second derivative at `0` is `0`. The NaN Hessian fails the KKT
3085/// factorization and forces a restoration entry each time a flow lands on
3086/// exactly zero — 11 of `dallass`'s 12 restoration calls on 0.13.0. Merged,
3087/// the tape differentiates `|x|^2.85` directly and every derivative at the
3088/// kink is the finite, correct one.
3089///
3090/// **Why it is exact.** `|e| ≥ 0`, so `|e|^p·|e|^q = |e|^(p+q)` holds for
3091/// every real `e` and every `p, q > 0` — including `e = 0` (`0·0 = 0`),
3092/// `|e| = ∞` (`∞·∞ = ∞`) and `e = NaN` (NaN either way); the two sides
3093/// differ by rounding only. The restrictions are what keep it exact:
3094///
3095/// * the base must be `abs(·)` — for a bare `e < 0` and non-integer
3096///   powers `e^0.5·e^0.5` is `NaN·NaN` while `e^1` is `e`;
3097/// * both exponents must be positive literals — `|e|^-1·|e|^2` is
3098///   `∞·0 = NaN` at `e = 0` but `|e|^1 = 0`, so a negative or zero power
3099///   would change a value, not only a derivative;
3100/// * the operands must be the *same* expression (same variable, same
3101///   shared `Cse` body, or structurally identical) — see [`same_operand`].
3102///
3103/// Away from `e = 0` the derivatives are unchanged mathematically:
3104/// `d/de |e|^(p+q)` and the product rule on `|e|^p·|e|^q` are the same
3105/// function there. Only the evaluation at the kink changes, from `NaN` to
3106/// the limit.
3107///
3108/// **Scope.** Only the two operands of this `o2` and, at most, one `Mul`
3109/// level inside each are inspected, which is what an AMPL left-associated
3110/// `k*abs(x)^2*abs(x)^0.85` produces; a pair inside one operand was already
3111/// merged when that operand was read. When nothing matches, the node is
3112/// built exactly as before (`Mul(a, b)`), so a model without the pattern
3113/// gets an identical tree and tape.
3114fn merge_abs_pow_product(a: Expr, b: Expr) -> Expr {
3115    fn factors(e: &Expr) -> Vec<&Expr> {
3116        match e {
3117            Expr::Binary(BinOp::Mul, x, y) => vec![&**x, &**y],
3118            other => vec![other],
3119        }
3120    }
3121    fn into_factors(e: Expr) -> Vec<Expr> {
3122        match e {
3123            Expr::Binary(BinOp::Mul, x, y) => vec![*x, *y],
3124            other => vec![other],
3125        }
3126    }
3127    let hit = {
3128        let (fa, fb) = (factors(&a), factors(&b));
3129        let mut hit = None;
3130        'outer: for (i, x) in fa.iter().enumerate() {
3131            let Some((ex, px)) = abs_pow_parts(x) else {
3132                continue;
3133            };
3134            for (j, y) in fb.iter().enumerate() {
3135                if let Some((ey, py)) = abs_pow_parts(y)
3136                    && (px + py).is_finite()
3137                    && same_operand(ex, ey)
3138                {
3139                    hit = Some((i, j, px + py));
3140                    break 'outer;
3141                }
3142            }
3143        }
3144        hit
3145    };
3146    let Some((i, j, p)) = hit else {
3147        return Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b));
3148    };
3149    let mut fa = into_factors(a);
3150    let mut fb = into_factors(b);
3151    fb.remove(j);
3152    let Expr::Binary(BinOp::Pow, base, _) = std::mem::replace(&mut fa[i], Expr::Const(0.0)) else {
3153        unreachable!("abs_pow_parts matched a Pow node");
3154    };
3155    fa[i] = Expr::Binary(BinOp::Pow, base, Box::new(Expr::Const(p)));
3156    let mut it = fa.into_iter().chain(fb);
3157    let first = it.next().expect("at least the merged factor");
3158    it.fold(first, |acc, f| {
3159        Expr::Binary(BinOp::Mul, Box::new(acc), Box::new(f))
3160    })
3161}
3162
3163/// `Some((e, p))` when `x` is `|e|^p` with a finite literal `p > 0`.
3164fn abs_pow_parts(x: &Expr) -> Option<(&Expr, f64)> {
3165    match x {
3166        Expr::Binary(BinOp::Pow, base, exp) => match (&**base, &**exp) {
3167            (Expr::Unary(UnaryOp::Abs, e), Expr::Const(p)) if p.is_finite() && *p > 0.0 => {
3168                Some((&**e, *p))
3169            }
3170            _ => None,
3171        },
3172        _ => None,
3173    }
3174}
3175
3176/// Conservative structural identity for [`merge_abs_pow_product`]: `true`
3177/// only when `x` and `y` are certainly the same function of the variables —
3178/// the same variable, the same shared `Cse` body (pointer identity), a
3179/// bit-identical constant, or the same unary/binary operator over operands
3180/// that are themselves identical. Anything else (sums, calls, logic,
3181/// conditionals) answers `false`, which only ever declines a merge.
3182fn same_operand(x: &Expr, y: &Expr) -> bool {
3183    match (x, y) {
3184        (Expr::Var(i), Expr::Var(j)) => i == j,
3185        (Expr::Const(a), Expr::Const(b)) => a.to_bits() == b.to_bits(),
3186        (Expr::Cse(a), Expr::Cse(b)) => Arc::ptr_eq(a, b),
3187        (Expr::Unary(oa, a), Expr::Unary(ob, b)) => oa == ob && same_operand(a, b),
3188        (Expr::Binary(oa, a1, a2), Expr::Binary(ob, b1, b2)) => {
3189            oa == ob && same_operand(a1, b1) && same_operand(a2, b2)
3190        }
3191        _ => false,
3192    }
3193}
3194
3195fn strip_comment(s: &str) -> &str {
3196    match s.find('#') {
3197        Some(i) => &s[..i],
3198        None => s,
3199    }
3200}
3201
3202fn split_comment(s: &str) -> (&str, &str) {
3203    match s.find('#') {
3204        Some(i) => (&s[..i], &s[i + 1..]),
3205        None => (s, ""),
3206    }
3207}
3208
3209// --------------------------------------------------------------------
3210// Expression evaluation and gradient (tree walkers, kept for tests).
3211// The hot paths in `NlTnlp` use the flat `Tape` AD in `nl_tape.rs`
3212// instead — see `Tape::gradient_seed` / `Tape::hessian_accumulate`.
3213// --------------------------------------------------------------------
3214
3215/// Forward-mode value evaluation.
3216pub fn eval_expr(e: &Expr, x: &[Number]) -> Number {
3217    match e {
3218        Expr::Const(c) => *c,
3219        Expr::Var(i) => x[*i],
3220        Expr::Binary(op, a, b) => {
3221            let va = eval_expr(a, x);
3222            let vb = eval_expr(b, x);
3223            match op {
3224                BinOp::Add => va + vb,
3225                BinOp::Sub => va - vb,
3226                BinOp::Mul => va * vb,
3227                BinOp::Div => va / vb,
3228                BinOp::Pow => va.powf(vb),
3229                BinOp::Atan2 => va.atan2(vb),
3230                BinOp::CEntropy => crate::nl_tape::centropy(va, vb),
3231            }
3232        }
3233        Expr::Unary(op, a) => {
3234            let va = eval_expr(a, x);
3235            match op {
3236                UnaryOp::Neg => -va,
3237                UnaryOp::Sqrt => va.sqrt(),
3238                UnaryOp::Log => va.ln(),
3239                UnaryOp::Log10 => va.log10(),
3240                UnaryOp::Exp => va.exp(),
3241                UnaryOp::Abs => va.abs(),
3242                UnaryOp::Sin => va.sin(),
3243                UnaryOp::Cos => va.cos(),
3244                UnaryOp::Tan => va.tan(),
3245                UnaryOp::Atan => va.atan(),
3246                UnaryOp::Acos => va.acos(),
3247                UnaryOp::Sinh => va.sinh(),
3248                UnaryOp::Cosh => va.cosh(),
3249                UnaryOp::Tanh => va.tanh(),
3250                UnaryOp::Asin => va.asin(),
3251                UnaryOp::Acosh => va.acosh(),
3252                UnaryOp::Asinh => va.asinh(),
3253                UnaryOp::Atanh => va.atanh(),
3254                UnaryOp::Erf => crate::nl_tape::erf(va),
3255                UnaryOp::XLogX => crate::nl_tape::xlogx(va),
3256            }
3257        }
3258        Expr::Sum(args) => args.iter().map(|a| eval_expr(a, x)).sum(),
3259        Expr::MinList(args) => args
3260            .iter()
3261            .map(|a| eval_expr(a, x))
3262            .fold(Number::INFINITY, Number::min),
3263        Expr::MaxList(args) => args
3264            .iter()
3265            .map(|a| eval_expr(a, x))
3266            .fold(Number::NEG_INFINITY, Number::max),
3267        Expr::Compare(op, a, b) => {
3268            let va = eval_expr(a, x);
3269            let vb = eval_expr(b, x);
3270            let truth = match op {
3271                CmpOp::Lt => va < vb,
3272                CmpOp::Le => va <= vb,
3273                CmpOp::Eq => va == vb,
3274                CmpOp::Ge => va >= vb,
3275                CmpOp::Gt => va > vb,
3276                CmpOp::Ne => va != vb,
3277            };
3278            if truth { 1.0 } else { 0.0 }
3279        }
3280        Expr::And(a, b) => {
3281            if eval_expr(a, x) != 0.0 && eval_expr(b, x) != 0.0 {
3282                1.0
3283            } else {
3284                0.0
3285            }
3286        }
3287        Expr::Or(a, b) => {
3288            if eval_expr(a, x) != 0.0 || eval_expr(b, x) != 0.0 {
3289                1.0
3290            } else {
3291                0.0
3292            }
3293        }
3294        Expr::Not(a) => {
3295            if eval_expr(a, x) == 0.0 {
3296                1.0
3297            } else {
3298                0.0
3299            }
3300        }
3301        Expr::Cond { cond, then_, else_ } => {
3302            if eval_expr(cond, x) != 0.0 {
3303                eval_expr(then_, x)
3304            } else {
3305                eval_expr(else_, x)
3306            }
3307        }
3308        Expr::Cse(body) => eval_expr(body, x),
3309        Expr::Funcall { .. } => panic!(
3310            "eval_expr: AMPL imported function called without an external resolver; \
3311             evaluate through the tape AD path (Tape::build_with_externals) instead"
3312        ),
3313    }
3314}
3315
3316/// Index of the active operand of an n-ary min (`want_min = true`) or
3317/// max (`want_min = false`) list at point `x`: the smallest / largest
3318/// value, with ties resolved to the first such operand (the
3319/// conventional subgradient choice). Returns `None` for an empty list.
3320fn argmin_argmax(args: &[Expr], x: &[Number], want_min: bool) -> Option<usize> {
3321    let mut best: Option<(usize, Number)> = None;
3322    for (i, a) in args.iter().enumerate() {
3323        let v = eval_expr(a, x);
3324        match best {
3325            None => best = Some((i, v)),
3326            Some((_, bv)) => {
3327                // Strict comparison keeps the FIRST extremal operand on
3328                // ties, matching the subgradient convention used by Abs
3329                // and Select elsewhere in the tape.
3330                if (want_min && v < bv) || (!want_min && v > bv) {
3331                    best = Some((i, v));
3332                }
3333            }
3334        }
3335    }
3336    best.map(|(i, _)| i)
3337}
3338
3339/// Reverse-mode gradient: accumulates `seed * d(expr)/dx_i` into `grad`.
3340pub fn grad_expr(e: &Expr, x: &[Number], seed: Number, grad: &mut [Number]) {
3341    match e {
3342        Expr::Const(_) => {}
3343        Expr::Var(i) => grad[*i] += seed,
3344        Expr::Binary(op, a, b) => {
3345            let va = eval_expr(a, x);
3346            let vb = eval_expr(b, x);
3347            match op {
3348                BinOp::Add => {
3349                    grad_expr(a, x, seed, grad);
3350                    grad_expr(b, x, seed, grad);
3351                }
3352                BinOp::Sub => {
3353                    grad_expr(a, x, seed, grad);
3354                    grad_expr(b, x, -seed, grad);
3355                }
3356                BinOp::Mul => {
3357                    grad_expr(a, x, seed * vb, grad);
3358                    grad_expr(b, x, seed * va, grad);
3359                }
3360                BinOp::Div => {
3361                    grad_expr(a, x, seed / vb, grad);
3362                    grad_expr(b, x, -seed * va / (vb * vb), grad);
3363                }
3364                BinOp::Pow => {
3365                    // d/da: b * a^(b-1)
3366                    let dpa = vb * va.powf(vb - 1.0);
3367                    grad_expr(a, x, seed * dpa, grad);
3368                    // d/db: a^b * ln(a) (only valid for a>0; simple branch)
3369                    if va > 0.0 {
3370                        let dpb = va.powf(vb) * va.ln();
3371                        grad_expr(b, x, seed * dpb, grad);
3372                    }
3373                }
3374                BinOp::Atan2 => {
3375                    // atan2(y=a, x=b): d/dy = x/(x²+y²), d/dx = -y/(x²+y²)
3376                    let d = va * va + vb * vb;
3377                    grad_expr(a, x, seed * vb / d, grad);
3378                    grad_expr(b, x, -seed * va / d, grad);
3379                }
3380                BinOp::CEntropy => {
3381                    grad_expr(a, x, seed * crate::nl_tape::centropy_da(va, vb), grad);
3382                    grad_expr(b, x, seed * crate::nl_tape::centropy_db(va, vb), grad);
3383                }
3384            }
3385        }
3386        Expr::Unary(op, a) => {
3387            let va = eval_expr(a, x);
3388            let d = match op {
3389                UnaryOp::Neg => -1.0,
3390                UnaryOp::Sqrt => 0.5 / va.sqrt(),
3391                UnaryOp::Log => 1.0 / va,
3392                UnaryOp::Log10 => 1.0 / (va * std::f64::consts::LN_10),
3393                UnaryOp::Exp => va.exp(),
3394                UnaryOp::Abs => {
3395                    if va > 0.0 {
3396                        1.0
3397                    } else if va < 0.0 {
3398                        -1.0
3399                    } else {
3400                        0.0
3401                    }
3402                }
3403                UnaryOp::Sin => va.cos(),
3404                UnaryOp::Cos => -va.sin(),
3405                UnaryOp::Tan => {
3406                    let t = va.tan();
3407                    1.0 + t * t
3408                }
3409                UnaryOp::Atan => 1.0 / (1.0 + va * va),
3410                UnaryOp::Acos => -1.0 / (1.0 - va * va).sqrt(),
3411                UnaryOp::Sinh => va.cosh(),
3412                UnaryOp::Cosh => va.sinh(),
3413                UnaryOp::Tanh => {
3414                    let t = va.tanh();
3415                    1.0 - t * t
3416                }
3417                UnaryOp::Asin => 1.0 / (1.0 - va * va).sqrt(),
3418                UnaryOp::Acosh => 1.0 / (va * va - 1.0).sqrt(),
3419                UnaryOp::Asinh => 1.0 / (va * va + 1.0).sqrt(),
3420                UnaryOp::Atanh => 1.0 / (1.0 - va * va),
3421                UnaryOp::Erf => crate::nl_tape::erf_d1(va),
3422                UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
3423            };
3424            grad_expr(a, x, seed * d, grad);
3425        }
3426        Expr::Sum(args) => {
3427            for arg in args {
3428                grad_expr(arg, x, seed, grad);
3429            }
3430        }
3431        // min/max are piecewise linear: the seed flows only through the
3432        // currently-active (smallest / largest) operand — a subgradient.
3433        // Ties resolve to the first such operand. Empty list: no operand,
3434        // no derivative (matches the ±inf eval fold).
3435        Expr::MinList(args) => {
3436            if let Some(k) = argmin_argmax(args, x, true) {
3437                grad_expr(&args[k], x, seed, grad);
3438            }
3439        }
3440        Expr::MaxList(args) => {
3441            if let Some(k) = argmin_argmax(args, x, false) {
3442                grad_expr(&args[k], x, seed, grad);
3443            }
3444        }
3445        // Comparisons and logical connectives are piecewise constant:
3446        // zero derivative, so no seed propagates into their operands.
3447        Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
3448        // if-then-else: differentiate only the active branch. The
3449        // branch-switch discontinuity contributes no derivative.
3450        Expr::Cond { cond, then_, else_ } => {
3451            if eval_expr(cond, x) != 0.0 {
3452                grad_expr(then_, x, seed, grad);
3453            } else {
3454                grad_expr(else_, x, seed, grad);
3455            }
3456        }
3457        Expr::Cse(body) => grad_expr(body, x, seed, grad),
3458        Expr::Funcall { .. } => {
3459            panic!("grad_expr: AMPL imported function called without an external resolver")
3460        }
3461    }
3462}
3463
3464/// Walk `e` and insert every `Var(i)` index into `out`.
3465///
3466/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
3467/// identity. Without that this is Θ(2^depth) on a DAG that shares
3468/// subexpressions — each reference re-walks the whole body — and presolve
3469/// calls this on every solve (`get_variables_linearity`). Skipping a
3470/// repeat visit cannot change the answer: `out` is a set, and a second
3471/// walk of the same body inserts exactly the indices the first already did.
3472/// [`collect_vars`] over a `V` body whose earlier `Cse` references already
3473/// have their variable sets in `cse_vars` (indexed through `index`): those are
3474/// read, not re-walked (gh#986 review). The recursion is through `e`'s own
3475/// syntax only, which the parser has just built on this stack.
3476fn collect_vars_seeded(
3477    e: &Expr,
3478    out: &mut BTreeSet<usize>,
3479    index: &std::collections::HashMap<*const Expr, usize>,
3480    cse_vars: &[Vec<u32>],
3481) {
3482    let mut kids: Vec<&Expr> = vec![e];
3483    while let Some(e) = kids.pop() {
3484        match e {
3485            Expr::Const(_) => {}
3486            Expr::Var(i) => {
3487                out.insert(*i);
3488            }
3489            Expr::Cse(body) => match index.get(&Arc::as_ptr(body)) {
3490                Some(&k) => out.extend(cse_vars[k].iter().map(|&v| v as usize)),
3491                None => collect_vars(body, out),
3492            },
3493            Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3494                kids.push(a);
3495                kids.push(b);
3496            }
3497            Expr::Unary(_, a) | Expr::Not(a) => kids.push(a),
3498            Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => kids.extend(args.iter()),
3499            Expr::Cond { cond, then_, else_ } => {
3500                kids.push(cond);
3501                kids.push(then_);
3502                kids.push(else_);
3503            }
3504            Expr::Funcall { args, .. } => {
3505                for a in args {
3506                    if let FuncallArg::Real(e) = a {
3507                        kids.push(e);
3508                    }
3509                }
3510            }
3511        }
3512    }
3513}
3514
3515pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
3516    // `HashSet::new` does not allocate until the first insert, so an
3517    // expression with no CSEs pays nothing for the memo.
3518    let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
3519    collect_vars_memo(e, out, &mut seen);
3520}
3521
3522fn collect_vars_memo(
3523    e: &Expr,
3524    out: &mut BTreeSet<usize>,
3525    seen: &mut std::collections::HashSet<*const Expr>,
3526) {
3527    match e {
3528        Expr::Const(_) => {}
3529        Expr::Var(i) => {
3530            out.insert(*i);
3531        }
3532        Expr::Binary(_, a, b) => {
3533            collect_vars_memo(a, out, seen);
3534            collect_vars_memo(b, out, seen);
3535        }
3536        Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
3537        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
3538            for a in args {
3539                collect_vars_memo(a, out, seen);
3540            }
3541        }
3542        // Collect from every child, including the condition: even
3543        // though the comparison/branch-test contributes no derivative,
3544        // the variables it reads are genuinely "used" by the problem,
3545        // and being conservative here only ever adds structural zeros
3546        // to the Jacobian/Hessian (never drops a real nonzero).
3547        Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3548            collect_vars_memo(a, out, seen);
3549            collect_vars_memo(b, out, seen);
3550        }
3551        Expr::Not(a) => collect_vars_memo(a, out, seen),
3552        Expr::Cond { cond, then_, else_ } => {
3553            collect_vars_memo(cond, out, seen);
3554            collect_vars_memo(then_, out, seen);
3555            collect_vars_memo(else_, out, seen);
3556        }
3557        Expr::Cse(body) => {
3558            if seen.insert(Arc::as_ptr(body)) {
3559                collect_vars_memo(body, out, seen);
3560            }
3561        }
3562        Expr::Funcall { args, .. } => {
3563            for a in args {
3564                if let FuncallArg::Real(e) = a {
3565                    collect_vars_memo(e, out, seen);
3566                }
3567            }
3568        }
3569    }
3570}
3571
3572// --------------------------------------------------------------------
3573// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
3574// Jacobian, and Hessian. Built once at construction; every solve-time
3575// callback is a tape sweep, no expression-tree recursion.
3576// --------------------------------------------------------------------
3577
3578/// Per-color decoding instruction for `eval_h` Hessian-coloring.
3579/// After a directional Hessian-vector product `compressed = H · s_c`,
3580/// the entry at row `row` came uniquely from column `col` (because
3581/// no two columns of color `c` share any nonzero row), so we
3582/// scatter `compressed[row]` into `values[hess_idx]`.
3583#[derive(Debug, Clone)]
3584struct ColorWrite {
3585    row: u32,
3586    hess_idx: u32,
3587}
3588
3589/// Constraint-block [`HybridTape`]: one local op list per summand plus a
3590/// **shared prelude** holding every CSE body referenced by two or more
3591/// summands, evaluated once per sweep.
3592///
3593/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
3594/// defined variable (`V` segment) referenced from many rows is re-emitted —
3595/// and re-evaluated — once per reference. That is invisible on most models
3596/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
3597/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
3598/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
3599/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
3600/// search. See pounce#476.
3601///
3602/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
3603/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
3604/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
3605/// overhead the flat tapes do not, so each derivative order has to earn
3606/// its switch.
3607#[derive(Debug, Clone)]
3608struct ConHybrid {
3609    tape: HybridTape,
3610    /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
3611    /// Length `m + 1`.
3612    row_start: Vec<usize>,
3613    /// Prelude forward values, sized to `tape.n_prelude_ops()`.
3614    prelude_vals: Vec<f64>,
3615    /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
3616    local_vals: Vec<f64>,
3617    /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
3618    /// value arenas above. `gradient_summand` zeroes only the slots a
3619    /// summand actually reaches, so these are allocated once and reused.
3620    local_adj: Vec<f64>,
3621    prelude_adj: Vec<f64>,
3622    /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
3623    /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
3624    /// unlike `eval_g`, the hybrid Jacobian is not a free win.
3625    use_for_jac: bool,
3626    /// Whether `eval_h` routes the constraint block through the shared
3627    /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
3628    use_for_hess: bool,
3629    // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
3630    // `try_new` after the Hessian coloring exists; cheap enough (a few
3631    // index tables plus one f64 per local op) to build whenever the
3632    // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
3633    /// Forward values of every summand, packed at
3634    /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
3635    /// fills it; every color then reuses the values, mirroring the flat
3636    /// path's forward-once-per-tape structure.
3637    local_vals_all: Vec<f64>,
3638    /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
3639    local_off: Vec<usize>,
3640    /// Constraint row of each summand (the inverse of `row_start`), for
3641    /// the `λ[row]` weight lookup.
3642    summand_row: Vec<u32>,
3643    /// Per color: the summands whose variables fall in that color — the
3644    /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
3645    /// exactly the live (color, summand) pairs.
3646    hess_color_summands: Vec<Vec<u32>>,
3647    /// Per color: the prelude slots that color's summands actually reach,
3648    /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
3649    /// Both prelude sweeps run once per color, so walking the whole
3650    /// prelude each time would cost `n_colors × |prelude|` where the
3651    /// op-ratio gate assumes `|prelude|`; iterating the union of the
3652    /// color's `prelude_reach` sets makes the cost proportional to what
3653    /// is used, so the gate does not need an `n_colors` term. Stored as
3654    /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
3655    /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
3656    /// this struct is the one #552 made O(n²) by holding per-color dense
3657    /// arrays.
3658    hess_color_reach: Vec<u32>,
3659    hess_color_reach_off: Vec<usize>,
3660    /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
3661    prelude_dot: Vec<f64>,
3662    /// First-/second-order prelude adjoint accumulators. NOT shared
3663    /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
3664    /// between-colors invariant (`prelude_reverse_directional`'s
3665    /// consume-and-zero contract), while `gradient_summand` zeroes only
3666    /// the slots it is about to use and leaves them dirty afterwards —
3667    /// sharing the buffer would seed a later `eval_h` with a stale
3668    /// Jacobian adjoint.
3669    hess_prelude_adj: Vec<f64>,
3670    prelude_adj_dot: Vec<f64>,
3671    /// Local tangent / second-order adjoint arenas, sized to
3672    /// `tape.max_summand_ops()`.
3673    local_dot: Vec<f64>,
3674    local_adj_dot: Vec<f64>,
3675}
3676
3677/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
3678/// shared-CSE prelude.
3679///
3680/// `eval_g` takes the hybrid path unconditionally because it only needs
3681/// *values*: the prelude is swept once for the whole constraint block and
3682/// the saving is the full op-count ratio. The Jacobian is different. Each
3683/// row needs its own gradient, so only the forward sweep can be shared —
3684/// the reverse sweep still walks each summand's `prelude_reach`
3685/// separately, and it pays a per-op cost the flat tape does not: a nested
3686/// `SummandOp` dispatch and an indirected walk over a reach list instead
3687/// of a straight loop over a contiguous `Vec<TapeOp>`.
3688///
3689/// So the hybrid Jacobian wins only when the shared bodies are large
3690/// enough for the halved forward sweep to outweigh that overhead.
3691/// Measured on chain models at CSE redundancy 40, varying the body size
3692/// (`eval_jac_g`, flat → hybrid):
3693///
3694/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
3695/// |---|---|---|---|---|---|---|---|---|
3696/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
3697///
3698/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
3699/// a model that does not clearly benefit stays on the flat path. For
3700/// reference `robot_a` (#476) measures 4.03×.
3701const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
3702
3703/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
3704/// block through the shared-CSE prelude (issue #557).
3705///
3706/// The Hessian shares **both** second-order sweeps of the prelude, not just
3707/// the forward one: the coloring hands every summand of a color the same
3708/// seed vector, so the prelude forward tangent runs once per color, and —
3709/// because reverse-over-tangent is linear in its adjoint seeds — the
3710/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
3711/// unit-weight prelude reverse sweep per color. That is why its crossover
3712/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
3713/// Jacobian can only share the forward half, and per-row gradients forbid
3714/// batching its reverse sweeps at all.
3715///
3716/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
3717/// bodies), varying the body size — the same protocol as the Jacobian
3718/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
3719/// flat/hybrid pairs per point**, with the observed range, because
3720/// single runs on a shared machine are not reproducible to the precision
3721/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
3722/// single ratio:
3723///
3724/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
3725/// |---|---|---|---|---|---|---|---|---|
3726/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
3727/// | min–max | 0.91–1.11 | 0.36–1.14 | 1.10–1.33 | 1.16–1.30 | 1.23–1.32 | 1.19–1.54 | 1.27–1.50 | 1.36–1.65 |
3728///
3729/// The gate sits at 3.0: that is the lowest ratio where **every** sample
3730/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
3731/// break-even and individual runs lose. Setting it there costs only a
3732/// marginal forgone gain — below the gate `eval_h` stays on the flat path
3733/// bit-identically, so a gate placed too high is merely conservative while
3734/// one placed too low risks a real regression. For reference `robot_a`
3735/// (#476) measures 4.03×.
3736const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
3737
3738// `Clone` supports the batched-solve path (pounce#126): one parsed
3739// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
3740// the clone is cheap relative to a solve) and each clone gets its own
3741// bound / starting-point overrides via [`NlTnlp::variant`].
3742#[derive(Debug, Clone)]
3743pub struct NlTnlp {
3744    prob: NlProblem,
3745    /// Per-summand objective tapes (one `Tape` per top-level
3746    /// summand after `split_top_sums`).
3747    obj_tapes: Vec<Tape>,
3748    /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
3749    /// one `Tape` per summand of constraint `i`.
3750    con_tapes: Vec<Vec<Tape>>,
3751    /// Constraint-block tape with a **shared** CSE prelude, used by
3752    /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
3753    /// `eval_g` on the per-summand `con_tapes` above.
3754    con_hybrid: Option<ConHybrid>,
3755    /// The degree-≤2 objective and rows, evaluated from their constant
3756    /// matrices instead of from a tape (gh #588, Q4). A row with a form here
3757    /// has an **empty** `con_tapes` entry, so every loop over the tapes
3758    /// naturally contributes nothing for it and only the sites that consult
3759    /// `quad` add it back: `eval_f`, `eval_grad_f`, `eval_g`, `eval_jac_g`,
3760    /// `eval_h`, and `hessian_vector_products` — the last of which the design
3761    /// note's list of five omitted, and which is the one where an omission
3762    /// would be silent. Empty for a model with nothing recognized, in which
3763    /// case everything below behaves bit for bit as it did before this
3764    /// existed.
3765    quad: QuadraticStructure,
3766    /// Which entries of `h_irow`/`h_jcol` some *tape* can contribute to.
3767    /// Empty when `quad` is — the pattern is then all tape. The coloring is
3768    /// built over this subset alone, which is what stops one dense quadratic
3769    /// block from forcing the color count to `n` for the benefit of tapes
3770    /// that no longer exist.
3771    h_tape_mask: Vec<bool>,
3772    /// Lower-triangle Hessian sparsity (row >= col), one entry per
3773    /// structurally nonzero second derivative in the Lagrangian.
3774    h_irow: Vec<i32>,
3775    h_jcol: Vec<i32>,
3776    /// Per-row sorted variable indices for the constraint Jacobian.
3777    jac_cols: Vec<Vec<usize>>,
3778    jac_nnz: usize,
3779    /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
3780    /// is in color `c`, else `0.0`. Each color is a set of
3781    /// variables whose Hessian columns have pairwise-disjoint
3782    /// nonzero rows; one directional H·s product per color
3783    /// recovers all those columns simultaneously. Dense for
3784    /// O(1) lookup in the per-op forward tangent.
3785    seeds: Vec<Vec<f64>>,
3786    /// Per-color decoding table: for each `(row, hess_idx)` entry,
3787    /// scatter `compressed_c[row] -> values[hess_idx]` after the
3788    /// per-color directional product.
3789    decoding: Vec<Vec<ColorWrite>>,
3790    /// For each objective tape: the distinct colors of vars it
3791    /// references. Lets us skip tape × color pairs where the tape
3792    /// has zero overlap with the color's seed.
3793    obj_tape_colors: Vec<Vec<u32>>,
3794    /// Same as `obj_tape_colors` but per constraint × summand.
3795    con_tape_colors: Vec<Vec<Vec<u32>>>,
3796    /// The same work, color-major, for the parallel flat Hessian path.
3797    hess_color_index: HessColorIndex,
3798    /// Offset of each row's run in the Jacobian `values` array, length
3799    /// `m + 1`. Lets a parallel row group take one contiguous slice.
3800    jac_row_off: Vec<u32>,
3801    /// Per-worker scratch for the parallel Jacobian, kept across evaluations:
3802    /// each holds a length-`n` gradient row, too big to allocate per call.
3803    #[cfg(not(target_arch = "wasm32"))]
3804    jac_scratch_pool: Vec<JacScratch>,
3805    /// Color of each variable's Hessian column, `u32::MAX` for a column
3806    /// that needs no pass of its own. Kept so
3807    /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
3808    /// pass in `compressed`.
3809    var_color: Vec<u32>,
3810    /// Columns peeled out of the conflict structure and given singleton
3811    /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
3812    peeled_cols: Vec<u32>,
3813    final_x: Option<Vec<Number>>,
3814    final_obj: Number,
3815    /// Converged constraint multipliers (length `m`, original `.nl` row
3816    /// order, user convention), captured from the same `finalize_solution`
3817    /// call as `final_x`. Kept so a frontend can write the `.sol` dual
3818    /// block without re-deriving it from the algorithm's internal `y_c` /
3819    /// `y_d` split and scaling.
3820    final_lambda: Option<Vec<Number>>,
3821    /// Converged bound multipliers (length `n` each, Ipopt's internal
3822    /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
3823    /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
3824    /// reads for reduced costs.
3825    final_z_l: Option<Vec<Number>>,
3826    final_z_u: Option<Vec<Number>>,
3827    /// Per-row Jacobian accumulator (length n).
3828    scratch_row_grad: Vec<f64>,
3829    /// Scratch buffers for `Tape::hessian_directional` (each sized
3830    /// to `max_tape_n`).
3831    vals_scratch: Vec<f64>,
3832    dot_scratch: Vec<f64>,
3833    adj_scratch: Vec<f64>,
3834    adj_dot_scratch: Vec<f64>,
3835    /// Per-color compressed Hessian-vector results, sized to
3836    /// `prob.n`. Reused across `eval_h` calls but allocated once.
3837    compressed: Vec<Vec<f64>>,
3838    /// Per-direction "carries signal" mask for `hessian_vector_products`,
3839    /// kept here rather than allocated per call. This crate holds an
3840    /// explicit no-per-call-allocation line on the tape sweeps (see
3841    /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
3842    /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
3843    /// Krylov iteration.
3844    hvp_live: Vec<bool>,
3845    /// Model-derived scaling factors (gh #703), computed on demand by
3846    /// [`NlTnlp::enable_curvature_scaling`] and served through
3847    /// [`TNLP::get_scaling_parameters`]. `None` until asked for, which is
3848    /// the state every solve that does not select `curvature-based` stays
3849    /// in — computing them costs a pass over every stored Hessian entry
3850    /// and nothing else reads them.
3851    curvature_scaling: Option<crate::nl_scaling::CurvatureScaling>,
3852}
3853
3854// ---------------------------------------------------------------------
3855// Human-readable equation rendering (`print equation` in the debugger).
3856//
3857// Turns a parsed constraint back into infix text using the model's
3858// variable / constraint names, so the debugger can show the actual
3859// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
3860// bare row index. This is the "print the specific equation, with
3861// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
3862// argue makes equation-oriented model diagnostics actionable.
3863//
3864// The renderer is intentionally separate from the evaluation `Tape`:
3865// tapes are lossy for display (CSEs flattened, externals opaque),
3866// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
3867// ---------------------------------------------------------------------
3868
3869/// Binding strength for parenthesization. Higher binds tighter.
3870const P_ADD: u8 = 10;
3871const P_MUL: u8 = 20;
3872const P_NEG: u8 = 30;
3873const P_POW: u8 = 40;
3874const P_ATOM: u8 = 100;
3875
3876/// Format a numeric literal compactly: integers without a trailing `.0`,
3877/// everything else via the shortest round-tripping `f64` form.
3878fn fmt_num(x: Number) -> String {
3879    if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
3880        format!("{}", x as i64)
3881    } else {
3882        format!("{x}")
3883    }
3884}
3885
3886/// Display label for variable `i`: its `.col` name when present, else
3887/// `x[i]`.
3888fn var_label(i: usize, var_names: &[String]) -> String {
3889    match var_names.get(i) {
3890        Some(s) if !s.is_empty() => s.clone(),
3891        _ => format!("x[{i}]"),
3892    }
3893}
3894
3895/// Precedence of an expression's top operator (for child wrapping).
3896fn expr_prec(e: &Expr) -> u8 {
3897    match e {
3898        Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
3899        Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
3900        Expr::Unary(UnaryOp::Neg, _) => P_NEG,
3901        Expr::Binary(BinOp::Pow, ..) => P_POW,
3902        Expr::Cse(inner) => expr_prec(inner),
3903        // Everything else renders as an atom / `f(...)` form.
3904        _ => P_ATOM,
3905    }
3906}
3907
3908/// Render an expression as infix text, using `var_names` for variable
3909/// labels where available (`x[i]` otherwise).
3910///
3911/// The debugger reaches the renderer through the constraint/objective
3912/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
3913/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
3914/// able to *see* the expression you just built is most of the debugging
3915/// story.
3916///
3917/// `Cse` bodies are inlined at every occurrence, so the output of a
3918/// heavily-shared DAG can be far larger than the DAG itself. Callers
3919/// rendering user-built expressions should bound the input first.
3920pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
3921    render_expr(e, var_names, &[])
3922}
3923
3924/// Render `e`, wrapping in parentheses iff its precedence is looser than
3925/// `min_prec`.
3926fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
3927    let s = render_expr(e, vn, funcs);
3928    if expr_prec(e) < min_prec {
3929        format!("({s})")
3930    } else {
3931        s
3932    }
3933}
3934
3935fn unary_name(op: UnaryOp) -> &'static str {
3936    match op {
3937        UnaryOp::Neg => "-",
3938        UnaryOp::Sqrt => "sqrt",
3939        UnaryOp::Log => "log",
3940        UnaryOp::Exp => "exp",
3941        UnaryOp::Abs => "abs",
3942        UnaryOp::Sin => "sin",
3943        UnaryOp::Cos => "cos",
3944        UnaryOp::Log10 => "log10",
3945        UnaryOp::Tan => "tan",
3946        UnaryOp::Atan => "atan",
3947        UnaryOp::Acos => "acos",
3948        UnaryOp::Sinh => "sinh",
3949        UnaryOp::Cosh => "cosh",
3950        UnaryOp::Tanh => "tanh",
3951        UnaryOp::Asin => "asin",
3952        UnaryOp::Acosh => "acosh",
3953        UnaryOp::Asinh => "asinh",
3954        UnaryOp::Atanh => "atanh",
3955        UnaryOp::Erf => "erf",
3956        // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
3957        // the rendered text is read by humans debugging a model and must not
3958        // imply a sign the op does not have.
3959        UnaryOp::XLogX => "xlogx",
3960    }
3961}
3962
3963fn cmp_sym(op: CmpOp) -> &'static str {
3964    match op {
3965        CmpOp::Lt => "<",
3966        CmpOp::Le => "<=",
3967        CmpOp::Eq => "==",
3968        CmpOp::Ge => ">=",
3969        CmpOp::Gt => ">",
3970        CmpOp::Ne => "!=",
3971    }
3972}
3973
3974/// Append an additive sub-term with a tidy sign: a rendered term that
3975/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
3976/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
3977/// the term is itself a sum. The first term is emitted verbatim.
3978fn push_additive(out: &mut String, rendered: &str, first: bool) {
3979    if first {
3980        out.push_str(rendered);
3981    } else if let Some(rest) = rendered.strip_prefix('-') {
3982        out.push_str(" - ");
3983        out.push_str(rest);
3984    } else {
3985        out.push_str(" + ");
3986        out.push_str(rendered);
3987    }
3988}
3989
3990/// Render an [`Expr`] DAG to infix text using model names.
3991fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
3992    match e {
3993        Expr::Const(c) => fmt_num(*c),
3994        Expr::Var(i) => var_label(*i, vn),
3995        Expr::Binary(op, l, r) => match op {
3996            BinOp::Add => {
3997                let mut s = render_prec(l, P_ADD, vn, funcs);
3998                push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
3999                s
4000            }
4001            // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
4002            BinOp::Sub => format!(
4003                "{} - {}",
4004                render_prec(l, P_ADD, vn, funcs),
4005                render_prec(r, P_ADD + 1, vn, funcs)
4006            ),
4007            BinOp::Mul => format!(
4008                "{}*{}",
4009                render_prec(l, P_MUL, vn, funcs),
4010                render_prec(r, P_MUL, vn, funcs)
4011            ),
4012            BinOp::Div => format!(
4013                "{}/{}",
4014                render_prec(l, P_MUL, vn, funcs),
4015                render_prec(r, P_MUL + 1, vn, funcs)
4016            ),
4017            // Pow is right-associative: tighten the left operand instead.
4018            BinOp::Pow => format!(
4019                "{}^{}",
4020                render_prec(l, P_POW + 1, vn, funcs),
4021                render_prec(r, P_POW, vn, funcs)
4022            ),
4023            BinOp::Atan2 => format!(
4024                "atan2({}, {})",
4025                render_expr(l, vn, funcs),
4026                render_expr(r, vn, funcs)
4027            ),
4028            BinOp::CEntropy => format!(
4029                "centropy({}, {})",
4030                render_expr(l, vn, funcs),
4031                render_expr(r, vn, funcs)
4032            ),
4033        },
4034        Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
4035        Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
4036        Expr::Sum(xs) => {
4037            if xs.is_empty() {
4038                "0".to_string()
4039            } else {
4040                let mut s = String::new();
4041                for (k, x) in xs.iter().enumerate() {
4042                    push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
4043                }
4044                s
4045            }
4046        }
4047        Expr::Cse(inner) => render_expr(inner, vn, funcs),
4048        Expr::Funcall { id, args } => {
4049            let name = funcs
4050                .iter()
4051                .find(|f| f.id == *id)
4052                .map(|f| f.name.clone())
4053                .unwrap_or_else(|| format!("extern#{id}"));
4054            let parts: Vec<String> = args
4055                .iter()
4056                .map(|a| match a {
4057                    FuncallArg::Real(x) => render_expr(x, vn, funcs),
4058                    FuncallArg::Str(s) => format!("{s:?}"),
4059                })
4060                .collect();
4061            format!("{name}({})", parts.join(", "))
4062        }
4063        Expr::Compare(op, a, b) => format!(
4064            "({} {} {})",
4065            render_expr(a, vn, funcs),
4066            cmp_sym(*op),
4067            render_expr(b, vn, funcs)
4068        ),
4069        Expr::And(a, b) => format!(
4070            "({} && {})",
4071            render_expr(a, vn, funcs),
4072            render_expr(b, vn, funcs)
4073        ),
4074        Expr::Or(a, b) => format!(
4075            "({} || {})",
4076            render_expr(a, vn, funcs),
4077            render_expr(b, vn, funcs)
4078        ),
4079        Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
4080        Expr::Cond { cond, then_, else_ } => format!(
4081            "if({}, {}, {})",
4082            render_expr(cond, vn, funcs),
4083            render_expr(then_, vn, funcs),
4084            render_expr(else_, vn, funcs)
4085        ),
4086        Expr::MinList(xs) => format!(
4087            "min({})",
4088            xs.iter()
4089                .map(|x| render_expr(x, vn, funcs))
4090                .collect::<Vec<_>>()
4091                .join(", ")
4092        ),
4093        Expr::MaxList(xs) => format!(
4094            "max({})",
4095            xs.iter()
4096                .map(|x| render_expr(x, vn, funcs))
4097                .collect::<Vec<_>>()
4098                .join(", ")
4099        ),
4100    }
4101}
4102
4103/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
4104/// `a + -2*b`). Returns `""` when there are no linear terms.
4105fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
4106    let mut out = String::new();
4107    // The `.nl` linear part carries an entry for every variable in the
4108    // row's Jacobian, including a 0 coefficient for variables that appear
4109    // only *nonlinearly* (they're rendered in the nonlinear part). Skip
4110    // those zeros so the equation reads as written, not as a sparsity map.
4111    let mut first = true;
4112    for (var, coef) in linear {
4113        if *coef == 0.0 {
4114            continue;
4115        }
4116        let neg = *coef < 0.0;
4117        let mag = coef.abs();
4118        let term = if mag == 1.0 {
4119            var_label(*var, vn)
4120        } else {
4121            format!("{}*{}", fmt_num(mag), var_label(*var, vn))
4122        };
4123        if first {
4124            if neg {
4125                out.push('-');
4126            }
4127            out.push_str(&term);
4128            first = false;
4129        } else {
4130            out.push_str(if neg { " - " } else { " + " });
4131            out.push_str(&term);
4132        }
4133    }
4134    out
4135}
4136
4137/// Render the constraint body (linear + nonlinear parts combined).
4138fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
4139    let mut s = render_linear(linear, &prob.var_names);
4140    let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
4141    if !nl_is_zero {
4142        let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
4143        if s.is_empty() {
4144            s = nl;
4145        } else {
4146            push_additive(&mut s, &nl, false);
4147        }
4148    }
4149    if s.is_empty() {
4150        s = "0".to_string();
4151    }
4152    s
4153}
4154
4155/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
4156/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
4157/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
4158pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
4159    // Diagnostic path: a recognized body has no tree, so this is one of
4160    // the places that pays to rebuild one. It runs once per rendered row.
4161    let body = render_body(&prob.con_linear[k], &prob.con_expr(k), prob);
4162    let lo = prob.g_l[k];
4163    let hi = prob.g_u[k];
4164    const INF: Number = 1.0e19;
4165    let has_lo = lo > -INF;
4166    let has_hi = hi < INF;
4167    match (has_lo, has_hi) {
4168        (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
4169        (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
4170        (true, false) => format!("{body} >= {}", fmt_num(lo)),
4171        (false, true) => format!("{body} <= {}", fmt_num(hi)),
4172        (false, false) => format!("{body}  (free)"),
4173    }
4174}
4175
4176/// Render every constraint to text, index-aligned to `g` (original `.nl`
4177/// row order). Used to build the debugger's static equation book.
4178pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
4179    (0..prob.m)
4180        .map(|k| render_constraint_equation(prob, k))
4181        .collect()
4182}
4183
4184/// Structural sparsity of the constraint Jacobian as flat 0-based
4185/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
4186/// structurally depends on — the union of its linear support and the
4187/// `Var(i)` indices appearing anywhere in its nonlinear tree
4188/// ([`collect_vars`]). Sorted and deduplicated within each row.
4189///
4190/// This is the input to the debugger's Dulmage–Mendelsohn
4191/// structural-rank check (`diagnose`), which names the over-determined
4192/// (candidate redundant / inconsistent) equations and under-determined
4193/// variables. Naming the dependent rows — rather than reporting
4194/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
4195/// equation-oriented model debugging. See
4196/// <https://doi.org/10.69997/sct.147875>.
4197pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
4198    let mut irow: Vec<Index> = Vec::new();
4199    let mut jcol: Vec<Index> = Vec::new();
4200    let mut support: BTreeSet<usize> = BTreeSet::new();
4201    for k in 0..prob.m {
4202        support.clear();
4203        for &(j, _coef) in &prob.con_linear[k] {
4204            support.insert(j);
4205        }
4206        prob.con_nonlinear[k].collect_vars(&mut support);
4207        for &j in &support {
4208            irow.push(k as Index);
4209            jcol.push(j as Index);
4210        }
4211    }
4212    (irow, jcol)
4213}
4214
4215/// Flatten an additive expression tree into independent summand
4216/// expressions, each of which becomes its own Hessian tape.
4217///
4218/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
4219/// each summand tape once *per color the summand touches*, so the
4220/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
4221/// small (few variables → few colors) is what makes a sparse Hessian
4222/// cheap. A single fused tape spanning all `n` variables, by
4223/// contrast, is walked once per color → `O(n · tape_len)`, which on a
4224/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
4225/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
4226///
4227/// We therefore descend through the *affine* envelope of the sum, not
4228/// just `+`/`Sum`:
4229///
4230///   * `Neg(x)`            → split `x`, negate each summand
4231///   * `Sub(l, r)`         → split `l`; split `r`, negate each summand
4232///   * `c * x` / `x * c`   → split `x`, scale each summand by `c`
4233///   * `x / c`             → split `x`, scale each summand by `1/c`
4234///
4235/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
4236/// least-squares / max-entropy shapes) still decomposes to its leaf
4237/// terms instead of collapsing into one giant tape. The carried
4238/// `factor` is materialised onto each leaf only when it differs from
4239/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
4240/// is unchanged and the per-summand op count grows by at most one.
4241fn split_top_sums(expr: &Expr) -> Vec<Expr> {
4242    let mut out = Vec::new();
4243    fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4244        if factor == 1.0 {
4245            out.push(e.clone());
4246        } else if factor == -1.0 {
4247            out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
4248        } else {
4249            out.push(Expr::Binary(
4250                BinOp::Mul,
4251                Box::new(Expr::Const(factor)),
4252                Box::new(e.clone()),
4253            ));
4254        }
4255    }
4256    fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4257        match e {
4258            Expr::Sum(terms) => {
4259                for t in terms {
4260                    go(t, factor, out);
4261                }
4262            }
4263            Expr::Binary(BinOp::Add, l, r) => {
4264                go(l, factor, out);
4265                go(r, factor, out);
4266            }
4267            Expr::Binary(BinOp::Sub, l, r) => {
4268                go(l, factor, out);
4269                go(r, -factor, out);
4270            }
4271            Expr::Unary(UnaryOp::Neg, x) => {
4272                go(x, -factor, out);
4273            }
4274            // Affine scaling: distribute a constant coefficient into
4275            // the summands so a leading `c·(Σ …)` still splits.
4276            Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
4277                (Expr::Const(c), _) => go(r, factor * c, out),
4278                (_, Expr::Const(c)) => go(l, factor * c, out),
4279                _ => push_leaf(e, factor, out),
4280            },
4281            Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
4282                Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
4283                _ => push_leaf(e, factor, out),
4284            },
4285            _ => push_leaf(e, factor, out),
4286        }
4287    }
4288    go(expr, 1.0, &mut out);
4289    if out.is_empty() {
4290        out.push(Expr::Const(0.0));
4291    }
4292    out
4293}
4294
4295/// Greedy column coloring of a symmetric sparsity pattern stored
4296/// as lower-triangle pairs.
4297///
4298/// Builds the column-intersection graph: columns `c1` and `c2` are
4299/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
4300/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
4301/// satisfies the direct-recovery condition for symmetric Hessians
4302/// (Coleman-Moré): for any color, the columns it contains have
4303/// pairwise disjoint row supports, so a single H·s product
4304/// recovers them all unambiguously.
4305///
4306/// Returns `(var_color, n_colors)` where `var_color[k]` is the
4307/// color assigned to variable `k`, or `u32::MAX` for variables
4308/// not in any Hessian pair (they contribute nothing and don't
4309/// need a color).
4310/// A column is treated as **dense** — and peeled out of the coloring —
4311/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
4312/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
4313/// factor keeps uniformly-dense Hessians (where every column looks like
4314/// every other) on the plain coloring path, and the absolute floor stops
4315/// a very sparse average from declaring a 10-entry column "dense".
4316const DENSE_COL_FACTOR: usize = 16;
4317const DENSE_COL_MIN: usize = 32;
4318/// Largest relative error a peeled column may inflict on the smallest
4319/// entry recovered from its pass before
4320/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
4321///
4322/// The ratio is a worst-case bound — the pass's roundoff floor over the
4323/// smallest entry read out of it — and it is a *loose* one, by an amount
4324/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
4325/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
4326/// measures 4e-16. The cut is therefore calibrated on the corpus, not
4327/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
4328/// models that peel anything, the highest bound on a column that recovers
4329/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
4330/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
4331/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
4332///
4333/// 1e-8 sits below both, which deliberately buys correctness with speed:
4334/// the errors are asymmetric, since a false veto costs one model a coloring
4335/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
4336/// while a missed veto costs a solve its certificate. Five of the 56 take a
4337/// veto they do not need; none takes a wrong answer. Raising this constant
4338/// to buy those five back would put the cut inside a 2.8x window measured
4339/// on two model families, which is not a margin worth trading a certificate
4340/// for.
4341const PEEL_MAX_REL_ERR: f64 = 1e-8;
4342/// Hard cap on how many columns get peeled, applied on top of the
4343/// pay-for-itself rule in [`select_peeled_cols`].
4344const MAX_PEELED_COLS: usize = 256;
4345
4346/// Lower bound on the color count that results from peeling `peeled`.
4347///
4348/// Peeling costs one color per peeled column. On what remains, any row
4349/// with `d` surviving entries makes those `d` columns pairwise
4350/// conflicting, so the greedy walk needs at least `d` colors. Hence
4351/// `|peeled| + max surviving row degree` is a lower bound on the total,
4352/// computable in one O(nnz) pass — no coloring required.
4353///
4354/// The bound is what makes the choice decidable at all: the plain
4355/// coloring cannot be run as a comparison baseline, because on the
4356/// one-dense-row case the plain walk is itself O(n^2) — precisely the
4357/// blowup peeling exists to avoid.
4358fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
4359    let mut deg = vec![0usize; n];
4360    for &(i, j) in lower_pairs {
4361        if peeled[i] || peeled[j] {
4362            continue;
4363        }
4364        deg[j] += 1;
4365        if i != j {
4366            deg[i] += 1;
4367        }
4368    }
4369    let n_peeled = peeled.iter().filter(|&&p| p).count();
4370    n_peeled + deg.iter().copied().max().unwrap_or(0)
4371}
4372
4373/// Choose which of the candidate dense columns to actually peel.
4374///
4375/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
4376/// top `k` candidates by degree, over a doubling ladder of `k` up to
4377/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
4378/// so peeling has to earn its colors.
4379///
4380/// **Why not just truncate an over-long candidate list.** Cutting the
4381/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
4382/// columns that miss the cut stay in the conflict structure, so the base
4383/// color count is untouched and the singleton colors are pure addition.
4384/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
4385/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
4386/// regression in exactly the quantity peeling exists to reduce. The
4387/// bound above sees it: peeling `k` of several thousand equal-degree
4388/// columns leaves the surviving max degree unchanged, so every `k > 0`
4389/// scores strictly worse than peeling nothing.
4390///
4391/// **Why not a simple degree rule.** "Peel only columns denser than some
4392/// fraction of `n`" would refuse three rows of degree 10,000 in a
4393/// 200,000-variable model, where peeling three columns takes the
4394/// coloring from >= 10,000 down to a handful. The win depends on what
4395/// peeling leaves behind, not on the peeled column's degree alone.
4396fn select_peeled_cols(
4397    n: usize,
4398    lower_pairs: &[(usize, usize)],
4399    deg: &[usize],
4400    mut candidates: Vec<usize>,
4401) -> Vec<usize> {
4402    if candidates.is_empty() {
4403        return candidates;
4404    }
4405    // Worst offenders first; they remove the most conflict per color spent.
4406    candidates.sort_unstable_by(|&a, &b| deg[b].cmp(&deg[a]).then(a.cmp(&b)));
4407    candidates.truncate(MAX_PEELED_COLS);
4408
4409    let mut mask = vec![false; n];
4410    let mut best_k = 0usize;
4411    // Peeling nothing: the bound is just the largest row degree.
4412    let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
4413
4414    // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
4415    // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
4416    // mask only ever gains entries, so each step just marks the new slice.
4417    let mut marked = 0usize;
4418    let mut k = 1usize;
4419    loop {
4420        let k_now = k.min(candidates.len());
4421        for &j in &candidates[marked..k_now] {
4422            mask[j] = true;
4423        }
4424        marked = k_now;
4425        let bound = peel_color_bound(n, lower_pairs, &mask);
4426        if bound < best_bound {
4427            best_bound = bound;
4428            best_k = k_now;
4429        }
4430        if k_now == candidates.len() {
4431            break;
4432        }
4433        k *= 2;
4434    }
4435
4436    candidates.truncate(best_k);
4437    candidates
4438}
4439
4440/// Greedy distance-1 coloring of the Hessian's column-intersection
4441/// graph, with **dense columns peeled out**.
4442///
4443/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
4444/// marks a column that needs no directional product of its own: either
4445/// it has no Hessian entries at all, or every entry it has is recovered
4446/// from a peeled column's pass (see below).
4447///
4448/// # Why peeling
4449///
4450/// The plain coloring rule is "two columns may share a color when they
4451/// have no common nonzero row". A single **dense row** — one variable
4452/// multiplying a sum over all the others, a total-cost variable, a
4453/// shared design parameter — puts a nonzero in *every* column at that
4454/// row, so every pair of columns conflicts and the greedy walk hands out
4455/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
4456/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
4457/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
4458/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
4459///
4460/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
4461/// own singleton color: one directional product with seed `e_d` recovers
4462/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
4463/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
4464/// at row `j` of that same pass. So row `d` no longer constrains any
4465/// other column's color and is dropped from the conflict structure, and
4466/// the remaining columns colour on their genuine sparsity. On the
4467/// one-dense-row case above this takes `n_colors` from `n` to a handful.
4468///
4469/// # What peeling costs, and `peel_veto`
4470///
4471/// Recovering `H[d, j]` from column `d`'s pass is exact in real
4472/// arithmetic but not in floating point: the pass is accumulated at the
4473/// scale of the whole dense column, so every entry read out of it carries
4474/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
4475/// ordinary path — column `j`'s own pass — would have left a floor of
4476/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
4477/// dense column is well scaled, and they do on every peel-firing model in
4478/// the benchmark corpus but one. Where a peeled column spans a wide
4479/// dynamic range, though, that floor swamps its small entries: a column
4480/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
4481///
4482/// Structure cannot see this — it is a property of the values — so
4483/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
4484/// and passes the offenders back here in `peel_veto`, which bars them
4485/// from being peeled again. A vetoed column is colored normally, its row
4486/// returns to the conflict structure, and its entries go back to the
4487/// accurate path.
4488fn greedy_hessian_coloring(
4489    n: usize,
4490    lower_pairs: &[(usize, usize)],
4491    peel_veto: &[bool],
4492) -> (Vec<u32>, usize, Vec<bool>) {
4493    if n == 0 {
4494        return (Vec::new(), 0, Vec::new());
4495    }
4496
4497    // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
4498    // i >= j contributes row i to column j and row j to column i; a
4499    // diagonal contributes once.
4500    let mut deg = vec![0usize; n];
4501    for &(i, j) in lower_pairs {
4502        deg[j] += 1;
4503        if i != j {
4504            deg[i] += 1;
4505        }
4506    }
4507
4508    // Pick the dense columns to peel.
4509    let total: usize = deg.iter().sum();
4510    let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
4511    let mut peeled = vec![false; n];
4512    let candidates: Vec<usize> = (0..n)
4513        .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
4514        .collect();
4515    let dense = select_peeled_cols(n, lower_pairs, &deg, candidates);
4516    for &j in &dense {
4517        peeled[j] = true;
4518    }
4519
4520    // Conflict structure over the *non-peeled* columns only. Pairs with
4521    // a peeled endpoint are recovered from that endpoint's own pass, so
4522    // they neither need a color nor constrain one.
4523    let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
4524    let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
4525    for &(i, j) in lower_pairs {
4526        if peeled[i] || peeled[j] {
4527            continue;
4528        }
4529        col_rows[j].push(i as u32);
4530        row_cols[i].push(j as u32);
4531        if i != j {
4532            col_rows[i].push(j as u32);
4533            row_cols[j].push(i as u32);
4534        }
4535    }
4536
4537    let mut var_color = vec![u32::MAX; n];
4538    let mut forbidden = vec![u32::MAX; n + 1];
4539    let mut n_colors: u32 = 0;
4540
4541    for j in 0..n {
4542        // Peeled columns are colored below; a column with no surviving
4543        // Hessian entries needs no color at all.
4544        if peeled[j] || col_rows[j].is_empty() {
4545            continue;
4546        }
4547        // Mark colors used by any column sharing a row with `j`.
4548        // Row-of-col -> col-in-row visit pattern collects all
4549        // distance-1 neighbors in the column-intersection graph.
4550        for &r in &col_rows[j] {
4551            for &c in &row_cols[r as usize] {
4552                if c as usize == j {
4553                    continue;
4554                }
4555                let cc = var_color[c as usize];
4556                if cc != u32::MAX {
4557                    forbidden[cc as usize] = j as u32;
4558                }
4559            }
4560        }
4561        // First color not stamped with `j as u32`.
4562        let mut chosen: u32 = 0;
4563        while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
4564            chosen += 1;
4565        }
4566        var_color[j] = chosen;
4567        if chosen + 1 > n_colors {
4568            n_colors = chosen + 1;
4569        }
4570    }
4571
4572    // One singleton color per peeled column, appended after the shared
4573    // ones so the non-peeled numbering is untouched.
4574    for &j in &dense {
4575        var_color[j] = n_colors;
4576        n_colors += 1;
4577    }
4578
4579    (var_color, n_colors as usize, peeled)
4580}
4581
4582/// Everything downstream of the Hessian coloring: seed vectors, the
4583/// per-color decode table, the per-tape color sets, and the shared-CSE
4584/// per-color summand / prelude-reach tables.
4585///
4586/// Split out of [`NlTnlp::new`] because
4587/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
4588/// time, with a peel veto in hand, once it has seen real Hessian values.
4589fn build_color_tables(
4590    n: usize,
4591    m: usize,
4592    lower_pairs: &[(usize, usize)],
4593    tape_mask: &[bool],
4594    peel_veto: &[bool],
4595    obj_tapes: &[Tape],
4596    con_tapes: &[Vec<Tape>],
4597    con_hybrid: Option<&mut ConHybrid>,
4598) -> ColorTables {
4599    // Hessian column coloring. The chromatic number of the
4600    // column-intersection graph bounds how many directional
4601    // Hessian-vector products we need per `eval_h` call —
4602    // typically O(stencil) for PDE-mesh problems.
4603    //
4604    // Only the entries a *tape* can write take part. `tape_mask` is empty
4605    // when every entry is a tape entry (no quadratic structure), which is
4606    // the pre-#588 behaviour bit for bit; otherwise the quadratic forms'
4607    // entries are excluded, since they are scattered from their stored
4608    // values and never read out of a directional product. A variable that
4609    // appears only in quadratic rows then has no color at all
4610    // (`u32::MAX`) — `greedy_hessian_coloring` already declines to color a
4611    // column with no surviving entries.
4612    let colored_pairs: Vec<(usize, usize)>;
4613    let color_input: &[(usize, usize)] = if tape_mask.is_empty() {
4614        lower_pairs
4615    } else {
4616        colored_pairs = lower_pairs
4617            .iter()
4618            .zip(tape_mask)
4619            .filter_map(|(p, &t)| t.then_some(*p))
4620            .collect();
4621        &colored_pairs
4622    };
4623    let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, color_input, peel_veto);
4624
4625    // Per-color seed vectors (dense for O(1) Var lookup in
4626    // `Tape::hessian_directional`).
4627    let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
4628    for (k, &c) in var_color.iter().enumerate() {
4629        if c != u32::MAX {
4630            seeds[c as usize][k] = 1.0;
4631        }
4632    }
4633
4634    // Per-color decoding table. For each lower-tri pair (i, j)
4635    // with i >= j, the entry belongs to column j's color: after
4636    // computing compressed_{c_j} = (H · s_{c_j}), the value at
4637    // row i is exactly H[i, j] (coloring guarantees no other
4638    // column in c_j has a nonzero at row i).
4639    // Built straight from `lower_pairs`, which is sorted, so each
4640    // color's table is in ascending `hess_idx` order and the decode
4641    // scatter walks `values` forward instead of hopping (the old
4642    // build drained a `HashMap`, whose iteration order is arbitrary).
4643    let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
4644    for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
4645        // An entry no tape writes has no pass to be decoded out of; the
4646        // quadratic scatter already put its value there.
4647        if !tape_mask.is_empty() && !tape_mask[idx] {
4648            continue;
4649        }
4650        // Which directional product recovers H[i, j]? Column `j`'s,
4651        // read at row `i` — except when `i` is a peeled column and
4652        // `j` is not: then `j` may have no color of its own, and the
4653        // entry is already in column `i`'s pass at row `j`, since
4654        // H[i, j] == H[j, i].
4655        let (c, row) = if peeled[i] && !peeled[j] {
4656            (var_color[i], j)
4657        } else {
4658            (var_color[j], i)
4659        };
4660        debug_assert!(
4661            c != u32::MAX,
4662            "Hessian pair ({i}, {j}) at index {idx} has no color"
4663        );
4664        decoding[c as usize].push(ColorWrite {
4665            row: row as u32,
4666            hess_idx: idx as u32,
4667        });
4668    }
4669
4670    // Per-tape distinct color set: for each tape, the colors
4671    // its variables fall into. `eval_h` loops over only these
4672    // (tape, color) pairs instead of n_tapes × n_colors.
4673    let tape_colors = |t: &Tape| -> Vec<u32> {
4674        let mut s: Vec<u32> = t
4675            .variables()
4676            .into_iter()
4677            .map(|v| var_color[v])
4678            .filter(|&c| c != u32::MAX)
4679            .collect();
4680        s.sort_unstable();
4681        s.dedup();
4682        s
4683    };
4684    let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
4685    let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
4686        .iter()
4687        .map(|row| row.iter().map(tape_colors).collect())
4688        .collect();
4689
4690    // Shared-CSE Hessian tables (issue #557): the per-color summand
4691    // lists, row lookup, and packed forward-value arena `eval_h`'s
4692    // hybrid path walks. Built whenever the hybrid tape is — not just
4693    // above the gate — so flipping `use_for_hess` on (tests, the
4694    // force env var) needs no extra setup; the cost is one f64 per
4695    // local op plus small index tables.
4696    if let Some(h) = con_hybrid {
4697        let n_sum = h.tape.n_summands();
4698        let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
4699        let mut acc = 0usize;
4700        for s in &h.tape.summands {
4701            local_off.push(acc);
4702            acc += s.ops.len();
4703        }
4704        local_off.push(acc);
4705        h.local_vals_all = vec![0.0; acc];
4706        h.local_off = local_off;
4707
4708        let mut summand_row = vec![0u32; n_sum];
4709        for i in 0..m {
4710            for si in h.row_start[i]..h.row_start[i + 1] {
4711                summand_row[si] = i as u32;
4712            }
4713        }
4714        h.summand_row = summand_row;
4715
4716        // A summand's variable set (`all_vars`) equals its flat tape's,
4717        // so this is `con_tape_colors` inverted to color-major order —
4718        // the loop `eval_h` actually runs.
4719        let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
4720        for (si, s) in h.tape.summands.iter().enumerate() {
4721            let mut cs: Vec<u32> = s
4722                .all_vars
4723                .iter()
4724                .map(|&v| var_color[v])
4725                .filter(|&c| c != u32::MAX)
4726                .collect();
4727            cs.sort_unstable();
4728            cs.dedup();
4729            for c in cs {
4730                by_color[c as usize].push(si as u32);
4731            }
4732        }
4733        // Per-color prelude reach: the union of `prelude_reach` over the
4734        // color's summands, ascending. A union of operand-closed
4735        // ascending sets is itself operand-closed and ascending, which is
4736        // exactly what the two prelude sweeps require. Deduped with an
4737        // epoch-tagged buffer so the build costs
4738        // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
4739        // it saves — rather than `n_colors × |prelude|`.
4740        let np = h.tape.n_prelude_ops();
4741        let mut seen: Vec<u32> = vec![0; np];
4742        let mut epoch: u32 = 0;
4743        let mut reach: Vec<u32> = Vec::new();
4744        let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
4745        for list in &by_color {
4746            reach_off.push(reach.len());
4747            epoch += 1;
4748            let start = reach.len();
4749            for &si in list {
4750                for &p in &h.tape.summands[si as usize].prelude_reach {
4751                    if seen[p] != epoch {
4752                        seen[p] = epoch;
4753                        reach.push(p as u32);
4754                    }
4755                }
4756            }
4757            reach[start..].sort_unstable();
4758        }
4759        reach_off.push(reach.len());
4760        h.hess_color_reach = reach;
4761        h.hess_color_reach_off = reach_off;
4762
4763        h.hess_color_summands = by_color;
4764        h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
4765        h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
4766        h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
4767        h.local_dot = vec![0.0; h.tape.max_summand_ops()];
4768        h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
4769    }
4770
4771    ColorTables {
4772        var_color,
4773        n_colors,
4774        peeled_cols: peeled
4775            .iter()
4776            .enumerate()
4777            .filter(|(_, p)| **p)
4778            .map(|(j, _)| j as u32)
4779            .collect(),
4780        seeds,
4781        decoding,
4782        hess_color_index: HessColorIndex::build(n_colors, &con_tape_colors),
4783        obj_tape_colors,
4784        con_tape_colors,
4785    }
4786}
4787
4788/// The color-dependent half of an [`NlTnlp`], as built by
4789/// [`build_color_tables`].
4790struct ColorTables {
4791    var_color: Vec<u32>,
4792    n_colors: usize,
4793    /// Columns given a singleton color and dropped from the conflict
4794    /// structure. Small by construction (`MAX_PEELED_COLS`).
4795    peeled_cols: Vec<u32>,
4796    seeds: Vec<Vec<f64>>,
4797    decoding: Vec<Vec<ColorWrite>>,
4798    obj_tape_colors: Vec<Vec<u32>>,
4799    con_tape_colors: Vec<Vec<Vec<u32>>>,
4800    /// `con_tape_colors` inverted to color-major, CSR-packed: the (row, tape)
4801    /// pairs whose tape touches each color, and the objective tapes likewise.
4802    ///
4803    /// The flat `eval_h` walks tapes and, inside each, the colors it touches,
4804    /// so several rows accumulate into one `compressed[c]` — fine serially,
4805    /// unshareable across threads. Walking color-major makes each color's
4806    /// accumulator private to its task, at the cost of forwarding a tape once
4807    /// per color it touches instead of once (measured at 2.9 colors per tape
4808    /// on an AC-SCOPF model, against a directional sweep that costs several
4809    /// forwards, so ~20% more serial work for 28-way parallelism).
4810    ///
4811    /// Within a color the pairs stay in ascending (row, tape) order, which is
4812    /// the order the tape-major loop contributes them in, so the two walks
4813    /// accumulate identical sums in identical order — bit-for-bit, not merely
4814    /// within tolerance. `parallel_matches_serial_bit_for_bit` pins that.
4815    hess_color_index: HessColorIndex,
4816}
4817
4818/// Color-major (row, tape) work lists for the flat Hessian path. See
4819/// [`ColorTables::hess_color_index`].
4820#[derive(Debug, Default, Clone)]
4821pub(crate) struct HessColorIndex {
4822    /// Constraint (row, tape) pairs per color, CSR. The objective's tapes are
4823    /// deliberately absent: `eval_h` accumulates them into `compressed`
4824    /// *before* the constraint block and keeps doing so serially, which is
4825    /// what makes the parallel walk's per-color ordering identical to the
4826    /// serial one — objective first, then rows ascending.
4827    con_off: Vec<u32>,
4828    con_row: Vec<u32>,
4829    con_tape: Vec<u32>,
4830}
4831
4832impl HessColorIndex {
4833    fn build(n_colors: usize, con_tape_colors: &[Vec<Vec<u32>>]) -> Self {
4834        // Rows ascending, then tapes ascending: the tape-major loop's order.
4835        let mut con: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_colors];
4836        for (k, row) in con_tape_colors.iter().enumerate() {
4837            for (ti, colors) in row.iter().enumerate() {
4838                for &c in colors {
4839                    con[c as usize].push((k as u32, ti as u32));
4840                }
4841            }
4842        }
4843        let mut out = HessColorIndex {
4844            con_off: Vec::with_capacity(n_colors + 1),
4845            ..Default::default()
4846        };
4847        out.con_off.push(0);
4848        for list in &con {
4849            for &(k, ti) in list {
4850                out.con_row.push(k);
4851                out.con_tape.push(ti);
4852            }
4853            out.con_off.push(out.con_row.len() as u32);
4854        }
4855        out
4856    }
4857
4858    /// The (row, tape) pairs of color `c`, as two parallel slices.
4859    fn con_pairs(&self, c: usize) -> (&[u32], &[u32]) {
4860        let (a, b) = (self.con_off[c] as usize, self.con_off[c + 1] as usize);
4861        (&self.con_row[a..b], &self.con_tape[a..b])
4862    }
4863}
4864
4865impl NlTnlp {
4866    /// Build the TNLP, panicking if AMPL external-function resolution fails.
4867    ///
4868    /// Kept for the many infallible call sites (tests) that operate on `.nl`
4869    /// models known to need no external libraries. Surfaces that can be
4870    /// handed an arbitrary user model — the CLI solve and `verify` paths, the
4871    /// Python `read_nl` binding — must call [`Self::try_new`] instead so a
4872    /// missing `$AMPLFUNC` library becomes an ordinary error rather than a
4873    /// panic (gh#1069: the CLI exited 101 with a backtrace hint).
4874    pub fn new(prob: NlProblem) -> Self {
4875        Self::try_new(prob)
4876            .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
4877    }
4878
4879    /// Build the TNLP, returning an error (instead of panicking) when AMPL
4880    /// imported functions named by the model can't be resolved — e.g.
4881    /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
4882    /// referenced function id isn't registered by any loaded library.
4883    pub fn try_new(prob: NlProblem) -> Result<Self, String> {
4884        // `POUNCE_DBG_NO_QUAD=1` forces the AD tape for every row and the
4885        // objective — the A/B reference for the constant-structure path,
4886        // mirroring `POUNCE_DBG_NO_HYBRID`. Diagnostic only: it is how the
4887        // fast path's derivatives are checked against the ones they replace
4888        // on a real model, and how a suspected fast-path bug is bisected
4889        // against a reference that computes the same numbers a different way.
4890        Self::try_new_with_quadratic(prob, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
4891    }
4892
4893    /// [`Self::try_new`] with the constant-structure fast path (gh #588, Q4)
4894    /// explicitly on or off.
4895    ///
4896    /// The env var `try_new` reads is process-global, which is exactly wrong
4897    /// for the differential test that has to build the *same* model both ways
4898    /// and compare the derivatives — so the knob is a parameter here and the
4899    /// env var only chooses its default.
4900    pub fn try_new_with_quadratic(prob: NlProblem, use_quadratic: bool) -> Result<Self, String> {
4901        // Resolve any AMPL imported (external) functions. Walk every
4902        // nonlinear expression to collect the funcall ids actually
4903        // referenced; load the libraries named in $AMPLFUNC and bind
4904        // each id to its (library, registered-name) pair so the tape
4905        // builder can emit live `TapeOp::Funcall` ops.
4906        let mut referenced: BTreeSet<usize> = BTreeSet::new();
4907        // Only trees can carry a `Funcall`: the recognizer refuses one, so
4908        // a recognized body provably contains none.
4909        for body in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
4910            if let Some(e) = body.tree() {
4911                super::nl_external::collect_funcall_ids(e, &mut referenced);
4912            }
4913        }
4914        let resolver = if referenced.is_empty() {
4915            super::nl_external::ExternalResolver::default()
4916        } else {
4917            super::nl_external::ExternalResolver::build_for_problem(
4918                &prob.imported_funcs,
4919                &referenced,
4920            )?
4921        };
4922
4923        // Recognize the degree-≤2 objective and rows *before* anything is
4924        // taped, because the win is not in evaluating the tape faster — it
4925        // is in never building it. On `qcqp500-3c` the ten quadratic rows
4926        // are 2.32 M monomials, one `Tape` each; recognizing them first
4927        // means those 2.32 M tapes, their color lists and their per-tape
4928        // sparsity sets are never allocated.
4929        //
4930        // A row that is trivially zero is left alone: it has no nonlinear
4931        // part to replace, and routing it through a (necessarily empty)
4932        // quadratic form would touch every model in the corpus to save
4933        // nothing.
4934        let mut quad = QuadraticStructure::new(prob.m);
4935        if use_quadratic {
4936            // `is_expanded_quadratic` is the accuracy gate, not an
4937            // optimization: it admits only forms whose read-out repeats the
4938            // additions the `.nl` writer already wrote. See its docs — and
4939            // note it is checked *before* recognition, so a factored form
4940            // costs one cheap structural walk rather than a full expansion.
4941            // Recognition is the parser's job now (gh #588, Q5) for the
4942            // bodies it could reach; `admitted_quad_form` reads its answer
4943            // back, and still walks a tree for the bodies that kept one —
4944            // a `from_expressions` model, a factored row rewound by the
4945            // parser, or any body at all under `POUNCE_DBG_NO_QUAD`.
4946            //
4947            // A form that gate refuses is offered the *factored* read-out
4948            // before it falls back to a tape (gh #673): the gate is a
4949            // verdict on the expansion, not on the body, and a sum of
4950            // squared residuals keeps its structure by keeping its squares.
4951            // `push_body_form` owns that order.
4952            if let Some(f) = push_body_form(&mut quad, &prob.obj_nonlinear) {
4953                quad.assign_objective(f);
4954            }
4955            for k in 0..prob.m {
4956                if let Some(f) = push_body_form(&mut quad, &prob.con_nonlinear[k]) {
4957                    quad.assign_row(k, f);
4958                }
4959            }
4960        }
4961
4962        // Flatten objective and each constraint into independent
4963        // summands. Each summand becomes its own `Tape` (CSE bodies
4964        // are deduplicated within a tape via Rc identity in
4965        // `Tape::build`; bodies shared across summands are
4966        // duplicated, which we accept as a simplicity tradeoff).
4967        let obj_tapes: Vec<Tape> = if quad.objective_form().is_some() {
4968            Vec::new()
4969        } else {
4970            split_top_sums(&prob.obj_expr())
4971                .iter()
4972                .map(|e| Tape::build_with_externals(e, &resolver))
4973                .collect()
4974        };
4975
4976        let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
4977        let mut con_roots: Vec<Expr> = Vec::new();
4978        let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
4979        for k in 0..prob.m {
4980            row_start.push(con_roots.len());
4981            // A row with a quadratic form contributes no tape and no
4982            // hybrid-tape root, so `con_tapes[k]` is empty and its
4983            // `row_start` range is empty too.
4984            if quad.row_form(k).is_some() {
4985                con_tapes.push(Vec::new());
4986                continue;
4987            }
4988            let summands = split_top_sums(&prob.con_expr(k));
4989            con_tapes.push(
4990                summands
4991                    .iter()
4992                    .map(|e| Tape::build_with_externals(e, &resolver))
4993                    .collect(),
4994            );
4995            // Move (not clone) the split summands into the root list: their
4996            // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
4997            // sharing on `Arc` pointer identity, so the roots must be the
4998            // same allocations the parse produced.
4999            con_roots.extend(summands);
5000        }
5001        row_start.push(con_roots.len());
5002
5003        // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
5004        // building only when some CSE body is actually referenced from two
5005        // or more summands — otherwise the prelude comes out empty and the
5006        // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
5007        // gates the opcodes `build_multi` would panic on.
5008        // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
5009        // whole constraint block. Diagnostic only: it is how the
5010        // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
5011        // on a real model, and how a suspected hybrid-path bug is bisected
5012        // against a reference that computes the same derivatives a
5013        // different way.
5014        let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
5015            None
5016        } else if hybrid_supported(&con_roots) {
5017            let tape = HybridTape::build_multi(&con_roots);
5018            (tape.n_prelude_ops() > 0).then(|| {
5019                let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5020                let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
5021                // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
5022                // regardless of the op ratio. Diagnostic only — it is how the
5023                // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
5024                // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
5025                // models that sit below the gate.
5026                let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
5027                ConHybrid {
5028                    prelude_vals: vec![0.0; tape.n_prelude_ops()],
5029                    local_vals: vec![0.0; tape.max_summand_ops()],
5030                    local_adj: vec![0.0; tape.max_summand_ops()],
5031                    prelude_adj: vec![0.0; tape.n_prelude_ops()],
5032                    use_for_jac: flat_ops as f64
5033                        >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
5034                    use_for_hess: force_hess
5035                        || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
5036                    local_vals_all: Vec::new(),
5037                    local_off: Vec::new(),
5038                    summand_row: Vec::new(),
5039                    hess_color_summands: Vec::new(),
5040                    hess_color_reach: Vec::new(),
5041                    hess_color_reach_off: Vec::new(),
5042                    prelude_dot: Vec::new(),
5043                    hess_prelude_adj: Vec::new(),
5044                    prelude_adj_dot: Vec::new(),
5045                    local_dot: Vec::new(),
5046                    local_adj_dot: Vec::new(),
5047                    row_start,
5048                    tape,
5049                }
5050            })
5051        } else {
5052            None
5053        };
5054        drop(con_roots);
5055
5056        // Hessian-of-Lagrangian sparsity: union of each tape's own
5057        // structural Hessian sparsity.
5058        // One flat `Vec`, sorted and deduped once, rather than a global
5059        // `BTreeSet` fed a single insert at a time across every summand
5060        // in the model: sort+dedup walks contiguous memory where the tree
5061        // chased a pointer and allocated a node per entry. The result is
5062        // exactly the ascending order the rest of this function wants, so
5063        // it doubles as `lower_pairs` instead of being copied into it.
5064        let mut tape_pairs: Vec<(usize, usize)> = Vec::new();
5065        for t in &obj_tapes {
5066            tape_pairs.extend(t.hessian_sparsity());
5067        }
5068        for row in &con_tapes {
5069            for t in row {
5070                tape_pairs.extend(t.hessian_sparsity());
5071            }
5072        }
5073        tape_pairs.sort_unstable();
5074        tape_pairs.dedup();
5075
5076        // The assembled pattern is the tapes' union *plus* the quadratic
5077        // forms'. They are kept apart because the coloring only ever needs
5078        // the tape half — a quadratic block's entries are scattered
5079        // directly, not recovered from a directional product — and coloring
5080        // a dense quadratic block would put the color count back at `n` to
5081        // pay for products nobody runs (`qcqp500-3c`: 500 colors → 0).
5082        let mut lower_pairs = tape_pairs.clone();
5083        if !quad.is_empty() {
5084            for f in quad
5085                .objective_form()
5086                .into_iter()
5087                .chain((0..prob.m).filter_map(|i| quad.row_form(i)))
5088            {
5089                lower_pairs.extend(
5090                    quad.lower_triangle(f)
5091                        .map(|(r, c, _)| (r as usize, c as usize)),
5092                );
5093            }
5094            lower_pairs.sort_unstable();
5095            lower_pairs.dedup();
5096        }
5097
5098        // Which assembled entries a tape can write. Both sides are sorted
5099        // and `tape_pairs ⊆ lower_pairs`, so this is a merge walk, and the
5100        // mask is left empty (meaning "all of them") when nothing was
5101        // recognized.
5102        let h_tape_mask: Vec<bool> = if quad.is_empty() {
5103            Vec::new()
5104        } else {
5105            let mut mask = vec![false; lower_pairs.len()];
5106            let mut t = 0usize;
5107            for (idx, pair) in lower_pairs.iter().enumerate() {
5108                if t < tape_pairs.len() && tape_pairs[t] == *pair {
5109                    mask[idx] = true;
5110                    t += 1;
5111                }
5112            }
5113            debug_assert_eq!(t, tape_pairs.len(), "every tape pair is in the union");
5114            mask
5115        };
5116        drop(tape_pairs);
5117
5118        let mut h_irow = Vec::with_capacity(lower_pairs.len());
5119        let mut h_jcol = Vec::with_capacity(lower_pairs.len());
5120        for &(hi, lo) in &lower_pairs {
5121            h_irow.push(hi as i32);
5122            h_jcol.push(lo as i32);
5123        }
5124
5125        // Bind each form's lower-triangle entries to their index in the
5126        // assembled pattern. `lower_pairs` is sorted, so the lookup is a
5127        // binary search — done once here, never again on the hot path.
5128        if !quad.is_empty() {
5129            quad.bind_slots(|r, c| {
5130                lower_pairs
5131                    .binary_search(&(r as usize, c as usize))
5132                    .unwrap_or_else(|_| {
5133                        unreachable!("quadratic entry ({r}, {c}) missing from the union pattern")
5134                    })
5135            });
5136        }
5137
5138        // Hessian column coloring and everything keyed off it. The
5139        // chromatic number of the column-intersection graph bounds how
5140        // many directional Hessian-vector products we need per `eval_h`
5141        // call — typically O(stencil) for PDE-mesh problems.
5142        let ColorTables {
5143            var_color,
5144            n_colors,
5145            peeled_cols,
5146            seeds,
5147            decoding,
5148            obj_tape_colors,
5149            con_tape_colors,
5150            hess_color_index,
5151        } = build_color_tables(
5152            prob.n,
5153            prob.m,
5154            &lower_pairs,
5155            &h_tape_mask,
5156            &vec![false; prob.n],
5157            &obj_tapes,
5158            &con_tapes,
5159            con_hybrid.as_mut(),
5160        );
5161
5162        // Per-row Jacobian sparsity = union of tape vars plus
5163        // linear-segment vars.
5164        let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
5165        let mut jac_nnz = 0;
5166        for (i, row_tapes) in con_tapes.iter().enumerate() {
5167            let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
5168            for t in row_tapes {
5169                cols.extend(t.variables());
5170            }
5171            // A quadratic row has no tape, so its Jacobian support comes
5172            // from the form: `Hx + a` is nonzero exactly on the union of the
5173            // Hessian's rows and the folded linear part.
5174            if let Some(f) = quad.row_form(i) {
5175                cols.extend(quad.gradient_support(f).iter().map(|&v| v as usize));
5176            }
5177            cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
5178            cols.sort_unstable();
5179            cols.dedup();
5180            cols.shrink_to_fit();
5181            jac_nnz += cols.len();
5182            jac_cols.push(cols);
5183        }
5184
5185        let mut max_tape_n: usize = 0;
5186        for t in &obj_tapes {
5187            max_tape_n = max_tape_n.max(t.ops.len());
5188        }
5189        for row in &con_tapes {
5190            for t in row {
5191                max_tape_n = max_tape_n.max(t.ops.len());
5192            }
5193        }
5194
5195        if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
5196            // Mean number of Hessian colors a single constraint tape touches.
5197            // It is the cost multiplier of visiting the work color-major
5198            // rather than tape-major: each (tape, color) pair needs the
5199            // tape's forward values, so a color-major walk forwards each tape
5200            // this many times instead of once.
5201            let (mut tape_color_pairs, mut n_live_tapes) = (0usize, 0usize);
5202            for row in &con_tape_colors {
5203                for cols in row {
5204                    if !cols.is_empty() {
5205                        tape_color_pairs += cols.len();
5206                        n_live_tapes += 1;
5207                    }
5208                }
5209            }
5210            let colors_per_tape = tape_color_pairs as f64 / n_live_tapes.max(1) as f64;
5211            let n_obj = obj_tapes.len();
5212            let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
5213            let total = n_obj + n_con;
5214            let mut sum_ops: usize = 0;
5215            for t in &obj_tapes {
5216                sum_ops += t.ops.len();
5217            }
5218            for row in &con_tapes {
5219                for t in row {
5220                    sum_ops += t.ops.len();
5221                }
5222            }
5223            let t = total.max(1);
5224            let nnz_h = h_irow.len();
5225            let avg_decode =
5226                decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
5227            eprintln!(
5228                "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
5229                 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
5230                 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h} \
5231                 colors_per_tape={colors_per_tape:.2}",
5232                sum_ops as f64 / t as f64,
5233            );
5234            // Flat vs shared-CSE op counts for the constraint block. The
5235            // ratio is how much duplicated CSE work `eval_g`'s hybrid path
5236            // avoids, and the ceiling on what routing the Jacobian /
5237            // Hessian through the same prelude could save.
5238            match &con_hybrid {
5239                Some(h) => {
5240                    let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5241                    let prelude = h.tape.n_prelude_ops();
5242                    let local = h.tape.total_local_ops();
5243                    eprintln!(
5244                        "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
5245                         local_ops={local} shared_total={} flat/shared={:.2}x \
5246                         jac_gate={} hess_gate={}",
5247                        prelude + local,
5248                        flat as f64 / (prelude + local).max(1) as f64,
5249                        if h.use_for_jac { "on" } else { "off" },
5250                        if h.use_for_hess { "on" } else { "off" },
5251                    );
5252                }
5253                None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
5254            }
5255            // What the constant-structure path took off the tape builder.
5256            // `forms` counts the objective too, which is why it can exceed
5257            // the row count by one.
5258            let quad_rows = (0..prob.m).filter(|&i| quad.row_form(i).is_some()).count();
5259            // How many of those forms the *parser* produced, i.e. how many
5260            // never cost an `Expr` (gh #588, Q5). The rest were recognized
5261            // from a tree that was built: a `from_expressions` model, or a
5262            // body the parser rewound because it was not degree 2.
5263            let parsed = std::iter::once(&prob.obj_nonlinear)
5264                .chain(prob.con_nonlinear.iter())
5265                .filter(|b| b.quad().is_some())
5266                .count();
5267            eprintln!(
5268                "[quad stats] forms={} (parse-time {parsed}) rows={quad_rows}/{} obj={} \
5269                 stored_h_entries={} colored_pairs={}/{}",
5270                quad.len(),
5271                prob.m,
5272                if quad.objective_form().is_some() {
5273                    "quadratic"
5274                } else {
5275                    "taped"
5276                },
5277                quad.stored_entries(),
5278                if h_tape_mask.is_empty() {
5279                    lower_pairs.len()
5280                } else {
5281                    h_tape_mask.iter().filter(|&&t| t).count()
5282                },
5283                lower_pairs.len(),
5284            );
5285        }
5286
5287        let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
5288
5289        let mut me = Self {
5290            prob,
5291            obj_tapes,
5292            con_tapes,
5293            con_hybrid,
5294            quad,
5295            h_tape_mask,
5296            h_irow,
5297            h_jcol,
5298            jac_cols,
5299            jac_nnz,
5300            seeds,
5301            decoding,
5302            obj_tape_colors,
5303            con_tape_colors,
5304            hess_color_index,
5305            jac_row_off: Vec::new(),
5306            #[cfg(not(target_arch = "wasm32"))]
5307            jac_scratch_pool: Vec::new(),
5308            var_color,
5309            peeled_cols,
5310            final_x: None,
5311            final_obj: 0.0,
5312            final_lambda: None,
5313            final_z_l: None,
5314            final_z_u: None,
5315            scratch_row_grad: Vec::new(),
5316            vals_scratch: vec![0.0; max_tape_n],
5317            dot_scratch: vec![0.0; max_tape_n],
5318            adj_scratch: vec![0.0; max_tape_n],
5319            adj_dot_scratch: vec![0.0; max_tape_n],
5320            compressed,
5321            hvp_live: Vec::new(),
5322            curvature_scaling: None,
5323        };
5324        me.veto_ill_conditioned_peels();
5325        Ok(me)
5326    }
5327
5328    /// Un-peel any dense column whose own pass is too ill-scaled to read
5329    /// its small entries out of, and re-color if that changes anything.
5330    ///
5331    /// `greedy_hessian_coloring` picks the peel set from structure alone,
5332    /// which is the right call for the memory and the color count but
5333    /// blind to the one thing that can go wrong: an entry recovered from
5334    /// column `d`'s pass inherits that pass's roundoff floor, about
5335    /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
5336    /// well-scaled dense column loses nothing to that — the recovered
5337    /// entries come back bit-identical to the uncompressed reference on
5338    /// every peel-firing model in the benchmark corpus but one. A column
5339    /// spanning many orders of magnitude, though, hands its small entries
5340    /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
5341    /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
5342    /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
5343    /// survives that, but the multipliers come out of the KKT system the
5344    /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
5345    /// the solve stalls short of `Optimal` on a problem it used to
5346    /// certify.
5347    ///
5348    /// Nothing structural distinguishes the two cases, so measure it: one
5349    /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
5350    /// column's exact pass sitting in `compressed`, and a column whose
5351    /// worst recovered entry would lose more than
5352    /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
5353    /// one `eval_h` — at the peeled color count, so cheap — and only for
5354    /// the ~3% of models that peel anything at all.
5355    fn veto_ill_conditioned_peels(&mut self) {
5356        if self.peeled_cols.is_empty() {
5357            return;
5358        }
5359
5360        let mut values = vec![0.0; self.h_irow.len()];
5361        let lambda = vec![1.0; self.prob.m];
5362        let x0 = self.prob.x0.clone();
5363        if !self.eval_h(
5364            Some(&x0),
5365            true,
5366            1.0,
5367            Some(&lambda),
5368            true,
5369            SparsityRequest::Values {
5370                values: &mut values,
5371            },
5372        ) {
5373            return;
5374        }
5375
5376        let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
5377        // A column whose whole pass is negligible against the Hessian as a
5378        // whole cannot move the KKT system no matter how badly its own
5379        // entries are rounded, and columns that are identically zero at
5380        // `x0` would otherwise veto on a ratio of pure noise.
5381        let h_scale = self
5382            .compressed
5383            .iter()
5384            .flat_map(|c| c.iter())
5385            .fold(0.0f64, |a, &v| a.max(v.abs()));
5386        let mut peel_veto = vec![false; self.prob.n];
5387        let mut vetoed = 0usize;
5388        for &d in &self.peeled_cols {
5389            let c = self.var_color[d as usize];
5390            if c == u32::MAX {
5391                continue;
5392            }
5393            let pass = &self.compressed[c as usize];
5394            // The floor the pass was accumulated at, against the smallest
5395            // entry actually read out of it.
5396            let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
5397            // Entries at or below the pass's own roundoff floor carry no
5398            // information to lose: `eps * scale` is the noise the pass was
5399            // accumulated at, so such an entry is already indistinguishable
5400            // from zero whether or not the column is peeled. Including them
5401            // would divide by that noise -- `orthregd` holds entries of
5402            // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
5403            // 4e-16 against an uncompressed reference.
5404            let noise = f64::EPSILON * scale;
5405            let smallest = self.decoding[c as usize]
5406                .iter()
5407                .map(|w| pass[w.row as usize].abs())
5408                .filter(|v| *v > noise)
5409                .fold(f64::INFINITY, f64::min);
5410            if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
5411                continue;
5412            }
5413            if scale <= h_scale * f64::EPSILON {
5414                continue;
5415            }
5416            let rel_err = f64::EPSILON * scale / smallest;
5417            if dbg {
5418                eprintln!(
5419                    "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
5420                     min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
5421                    if rel_err > PEEL_MAX_REL_ERR {
5422                        " VETO"
5423                    } else {
5424                        ""
5425                    }
5426                );
5427            }
5428            if rel_err > PEEL_MAX_REL_ERR {
5429                peel_veto[d as usize] = true;
5430                vetoed += 1;
5431            }
5432        }
5433
5434        if vetoed == 0 {
5435            return;
5436        }
5437        if dbg {
5438            eprintln!(
5439                "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
5440                self.peeled_cols.len()
5441            );
5442        }
5443        self.recolor(&peel_veto);
5444    }
5445
5446    /// Rebuild the coloring and everything keyed off it, barring
5447    /// `peel_veto` from the peel set.
5448    fn recolor(&mut self, peel_veto: &[bool]) {
5449        let lower_pairs: Vec<(usize, usize)> = self
5450            .h_irow
5451            .iter()
5452            .zip(&self.h_jcol)
5453            .map(|(&i, &j)| (i as usize, j as usize))
5454            .collect();
5455        let ColorTables {
5456            var_color,
5457            n_colors,
5458            peeled_cols,
5459            seeds,
5460            decoding,
5461            obj_tape_colors,
5462            con_tape_colors,
5463            hess_color_index,
5464        } = build_color_tables(
5465            self.prob.n,
5466            self.prob.m,
5467            &lower_pairs,
5468            &self.h_tape_mask,
5469            peel_veto,
5470            &self.obj_tapes,
5471            &self.con_tapes,
5472            self.con_hybrid.as_mut(),
5473        );
5474        self.var_color = var_color;
5475        self.peeled_cols = peeled_cols;
5476        self.seeds = seeds;
5477        self.decoding = decoding;
5478        self.obj_tape_colors = obj_tape_colors;
5479        self.con_tape_colors = con_tape_colors;
5480        // Recoloring invalidates the color-major index: its entries name
5481        // colors, so a stale one would send a row's contribution to whatever
5482        // color now holds that slot. Refreshed here, not rebuilt lazily,
5483        // because nothing downstream can detect the staleness.
5484        self.hess_color_index = hess_color_index;
5485        self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
5486    }
5487
5488    pub fn final_x(&self) -> Option<&[Number]> {
5489        self.final_x.as_deref()
5490    }
5491
5492    pub fn final_obj(&self) -> Number {
5493        self.final_obj
5494    }
5495
5496    /// Converged constraint multipliers from the last solve, in original
5497    /// `.nl` row order. `None` before a solve finishes. See
5498    /// [`Self::final_x`] for the primal counterpart.
5499    pub fn final_lambda(&self) -> Option<&[Number]> {
5500        self.final_lambda.as_deref()
5501    }
5502
5503    /// Converged lower / upper bound multipliers from the last solve, in
5504    /// original `.nl` variable order and Ipopt's internal convention (both
5505    /// `>= 0`). `None` before a solve finishes.
5506    pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
5507        Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
5508    }
5509
5510    /// The parsed problem this TNLP evaluates (bounds, starting point,
5511    /// names, suffixes). Read-only; per-instance overrides go through
5512    /// [`Self::variant`].
5513    pub fn problem(&self) -> &NlProblem {
5514        &self.prob
5515    }
5516
5517    /// Opt this model in to **curvature-based** scaling (gh #703): compute
5518    /// the per-variable and per-row factors of
5519    /// [`crate::nl_scaling::curvature_scaling`] and serve them from
5520    /// [`TNLP::get_scaling_parameters`], so `nlp_scaling_method` reaches
5521    /// them through the channel it already has for user factors.
5522    ///
5523    /// Returns `false` when the model is not one the scheme is defined for
5524    /// — some row or the objective is not degree ≤ 2, so no constant `Qᵢ`
5525    /// exists. The caller must surface that as an error rather than solving
5526    /// unscaled: an accepted scaling option that is then quietly not applied
5527    /// is exactly the gh #483 failure.
5528    ///
5529    /// Costs one pass over every stored Hessian entry plus `RUIZ_SWEEPS`
5530    /// passes over the magnitude surrogates, and nothing at all for a solve
5531    /// that never calls it.
5532    pub fn enable_curvature_scaling(&mut self) -> bool {
5533        match crate::nl_scaling::curvature_scaling(&self.prob) {
5534            Some(sc) => {
5535                self.curvature_scaling = Some(sc);
5536                true
5537            }
5538            None => false,
5539        }
5540    }
5541
5542    /// Whether [`Self::enable_curvature_scaling`] has been called and
5543    /// succeeded.
5544    pub fn curvature_scaling_enabled(&self) -> bool {
5545        self.curvature_scaling.is_some()
5546    }
5547
5548    /// Whether the enabled curvature scaling actually read any curvature —
5549    /// see [`crate::nl_scaling::CurvatureScaling::quadratic`]. `false` when
5550    /// scaling is not enabled, and `false` for a degree-≤2 model whose every
5551    /// `Q` is empty (an LP), where the scheme degenerates to plain Ruiz
5552    /// equilibration of `[A b]`.
5553    pub fn curvature_scaling_read_curvature(&self) -> bool {
5554        self.curvature_scaling
5555            .as_ref()
5556            .is_some_and(|sc| sc.quadratic)
5557    }
5558
5559    /// Is constraint row `i` evaluated from a constant quadratic form
5560    /// rather than from an AD tape (gh #588, Q4)?
5561    ///
5562    /// Structural, so it answers before any evaluation. Exposed for the
5563    /// differential test, which has to know which models exercise the fast
5564    /// path at all, and for `POUNCE_DBG_TAPE_STATS`.
5565    pub fn quadratic_row(&self, i: usize) -> bool {
5566        self.quad.row_form(i).is_some()
5567    }
5568
5569    /// As [`Self::quadratic_row`], for the objective.
5570    pub fn quadratic_objective(&self) -> bool {
5571        self.quad.objective_form().is_some()
5572    }
5573
5574    /// Structural set of variables that appear in *some* nonlinear part —
5575    /// the union of `collect_vars` over the objective and every constraint
5576    /// row. Shared by `get_variables_linearity` and the nonlinear-variable
5577    /// list below so the two can never disagree.
5578    fn nonlinear_var_set(&self) -> BTreeSet<usize> {
5579        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
5580        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
5581        for row in &self.prob.con_nonlinear {
5582            row.collect_vars(&mut nonlinear);
5583        }
5584        nonlinear
5585    }
5586
5587    /// The nonlinear-variable list published through the TNLP contract
5588    /// (`get_number_of_nonlinear_variables` / `get_list_of_nonlinear_variables`),
5589    /// ascending, in the C index style [`NlTnlp`] reports.
5590    ///
5591    /// The contract's asymmetry decides how this is computed. A consumer
5592    /// treats every variable *absent* from the list as linear — Ipopt's
5593    /// limited-memory Hessian skips the quasi-Newton update in that
5594    /// subspace — so naming too few variables is a wrong answer, while
5595    /// naming too many merely costs work. Hence:
5596    ///
5597    /// * When the `.nl` header says **every** variable is nonlinear, publish
5598    ///   that and skip the walk. This is the maximally conservative answer,
5599    ///   so it is sound whatever the header's provenance, and it is the
5600    ///   common case for the models this matters on (`eigena2`: 110 of 110).
5601    /// * Otherwise walk the trees. The header's prefix
5602    ///   (`nlvc + nlvo − nlvb` — see [`NlCounts`]) would also be an O(1)
5603    ///   answer, but it would be one that *trusts* the writer to have
5604    ///   ordered the variables as the format requires, and the walk is
5605    ///   already paid once per solve by `get_variables_linearity`. The
5606    ///   walk is also the only option for a model built through
5607    ///   [`NlProblem::from_expressions`], which has no header at all.
5608    ///
5609    /// The two disagree only in the safe direction, which
5610    /// `crates/pounce-cli/tests/nl_header_counts.rs` asserts over the
5611    /// fixture corpus: the walked set always sits inside the header's
5612    /// prefix, because `parse_nl_text` folds constant `C` bodies away
5613    /// (`gh #492`) and AMPL's own census predates that fold.
5614    fn nonlinear_variables(&self) -> Vec<Index> {
5615        if let Some(c) = self.prob.nl_counts
5616            && c.nonlinear_vars() >= self.prob.n
5617        {
5618            return (0..self.prob.n as Index).collect();
5619        }
5620        self.nonlinear_var_set()
5621            .into_iter()
5622            .map(|i| i as Index)
5623            .collect()
5624    }
5625
5626    /// Mutable access to that same problem, for a caller that owns this
5627    /// TNLP outright.
5628    ///
5629    /// The tapes were built from the expressions in [`Self::problem`] and
5630    /// are not rebuilt, so editing an expression here does **not** change
5631    /// what this TNLP evaluates. It exists for teardown: the Python
5632    /// binding takes the expression trees out through here so a deeply
5633    /// nested one is dropped on a stack chosen for it rather than
5634    /// recursively on whatever thread collected the object (pounce#472).
5635    pub fn problem_mut(&mut self) -> &mut NlProblem {
5636        &mut self.prob
5637    }
5638
5639    /// Hessian-vector product of the Lagrangian:
5640    /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
5641    ///
5642    /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
5643    /// [`Tape::hessian_directional`] pass *per color* and then decodes the
5644    /// compressed columns into the sparse lower triangle; here the seed is
5645    /// the caller's `v` directly, so it is a single forward-over-reverse
5646    /// pass per tape — O(tape ops), independent of `n` and of the coloring's
5647    /// chromatic number. That is what makes it usable on models where
5648    /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
5649    /// truncated-CG step only ever needs `∇²L · v`.
5650    ///
5651    /// Sign convention matches `eval_h` and the rest of this evaluator: a
5652    /// `maximize` model's objective is negated so the returned operator is
5653    /// the one that minimizing solves. `lambda` is `None` for the objective
5654    /// block alone.
5655    ///
5656    /// `out` is overwritten (not accumulated into). Errors on any length
5657    /// mismatch rather than panicking, since the Python binding hands this
5658    /// arbitrary user arrays.
5659    pub fn hessian_vector_product(
5660        &mut self,
5661        x: &[Number],
5662        v: &[Number],
5663        obj_factor: Number,
5664        lambda: Option<&[Number]>,
5665        out: &mut [Number],
5666    ) -> Result<(), String> {
5667        self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
5668    }
5669
5670    /// Block form of [`Self::hessian_vector_product`]: `k` directions at
5671    /// once, `out[:, c] = ∇²L · v[:, c]`.
5672    ///
5673    /// `v` and `out` are `n × k` in **column-major** order — direction `c`
5674    /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
5675    ///
5676    /// Worth having as its own entry point rather than a loop over the
5677    /// single-vector call: the forward sweep depends only on `x`, so a block
5678    /// runs it *once per tape* and reuses `vals` across all `k` directions,
5679    /// where `k` separate calls would redo it `k` times. Only the
5680    /// forward-tangent + reverse-over-tangent passes are per-direction. That
5681    /// is the shape a block-Krylov solve, a directional-derivative probe, or
5682    /// a densify-the-Hessian loop wants.
5683    ///
5684    /// An all-zero direction is skipped, so passing a sparse block whose
5685    /// columns are mostly empty costs only the columns that carry signal.
5686    /// (The sparsity that dominates is the model's own: each tape touches
5687    /// only its own variables, and `hessian_directional` is O(tape ops), not
5688    /// O(n).)
5689    pub fn hessian_vector_products(
5690        &mut self,
5691        x: &[Number],
5692        v: &[Number],
5693        k: usize,
5694        obj_factor: Number,
5695        lambda: Option<&[Number]>,
5696        out: &mut [Number],
5697    ) -> Result<(), String> {
5698        let (n, m) = (self.prob.n, self.prob.m);
5699        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5700            if got == want {
5701                Ok(())
5702            } else {
5703                Err(format!(
5704                    "hessian_vector_product: {name} has length {got}, expected {want}"
5705                ))
5706            }
5707        };
5708        check("x", x.len(), n)?;
5709        check("v", v.len(), n * k)?;
5710        check("out", out.len(), n * k)?;
5711        if let Some(lam) = lambda {
5712            check("lambda", lam.len(), m)?;
5713        }
5714
5715        out.fill(0.0);
5716        if k == 0 || n == 0 {
5717            return Ok(());
5718        }
5719
5720        // Which directions carry signal. Computed once, not per (tape,
5721        // direction) pair — with many small summand tapes the scan would
5722        // otherwise dominate the work it is meant to save. Reuses the
5723        // persistent mask so a Krylov loop allocates nothing per iteration.
5724        self.hvp_live.clear();
5725        self.hvp_live
5726            .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
5727        if !self.hvp_live.iter().any(|&l| l) {
5728            return Ok(());
5729        }
5730
5731        let obj_seed = if self.prob.minimize {
5732            obj_factor
5733        } else {
5734            -obj_factor
5735        };
5736        // The constant blocks. `H · v` for a quadratic form is a matvec
5737        // against stored values, so unlike a tape it needs no forward sweep
5738        // and no `x` at all. Easy to forget — a matrix-free solve that
5739        // silently dropped the quadratic part of `∇²L` would still converge,
5740        // just to a different point by a different route, which is the
5741        // failure mode this series is most exposed to.
5742        if !self.quad.is_empty() {
5743            if obj_seed != 0.0 {
5744                if let Some(f) = self.quad.objective_form() {
5745                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5746                        if self.hvp_live[c] {
5747                            self.quad.add_hessian_vector(
5748                                f,
5749                                &v[c * n..(c + 1) * n],
5750                                obj_seed,
5751                                out_col,
5752                            );
5753                        }
5754                    }
5755                }
5756            }
5757            if let Some(lam) = lambda {
5758                for (i, &w) in lam.iter().enumerate() {
5759                    if w == 0.0 {
5760                        continue;
5761                    }
5762                    let Some(f) = self.quad.row_form(i) else {
5763                        continue;
5764                    };
5765                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5766                        if self.hvp_live[c] {
5767                            self.quad
5768                                .add_hessian_vector(f, &v[c * n..(c + 1) * n], w, out_col);
5769                        }
5770                    }
5771                }
5772            }
5773        }
5774
5775        if obj_seed != 0.0 {
5776            for t in &self.obj_tapes {
5777                if t.ops.is_empty() {
5778                    continue;
5779                }
5780                // Once per tape, not once per direction — the whole point
5781                // of the block form.
5782                t.forward_into(x, &mut self.vals_scratch);
5783                for (c, out_col) in out.chunks_mut(n).enumerate() {
5784                    if !self.hvp_live[c] {
5785                        continue;
5786                    }
5787                    t.hessian_directional(
5788                        &self.vals_scratch,
5789                        &v[c * n..(c + 1) * n],
5790                        obj_seed,
5791                        out_col,
5792                        &mut self.dot_scratch,
5793                        &mut self.adj_scratch,
5794                        &mut self.adj_dot_scratch,
5795                    );
5796                }
5797            }
5798        }
5799
5800        if let Some(lam) = lambda {
5801            // `lam.len() == m` was checked above, so `con_tapes[k]` is in
5802            // range for every k.
5803            for (i, &w) in lam.iter().enumerate() {
5804                if w == 0.0 {
5805                    continue;
5806                }
5807                for t in &self.con_tapes[i] {
5808                    if t.ops.is_empty() {
5809                        continue;
5810                    }
5811                    t.forward_into(x, &mut self.vals_scratch);
5812                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5813                        if !self.hvp_live[c] {
5814                            continue;
5815                        }
5816                        t.hessian_directional(
5817                            &self.vals_scratch,
5818                            &v[c * n..(c + 1) * n],
5819                            w,
5820                            out_col,
5821                            &mut self.dot_scratch,
5822                            &mut self.adj_scratch,
5823                            &mut self.adj_dot_scratch,
5824                        );
5825                    }
5826                }
5827            }
5828        }
5829
5830        Ok(())
5831    }
5832
5833    /// Clone this TNLP with per-instance overrides applied — the
5834    /// "one structure, many bound / starting-point variations" case of
5835    /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
5836    /// coloring are reused via `Clone` (they depend only on the model
5837    /// structure, which a variation cannot change); only the values in
5838    /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
5839    /// are replaced. Any stale `final_x` from a previous solve of
5840    /// `self` is cleared on the clone.
5841    ///
5842    /// Errors when an override's length does not match the model
5843    /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
5844    pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
5845        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5846            if got == want {
5847                Ok(())
5848            } else {
5849                Err(format!(
5850                    "NlVariation.{name} has length {got}, expected {want}"
5851                ))
5852            }
5853        };
5854        let mut out = self.clone();
5855        out.final_x = None;
5856        out.final_obj = 0.0;
5857        out.final_lambda = None;
5858        out.final_z_l = None;
5859        out.final_z_u = None;
5860        if let Some(x0) = &v.x0 {
5861            check("x0", x0.len(), self.prob.n)?;
5862            out.prob.x0.clone_from(x0);
5863        }
5864        if let Some(x_l) = &v.x_l {
5865            check("x_l", x_l.len(), self.prob.n)?;
5866            out.prob.x_l.clone_from(x_l);
5867        }
5868        if let Some(x_u) = &v.x_u {
5869            check("x_u", x_u.len(), self.prob.n)?;
5870            out.prob.x_u.clone_from(x_u);
5871        }
5872        if let Some(g_l) = &v.g_l {
5873            check("g_l", g_l.len(), self.prob.m)?;
5874            out.prob.g_l.clone_from(g_l);
5875        }
5876        if let Some(g_u) = &v.g_u {
5877            check("g_u", g_u.len(), self.prob.m)?;
5878            out.prob.g_u.clone_from(g_u);
5879        }
5880        Ok(out)
5881    }
5882
5883    /// Build one [`NlTnlp`] per variation, sharing this instance's
5884    /// structure (see [`Self::variant`]). Returns instances in input
5885    /// order; errors on the first length-mismatched variation.
5886    pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
5887        vs.iter().map(|v| self.variant(v)).collect()
5888    }
5889}
5890
5891/// Per-instance overrides for building a family of related NLP
5892/// instances from one parsed `.nl` model (pounce#126): same structure
5893/// and tapes, different starting point and/or bounds — parametric
5894/// sweeps, multi-start, or branch-and-bound node relaxations where
5895/// each node only tightens variable bounds. `None` keeps the base
5896/// model's value.
5897#[derive(Debug, Clone, Default)]
5898pub struct NlVariation {
5899    pub x0: Option<Vec<Number>>,
5900    pub x_l: Option<Vec<Number>>,
5901    pub x_u: Option<Vec<Number>>,
5902    pub g_l: Option<Vec<Number>>,
5903    pub g_u: Option<Vec<Number>>,
5904}
5905
5906impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
5907    /// Per-`.nl`-row constraint expression tape, with the linear
5908    /// part folded in. Returns `None` for constraints that contribute
5909    /// neither a nonlinear expression nor any linear coefficients
5910    /// (so FBBT skips them — there's nothing to tighten).
5911    fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
5912        if i >= self.prob.con_nonlinear.len() {
5913            return None;
5914        }
5915        let nonlinear = self.prob.con_expr(i);
5916        let linear = self
5917            .prob
5918            .con_linear
5919            .get(i)
5920            .map(|v| v.as_slice())
5921            .unwrap_or(&[]);
5922        // FBBT needs the tree, so a recognized body is rebuilt here. It is
5923        // rebuilt once per row per presolve pass and dropped again, so the
5924        // DAG never all exists at once — which is the property the phase is
5925        // actually about. Translating the stored coefficients instead would
5926        // propagate bounds through a different association and change the
5927        // tightening; that is not a trade this phase makes.
5928        crate::nl_fbbt_translate::translate_constraint(&nonlinear, linear)
5929    }
5930
5931    /// Variable name from the sibling `.col` file, if one was loaded.
5932    /// Index is original `.nl` column order.
5933    fn variable_name(&self, i: usize) -> Option<&str> {
5934        self.prob.var_names.get(i).map(String::as_str)
5935    }
5936
5937    /// Constraint name from the sibling `.row` file, if one was loaded.
5938    /// Index is original `.nl` row order.
5939    fn constraint_name(&self, i: usize) -> Option<&str> {
5940        self.prob.con_names.get(i).map(String::as_str)
5941    }
5942}
5943
5944/// Per-worker scratch for the parallel Hessian walk. Four buffers of
5945/// `max_tape_n` — the largest tape's node count, tens of entries on the models
5946/// this path is for — so a worker's copy costs about a kilobyte.
5947#[cfg(not(target_arch = "wasm32"))]
5948struct HessScratch {
5949    vals: Vec<f64>,
5950    dot: Vec<f64>,
5951    adj: Vec<f64>,
5952    adj_dot: Vec<f64>,
5953}
5954
5955/// Per-worker scratch for the parallel Jacobian walk: one gradient row of
5956/// length `n` plus the two small tape arenas `gradient_seed_into` fills.
5957#[cfg(not(target_arch = "wasm32"))]
5958#[derive(Debug, Clone)]
5959struct JacScratch {
5960    row_grad: Vec<f64>,
5961    vals: Vec<f64>,
5962    adj: Vec<f64>,
5963}
5964
5965#[cfg(not(target_arch = "wasm32"))]
5966impl JacScratch {
5967    fn new(n: usize, max_tape_n: usize) -> Self {
5968        Self {
5969            row_grad: vec![0.0; n],
5970            vals: vec![0.0; max_tape_n],
5971            adj: vec![0.0; max_tape_n],
5972        }
5973    }
5974}
5975
5976#[cfg(not(target_arch = "wasm32"))]
5977impl HessScratch {
5978    fn new(n: usize) -> Self {
5979        Self {
5980            vals: vec![0.0; n],
5981            dot: vec![0.0; n],
5982            adj: vec![0.0; n],
5983            adj_dot: vec![0.0; n],
5984        }
5985    }
5986}
5987
5988impl NlTnlp {
5989    /// Smallest (row, tape) × color work list that earns a parallel walk.
5990    /// Below it the rayon dispatch costs more than the sweeps it splits, and
5991    /// the serial tape-major walk is also cheaper per unit work because it
5992    /// forwards each tape once rather than once per color.
5993    ///
5994    /// Measured on a banded synthetic (`sin(x_i·x_{i+1}) + x_{i+1}²·x_{i+2}`
5995    /// per row, the model in `parallel_hessian_matches_serial.rs`), serial ->
5996    /// parallel with the gate forced open:
5997    ///
5998    /// | pairs | serial | parallel | ratio |
5999    /// |---|---|---|---|
6000    /// | 400 | 0.028 ms | 0.036 ms | 0.79x |
6001    /// | 1 200 | 0.073 ms | 0.066 ms | 1.11x |
6002    /// | 4 000 | 0.179 ms | 0.112 ms | 1.60x |
6003    /// | 12 000 | 0.344 ms | 0.203 ms | 1.69x |
6004    ///
6005    /// The crossover sits between 400 and 1 200, hence this value.
6006    ///
6007    /// **What that table does not say is how big the win gets.** That model
6008    /// has three Hessian colors, so it has three tasks to hand out however
6009    /// many cores are free, and 1.7x is near its ceiling. The parallelism
6010    /// available here *is* the color count: an AC-SCOPF model with 28 colors
6011    /// runs the same walk 4.9x faster (108.3 ms -> 22.2 ms at 54 859
6012    /// variables, 421.0 ms -> 89.0 ms at 209 755). Gating on pair count alone
6013    /// is therefore conservative in the safe direction — at equal pairs, more
6014    /// colors can only help.
6015    const HESS_PAR_MIN_PAIRS: usize = 1024;
6016
6017    /// Whether the flat Hessian walk runs color-major and in parallel.
6018    ///
6019    /// `POUNCE_NL_PARALLEL_EVAL` overrides the size gate: `0` forces the
6020    /// serial walk, `1` forces the parallel one whatever the size. The two
6021    /// produce bit-identical values, so this is a lever for measurement and
6022    /// for pinning a suspected parallelism bug, not a correctness switch —
6023    /// and `1` is how the threshold below was measured, since the gate would
6024    /// otherwise hide the small end of the curve.
6025    #[cfg(not(target_arch = "wasm32"))]
6026    fn hess_flat_parallel(&self) -> bool {
6027        // More than one color is a hard requirement, not a heuristic: with one
6028        // color there is one task and the inversion is pure overhead.
6029        let multi_color = self.compressed.len() > 1;
6030        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6031            Ok("0") | Ok("no") | Ok("false") => false,
6032            Ok("1") | Ok("yes") | Ok("true") => multi_color,
6033            _ => multi_color && self.hess_color_index.con_row.len() >= Self::HESS_PAR_MIN_PAIRS,
6034        }
6035    }
6036
6037    /// Smallest Jacobian nonzero count that earns a parallel walk. Same shape
6038    /// of trade as [`Self::HESS_PAR_MIN_PAIRS`], but a cheaper one to cross:
6039    /// the row walk is *not* reordered or duplicated, so the only cost is the
6040    /// dispatch and one length-`n` scratch row per worker.
6041    #[cfg(not(target_arch = "wasm32"))]
6042    const JAC_PAR_MIN_NNZ: usize = 4096;
6043
6044    /// Smallest row count that earns a parallel constraint evaluation. This is
6045    /// the cheapest of the three walks per row — one forward sweep, no
6046    /// gradient, no directional derivative — so the dispatch is a larger share
6047    /// of it and the threshold is the highest of the three.
6048    ///
6049    /// Measured on the banded synthetic, serial -> parallel with the gate
6050    /// forced open: 500 rows 0.34x, 2 000 0.84x, 5 000 1.07x, 8 000 1.53x,
6051    /// 20 000 3.19x, 60 000 4.97x, 200 000 5.78x. The crossover is near 5 000,
6052    /// so the gate sits just under it. Unlike the Hessian, this walk has no
6053    /// color ceiling — every row is its own task — which is why it keeps
6054    /// climbing to ~6x where the Hessian's 3-color model stalls at 1.7x.
6055    #[cfg(not(target_arch = "wasm32"))]
6056    const EVAL_G_PAR_MIN_ROWS: usize = 4096;
6057
6058    /// Whether `eval_g` runs in parallel over rows. Shares
6059    /// `POUNCE_NL_PARALLEL_EVAL` with the other two walks.
6060    #[cfg(not(target_arch = "wasm32"))]
6061    fn eval_g_parallel(&self) -> bool {
6062        // The shared-CSE arm returns earlier; this gate only ever sees the
6063        // flat path.
6064        let big = self.prob.m >= Self::EVAL_G_PAR_MIN_ROWS;
6065        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6066            Ok("0") | Ok("no") | Ok("false") => false,
6067            Ok("1") | Ok("yes") | Ok("true") => true,
6068            _ => big,
6069        }
6070    }
6071
6072    /// Whether the flat Jacobian walk runs in parallel over row groups.
6073    /// Shares `POUNCE_NL_PARALLEL_EVAL` with the Hessian.
6074    #[cfg(not(target_arch = "wasm32"))]
6075    fn jac_flat_parallel(&self) -> bool {
6076        let big = self.jac_nnz >= Self::JAC_PAR_MIN_NNZ;
6077        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6078            Ok("0") | Ok("no") | Ok("false") => false,
6079            Ok("1") | Ok("yes") | Ok("true") => true,
6080            _ => big,
6081        }
6082    }
6083
6084    /// The flat Jacobian, walked in parallel over contiguous groups of rows.
6085    ///
6086    /// Unlike the Hessian this needs no inversion: rows are already
6087    /// independent — each zeroes only its own columns of the gradient scratch,
6088    /// accumulates into them, and writes a contiguous run of `values`. So the
6089    /// work is split by *rows*, each group keeping the serial order inside
6090    /// itself, and every row computes exactly what it computed serially. The
6091    /// results are bit-identical for the stronger reason that nothing was
6092    /// reordered at all.
6093    ///
6094    /// Two things are per-worker rather than per-call: the length-`n` gradient
6095    /// row (the reason this is not simply `par_iter`, since `n` can be tens of
6096    /// thousands and allocating one per row would dwarf the work) and the two
6097    /// small tape arenas. They are pooled on `self` and reused across
6098    /// evaluations.
6099    #[cfg(not(target_arch = "wasm32"))]
6100    fn jac_flat_rows_parallel(&mut self, x_arg: Option<&[Number]>, values: &mut [Number]) {
6101        use rayon::prelude::*;
6102        let n = self.prob.n;
6103        let m = self.prob.m;
6104        let groups = rayon::current_num_threads().clamp(1, m.max(1));
6105        let max_tape_n = self.vals_scratch.len();
6106
6107        // Row -> offset of its run in `values`, so a group can be hnaded one
6108        // contiguous slice. Rebuilt only when the shape changes.
6109        if self.jac_row_off.len() != m + 1 {
6110            self.jac_row_off = Vec::with_capacity(m + 1);
6111            let mut acc = 0u32;
6112            for cols in &self.jac_cols {
6113                self.jac_row_off.push(acc);
6114                acc += cols.len() as u32;
6115            }
6116            self.jac_row_off.push(acc);
6117        }
6118        if self.jac_scratch_pool.len() != groups {
6119            self.jac_scratch_pool = (0..groups)
6120                .map(|_| JacScratch::new(n, max_tape_n))
6121                .collect();
6122        }
6123
6124        // Equal-row groups. Rows differ in width, so this is not a perfect
6125        // balance; rayon is given more groups than threads only when that is
6126        // free, and the measurement below is taken with this split.
6127        let per = m.div_ceil(groups.max(1));
6128        let Self {
6129            prob,
6130            con_tapes,
6131            quad,
6132            jac_cols,
6133            jac_row_off,
6134            jac_scratch_pool,
6135            ..
6136        } = self;
6137        let xs = x_arg.unwrap_or(&prob.x0);
6138
6139        // Hand each group its own contiguous run of `values`.
6140        let mut rest: &mut [Number] = values;
6141        let mut slices: Vec<&mut [Number]> = Vec::with_capacity(groups);
6142        for g in 0..groups {
6143            let end_row = ((g + 1) * per).min(m);
6144            let start_row = (g * per).min(m);
6145            let len = (jac_row_off[end_row] - jac_row_off[start_row]) as usize;
6146            let (head, tail) = rest.split_at_mut(len);
6147            slices.push(head);
6148            rest = tail;
6149        }
6150
6151        jac_scratch_pool
6152            .par_iter_mut()
6153            .zip(slices.into_par_iter())
6154            .enumerate()
6155            .for_each(|(g, (sc, out))| {
6156                let (lo, hi) = ((g * per).min(m), ((g + 1) * per).min(m));
6157                let JacScratch {
6158                    row_grad,
6159                    vals,
6160                    adj,
6161                } = sc;
6162                let mut k = 0usize;
6163                for i in lo..hi {
6164                    for &j in &jac_cols[i] {
6165                        row_grad[j] = 0.0;
6166                    }
6167                    for t in &con_tapes[i] {
6168                        t.gradient_seed_into(xs, 1.0, row_grad, vals, adj);
6169                    }
6170                    if let Some(f) = quad.row_form(i) {
6171                        quad.add_gradient(f, xs, 1.0, row_grad);
6172                    }
6173                    for &(v, c) in &prob.con_linear[i] {
6174                        row_grad[v] += c;
6175                    }
6176                    for &j in &jac_cols[i] {
6177                        out[k] = row_grad[j];
6178                        k += 1;
6179                    }
6180                }
6181            });
6182    }
6183
6184    /// The flat Hessian's constraint block, walked color-major with one rayon
6185    /// task per color.
6186    ///
6187    /// Each task owns its color's `compressed[c]` outright, so nothing is
6188    /// shared mutably and no reduction is needed. Within a color the pairs are
6189    /// visited in ascending (row, tape) order — the order the serial walk
6190    /// contributes them in — so the sums are identical term by term and the
6191    /// two paths agree bit-for-bit, which
6192    /// `parallel_matches_serial_bit_for_bit` pins.
6193    ///
6194    /// The cost of the inversion is forwarding a tape once per color it
6195    /// touches instead of once for all of them (2.9 colors per tape measured
6196    /// on AC-SCOPF). That is paid back by the parallelism and only above
6197    /// `HESS_PAR_MIN_PAIRS`.
6198    #[cfg(not(target_arch = "wasm32"))]
6199    fn hess_flat_colors_parallel(&mut self, x_arg: Option<&[Number]>, lam: &[Number]) {
6200        use rayon::prelude::*;
6201        let Self {
6202            compressed,
6203            seeds,
6204            con_tapes,
6205            hess_color_index: idx,
6206            vals_scratch,
6207            prob,
6208            ..
6209        } = self;
6210        let x = x_arg.unwrap_or(&prob.x0);
6211        let max_tape_n = vals_scratch.len();
6212        compressed.par_iter_mut().enumerate().for_each_init(
6213            || HessScratch::new(max_tape_n),
6214            |sc, (c, out)| {
6215                let (rows, tapes) = idx.con_pairs(c);
6216                let seed = &seeds[c];
6217                let HessScratch {
6218                    vals,
6219                    dot,
6220                    adj,
6221                    adj_dot,
6222                } = sc;
6223                for (&k, &ti) in rows.iter().zip(tapes.iter()) {
6224                    let w = lam[k as usize];
6225                    if w == 0.0 {
6226                        continue;
6227                    }
6228                    let t = &con_tapes[k as usize][ti as usize];
6229                    if t.ops.is_empty() {
6230                        continue;
6231                    }
6232                    t.forward_into(x, vals);
6233                    t.hessian_directional(vals, seed, w, out, dot, adj, adj_dot);
6234                }
6235            },
6236        );
6237    }
6238}
6239
6240impl TNLP for NlTnlp {
6241    /// An `.nl` model carries no callback: the CLI and the browser build
6242    /// read the iteration log, not `IterStats` (gh#965).
6243    fn wants_intermediate_callback(&self) -> bool {
6244        false
6245    }
6246
6247    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
6248        Some(NlpInfo {
6249            n: self.prob.n as Index,
6250            m: self.prob.m as Index,
6251            nnz_jac_g: self.jac_nnz as Index,
6252            nnz_h_lag: self.h_irow.len() as Index,
6253            index_style: IndexStyle::C,
6254        })
6255    }
6256
6257    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
6258        b.x_l.copy_from_slice(&self.prob.x_l);
6259        b.x_u.copy_from_slice(&self.prob.x_u);
6260        if !self.prob.g_l.is_empty() {
6261            b.g_l.copy_from_slice(&self.prob.g_l);
6262            b.g_u.copy_from_slice(&self.prob.g_u);
6263        }
6264        true
6265    }
6266
6267    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
6268        sp.x.copy_from_slice(&self.prob.x0);
6269        // The `.nl` `d` segment supplies initial constraint multipliers
6270        // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
6271        // the engine when `warm_start_init_point yes` — by handing them
6272        // back; `OrigIpoptNlp::get_starting_point` then compresses them into
6273        // the algorithm-side y_c / y_d. Without this the warm start silently
6274        // began from zero multipliers, discarding the parsed duals. (Code
6275        // review 2026-06 item M19.)
6276        if sp.init_lambda {
6277            sp.lambda.copy_from_slice(&self.prob.lambda0);
6278        }
6279        // Bound multipliers come from the `ipopt_zL_in` / `ipopt_zU_in`
6280        // variable suffixes, as in Ipopt's `AmplTNLP::get_starting_point`:
6281        // `z_L = sigma * zL_in`, `z_U = -sigma * zU_in` (sigma = -1 for a
6282        // maximize model) — the inverse of the CLI's `ipopt_zL_out` /
6283        // `ipopt_zU_out`, so a `.sol` result fed back in round-trips. A
6284        // suffix the file does not carry leaves that block to the engine's
6285        // defaults, as before.
6286        if sp.init_z {
6287            let sigma = if self.prob.minimize { 1.0 } else { -1.0 };
6288            let sfx = &self.prob.suffixes;
6289            for (name, out, sign) in [
6290                ("ipopt_zL_in", &mut *sp.z_l, sigma),
6291                ("ipopt_zU_in", &mut *sp.z_u, -sigma),
6292            ] {
6293                // A writer may emit an all-integer suffix as integer-kind.
6294                let vals: Option<Vec<Number>> = sfx.var_real.get(name).cloned().or_else(|| {
6295                    sfx.var_int
6296                        .get(name)
6297                        .map(|v| v.iter().map(|&x| x as Number).collect())
6298                });
6299                if let Some(v) = vals.filter(|v| v.len() == out.len()) {
6300                    for (o, x) in out.iter_mut().zip(v) {
6301                        *o = sign * x;
6302                    }
6303                }
6304            }
6305        }
6306        true
6307    }
6308
6309    /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
6310    /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
6311    /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
6312    /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
6313    /// writes into. Before gh#483 nothing implemented this callback for
6314    /// `.nl` input, so a tagged model reached the solver with the option
6315    /// accepted and *no* scaling applied, silently.
6316    ///
6317    /// Returns `false` (engine falls back to no scaling) when the file
6318    /// declares no `scaling_factor` suffix at all — the same "user
6319    /// supplied nothing" answer as the default `TNLP` impl.
6320    ///
6321    /// AMPL suffix vectors default to **0** for components the model did
6322    /// not tag, and 0 is not a usable scale factor. A zero entry is
6323    /// therefore read as "not tagged" and becomes 1.0, which is what
6324    /// "unlisted components are unscaled" means. Per-variable factors
6325    /// are passed straight through: `OrigIpoptNlp` does not model them
6326    /// and refuses the solve with a message rather than dropping them.
6327    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
6328        const NAME: &str = "scaling_factor";
6329        let sfx = &self.prob.suffixes;
6330        let obj = sfx.obj_real.get(NAME);
6331        let var = sfx.var_real.get(NAME);
6332        let con = sfx.con_real.get(NAME);
6333        let computed = self.curvature_scaling.as_ref();
6334        if obj.is_none() && var.is_none() && con.is_none() && computed.is_none() {
6335            return false;
6336        }
6337        // Curvature-based factors (gh #703) are the *base*; a
6338        // `scaling_factor` suffix the model actually carries overrides
6339        // them component by component, because an explicit factor from the
6340        // modeller beats one inferred from the coefficients. A model with
6341        // no suffixes is the ordinary case and gets the computed vectors
6342        // whole.
6343        if let Some(sc) = computed {
6344            // The length guards are a `copy_from_slice` panic guard, not a
6345            // policy: `curvature_scaling` sizes both vectors from the same
6346            // `NlProblem` this callback is answering for, so a mismatch is a
6347            // bug upstream, not a model the scheme declines. Declining is the
6348            // *worst* available response to it — `use_*_scaling` stays false,
6349            // the engine reads that as "user supplied nothing", and the run
6350            // proceeds unscaled with the option accepted, which is gh #483
6351            // again. Assert it in debug builds so a mismatch is found here,
6352            // where it is one line, instead of as a slow solve later.
6353            debug_assert_eq!(
6354                sc.x.len(),
6355                req.x_scaling.len(),
6356                "curvature x-scaling sized {} for a {}-variable request",
6357                sc.x.len(),
6358                req.x_scaling.len()
6359            );
6360            debug_assert_eq!(
6361                sc.g.len(),
6362                req.g_scaling.len(),
6363                "curvature g-scaling sized {} for a {}-row request",
6364                sc.g.len(),
6365                req.g_scaling.len()
6366            );
6367            if sc.x.len() == req.x_scaling.len() {
6368                req.x_scaling.copy_from_slice(&sc.x);
6369                *req.use_x_scaling = true;
6370            }
6371            if sc.g.len() == req.g_scaling.len() {
6372                req.g_scaling.copy_from_slice(&sc.g);
6373                *req.use_g_scaling = true;
6374            }
6375        }
6376        // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
6377        // are parsed and ignored), so its entry is the objective scale.
6378        *req.obj_scaling = obj
6379            .and_then(|v| v.first().copied())
6380            .filter(|&s| s != 0.0)
6381            .unwrap_or(1.0);
6382        // A zero entry is AMPL's "untagged" default, not a scale factor.
6383        // An untagged component therefore falls back to the base: the
6384        // curvature factor when one was computed, and an explicit 1.0
6385        // otherwise — explicit because the callback's contract is to fill
6386        // the buffer, not to assume the caller pre-filled it with ones.
6387        if let Some(v) = var.filter(|v| v.len() == req.x_scaling.len()) {
6388            for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
6389                if s != 0.0 {
6390                    *slot = s;
6391                } else if computed.is_none() {
6392                    *slot = 1.0;
6393                }
6394            }
6395            *req.use_x_scaling = true;
6396        } else if computed.is_none() {
6397            *req.use_x_scaling = false;
6398        }
6399        if let Some(g) = con.filter(|g| g.len() == req.g_scaling.len()) {
6400            for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
6401                if s != 0.0 {
6402                    *slot = s;
6403                } else if computed.is_none() {
6404                    *slot = 1.0;
6405                }
6406            }
6407            *req.use_g_scaling = true;
6408        } else if computed.is_none() {
6409            *req.use_g_scaling = false;
6410        }
6411        true
6412    }
6413
6414    fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
6415        // Reuse the shared forward-value arena (sized to `max_tape_n`) so
6416        // each summand sweep allocates nothing — see `Tape::eval_into`.
6417        let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
6418        let mut nl: Number = 0.0;
6419        for t in obj_tapes {
6420            nl += t.eval_into(x, vals);
6421        }
6422        // A recognized objective has no tapes, so the loop above added
6423        // nothing and the form supplies the whole nonlinear part — including
6424        // the linear and constant terms AMPL folded into that tree, which is
6425        // why they are *not* also in `obj_linear` / `obj_constant`.
6426        if let Some(f) = self.quad.objective_form() {
6427            nl += self.quad.value(f, x);
6428        }
6429        let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
6430        let v = self.prob.obj_constant + nl + lin;
6431        let signed = if self.prob.minimize { v } else { -v };
6432        Some(signed)
6433    }
6434
6435    fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
6436        grad.fill(0.0);
6437        // Reuse the forward-value / adjoint scratch arenas (sized to
6438        // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
6439        // nothing — see `Tape::gradient_seed_into` (M18).
6440        for t in &self.obj_tapes {
6441            t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
6442        }
6443        if let Some(f) = self.quad.objective_form() {
6444            self.quad.add_gradient(f, x, 1.0, grad);
6445        }
6446        for (i, c) in &self.prob.obj_linear {
6447            grad[*i] += c;
6448        }
6449        if !self.prob.minimize {
6450            for g in grad.iter_mut() {
6451                *g = -*g;
6452            }
6453        }
6454        true
6455    }
6456
6457    fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
6458        // Constraint values are the line search's inner loop: on a
6459        // constraint-heavy model (m >> n) this runs ~10x per iteration over
6460        // every summand tape in the problem. Reuse the shared forward-value
6461        // arena so the sweep allocates nothing — the per-summand `Vec` the
6462        // allocating `Tape::eval` used to build was ~20% of `eval_g` on
6463        // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
6464        // `Tape::eval_into`.
6465        let m = self.prob.m;
6466        let con_linear = &self.prob.con_linear;
6467        let quad = &self.quad;
6468        if let Some(h) = &mut self.con_hybrid {
6469            // Shared CSE bodies once for the whole constraint block, then one
6470            // local sweep per summand (pounce#476).
6471            let ConHybrid {
6472                tape,
6473                row_start,
6474                prelude_vals,
6475                local_vals,
6476                ..
6477            } = h;
6478            tape.forward_prelude(x, prelude_vals);
6479            for i in 0..m {
6480                let mut nl: Number = 0.0;
6481                for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6482                    tape.forward_summand(s, x, prelude_vals, local_vals);
6483                    nl += tape.root_value(s, local_vals);
6484                }
6485                if let Some(f) = quad.row_form(i) {
6486                    nl += quad.value(f, x);
6487                }
6488                let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6489                g[i] = nl + lin;
6490            }
6491            return true;
6492        }
6493        // Row `i` reads only `x` and writes only `g[i]`, so this one is
6494        // parallel as written — no inversion, no row-offset bookkeeping, and
6495        // the per-row arithmetic is untouched, which is why the values are
6496        // bit-identical. Only the forward-value arena is per-worker.
6497        #[cfg(not(target_arch = "wasm32"))]
6498        if self.eval_g_parallel() {
6499            use rayon::prelude::*;
6500            let max_tape_n = self.vals_scratch.len();
6501            let con_tapes = &self.con_tapes;
6502            g.par_iter_mut().enumerate().for_each_init(
6503                || vec![0.0; max_tape_n],
6504                |vals, (i, gi)| {
6505                    let mut nl: Number = 0.0;
6506                    for t in &con_tapes[i] {
6507                        nl += t.eval_into(x, vals);
6508                    }
6509                    if let Some(f) = quad.row_form(i) {
6510                        nl += quad.value(f, x);
6511                    }
6512                    let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6513                    *gi = nl + lin;
6514                },
6515            );
6516            return true;
6517        }
6518        let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
6519        for i in 0..m {
6520            let mut nl: Number = 0.0;
6521            for t in &con_tapes[i] {
6522                nl += t.eval_into(x, vals);
6523            }
6524            // A quadratic row's summand range is empty above, so this is its
6525            // whole nonlinear part: one matvec against a constant matrix in
6526            // place of a walk over every monomial's tape.
6527            if let Some(f) = quad.row_form(i) {
6528                nl += quad.value(f, x);
6529            }
6530            let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6531            g[i] = nl + lin;
6532        }
6533        true
6534    }
6535
6536    fn eval_jac_g(
6537        &mut self,
6538        x: Option<&[Number]>,
6539        _new_x: bool,
6540        mode: SparsityRequest<'_>,
6541    ) -> bool {
6542        match mode {
6543            SparsityRequest::Structure { irow, jcol } => {
6544                let mut k = 0;
6545                for i in 0..self.prob.m {
6546                    for &j in &self.jac_cols[i] {
6547                        irow[k] = i as Index;
6548                        jcol[k] = j as Index;
6549                        k += 1;
6550                    }
6551                }
6552                true
6553            }
6554            SparsityRequest::Values { values } => {
6555                let n = self.prob.n;
6556                if self.scratch_row_grad.len() < n {
6557                    self.scratch_row_grad.resize(n, 0.0);
6558                }
6559                // Rows are independent and write disjoint runs of `values`, so
6560                // the parallel walk is the same work in the same order, split
6561                // by row group. Only the flat path: the shared-CSE arm below
6562                // forwards one prelude for the whole block, which is exactly
6563                // the sharing a row split would have to undo.
6564                #[cfg(not(target_arch = "wasm32"))]
6565                if self.con_hybrid.as_ref().filter(|h| h.use_for_jac).is_none()
6566                    && self.jac_flat_parallel()
6567                {
6568                    self.jac_flat_rows_parallel(x, values);
6569                    return true;
6570                }
6571                let Self {
6572                    prob,
6573                    con_tapes,
6574                    con_hybrid,
6575                    quad,
6576                    jac_cols,
6577                    scratch_row_grad,
6578                    vals_scratch,
6579                    adj_scratch,
6580                    ..
6581                } = self;
6582                let xs = x.unwrap_or(&prob.x0);
6583                let mut k = 0;
6584                // Shared-CSE path: the forward sweep over the CSE bodies runs
6585                // once for the whole constraint block instead of once per
6586                // referencing summand. The reverse sweep cannot be shared —
6587                // each row needs its own gradient — so a summand still walks
6588                // its own `prelude_reach` backwards.
6589                if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
6590                    let ConHybrid {
6591                        tape,
6592                        row_start,
6593                        prelude_vals,
6594                        local_vals,
6595                        local_adj,
6596                        prelude_adj,
6597                        ..
6598                    } = h;
6599                    tape.forward_prelude(xs, prelude_vals);
6600                    for i in 0..prob.m {
6601                        for &j in &jac_cols[i] {
6602                            scratch_row_grad[j] = 0.0;
6603                        }
6604                        for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6605                            tape.forward_summand(s, xs, prelude_vals, local_vals);
6606                            tape.gradient_summand(
6607                                s,
6608                                prelude_vals,
6609                                local_vals,
6610                                1.0,
6611                                scratch_row_grad,
6612                                local_adj,
6613                                prelude_adj,
6614                            );
6615                        }
6616                        if let Some(f) = quad.row_form(i) {
6617                            quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6618                        }
6619                        for &(v, c) in &prob.con_linear[i] {
6620                            scratch_row_grad[v] += c;
6621                        }
6622                        for &j in &jac_cols[i] {
6623                            values[k] = scratch_row_grad[j];
6624                            k += 1;
6625                        }
6626                    }
6627                    return true;
6628                }
6629                for i in 0..prob.m {
6630                    for &j in &jac_cols[i] {
6631                        scratch_row_grad[j] = 0.0;
6632                    }
6633                    for t in &con_tapes[i] {
6634                        // Allocation-free reverse-AD per summand tape (M18):
6635                        // reuse the shared forward/adjoint scratch arenas.
6636                        t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
6637                    }
6638                    // `Hx + a` for a quadratic row: one matvec over the
6639                    // row's support, in place of a reverse sweep per
6640                    // monomial. `jac_cols[i]` already covers the form's
6641                    // gradient support, so the scatter lands inside the
6642                    // window zeroed above.
6643                    if let Some(f) = quad.row_form(i) {
6644                        quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6645                    }
6646                    for &(v, c) in &prob.con_linear[i] {
6647                        scratch_row_grad[v] += c;
6648                    }
6649                    for &j in &jac_cols[i] {
6650                        values[k] = scratch_row_grad[j];
6651                        k += 1;
6652                    }
6653                }
6654                true
6655            }
6656        }
6657    }
6658
6659    fn eval_h(
6660        &mut self,
6661        x: Option<&[Number]>,
6662        _new_x: bool,
6663        obj_factor: Number,
6664        lambda: Option<&[Number]>,
6665        _new_lambda: bool,
6666        mode: SparsityRequest<'_>,
6667    ) -> bool {
6668        match mode {
6669            SparsityRequest::Structure { irow, jcol } => {
6670                irow.copy_from_slice(&self.h_irow);
6671                jcol.copy_from_slice(&self.h_jcol);
6672                true
6673            }
6674            SparsityRequest::Values { values } => {
6675                // Kept alongside the resolved slice: the parallel walk below
6676                // resolves it again *after* destructuring `self`, so `x` and
6677                // the buffers it writes borrow different fields and neither
6678                // has to be copied per evaluation.
6679                let x_arg = x;
6680                let x = x_arg.unwrap_or(&self.prob.x0);
6681                values.fill(0.0);
6682
6683                let obj_seed = if self.prob.minimize {
6684                    obj_factor
6685                } else {
6686                    -obj_factor
6687                };
6688                // Coloring path. For each (tape, weight) we do
6689                // one forward pass into `vals_scratch`, then one
6690                // forward-tangent+reverse-over-tangent per color
6691                // touched by that tape. Each pass accumulates a
6692                // weighted contribution of (H_tape · seed_c) into
6693                // `compressed[c]`. After all tapes done, we
6694                // decode each color's compressed vector into the
6695                // sparse `values` array.
6696                for buf in &mut self.compressed {
6697                    buf.fill(0.0);
6698                }
6699
6700                // The constant blocks first: no forward sweep, no
6701                // directional product, no decode — the multipliers are the
6702                // only thing that changed since the model was read, so this
6703                // is one `values[slot] += w · h` pass per live form. Skipped
6704                // wholesale on a model with nothing recognized, so such a
6705                // model does not pay an `O(m)` scan for a structure that is
6706                // empty.
6707                if !self.quad.is_empty() {
6708                    if obj_seed != 0.0 {
6709                        if let Some(f) = self.quad.objective_form() {
6710                            self.quad.accumulate_hessian(f, obj_seed, values);
6711                        }
6712                    }
6713                    if let Some(lam) = lambda {
6714                        for i in 0..self.prob.m {
6715                            let w = lam[i];
6716                            if w == 0.0 {
6717                                continue;
6718                            }
6719                            if let Some(f) = self.quad.row_form(i) {
6720                                self.quad.accumulate_hessian(f, w, values);
6721                            }
6722                        }
6723                    }
6724                }
6725
6726                if obj_seed != 0.0 {
6727                    for (ti, t) in self.obj_tapes.iter().enumerate() {
6728                        if t.ops.is_empty() {
6729                            continue;
6730                        }
6731                        t.forward_into(x, &mut self.vals_scratch);
6732                        for &c in &self.obj_tape_colors[ti] {
6733                            t.hessian_directional(
6734                                &self.vals_scratch,
6735                                &self.seeds[c as usize],
6736                                obj_seed,
6737                                &mut self.compressed[c as usize],
6738                                &mut self.dot_scratch,
6739                                &mut self.adj_scratch,
6740                                &mut self.adj_dot_scratch,
6741                            );
6742                        }
6743                    }
6744                }
6745
6746                // Computed before the match: its scrutinee takes `self` mutably,
6747                // so a guard cannot also read `self`. Absent on wasm32 along
6748                // with the arm it guards — the arm still has to *typecheck*
6749                // there, dead or not, and the method it calls does not exist.
6750                #[cfg(not(target_arch = "wasm32"))]
6751                let hess_flat_parallel = self.hess_flat_parallel();
6752                match (lambda, self.con_hybrid.as_mut()) {
6753                    // Shared-CSE path (issue #557). Per color the prelude's
6754                    // second-order work runs ONCE for the whole constraint
6755                    // block: one forward tangent, then — because
6756                    // reverse-over-tangent is linear in its adjoint seeds —
6757                    // one unit-weight reverse sweep over the λ-weighted
6758                    // adjoints accumulated by every summand of that color.
6759                    // The flat path below repeats both sweeps over the
6760                    // inlined CSE body once per referencing summand.
6761                    (Some(lam), Some(h)) if h.use_for_hess => {
6762                        let ConHybrid {
6763                            tape,
6764                            prelude_vals,
6765                            local_vals_all,
6766                            local_off,
6767                            summand_row,
6768                            hess_color_summands,
6769                            hess_color_reach,
6770                            hess_color_reach_off,
6771                            prelude_dot,
6772                            hess_prelude_adj,
6773                            prelude_adj_dot,
6774                            local_dot,
6775                            local_adj,
6776                            local_adj_dot,
6777                            ..
6778                        } = h;
6779                        // Forward once (values are color-independent):
6780                        // prelude for the block, then each summand of a row
6781                        // with a live multiplier into its packed slice.
6782                        tape.forward_prelude(x, prelude_vals);
6783                        for (si, s) in tape.summands.iter().enumerate() {
6784                            if lam[summand_row[si] as usize] == 0.0 {
6785                                continue;
6786                            }
6787                            tape.forward_summand(
6788                                s,
6789                                x,
6790                                prelude_vals,
6791                                &mut local_vals_all[local_off[si]..local_off[si + 1]],
6792                            );
6793                        }
6794                        for (c, list) in hess_color_summands.iter().enumerate() {
6795                            if !list
6796                                .iter()
6797                                .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
6798                            {
6799                                continue;
6800                            }
6801                            let seed = &self.seeds[c];
6802                            let out = &mut self.compressed[c];
6803                            let creach = &hess_color_reach
6804                                [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
6805                            tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
6806                            for &si in list {
6807                                let si = si as usize;
6808                                let w = lam[summand_row[si] as usize];
6809                                if w == 0.0 {
6810                                    continue;
6811                                }
6812                                tape.hessian_summand_directional(
6813                                    &tape.summands[si],
6814                                    &local_vals_all[local_off[si]..local_off[si + 1]],
6815                                    prelude_dot,
6816                                    seed,
6817                                    w,
6818                                    out,
6819                                    local_dot,
6820                                    local_adj,
6821                                    local_adj_dot,
6822                                    hess_prelude_adj,
6823                                    prelude_adj_dot,
6824                                );
6825                            }
6826                            tape.prelude_reverse_directional(
6827                                prelude_vals,
6828                                prelude_dot,
6829                                creach,
6830                                out,
6831                                hess_prelude_adj,
6832                                prelude_adj_dot,
6833                            );
6834                        }
6835                    }
6836                    // Color-major and parallel when the work is big enough
6837                    // to pay for the dispatch; bit-identical to the walk
6838                    // below, which stays the path every small model takes.
6839                    #[cfg(not(target_arch = "wasm32"))]
6840                    (Some(lam), _) if hess_flat_parallel => {
6841                        self.hess_flat_colors_parallel(x_arg, lam);
6842                    }
6843                    (Some(lam), _) => {
6844                        for k in 0..self.prob.m {
6845                            let w = lam[k];
6846                            if w == 0.0 {
6847                                continue;
6848                            }
6849                            for (ti, t) in self.con_tapes[k].iter().enumerate() {
6850                                if t.ops.is_empty() {
6851                                    continue;
6852                                }
6853                                t.forward_into(x, &mut self.vals_scratch);
6854                                for &c in &self.con_tape_colors[k][ti] {
6855                                    t.hessian_directional(
6856                                        &self.vals_scratch,
6857                                        &self.seeds[c as usize],
6858                                        w,
6859                                        &mut self.compressed[c as usize],
6860                                        &mut self.dot_scratch,
6861                                        &mut self.adj_scratch,
6862                                        &mut self.adj_dot_scratch,
6863                                    );
6864                                }
6865                            }
6866                        }
6867                    }
6868                    (None, _) => {}
6869                }
6870
6871                // Decode each color's compressed Hessian-vector
6872                // result into the lower-triangle `values` array.
6873                for (c, table) in self.decoding.iter().enumerate() {
6874                    let comp = &self.compressed[c];
6875                    for w in table {
6876                        values[w.hess_idx as usize] += comp[w.row as usize];
6877                    }
6878                }
6879                true
6880            }
6881        }
6882    }
6883
6884    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
6885        self.final_x = Some(sol.x.to_vec());
6886        self.final_obj = sol.obj_value;
6887        self.final_lambda = Some(sol.lambda.to_vec());
6888        self.final_z_l = Some(sol.z_l.to_vec());
6889        self.final_z_u = Some(sol.z_u.to_vec());
6890    }
6891
6892    /// Publish the `.col` / `.row` names (captured at load time) under the
6893    /// conventional `idx_names` metadata key, in original `.nl` order. The
6894    /// adapter permutes these into split space (see
6895    /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
6896    /// near-singular Jacobian row as the `mass_balance` equation rather
6897    /// than "row 3" — the model-vs-index gap Lee et al. (2024,
6898    /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
6899    /// model debugging. Declines (returns false) when the model shipped no
6900    /// name files so callers fall back to index labels.
6901    fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
6902        let mut any = false;
6903        if !self.prob.var_names.is_empty() {
6904            var.strings
6905                .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
6906            any = true;
6907        }
6908        if !self.prob.con_names.is_empty() {
6909            con.strings
6910                .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
6911            any = true;
6912        }
6913        any
6914    }
6915
6916    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
6917        // A row is linear iff its nonlinear-part expression is the
6918        // identity zero — either left over from initial allocation ("no
6919        // `C<idx>` segment touched this row") or installed by the
6920        // constant-row-body fold in `parse_nl_text`, which shifts a
6921        // variable-free `C<idx>` body into the row bounds precisely so
6922        // that this test is a genuine linearity test and not just an
6923        // identity check (`gh #492`).
6924        for (i, t) in types.iter_mut().enumerate() {
6925            *t = if self.prob.con_nonlinear[i].is_trivially_zero() {
6926                Linearity::Linear
6927            } else {
6928                Linearity::NonLinear
6929            };
6930        }
6931        true
6932    }
6933
6934    fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
6935        // Global linearity, per the upstream TNLP contract: a variable is
6936        // NonLinear iff it appears in the nonlinear part of the objective
6937        // or of any constraint; otherwise Linear. The parsed `.nl` splits
6938        // every row into a linear part (J/G coefficient list) and a
6939        // nonlinear expression, so the set of nonlinear variables is
6940        // exactly the structural union of `collect_vars` over
6941        // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
6942        // only by a linear part — or not referenced at all — is Linear.
6943        //
6944        let nonlinear = self.nonlinear_var_set();
6945        for (i, t) in types.iter_mut().enumerate() {
6946            *t = if nonlinear.contains(&i) {
6947                Linearity::NonLinear
6948            } else {
6949                Linearity::Linear
6950            };
6951        }
6952        true
6953    }
6954
6955    fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
6956        // Objective-scoped variant of `get_variables_linearity`: only
6957        // `obj_nonlinear` contributes. This is what engages the presolve
6958        // auxiliary-elimination safeguard (pounce-presolve H11): a variable
6959        // that is nonlinear in the objective but happens to have a zero
6960        // gradient at the single probe point (e.g. `f = (x - x0)^2`
6961        // warm-started at `x0`) is kept in the objective support instead of
6962        // being mis-classified objective-free and eliminated. A variable
6963        // that is nonlinear only in *constraints* stays `Linear` here, so
6964        // the guard does not block legitimate eliminations of
6965        // objective-free equality blocks (the gas-network case).
6966        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
6967        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
6968        for (i, t) in types.iter_mut().enumerate() {
6969            *t = if nonlinear.contains(&i) {
6970                Linearity::NonLinear
6971            } else {
6972                Linearity::Linear
6973            };
6974        }
6975        true
6976    }
6977
6978    fn get_number_of_nonlinear_variables(&mut self) -> Index {
6979        self.nonlinear_variables().len() as Index
6980    }
6981
6982    fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
6983        let list = self.nonlinear_variables();
6984        if pos_nonlin_vars.len() < list.len() {
6985            return false;
6986        }
6987        pos_nonlin_vars[..list.len()].copy_from_slice(&list);
6988        true
6989    }
6990
6991    fn derivative_proofs(&mut self) -> DerivativeProofs {
6992        // Degree is the whole argument (gh #588, Q6). A body proved
6993        // degree ≤ 1 has a constant gradient and no second derivative; a
6994        // body proved degree 2 has a nonzero second derivative, hence a
6995        // gradient that moves. A body the recognizer refuses is
6996        // `Unknown` — and `Unknown` must stay `Unknown`, because the
6997        // refusal is structural, not a finding of nonlinearity.
6998        fn proof(affine: Option<bool>) -> DerivativeProof {
6999            match affine {
7000                Some(true) => DerivativeProof::Constant,
7001                Some(false) => DerivativeProof::Varying,
7002                None => DerivativeProof::Unknown,
7003            }
7004        }
7005        let obj_affine = self.prob.obj_nonlinear.provably_affine();
7006        let jac: Vec<DerivativeProof> = self
7007            .prob
7008            .con_nonlinear
7009            .iter()
7010            .map(|b| proof(b.provably_affine()))
7011            .collect();
7012
7013        // `∇²L = σ·∇²f + Σᵢ λᵢ·∇²gᵢ`. One row proved genuinely quadratic
7014        // makes `∇²L` a non-constant function of `λ` — this is the QCQP
7015        // case, and it is exactly the assertion Ipopt would honour and
7016        // get wrong (§4 Lever 2). Otherwise every row must be *proved*
7017        // affine and the objective *proved* degree ≤ 2, at which point
7018        // `∇²L = σ·∇²f`, constant for a given `σ`; the caller keys its
7019        // reuse on `σ` because the restoration phase passes a different
7020        // one.
7021        let hessian = if jac.iter().any(|&p| p == DerivativeProof::Varying) {
7022            DerivativeProof::Varying
7023        } else if obj_affine.is_some() && jac.iter().all(|&p| p == DerivativeProof::Constant) {
7024            DerivativeProof::Constant
7025        } else {
7026            DerivativeProof::Unknown
7027        };
7028
7029        DerivativeProofs {
7030            grad_f: proof(obj_affine),
7031            hessian,
7032            jac,
7033        }
7034    }
7035}
7036
7037/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
7038pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
7039    let prob = read_nl_file(path)?;
7040    Ok(Rc::new(RefCell::new(NlTnlp::try_new(prob)?)))
7041}
7042
7043#[cfg(test)]
7044mod tests {
7045    /// `.nl` text for `min sum_i exp(x_i) + x_i^2` whose sum is a left-deep
7046    /// chain of `2n - 1` binary `o0`, wrapped in `wraps` pairs of unary `o16`
7047    /// (negation, an even count so the value is unchanged). The `o0` chain is
7048    /// read as one n-ary `Sum`; the negations are what stays genuinely deep.
7049    fn deep_sum_nl(n: usize, wraps: usize) -> String {
7050        let mut l = vec![
7051            "g3 1 1 0".to_string(),
7052            format!(" {n} 1 1 0 1"),
7053            " 0 1".into(),
7054            " 0 0".into(),
7055            format!(" 0 {n} 0"),
7056            " 0 0 0 1 0".into(),
7057            " 0 0 0 0 0".into(),
7058            format!(" {n} {n}"),
7059            " 0 0".into(),
7060            " 0 0 0 0 0".into(),
7061            "C0".into(),
7062            "n0".into(),
7063            "O0 0".into(),
7064        ];
7065        l.extend(std::iter::repeat_n("o16".to_string(), 2 * wraps));
7066        l.extend(std::iter::repeat_n("o0".to_string(), 2 * n - 1));
7067        for i in 0..n {
7068            l.extend([
7069                "o44".into(),
7070                format!("v{i}"),
7071                "o2".into(),
7072                format!("v{i}"),
7073                format!("v{i}"),
7074            ]);
7075        }
7076        l.extend(["r".into(), "4 0.5".into(), "b".into()]);
7077        l.extend((0..n).map(|_| "0 -1.0 1.0".to_string()));
7078        l.push(format!("k{}", n - 1));
7079        l.extend((0..n - 1).map(|i| (i + 1).to_string()));
7080        l.push(format!("J0 {n}"));
7081        l.extend((0..n).map(|i| format!("{i} 1.0")));
7082        l.push(format!("G0 {n}"));
7083        l.extend((0..n).map(|i| format!("{i} 0.0")));
7084        l.join("\n") + "\n"
7085    }
7086
7087    /// Run `f` on a thread with the stack the CLI and `pounce-py` provide,
7088    /// declared to the reader the way they declare it.
7089    fn on_big_stack(f: impl FnOnce() + Send + 'static) {
7090        std::thread::Builder::new()
7091            .stack_size(1 << 30)
7092            .spawn(move || {
7093                set_parse_stack_budget(Some(1 << 30));
7094                f();
7095                set_parse_stack_budget(None);
7096            })
7097            .unwrap()
7098            .join()
7099            .unwrap();
7100    }
7101
7102    /// A model whose objective is the last of `len` defined variables, each
7103    /// referencing the previous: `V_0 = x0^2`, `V_k = V_{k-1} + x0`, so the
7104    /// objective is `x0^2 + (len - 1) x0`. Every `V` segment is one level
7105    /// deep to the parser; the chain is `len` deep to anything that walks
7106    /// through it.
7107    fn v_chain_nl(len: usize) -> String {
7108        let mut l = vec![
7109            "g3 1 1 0".to_string(),
7110            " 1 1 1 0 1".into(),
7111            " 0 1".into(),
7112            " 0 0".into(),
7113            " 0 1 0".into(),
7114            " 0 0 0 1 0".into(),
7115            format!(" 0 0 {len} 0 0"),
7116            " 1 1".into(),
7117            " 0 0".into(),
7118            " 0 0 0 0 0".into(),
7119        ];
7120        l.extend(["V1 0 0".into(), "o5".into(), "v0".into(), "n2".into()]);
7121        for k in 1..len {
7122            l.extend([
7123                format!("V{} 0 0", k + 1),
7124                "o0".into(),
7125                format!("v{k}"),
7126                "v0".into(),
7127            ]);
7128        }
7129        l.extend(["C0".into(), "n0".into()]);
7130        l.extend(["O0 0".into(), format!("v{len}")]);
7131        l.extend(["r".into(), "4 0.5".into(), "b".into(), "0 -1.0 1.0".into()]);
7132        l.extend(["k0".into(), "J0 1".into(), "0 1.0".into()]);
7133        l.extend(["G0 1".into(), "0 0.0".into()]);
7134        l.join("\n") + "\n"
7135    }
7136
7137    /// gh#986 review item 9: a chain of defined variables is counted by the
7138    /// depth guard. Each `V` segment is shallow to the parser, so the first
7139    /// pass let a chain of any length through, and every pass that walks
7140    /// through a `Cse` (`collect_vars`, the tape builder, drop glue) then
7141    /// recursed once per link -- an abort on an ordinary stack. On a thread
7142    /// that declared no stack (the default budget) a chain far past the guard
7143    /// is now a clean `Err`.
7144    #[test]
7145    fn gh986_a_long_defined_variable_chain_is_refused_not_overflowed() {
7146        let limit = parse_depth_limit() as usize;
7147        let err = parse_nl_text(&v_chain_nl(limit + 50))
7148            .err()
7149            .expect("a V chain past the guard must be an Err");
7150        assert!(err.contains("defined") && err.contains("levels"), "{err}");
7151        // A short chain on the same thread is fine, and evaluates.
7152        let prob = parse_nl_text(&v_chain_nl(limit / 2)).expect("inside the guard");
7153        let x = [0.5];
7154        let want = 0.25 + (limit / 2 - 1) as f64 * 0.5;
7155        let got = eval_expr(&prob.obj_expr(), &x);
7156        assert!((got - want).abs() < 1e-9, "{got} vs {want}");
7157    }
7158
7159    /// gh#986 review item 9: the depth of a `V` body is memoized, so a long
7160    /// chain inside the guard costs time linear in the chain (the first
7161    /// pass's `expr_tree_depth` re-walked the whole chain on every segment).
7162    #[test]
7163    fn gh986_a_long_defined_variable_chain_inside_the_guard_parses() {
7164        on_big_stack(|| {
7165            // Two levels per link (the `o0` and the `Cse` reference), just
7166            // inside this thread's limit.
7167            let len = (parse_depth_limit() as usize - 100) / 2;
7168            let t = std::time::Instant::now();
7169            let prob = parse_nl_text(&v_chain_nl(len)).expect("inside the guard on a big stack");
7170            let got = eval_expr(&prob.obj_expr(), &[0.5]);
7171            let want = 0.25 + (len - 1) as f64 * 0.5;
7172            assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
7173            // Linear. With only `expr_tree_depth` memoized, the parse-time
7174            // quadratic passes still re-walked the chain per segment and this
7175            // ran past 60 s (measured, test profile); seeded, ~50 ms.
7176            let took = t.elapsed();
7177            eprintln!("v chain {len}: parsed and evaluated in {took:?}");
7178            assert!(took.as_secs() < 30, "{took:?}");
7179        });
7180    }
7181
7182    /// gh#986 review item 9: a thread that declares no stack is assumed to
7183    /// have Rust's default thread stack (wasm: 1 MiB), not the CLI's 1 GiB.
7184    #[test]
7185    fn gh986_an_undeclared_stack_gets_the_default_assumption() {
7186        std::thread::spawn(|| {
7187            set_parse_stack_budget(None);
7188            assert_eq!(
7189                parse_depth_limit() as usize,
7190                DEFAULT_ASSUMED_STACK / PARSE_LEVEL_BYTES
7191            );
7192            assert!((parse_depth_limit() as usize) < MAX_PARSE_DEPTH as usize);
7193        })
7194        .join()
7195        .unwrap();
7196    }
7197
7198    /// gh#986: a left-deep `o0` chain of any length is read as one n-ary
7199    /// `Sum` — one level deep — so the issue's n = 20 000 (39 999 nested
7200    /// `o0`) parses, and so does 100k, with the same value as the nested
7201    /// binary tree would have had.
7202    #[test]
7203    fn gh986_long_o0_chain_is_read_as_a_flat_sum() {
7204        on_big_stack(|| {
7205            for n in [5_000usize, 20_000, 100_000] {
7206                let prob = parse_nl_text(&deep_sum_nl(n, 0)).expect("a long o0 sum must parse");
7207                let x: Vec<f64> = (0..n).map(|i| (i % 7) as f64 * 0.1).collect();
7208                let want: f64 = x.iter().map(|v| v.exp() + v * v).sum();
7209                let tree = prob.obj_expr();
7210                assert!(expr_tree_depth(&tree, &Default::default()) < 16, "n = {n}");
7211                let got = eval_expr(&tree, &x);
7212                assert!((got - want).abs() <= 1e-9 * want.abs(), "{got} vs {want}");
7213            }
7214        });
7215    }
7216
7217    /// gh#986: the guard follows the stack the thread declares, so a thread
7218    /// with an ordinary 8 MiB stack refuses deep nesting cleanly — in an
7219    /// unoptimised build (the worst frame size) exactly as in an optimised
7220    /// one — instead of overflowing it. This is the reliability the
7221    /// `parse_nl_text` SIGBUS/SIGSEGV reports lacked.
7222    #[test]
7223    fn gh986_guard_follows_the_declared_stack() {
7224        let stack = 8 << 20;
7225        std::thread::Builder::new()
7226            .stack_size(stack)
7227            .spawn(move || {
7228                set_parse_stack_budget(Some(stack));
7229                assert_eq!(parse_depth_limit() as usize, stack / PARSE_LEVEL_BYTES);
7230                let msg = parse_nl_text(&deep_sum_nl(4, 20_000))
7231                    .err()
7232                    .expect("40 000 levels cannot fit 8 MiB");
7233                assert!(msg.contains("levels"), "{msg}");
7234                // and a shallow model on the same thread is fine
7235                assert!(parse_nl_text(&deep_sum_nl(4, 50)).is_ok());
7236                set_parse_stack_budget(None);
7237            })
7238            .unwrap()
7239            .join()
7240            .unwrap();
7241    }
7242
7243    /// gh#986: nesting that really is deep (negations) past the guard is
7244    /// refused with an `Err`, at any depth, instead of overflowing the stack;
7245    /// nesting just inside the guard still parses. Both on the stack the
7246    /// callers provide, in debug as in release — the guard's headroom is
7247    /// what this pins.
7248    #[test]
7249    fn gh986_deep_nesting_is_a_clean_error_past_the_guard_and_parses_inside_it() {
7250        on_big_stack(|| {
7251            let deep = parse_nl_text(&deep_sum_nl(4, 30_000));
7252            let msg = deep.err().expect("past the guard must be an Err");
7253            assert!(msg.contains("levels"), "{msg}");
7254            // Just inside the limit this thread's declared stack gives
7255            // (`MAX_PARSE_DEPTH` in an optimised build, 32 768 in an
7256            // unoptimised one, whose worst-case frame is 32 KiB).
7257            let wraps = (parse_depth_limit() as usize - 100) / 2;
7258            assert!(parse_nl_text(&deep_sum_nl(4, wraps)).is_ok());
7259        });
7260    }
7261
7262    use super::*;
7263
7264    /// Compile-time guarantee for the batched-solve path (pounce#126):
7265    /// a parsed problem and the TNLP built from it must be movable to a
7266    /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
7267    /// `!Send` state) into the `Expr` DAG / tape pipeline.
7268    #[test]
7269    fn nl_problem_and_tnlp_are_send() {
7270        fn assert_send<T: Send>() {}
7271        assert_send::<NlProblem>();
7272        assert_send::<NlTnlp>();
7273        assert_send::<Expr>();
7274    }
7275
7276    /// `variant()` patches starting point / bounds on a clone and
7277    /// validates override lengths; the base instance is untouched.
7278    #[test]
7279    fn variant_overrides_bounds_and_x0() {
7280        let p = parse_nl_text(SIMPLE).expect("parse");
7281        let base = NlTnlp::new(p);
7282        let var = base
7283            .variant(&NlVariation {
7284                x0: Some(vec![3.0, 4.0]),
7285                x_l: Some(vec![-1.0, -2.0]),
7286                x_u: Some(vec![5.0, 6.0]),
7287                ..Default::default()
7288            })
7289            .expect("variant");
7290        let mut var = var;
7291        let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
7292        let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
7293        assert!(var.get_bounds_info(BoundsInfo {
7294            x_l: &mut x_l,
7295            x_u: &mut x_u,
7296            g_l: &mut g_l,
7297            g_u: &mut g_u,
7298        }));
7299        assert_eq!(x_l, [-1.0, -2.0]);
7300        assert_eq!(x_u, [5.0, 6.0]);
7301        let mut x = [0.0; 2];
7302        let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
7303        assert!(var.get_starting_point(StartingPoint {
7304            init_x: true,
7305            x: &mut x,
7306            init_z: false,
7307            z_l: &mut zl,
7308            z_u: &mut zu,
7309            init_lambda: false,
7310            lambda: &mut lam,
7311        }));
7312        assert_eq!(x, [3.0, 4.0]);
7313        // Base keeps its parsed (free) bounds.
7314        assert!(base.problem().x_l[0] < -1.0e18);
7315        // Length mismatch is an error, not a panic.
7316        assert!(
7317            base.variant(&NlVariation {
7318                x0: Some(vec![1.0]),
7319                ..Default::default()
7320            })
7321            .is_err()
7322        );
7323    }
7324
7325    /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
7326    /// Header values:
7327    ///   line 2: n=2 m=0 num_obj=1 0 0
7328    ///   line 3: 0 1   (1 nonlinear objective)
7329    ///   line 4: 0 0
7330    ///   line 5: 0 2 0 (nonlinear vars in obj=2)
7331    ///   line 6: 0 0 0 1
7332    ///   line 7: 0 0 0 0 0
7333    ///   line 8: 0 0   (no Jacobian nonzeros, no linear obj)
7334    ///   line 9: 0 0
7335    ///   line 10: 0 0 0 0 0
7336    /// Then `O0 0` followed by an expression tree:
7337    /// `(x0 - 1)^2 + (x1 - 2)^2` =
7338    ///   o0
7339    ///     o5 (o1 v0 n1) n2
7340    ///     o5 (o1 v1 n2) n2
7341    /// Then `b` segment: free for both.
7342    /// gh#987: Gay's ordering places nonlinear integers at the END of each
7343    /// nonlinear block and linear ones at the end of the model.
7344    #[test]
7345    fn discrete_census_locates_nonlinear_and_linear_integer_columns() {
7346        // n = 10: nlvb = 2 (1 int), con-only 3 (1 int), obj-only 1 (1 int),
7347        // then linear, ending in nbv = 1, niv = 1.
7348        let d = DiscreteCensus {
7349            binary: 1,
7350            integer: 1,
7351            nl_both: 1,
7352            nl_cons: 1,
7353            nl_objs: 1,
7354        };
7355        let c = NlCounts {
7356            nl_cons: 1,
7357            nl_objs: 1,
7358            nl_vars_cons: 5,
7359            nl_vars_objs: 6,
7360            nl_vars_both: 2,
7361        };
7362        assert_eq!(d.total(), 5);
7363        assert_eq!(d.integer_columns(10, Some(c)), Some(vec![1, 4, 5, 8, 9]));
7364        // Without the line-5 census the nonlinear columns are unknowable.
7365        assert_eq!(d.integer_columns(10, None), None);
7366        // Linear-only integers need no census.
7367        let lin = DiscreteCensus {
7368            binary: 2,
7369            ..Default::default()
7370        };
7371        assert_eq!(lin.integer_columns(4, None), Some(vec![2, 3]));
7372        // Inconsistent counts: more nonlinear-in-both integers than columns.
7373        let bad = DiscreteCensus {
7374            nl_both: 3,
7375            ..Default::default()
7376        };
7377        assert_eq!(bad.integer_columns(10, Some(c)), None);
7378    }
7379
7380    const SIMPLE: &str = "g3 0 1 0
73812 0 1 0 0
73820 1
73830 0
73840 2 0
73850 0 0 1
73860 0 0 0 0
73870 0
73880 0
73890 0 0 0 0
7390O0 0
7391o0
7392o5
7393o1
7394v0
7395n1
7396n2
7397o5
7398o1
7399v1
7400n2
7401n2
7402b
74033
74043
7405";
7406
7407    #[test]
7408    fn parses_simple_quadratic() {
7409        let p = parse_nl_text(SIMPLE).expect("parse");
7410        assert_eq!(p.n, 2);
7411        assert_eq!(p.m, 0);
7412        assert_eq!(p.num_obj, 1);
7413        // f(0,0) = 1 + 4 = 5
7414        let f = eval_expr(&p.obj_expr(), &[0.0, 0.0]);
7415        assert!((f - 5.0).abs() < 1e-12);
7416        // f(1,2) = 0
7417        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
7418        assert!(f.abs() < 1e-12);
7419    }
7420
7421    #[test]
7422    fn gradient_matches_analytic() {
7423        let p = parse_nl_text(SIMPLE).expect("parse");
7424        let x = [0.5, 1.0];
7425        let mut g = [0.0_f64; 2];
7426        grad_expr(&p.obj_expr(), &x, 1.0, &mut g);
7427        // d/dx0 = 2*(x0-1) = -1.0
7428        // d/dx1 = 2*(x1-2) = -2.0
7429        assert!((g[0] - (-1.0)).abs() < 1e-12);
7430        assert!((g[1] - (-2.0)).abs() < 1e-12);
7431    }
7432
7433    /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
7434    /// with global semantics so the presolve auxiliary-elimination
7435    /// safeguard actually engages. Pre-fix the default trait stub returned
7436    /// `false` and left the slice untouched, so a variable that is
7437    /// nonlinear in the objective but zero-gradient at the probe point
7438    /// could be wrongly eliminated.
7439    ///
7440    /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
7441    /// of the objective (NonLinear); x1 appears only in the linear part
7442    /// (Linear).
7443    #[test]
7444    fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
7445        // (x0 - 1)^2
7446        let obj_nl = Expr::Binary(
7447            BinOp::Pow,
7448            Box::new(Expr::Binary(
7449                BinOp::Sub,
7450                Box::new(Expr::Var(0)),
7451                Box::new(Expr::Const(1.0)),
7452            )),
7453            Box::new(Expr::Const(2.0)),
7454        );
7455        let prob = NlProblem {
7456            src: None,
7457            cse_bodies: Vec::new(),
7458            n: 2,
7459            m: 0,
7460            num_obj: 1,
7461            minimize: true,
7462            obj_nonlinear: NlBody::Tree(obj_nl),
7463            obj_linear: vec![(1, 3.0)],
7464            obj_constant: 0.0,
7465            con_nonlinear: vec![],
7466            con_linear: vec![],
7467            x_l: vec![f64::NEG_INFINITY; 2],
7468            x_u: vec![f64::INFINITY; 2],
7469            g_l: vec![],
7470            g_u: vec![],
7471            x0: vec![0.0; 2],
7472            lambda0: vec![],
7473            suffixes: NlSuffixes::default(),
7474            imported_funcs: vec![],
7475            ampl_options: vec![],
7476            nl_counts: None,
7477            n_discrete: None,
7478            var_names: vec![],
7479            con_names: vec![],
7480        };
7481        let mut tnlp = NlTnlp::new(prob);
7482        let mut types = vec![Linearity::Linear; 2];
7483        let ok = tnlp.get_variables_linearity(&mut types);
7484        // Pre-fix: default stub returns false (slice untouched).
7485        assert!(
7486            ok,
7487            "get_variables_linearity must report it filled the slice"
7488        );
7489        assert!(
7490            matches!(types[0], Linearity::NonLinear),
7491            "x0 is nonlinear in the objective"
7492        );
7493        assert!(
7494            matches!(types[1], Linearity::Linear),
7495            "x1 appears only in the linear part"
7496        );
7497    }
7498
7499    /// Objective-scoped linearity must NOT inherit constraint
7500    /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
7501    /// (constraint tape) but linear w.r.t. the objective, so the presolve
7502    /// H11 guard must not treat it as objective-coupled — that was the CI
7503    /// regression where every gas-network variable (nonlinear in the flow
7504    /// equations, absent from the linear objective) blocked Phase-0
7505    /// elimination.
7506    #[test]
7507    fn objective_variables_linearity_ignores_constraint_nonlinearity() {
7508        // x0^2
7509        let con_nl = Expr::Binary(
7510            BinOp::Pow,
7511            Box::new(Expr::Var(0)),
7512            Box::new(Expr::Const(2.0)),
7513        );
7514        let prob = NlProblem {
7515            src: None,
7516            cse_bodies: Vec::new(),
7517            n: 2,
7518            m: 1,
7519            num_obj: 1,
7520            minimize: true,
7521            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
7522            obj_linear: vec![(1, 3.0)],
7523            obj_constant: 0.0,
7524            con_nonlinear: vec![NlBody::Tree(con_nl)],
7525            con_linear: vec![vec![]],
7526            x_l: vec![f64::NEG_INFINITY; 2],
7527            x_u: vec![f64::INFINITY; 2],
7528            g_l: vec![4.0],
7529            g_u: vec![4.0],
7530            x0: vec![0.0; 2],
7531            lambda0: vec![0.0],
7532            suffixes: NlSuffixes::default(),
7533            imported_funcs: vec![],
7534            ampl_options: vec![],
7535            nl_counts: None,
7536            n_discrete: None,
7537            var_names: vec![],
7538            con_names: vec![],
7539        };
7540        let mut tnlp = NlTnlp::new(prob);
7541
7542        let mut global = vec![Linearity::Linear; 2];
7543        assert!(tnlp.get_variables_linearity(&mut global));
7544        assert!(
7545            matches!(global[0], Linearity::NonLinear),
7546            "global tags see x0's constraint nonlinearity"
7547        );
7548
7549        let mut obj = vec![Linearity::NonLinear; 2];
7550        assert!(tnlp.get_objective_variables_linearity(&mut obj));
7551        assert!(
7552            matches!(obj[0], Linearity::Linear),
7553            "x0 is linear w.r.t. the objective despite the nonlinear constraint"
7554        );
7555        assert!(
7556            matches!(obj[1], Linearity::Linear),
7557            "x1 is linear everywhere"
7558        );
7559    }
7560
7561    /// Header lines 3 and 5 land in [`NlCounts`], with the field order the
7562    /// format documents: `nlc nlo` then `nlvc nlvo nlvb`. `SIMPLE` is
7563    /// `min (x0-1)^2 + (x1-2)^2`, so one nonlinear objective, no nonlinear
7564    /// constraints, and both variables nonlinear in the objective only.
7565    #[test]
7566    fn header_census_is_parsed() {
7567        let p = parse_nl_text(SIMPLE).expect("parse");
7568        let c = p.nl_counts.expect("SIMPLE has a well-formed header");
7569        assert_eq!(c.nl_cons, 0);
7570        assert_eq!(c.nl_objs, 1);
7571        assert_eq!((c.nl_vars_cons, c.nl_vars_objs, c.nl_vars_both), (0, 2, 0));
7572        assert_eq!(c.nonlinear_vars(), 2);
7573    }
7574
7575    /// `nlvb` is inside both `nlvc` and `nlvo`, so the total is
7576    /// `nlvc + nlvo − nlvb`. The two degenerate directions matter as much
7577    /// as the overlapping one: disjoint sets add, and `max` would be wrong
7578    /// for them.
7579    #[test]
7580    fn nonlinear_var_total_uses_inclusion_exclusion() {
7581        let c = |vc, vo, vb| NlCounts {
7582            nl_cons: 0,
7583            nl_objs: 0,
7584            nl_vars_cons: vc,
7585            nl_vars_objs: vo,
7586            nl_vars_both: vb,
7587        };
7588        // Fully shared: the same 5 variables in both.
7589        assert_eq!(c(5, 5, 5).nonlinear_vars(), 5);
7590        // Disjoint: `min x0^2 s.t. x1^2 <= 1` is two nonlinear variables,
7591        // not the `max(nlvc, nlvo) = 1` a naive reading gives.
7592        assert_eq!(c(1, 1, 0).nonlinear_vars(), 2);
7593        // Partial overlap: 4 + 3 - 2.
7594        assert_eq!(c(4, 3, 2).nonlinear_vars(), 5);
7595        // Nonsense header: saturates instead of underflowing.
7596        assert_eq!(c(1, 1, 9).nonlinear_vars(), 0);
7597    }
7598
7599    /// A header that does not carry the documented fields records no
7600    /// census at all rather than a guess of zero — "no nonlinear
7601    /// variables" is a claim, and a truncated header has not made it.
7602    #[test]
7603    fn short_header_line_records_no_census() {
7604        // Line 5 with two fields instead of `nlvc nlvo nlvb`.
7605        let txt = SIMPLE.replacen("0 2 0\n", "0 2\n", 1);
7606        assert_ne!(txt, SIMPLE, "the substitution must have applied");
7607        let p = parse_nl_text(&txt).expect("parse");
7608        assert!(p.nl_counts.is_none());
7609    }
7610
7611    /// `get_number_of_nonlinear_variables` used to be the trait default,
7612    /// `-1` ("assume everything is nonlinear"). It now answers from the
7613    /// trees, and `get_list_of_nonlinear_variables` agrees with it.
7614    ///
7615    /// `min (x0 - 1)^2 + 3*x1`: x0 is nonlinear, x1 is not.
7616    #[test]
7617    fn nonlinear_variable_list_excludes_linear_columns() {
7618        let obj_nl = Expr::Binary(
7619            BinOp::Pow,
7620            Box::new(Expr::Binary(
7621                BinOp::Sub,
7622                Box::new(Expr::Var(0)),
7623                Box::new(Expr::Const(1.0)),
7624            )),
7625            Box::new(Expr::Const(2.0)),
7626        );
7627        let parts = NlProblemParts {
7628            minimize: true,
7629            objective: obj_nl,
7630            obj_constant: 0.0,
7631            constraints: vec![],
7632            x_l: vec![-1e19; 2],
7633            x_u: vec![1e19; 2],
7634            x0: vec![0.0; 2],
7635            g_l: vec![],
7636            g_u: vec![],
7637            var_names: vec![],
7638            con_names: vec![],
7639        };
7640        let prob = NlProblem::from_expressions(parts).expect("build");
7641        assert!(
7642            prob.nl_counts.is_none(),
7643            "a model built in memory has no header to read"
7644        );
7645        let mut tnlp = NlTnlp::new(prob);
7646        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 1);
7647        let mut list = [-1 as Index; 2];
7648        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7649        assert_eq!(list[0], 0);
7650    }
7651
7652    /// When the header says every variable is nonlinear the walk is
7653    /// skipped, and the answer is the same one the walk would give for
7654    /// `SIMPLE` (both variables appear in the objective's nonlinear part).
7655    #[test]
7656    fn all_nonlinear_header_short_circuits_to_n() {
7657        let p = parse_nl_text(SIMPLE).expect("parse");
7658        assert_eq!(p.nl_counts.expect("census").nonlinear_vars(), p.n);
7659        let mut tnlp = NlTnlp::new(p);
7660        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 2);
7661        let mut list = [-1 as Index; 2];
7662        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7663        assert_eq!(list, [0, 1]);
7664    }
7665
7666    /// The list must not be written when the caller's slice is too small —
7667    /// the contract's `false` return, not a panic.
7668    #[test]
7669    fn nonlinear_variable_list_declines_a_short_slice() {
7670        let p = parse_nl_text(SIMPLE).expect("parse");
7671        let mut tnlp = NlTnlp::new(p);
7672        let mut list = [-1 as Index; 1];
7673        assert!(!tnlp.get_list_of_nonlinear_variables(&mut list));
7674        assert_eq!(list, [-1]);
7675    }
7676
7677    /// `min x0^2 + x1^2  s.t.  x0 + x1 = 1`.
7678    /// One equality constraint with a purely linear Jacobian — exercises
7679    /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
7680    /// kind 4).
7681    ///
7682    /// Header layout:
7683    ///   line 1: g3 0 1 0
7684    ///   line 2: 2 1 1 0 0   (n=2, m=1, num_obj=1)
7685    ///   line 3: 0 1         (1 nonlinear obj, 0 nonlinear cons)
7686    ///   line 4: 0 0
7687    ///   line 5: 0 2 0       (nonlinear vars in obj=2)
7688    ///   line 6: 0 0 0 1
7689    ///   line 7: 0 0 0 0 0
7690    ///   line 8: 2 0         (Jacobian nnz=2, no linear obj)
7691    ///   line 9: 0 0
7692    ///   line 10: 0 0 0 0 0
7693    /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
7694    /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
7695    const EQ_LIN: &str = "g3 0 1 0
76962 1 1 0 0
76970 1
76980 0
76990 2 0
77000 0 0 1
77010 0 0 0 0
77022 0
77030 0
77040 0 0 0 0
7705C0
7706n0
7707O0 0
7708o0
7709o5
7710v0
7711n2
7712o5
7713v1
7714n2
7715r
77164 1
7717b
77183
77193
7720k1
77212
7722J0 2
77230 1
77241 1
7725";
7726
7727    #[test]
7728    fn parses_constrained_problem() {
7729        let p = parse_nl_text(EQ_LIN).expect("parse");
7730        assert_eq!(p.n, 2);
7731        assert_eq!(p.m, 1);
7732        // r-segment kind 4 (equality with rhs=1).
7733        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7734        assert!((p.g_u[0] - 1.0).abs() < 1e-12);
7735        // J-row 0: x0 (coef 1), x1 (coef 1).
7736        assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
7737    }
7738
7739    #[test]
7740    fn malformed_j_variable_index_is_parse_error_not_panic() {
7741        // Code review L32: a J-segment entry's variable (column) index was
7742        // pushed into con_linear unchecked, so an out-of-range index (here 5
7743        // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
7744        // constraint evaluation. It must instead surface as a clean parse
7745        // error, consistent with the existing `J<row> out of range` check.
7746        let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
7747        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7748        let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
7749        assert!(err.contains("out of range"), "unexpected error: {err}");
7750    }
7751
7752    #[test]
7753    fn out_of_range_x_segment_index_is_parse_error() {
7754        // Same strictness for the initial-primal `x` segment: an index past
7755        // `n` used to be silently dropped; now it is a parse error, so the
7756        // four index-bearing segments (J/G/x/d) behave consistently.
7757        let bad = format!("{EQ_LIN}x1\n5 0.5\n");
7758        let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
7759        assert!(err.contains("out of range"), "unexpected error: {err}");
7760    }
7761
7762    // ---------------------------------------------------------------
7763    // gh #492 — a constant `C<i>` body folds into the row bounds.
7764    //
7765    // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
7766    // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
7767    // constant-body rows to fold.
7768    // ---------------------------------------------------------------
7769
7770    /// Linearity is a *linearity* test, not "did a `C` segment touch this
7771    /// row". A row whose body is the bare constant `3` is affine.
7772    #[test]
7773    fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
7774        // `x0 + x1 + 3 = 1`  ⇔  `x0 + x1 = -2`.
7775        let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7776        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7777        let p = parse_nl_text(&nl).expect("parse");
7778
7779        assert!(
7780            p.con_nonlinear[0].is_trivially_zero(),
7781            "the constant body must be replaced by the identity zero, got {:?}",
7782            p.con_nonlinear[0]
7783        );
7784        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7785        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7786
7787        let mut lin = [Linearity::NonLinear];
7788        let mut t = NlTnlp::new(p);
7789        assert!(t.get_constraints_linearity(&mut lin));
7790        assert_eq!(lin[0], Linearity::Linear);
7791    }
7792
7793    /// The shift must be exact, not merely "linear now": the folded model
7794    /// and the hand-folded one must be the same problem, row body for row
7795    /// body and bound for bound. That is what keeps feasibility, the
7796    /// active set, and the duals unchanged.
7797    #[test]
7798    fn folding_a_row_constant_gives_the_hand_folded_problem() {
7799        // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
7800        let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7801        let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
7802        assert_ne!(offset, EQ_LIN);
7803        assert_ne!(folded, EQ_LIN);
7804
7805        let a = parse_nl_text(&offset).expect("parse offset form");
7806        let b = parse_nl_text(&folded).expect("parse folded form");
7807        assert_eq!(a.g_l, b.g_l);
7808        assert_eq!(a.g_u, b.g_u);
7809        assert_eq!(a.con_linear, b.con_linear);
7810
7811        // And the row *values* agree pointwise, which is the property the
7812        // duals ride on: `g(x) - g_l` is the same residual either way.
7813        let mut ga = [0.0];
7814        let mut gb = [0.0];
7815        let x = [0.75, -1.25];
7816        assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
7817        assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
7818        assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
7819    }
7820
7821    /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
7822    /// constant as `n3`, and AMPL emits such trees when a expression
7823    /// collapses without being re-simplified.
7824    #[test]
7825    fn a_row_body_that_evaluates_to_a_constant_folds_too() {
7826        // `C0` = `1 + 2`.
7827        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
7828        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7829        let p = parse_nl_text(&nl).expect("parse");
7830        assert!(p.con_nonlinear[0].is_trivially_zero());
7831        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7832        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7833    }
7834
7835    /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
7836    /// "absent", not "a very large number". Shifting one turns it into a
7837    /// real bound and invents a constraint that is not in the model.
7838    ///
7839    /// The constants here are deliberately huge. An everyday `3` is
7840    /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
7841    /// presence guard would go unnoticed at ordinary magnitudes and then
7842    /// bite on a model that scales its rows. The guard is what makes the
7843    /// sentinel untouchable at *any* magnitude.
7844    #[test]
7845    fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
7846        // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
7847        // bound. Shifting the lower sentinel would leave `-9e18`, a real
7848        // bound, so the row would gain a floor the model never stated.
7849        let nl = EQ_LIN
7850            .replace("C0\nn0\n", "C0\nn-1e18\n")
7851            .replace("r\n4 1\n", "r\n1 1\n");
7852        let p = parse_nl_text(&nl).expect("parse");
7853        assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
7854        assert!(
7855            !lower_bound_present(p.g_l[0]),
7856            "the absent-lower sentinel became a real bound: {}",
7857            p.g_l[0]
7858        );
7859
7860        // The mirror case: a positive constant on a lower-bounded row is
7861        // the one that would pull the *upper* sentinel below 1e19.
7862        let nl = EQ_LIN
7863            .replace("C0\nn0\n", "C0\nn1e18\n")
7864            .replace("r\n4 1\n", "r\n2 1\n");
7865        let p = parse_nl_text(&nl).expect("parse");
7866        assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
7867        assert!(
7868            !upper_bound_present(p.g_u[0]),
7869            "the absent-upper sentinel became a real bound: {}",
7870            p.g_u[0]
7871        );
7872    }
7873
7874    /// A row body that mentions a variable is not a constant, however
7875    /// simple it looks. Folding it would delete the term.
7876    #[test]
7877    fn a_row_body_with_a_variable_is_not_folded() {
7878        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
7879        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7880        let p = parse_nl_text(&nl).expect("parse");
7881        assert!(
7882            !matches!(p.con_nonlinear[0].tree(), Some(Expr::Const(_))),
7883            "a row in x0 was folded away: {:?}",
7884            p.con_nonlinear[0]
7885        );
7886        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
7887        assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
7888    }
7889
7890    /// A variable-free body whose value is not finite is left in place. It
7891    /// makes the row infeasible (or ill-posed) and that is the solver's
7892    /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
7893    /// corrupt the bound pair and take every downstream presence test with
7894    /// it.
7895    #[test]
7896    fn a_non_finite_constant_row_body_is_not_folded() {
7897        // `C0` = `log(-1)` = NaN.
7898        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
7899        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7900        let p = parse_nl_text(&nl).expect("parse");
7901        assert!(
7902            !p.con_nonlinear[0].is_trivially_zero(),
7903            "a NaN body was folded into the bounds"
7904        );
7905        assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
7906        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7907    }
7908
7909    /// An imported-function call is not a parse-time constant even with
7910    /// constant arguments — it is resolved to a shared library much later
7911    /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
7912    /// rather than guess. The fold must decline before it evaluates.
7913    #[test]
7914    fn a_constant_argument_funcall_row_body_is_not_folded() {
7915        // Declare one imported function and call it with a literal.
7916        let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
7917        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7918        let p = parse_nl_text(&nl).expect("parse");
7919        assert!(
7920            matches!(p.con_nonlinear[0].tree(), Some(Expr::Funcall { .. })),
7921            "expected the funcall to survive the fold, got {:?}",
7922            p.con_nonlinear[0]
7923        );
7924        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
7925    }
7926
7927    #[test]
7928    fn k_segment_nonstandard_count_is_parse_error_at_source() {
7929        // Code review L35: the `k` (Jacobian column-count) segment header
7930        // declares how many count lines follow — `k<count>` — and the
7931        // standard value is n-1. The parser used to *assume* n-1 and ignore
7932        // the header, so a file declaring a different count read the wrong
7933        // number of data lines, desynced the segment stream, and failed far
7934        // downstream with a confusing error (or silently mis-parsed). With
7935        // the declared count now read and validated, a nonstandard count is
7936        // a clear parse error at its source. Here EQ_LIN has n=2 (expected
7937        // count 1); rewrite its `k1` + one count line to `k0`.
7938        let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
7939        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7940        let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
7941        assert!(
7942            err.contains("k-segment declares"),
7943            "expected a clear k-segment count error, got: {err}"
7944        );
7945    }
7946
7947    #[test]
7948    fn get_starting_point_returns_nl_initial_duals() {
7949        // Code review 2026-06 item M19: the `.nl` `d` segment supplies
7950        // initial constraint multipliers. They are parsed into `lambda0`,
7951        // but `get_starting_point` previously ignored them — so a
7952        // `warm_start_init_point yes` solve silently began from zero duals.
7953        // `get_starting_point` must hand the parsed duals back when the
7954        // engine requests them (`init_lambda`), and leave the buffer
7955        // untouched when it does not.
7956        let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
7957        let p = parse_nl_text(&nl).expect("parse");
7958        // gh#1033: a `d` entry is an AMPL dual, so it is stored negated.
7959        assert_eq!(p.lambda0, vec![-2.5], "the `d` segment fills lambda0");
7960
7961        let mut t = NlTnlp::new(p);
7962        let info = t.get_nlp_info().unwrap();
7963        let (n, m) = (info.n as usize, info.m as usize);
7964
7965        // Warm-start request: init_lambda = true → the parsed `.nl` duals
7966        // must be returned (pre-fix this stayed zero).
7967        let mut x = vec![0.0; n];
7968        let mut z_l = vec![0.0; n];
7969        let mut z_u = vec![0.0; n];
7970        let mut lambda = vec![0.0; m];
7971        assert!(t.get_starting_point(StartingPoint {
7972            init_x: true,
7973            x: &mut x,
7974            init_z: false,
7975            z_l: &mut z_l,
7976            z_u: &mut z_u,
7977            init_lambda: true,
7978            lambda: &mut lambda,
7979        }));
7980        assert_eq!(
7981            lambda,
7982            vec![-2.5],
7983            "a warm start must use the `.nl` initial duals, not zero"
7984        );
7985
7986        // No warm-start request: the multiplier buffer is left alone (the
7987        // engine owns its default), so honoring the flag does not clobber it.
7988        let mut lambda_untouched = vec![7.0; m];
7989        assert!(t.get_starting_point(StartingPoint {
7990            init_x: true,
7991            x: &mut x,
7992            init_z: false,
7993            z_l: &mut z_l,
7994            z_u: &mut z_u,
7995            init_lambda: false,
7996            lambda: &mut lambda_untouched,
7997        }));
7998        assert_eq!(
7999            lambda_untouched,
8000            vec![7.0],
8001            "without init_lambda the multiplier buffer must be untouched"
8002        );
8003    }
8004
8005    /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
8006    /// unconstrained, `n` variables. The trailing product puts a nonzero
8007    /// in row 0 of *every* Hessian column, which is the shape that used
8008    /// to force the greedy coloring to hand out one color per variable.
8009    fn dense_row_objective_nl(n: usize) -> String {
8010        let mut s = String::new();
8011        s.push_str("g3 1 1 0\n");
8012        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8013        s.push_str(" 0 1\n 0 0\n");
8014        s.push_str(&format!(" {n} {n} {n}\n"));
8015        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8016        s.push_str(&format!(" 0 {n}\n"));
8017        s.push_str(" 0 0\n 0 0 0 0 0\n");
8018        // objective
8019        s.push_str("O0 0\n");
8020        s.push_str(&format!("o54\n{}\n", n + 1));
8021        for j in 0..n {
8022            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8023        }
8024        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8025        for j in 0..n {
8026            s.push_str(&format!("v{j}\n"));
8027        }
8028        // start, bounds, gradient
8029        s.push_str(&format!("x{n}\n"));
8030        for j in 0..n {
8031            s.push_str(&format!("{j} 0.5\n"));
8032        }
8033        s.push_str("b\n");
8034        for _ in 0..n {
8035            s.push_str("3\n");
8036        }
8037        s.push_str(&format!("G0 {n}\n"));
8038        for j in 0..n {
8039            s.push_str(&format!("{j} 0.0\n"));
8040        }
8041        s
8042    }
8043
8044    /// Same shape as [`dense_row_objective_nl`], but the coupling term
8045    /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
8046    /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
8047    /// magnitude makes the dense column ill-scaled — the case where
8048    /// reading its entries out of its own pass costs real digits.
8049    fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
8050        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8051        let mut s = String::new();
8052        s.push_str("g3 1 1 0\n");
8053        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8054        s.push_str(" 0 1\n 0 0\n");
8055        s.push_str(&format!(" {n} {n} {n}\n"));
8056        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8057        s.push_str(&format!(" 0 {n}\n"));
8058        s.push_str(" 0 0\n 0 0 0 0 0\n");
8059        s.push_str("O0 0\n");
8060        s.push_str(&format!("o54\n{}\n", n + 1));
8061        for j in 0..n {
8062            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8063        }
8064        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8065        for j in 0..n {
8066            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8067        }
8068        s.push_str(&format!("x{n}\n"));
8069        for j in 0..n {
8070            s.push_str(&format!("{j} 0.5\n"));
8071        }
8072        s.push_str("b\n");
8073        for _ in 0..n {
8074            s.push_str("3\n");
8075        }
8076        s.push_str(&format!("G0 {n}\n"));
8077        for j in 0..n {
8078            s.push_str(&format!("{j} 0.0\n"));
8079        }
8080        s
8081    }
8082
8083    /// Locate a model in the benchmark corpus, or `None` if the corpus is
8084    /// not on this machine.
8085    ///
8086    /// The corpus is ~2 GB and deliberately outside the checkout (see
8087    /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
8088    /// no-op rather than fail. That is a real limitation — a check that
8089    /// silently does nothing is how the gap below went unnoticed in the
8090    /// first place — so anything using this must also be covered by a
8091    /// synthetic case that always runs.
8092    fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
8093        let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
8094        let p = std::path::PathBuf::from(root).join(rel);
8095        p.is_file().then_some(p)
8096    }
8097
8098    /// The corpus check the dense-column optimization never got.
8099    ///
8100    /// `cho_parmest` is the model whose certificate the optimization cost,
8101    /// and it could not have caught it: the model is 4.3 MB and lives in
8102    /// the benchmark data set, not in the repository, so validating
8103    /// against the in-repo `.nl` fixtures said nothing about it. This test
8104    /// closes that by checking the corpus directly wherever the corpus
8105    /// exists, which is every machine and CI job that runs the benchmarks.
8106    ///
8107    /// The assertion is the one that matters and the one that was never
8108    /// made: whatever the guard leaves peeled must decode to the same
8109    /// Hessian as peeling nothing. Against the pre-fix code this fails —
8110    /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
8111    #[test]
8112    fn cho_parmest_decodes_to_its_unpeeled_reference() {
8113        let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
8114            eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
8115            return;
8116        };
8117        let p = read_nl_file(&path).expect("read cho_parmest");
8118        let n = p.n;
8119        let mut t = NlTnlp::new(p);
8120        // The guard vetoes 7 of cho's 12 peeled columns, and putting those
8121        // seven dense rows back into the conflict graph costs the coloring
8122        // outright: it goes from 17 colors to 9010, and no column clears the
8123        // density threshold afterwards, so the model ends up fully unpeeled.
8124        // That is the price of the certificate on this model, and it is worth
8125        // knowing rather than assuming the other five survive.
8126        assert!(t.peeled_cols.is_empty());
8127
8128        let info = t.get_nlp_info().unwrap();
8129        let nnz = info.nnz_h_lag as usize;
8130        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8131        assert!(t.eval_h(
8132            None,
8133            true,
8134            1.0,
8135            None,
8136            true,
8137            SparsityRequest::Structure {
8138                irow: &mut irow,
8139                jcol: &mut jcol
8140            }
8141        ));
8142        // Evaluate away from `x0` with non-uniform multipliers. The damage
8143        // is point-dependent, and `x0` is close to where it is least
8144        // visible: only 5 entries move there, against 48,931 here. Note the
8145        // guard's own probe runs at `x0` — it fires anyway because it tests
8146        // a bound on what a pass *could* lose, not the damage it happens to
8147        // commit at one point. That is the property that makes it robust,
8148        // and this asymmetry is worth keeping in front of anyone who
8149        // retunes it.
8150        let x: Vec<f64> = t
8151            .prob
8152            .x0
8153            .iter()
8154            .enumerate()
8155            .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
8156            .collect();
8157        let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
8158
8159        let mut got = vec![0.0_f64; nnz];
8160        assert!(t.eval_h(
8161            Some(&x),
8162            true,
8163            1.0,
8164            Some(&lambda),
8165            true,
8166            SparsityRequest::Values { values: &mut got }
8167        ));
8168
8169        t.recolor(&vec![true; n]);
8170        assert!(t.peeled_cols.is_empty());
8171        let mut want = vec![0.0_f64; nnz];
8172        assert!(t.eval_h(
8173            Some(&x),
8174            true,
8175            1.0,
8176            Some(&lambda),
8177            true,
8178            SparsityRequest::Values { values: &mut want }
8179        ));
8180
8181        let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
8182        let mut worst = 0.0_f64;
8183        let mut at = 0usize;
8184        for k in 0..nnz {
8185            let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8186            if rel > worst {
8187                worst = rel;
8188                at = k;
8189            }
8190        }
8191        assert!(
8192            worst <= 1e-13,
8193            "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
8194             {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
8195             out of a pass it cannot be read out of.",
8196            irow[at],
8197            jcol[at],
8198            got[at],
8199            want[at]
8200        );
8201
8202        // The check above passes trivially on correct code, because the
8203        // guard leaves cho fully unpeeled and so compares a configuration
8204        // against itself. It only has teeth against a regression. So prove
8205        // the guard is doing necessary work rather than assuming it: put
8206        // the peels back the way the pre-fix reader had them and confirm
8207        // the Hessian really does come apart.
8208        t.recolor(&vec![false; n]);
8209        assert!(
8210            !t.peeled_cols.is_empty(),
8211            "restoring the unvetoed coloring must peel again"
8212        );
8213        let mut unguarded = vec![0.0_f64; nnz];
8214        assert!(t.eval_h(
8215            Some(&x),
8216            true,
8217            1.0,
8218            Some(&lambda),
8219            true,
8220            SparsityRequest::Values {
8221                values: &mut unguarded
8222            }
8223        ));
8224        let mut bad = 0usize;
8225        let mut worst_unguarded = 0.0_f64;
8226        for k in 0..nnz {
8227            let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8228            if rel > 1e-13 {
8229                bad += 1;
8230            }
8231            worst_unguarded = worst_unguarded.max(rel);
8232        }
8233        // Two different counts get quoted about this model and they measure
8234        // different things: 48,931 of the 96,000 entries differ from the
8235        // uncompressed reference *at all*, down to the last bit, while 88
8236        // exceed 1e-13 relative. The second is the one worth asserting on.
8237        assert!(
8238            bad >= 50 && worst_unguarded > 1e-11,
8239            "peeling cho_parmest unguarded should damage the entries the guard \
8240             exists to protect (measured: 88 entries past 1e-13 relative, \
8241             worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
8242             this fires, the model or the corpus changed and the guard's \
8243             calibration should be re-derived rather than the bound relaxed."
8244        );
8245        eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
8246    }
8247
8248    /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
8249    /// the dense variable is `x_{n-1}` instead of `x_0`:
8250    /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
8251    ///
8252    /// The index matters, and it is the whole reason this helper exists.
8253    /// With the dense variable at 0 every coupling entry is stored as
8254    /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
8255    /// out of column 0's own pass whether or not peeling is on, so the two
8256    /// paths are bit-identical and no test built on that shape can tell
8257    /// them apart. Putting the dense variable last stores them as
8258    /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
8259    /// back from column `n-1`'s pass by symmetry, carrying that pass's
8260    /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
8261    /// That is the category every one of `cho_parmest`'s 48,931 damaged
8262    /// entries fell into.
8263    fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
8264        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
8265        let d = n - 1;
8266        let mut s = String::new();
8267        s.push_str("g3 1 1 0\n");
8268        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8269        s.push_str(" 0 1\n 0 0\n");
8270        s.push_str(&format!(" {n} {n} {n}\n"));
8271        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8272        s.push_str(&format!(" 0 {n}\n"));
8273        s.push_str(" 0 0\n 0 0 0 0 0\n");
8274        s.push_str("O0 0\n");
8275        s.push_str(&format!("o54\n{}\n", n + 1));
8276        for j in 0..n {
8277            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8278        }
8279        s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
8280        for j in 0..d {
8281            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8282        }
8283        s.push_str(&format!("x{n}\n"));
8284        for j in 0..n {
8285            s.push_str(&format!("{j} 0.5\n"));
8286        }
8287        s.push_str("b\n");
8288        for _ in 0..n {
8289            s.push_str("3\n");
8290        }
8291        s.push_str(&format!("G0 {n}\n"));
8292        for j in 0..n {
8293            s.push_str(&format!("{j} 0.0\n"));
8294        }
8295        s
8296    }
8297
8298    /// A well-scaled dense column is still peeled, and the entries read
8299    /// out of its pass are exact — the property the peel guard must not
8300    /// cost us.
8301    #[test]
8302    fn a_well_scaled_dense_column_is_still_peeled() {
8303        let n = 200;
8304        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
8305        let t = NlTnlp::new(p);
8306        assert_eq!(
8307            t.peeled_cols,
8308            vec![0],
8309            "a dense column that costs no accuracy must stay peeled"
8310        );
8311        assert!(
8312            t.seeds.len() <= 4,
8313            "peeling should keep the color count at O(1), got {}",
8314            t.seeds.len()
8315        );
8316    }
8317
8318    /// An ill-scaled dense column must be un-peeled and colored the
8319    /// ordinary way, so its small entries come back to full precision.
8320    ///
8321    /// This test covers the *guard*, not the damage. It asserts that a
8322    /// column spanning 12 orders is un-peeled and that its entries are then
8323    /// exact — and it is worth being precise that it does not, and cannot,
8324    /// show what peeling would have cost, because on this model peeling
8325    /// costs nothing: force the peel through and every entry still comes
8326    /// back bit-identical to its analytic weight. The Hessian here is
8327    /// constant and each entry is a single product, so there is no
8328    /// accumulation for a large-magnitude pass to pollute.
8329    ///
8330    /// Reproducing the actual digit loss takes a model whose entries are
8331    /// summed through shared intermediates, which is why the demonstration
8332    /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
8333    /// real model rather than a synthetic one.
8334    #[test]
8335    fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
8336        let n = 200;
8337        let span = 12.0;
8338        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8339        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
8340        let mut t = NlTnlp::new(p);
8341
8342        assert!(
8343            t.peeled_cols.is_empty(),
8344            "a dense column spanning {span} orders must not be peeled; got {:?}",
8345            t.peeled_cols
8346        );
8347
8348        let info = t.get_nlp_info().unwrap();
8349        let nnz = info.nnz_h_lag as usize;
8350        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8351        assert!(t.eval_h(
8352            None,
8353            true,
8354            1.0,
8355            None,
8356            true,
8357            SparsityRequest::Structure {
8358                irow: &mut irow,
8359                jcol: &mut jcol
8360            }
8361        ));
8362        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8363        let mut vals = vec![0.0_f64; nnz];
8364        assert!(t.eval_h(
8365            Some(&x),
8366            true,
8367            1.0,
8368            None,
8369            true,
8370            SparsityRequest::Values { values: &mut vals }
8371        ));
8372
8373        // Every coupling entry must be its weight to full relative
8374        // precision. Peeled, the smallest of them came back with a
8375        // relative error near 1e-4.
8376        let mut checked = 0;
8377        for k in 0..nnz {
8378            let (i, j) = (irow[k] as usize, jcol[k] as usize);
8379            if i == j {
8380                continue;
8381            }
8382            assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8383            checked += 1;
8384            let want = w(i);
8385            assert!(
8386                (vals[k] - want).abs() <= 1e-13 * want,
8387                "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
8388                vals[k],
8389                (vals[k] - want).abs() / want
8390            );
8391        }
8392        assert_eq!(checked, n - 1);
8393    }
8394
8395    /// Whatever survives the peel guard must decode to the same Hessian
8396    /// that not peeling at all produces — every entry, not just the
8397    /// objective.
8398    ///
8399    /// This exists because the original dense-column optimization was
8400    /// validated by running the repository's `.nl` fixtures and comparing
8401    /// objective value and exit status, which was doubly blind: not one of
8402    /// the 60 fixtures has a Hessian row dense enough to peel anything, so
8403    /// the decode path under test never executed, and even had it executed,
8404    /// the defect cost digits in the multipliers while leaving the
8405    /// objective intact. So the instrument has to be the assembled Hessian
8406    /// and the input has to actually peel — hence `peeled_any` below, which
8407    /// fails if the sweep ever goes vacuous the way the fixture suite
8408    /// silently did.
8409    ///
8410    /// What this catches is a *decode* fault: an entry recovered from the
8411    /// wrong pass, which is wrong by O(1). It would not have caught the
8412    /// `cho_parmest` stall, because these synthetic models lose no
8413    /// precision under peeling at all (see
8414    /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
8415    /// precision half is covered against the real model in
8416    /// `cho_parmest_decodes_to_its_unpeeled_reference`.
8417    #[test]
8418    fn a_peeled_decode_matches_an_unpeeled_reference() {
8419        // `last = true` puts the dense variable at `n-1`, so the coupling
8420        // entries are stored with the peeled column as their *row* — the
8421        // only shape in which peeling and not peeling read an entry out of
8422        // different passes, and so the only shape that can detect a
8423        // difference at all. See `weighted_dense_last_col_nl`.
8424        let cases = [
8425            (200, 0.0, false),
8426            (200, 2.0, false),
8427            (200, 0.0, true),
8428            (200, 1.0, true),
8429            (200, 2.0, true),
8430            (400, 3.0, true),
8431            (600, 0.0, true),
8432        ];
8433        let mut peeled_any = false;
8434        let mut worst = 0.0_f64;
8435
8436        for &(n, span, last) in &cases {
8437            let text = if last {
8438                weighted_dense_last_col_nl(n, span)
8439            } else {
8440                weighted_dense_row_objective_nl(n, span)
8441            };
8442            let p = parse_nl_text(&text).expect("parse");
8443            let mut t = NlTnlp::new(p);
8444            let peeled = t.peeled_cols.clone();
8445            peeled_any |= !peeled.is_empty();
8446
8447            let info = t.get_nlp_info().unwrap();
8448            let nnz = info.nnz_h_lag as usize;
8449            let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8450            assert!(t.eval_h(
8451                None,
8452                true,
8453                1.0,
8454                None,
8455                true,
8456                SparsityRequest::Structure {
8457                    irow: &mut irow,
8458                    jcol: &mut jcol
8459                }
8460            ));
8461            let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
8462
8463            let mut got = vec![0.0_f64; nnz];
8464            assert!(t.eval_h(
8465                Some(&x),
8466                true,
8467                1.0,
8468                None,
8469                true,
8470                SparsityRequest::Values { values: &mut got }
8471            ));
8472
8473            // Same object, same tapes, same point — only the coloring
8474            // differs, so any disagreement is the decode path.
8475            t.recolor(&vec![true; n]);
8476            assert!(
8477                t.peeled_cols.is_empty(),
8478                "a fully vetoed model must peel nothing"
8479            );
8480            let mut want = vec![0.0_f64; nnz];
8481            assert!(t.eval_h(
8482                Some(&x),
8483                true,
8484                1.0,
8485                None,
8486                true,
8487                SparsityRequest::Values { values: &mut want }
8488            ));
8489
8490            for k in 0..nnz {
8491                let scale = want[k].abs().max(f64::MIN_POSITIVE);
8492                let rel = (got[k] - want[k]).abs() / scale;
8493                worst = worst.max(rel);
8494                assert!(
8495                    rel <= 1e-13,
8496                    "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
8497                     unpeeled reference {:e} (relative error {rel:e})",
8498                    irow[k],
8499                    jcol[k],
8500                    got[k],
8501                    want[k]
8502                );
8503            }
8504        }
8505
8506        assert!(
8507            peeled_any,
8508            "no case peeled anything, so this test proved nothing about the \
8509             decode path — the exact way the fixture suite missed the bug"
8510        );
8511        assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
8512    }
8513
8514    /// `peel_veto` bars a column from the peel set, and the row it puts
8515    /// back into the conflict structure costs the colors it used to save.
8516    #[test]
8517    fn a_vetoed_column_is_colored_the_ordinary_way() {
8518        let n = 300;
8519        // One dense row plus a diagonal: column 0 touches every row.
8520        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8521        pairs.extend((1..n).map(|i| (i, 0)));
8522        pairs.sort_unstable();
8523
8524        let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8525        assert!(peeled[0], "the dense column should peel by default");
8526        assert!(
8527            colors_peeled <= 4,
8528            "peeling should collapse the count, got {colors_peeled}"
8529        );
8530
8531        let mut veto = vec![false; n];
8532        veto[0] = true;
8533        let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
8534        assert!(!peeled[0], "a vetoed column must not be peeled");
8535        assert!(
8536            colors_vetoed > colors_peeled,
8537            "un-peeling restores row 0's conflicts, so colors must rise: \
8538             {colors_vetoed} vs {colors_peeled}"
8539        );
8540    }
8541
8542    /// A single dense Hessian row must not blow the coloring up to one
8543    /// color per variable. It used to: every column shares row 0, so no
8544    /// two columns could be colored alike, and `seeds` / `compressed`
8545    /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
8546    /// per `eval_h` on a Hessian holding only ~2n nonzeros.
8547    #[test]
8548    fn a_dense_hessian_row_does_not_explode_the_coloring() {
8549        let n = 200;
8550        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8551        let t = NlTnlp::new(p);
8552        // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
8553        // for j >= 1 from the coupling term.
8554        assert_eq!(t.h_irow.len(), 2 * n - 1);
8555        assert!(
8556            t.seeds.len() <= 4,
8557            "one dense row should cost one extra color, not n; got {} colors for n={n}",
8558            t.seeds.len()
8559        );
8560        assert_eq!(t.seeds.len(), t.compressed.len());
8561    }
8562
8563    /// Peeling changes *which* directional product recovers an entry, so
8564    /// the recovered Hessian must still be exactly right — including the
8565    /// entries read out of a peeled column's pass by symmetry.
8566    #[test]
8567    fn peeled_dense_column_still_recovers_the_exact_hessian() {
8568        let n = 200;
8569        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8570        let mut t = NlTnlp::new(p);
8571        let info = t.get_nlp_info().unwrap();
8572        let nnz = info.nnz_h_lag as usize;
8573
8574        let mut irow = vec![0_i32; nnz];
8575        let mut jcol = vec![0_i32; nnz];
8576        assert!(t.eval_h(
8577            None,
8578            true,
8579            1.0,
8580            None,
8581            true,
8582            SparsityRequest::Structure {
8583                irow: &mut irow,
8584                jcol: &mut jcol
8585            }
8586        ));
8587
8588        // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
8589        //   H[0,0] = 2 + 2 = 4;  H[j,j] = 2 (j >= 1);  H[j,0] = 1 (j >= 1).
8590        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8591        let obj_factor = 2.5;
8592        let mut vals = vec![0.0_f64; nnz];
8593        assert!(t.eval_h(
8594            Some(&x),
8595            true,
8596            obj_factor,
8597            None,
8598            true,
8599            SparsityRequest::Values { values: &mut vals }
8600        ));
8601
8602        let mut seen_diag = 0;
8603        let mut seen_coupling = 0;
8604        for k in 0..nnz {
8605            let (i, j) = (irow[k] as usize, jcol[k] as usize);
8606            let want = if i == 0 && j == 0 {
8607                4.0
8608            } else if i == j {
8609                seen_diag += 1;
8610                2.0
8611            } else {
8612                assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8613                seen_coupling += 1;
8614                1.0
8615            } * obj_factor;
8616            assert!(
8617                (vals[k] - want).abs() < 1e-12,
8618                "H[{i},{j}] = {}, want {want}",
8619                vals[k]
8620            );
8621        }
8622        assert_eq!(seen_diag, n - 1);
8623        assert_eq!(seen_coupling, n - 1);
8624    }
8625
8626    /// The peeling threshold must leave ordinary sparse models on
8627    /// exactly the coloring they had before: a banded Hessian colors by
8628    /// its bandwidth, with nothing peeled.
8629    #[test]
8630    fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
8631        let n = 400;
8632        let pairs: Vec<(usize, usize)> = (0..n)
8633            .flat_map(|j| {
8634                let mut v = vec![(j, j)];
8635                if j + 1 < n {
8636                    v.push((j + 1, j));
8637                }
8638                v
8639            })
8640            .collect();
8641        let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8642        assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
8643        assert!(
8644            n_colors <= 3,
8645            "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
8646        );
8647        assert!(var_color.iter().all(|&c| c != u32::MAX));
8648    }
8649
8650    /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
8651    /// scattered through an otherwise diagonal `n`-variable problem.
8652    /// A plain coloring needs exactly `size` colors no matter how many
8653    /// blocks there are, because the blocks share no rows.
8654    fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
8655        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8656        let stride = n / blocks;
8657        for b in 0..blocks {
8658            let base = b * stride;
8659            for i in 0..size {
8660                for j in 0..=i {
8661                    if i != j {
8662                        pairs.push((base + i, base + j));
8663                    }
8664                }
8665            }
8666        }
8667        pairs
8668    }
8669
8670    /// Many medium-degree columns must not be peeled.
8671    ///
8672    /// Regression: selecting candidates by "degree > 16x average" and then
8673    /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
8674    /// The columns that miss the cut stay in the conflict structure, so the
8675    /// base color count is untouched and the singleton colors are pure
8676    /// addition — these two patterns colored to 306 and 290 against a plain
8677    /// walk's 50 and 34.
8678    #[test]
8679    fn thousands_of_medium_degree_cols_are_not_peeled() {
8680        for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
8681            let pairs = disjoint_blocks(n, blocks, size);
8682            let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8683            let n_peeled = peeled.iter().filter(|&&p| p).count();
8684            assert_eq!(
8685                n_peeled, 0,
8686                "degree-{size} columns do not pay for a singleton color \
8687                 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
8688            );
8689            assert!(
8690                n_colors <= size + 1,
8691                "disjoint {size}x{size} blocks color by block size regardless \
8692                 of block count; got {n_colors} for n={n}, blocks={blocks}"
8693            );
8694        }
8695    }
8696
8697    /// The pay-for-itself rule must not throw away the case peeling exists
8698    /// for: a handful of genuinely dense rows still get peeled, and still
8699    /// collapse the coloring.
8700    #[test]
8701    fn a_few_truly_dense_rows_are_still_peeled() {
8702        let n = 5_000;
8703        let dense_rows = 4;
8704        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8705        for d in 0..dense_rows {
8706            for j in 0..n {
8707                if j != d {
8708                    pairs.push((j.max(d), j.min(d)));
8709                }
8710            }
8711        }
8712        let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8713        let n_peeled = peeled.iter().filter(|&&p| p).count();
8714        assert_eq!(n_peeled, dense_rows, "every full row should peel");
8715        assert!(
8716            n_colors <= dense_rows + 2,
8717            "peeling {dense_rows} full rows should leave a diagonal remainder, \
8718             got {n_colors} colors"
8719        );
8720    }
8721
8722    /// The cap still binds, and when it does the kept columns are the
8723    /// highest-degree ones.
8724    #[test]
8725    fn peeling_is_capped_and_keeps_the_worst_offenders() {
8726        let n = 20_000;
8727        let dense_rows = 400;
8728        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8729        for d in 0..dense_rows {
8730            // Row d touches the first (n - d) columns, so degree strictly
8731            // decreases with d and the ordering is unambiguous.
8732            for j in 0..(n - d) {
8733                if j != d {
8734                    pairs.push((j.max(d), j.min(d)));
8735                }
8736            }
8737        }
8738        let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8739        let n_peeled = peeled.iter().filter(|&&p| p).count();
8740        assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
8741        assert!(
8742            (0..MAX_PEELED_COLS).all(|d| peeled[d]),
8743            "the {MAX_PEELED_COLS} densest rows are the ones kept"
8744        );
8745    }
8746
8747    /// Header line 0 is `g<count> <opt0> ...`; the option words are the
8748    /// model's own and a solver echoes them into the `.sol` `Options`
8749    /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
8750    #[test]
8751    fn header_option_words_are_kept_verbatim() {
8752        let p = parse_nl_text(EQ_LIN).expect("parse");
8753        assert_eq!(p.ampl_options, vec![0, 1, 0]);
8754    }
8755
8756    /// A count that does not match the words present must not be
8757    /// guessed at — the writer falls back rather than emit a wrong block.
8758    #[test]
8759    fn a_truncated_option_list_is_dropped_not_padded() {
8760        let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
8761        let p = parse_nl_text(&text).expect("parse");
8762        assert!(
8763            p.ampl_options.is_empty(),
8764            "9 declared but 3 present: {:?}",
8765            p.ampl_options
8766        );
8767    }
8768
8769    #[test]
8770    fn constrained_tnlp_eval_g_jac_h() {
8771        let p = parse_nl_text(EQ_LIN).expect("parse");
8772        let mut t = NlTnlp::new(p);
8773        let info = t.get_nlp_info().unwrap();
8774        assert_eq!(info.m, 1);
8775        assert_eq!(info.nnz_jac_g, 2);
8776
8777        // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
8778        let mut g = [0.0_f64; 1];
8779        assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
8780        assert!((g[0] - 0.7).abs() < 1e-12);
8781
8782        // Jacobian structure: row 0, cols [0, 1].
8783        let mut irow = [0_i32; 2];
8784        let mut jcol = [0_i32; 2];
8785        assert!(t.eval_jac_g(
8786            None,
8787            true,
8788            SparsityRequest::Structure {
8789                irow: &mut irow,
8790                jcol: &mut jcol
8791            }
8792        ));
8793        assert_eq!(irow, [0, 0]);
8794        assert_eq!(jcol, [0, 1]);
8795
8796        // Jacobian values: both 1.0.
8797        let mut vals = [0.0_f64; 2];
8798        assert!(t.eval_jac_g(
8799            Some(&[0.3, 0.4]),
8800            true,
8801            SparsityRequest::Values { values: &mut vals }
8802        ));
8803        assert!((vals[0] - 1.0).abs() < 1e-12);
8804        assert!((vals[1] - 1.0).abs() < 1e-12);
8805
8806        // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
8807        // λ contributes nothing because the constraint is linear, and
8808        // x0^2 + x1^2 is separable so there's no (1,0) entry in the
8809        // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
8810        assert_eq!(info.nnz_h_lag, 2);
8811        let mut hirow = [0_i32; 2];
8812        let mut hjcol = [0_i32; 2];
8813        assert!(t.eval_h(
8814            None,
8815            true,
8816            1.0,
8817            None,
8818            true,
8819            SparsityRequest::Structure {
8820                irow: &mut hirow,
8821                jcol: &mut hjcol
8822            }
8823        ));
8824        assert_eq!(hirow, [0, 1]);
8825        assert_eq!(hjcol, [0, 1]);
8826        let mut hvals = [0.0_f64; 2];
8827        assert!(t.eval_h(
8828            Some(&[0.3, 0.4]),
8829            true,
8830            1.0,
8831            Some(&[0.5]),
8832            true,
8833            SparsityRequest::Values { values: &mut hvals }
8834        ));
8835        assert!((hvals[0] - 2.0).abs() < 1e-12);
8836        assert!((hvals[1] - 2.0).abs() < 1e-12);
8837    }
8838
8839    /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
8840    /// encoded as common-subexpression `V2`. Header line 10 declares
8841    /// one obj-only CSE; expression tree references `v2` twice.
8842    const CSE_OBJ: &str = "g3 0 1 0
88432 0 1 0 0
88440 1
88450 0
88460 2 0
88470 0 0 1
88480 0 0 0 0
88490 0
88500 0
88510 1 0 0 0
8852V2 0 0
8853o0
8854v0
8855v1
8856O0 0
8857o0
8858o5
8859v2
8860n2
8861v2
8862b
88633
88643
8865";
8866
8867    #[test]
8868    fn parses_v_segment_cse() {
8869        let p = parse_nl_text(CSE_OBJ).expect("parse");
8870        assert_eq!(p.n, 2);
8871        // f(1,2) = 9 + 3 = 12
8872        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
8873        assert!((f - 12.0).abs() < 1e-12, "got {f}");
8874        // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
8875        let mut g = [0.0_f64; 2];
8876        grad_expr(&p.obj_expr(), &[1.0, 2.0], 1.0, &mut g);
8877        assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
8878        assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
8879        // collect_vars reaches into the CSE body and finds {0, 1}.
8880        let mut vs = BTreeSet::new();
8881        p.obj_nonlinear.collect_vars(&mut vs);
8882        assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
8883    }
8884
8885    /// `min (x0 - 1)^2` with three suffix segments attached: an
8886    /// integer constraint-suffix (target=1, kind=1), an integer var-
8887    /// suffix (target=0, kind=0), and a real var-suffix (target=0,
8888    /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
8889    /// `<idx> <value>` lines.
8890    const WITH_SUFFIXES: &str = "g3 0 1 0
88911 0 1 0 0
88920 1
88930 0
88940 1 0
88950 0 0 1
88960 0 0 0 0
88970 0
88980 0
88990 0 0 0 0
8900O0 0
8901o5
8902o1
8903v0
8904n1
8905n2
8906b
89073
8908S0 1 sens_state_1
89090 7
8910S4 1 sens_state_value_1
89110 4.5
8912";
8913
8914    #[test]
8915    fn parses_var_int_and_var_real_suffixes() {
8916        let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
8917        // Integer var-suffix: dense length 1, slot 0 = 7.
8918        let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
8919        assert_eq!(v.as_slice(), &[7]);
8920        // Real var-suffix: dense length 1, slot 0 = 4.5.
8921        let r = p
8922            .suffixes
8923            .var_real
8924            .get("sens_state_value_1")
8925            .expect("var_real");
8926        assert_eq!(r.len(), 1);
8927        assert!((r[0] - 4.5).abs() < 1e-12);
8928        // Other suffix slots stay empty.
8929        assert!(p.suffixes.con_int.is_empty());
8930        assert!(p.suffixes.con_real.is_empty());
8931    }
8932
8933    /// Two-variable + two-constraint problem with a constraint-level
8934    /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
8935    const WITH_CON_SUFFIX: &str = "g3 0 1 0
89362 2 1 0 0
89370 0
89380 0
89390 2 0
89400 0 0 1
89410 0 0 0 0
89424 0
89430 0
89440 0 0 0 0 0
8945C0
8946n0
8947C1
8948n0
8949O0 0
8950n0
8951r
89524 0.0
89534 0.0
8954b
89553
89563
8957k1
89580
8959J0 2
89600 1
89611 1
8962J1 2
89630 1
89641 -1
8965S1 2 sens_init_constr
89660 1
89671 2
8968";
8969
8970    #[test]
8971    fn parses_con_int_suffix() {
8972        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
8973        let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
8974        // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
8975        assert_eq!(s.as_slice(), &[1, 2]);
8976    }
8977
8978    // ---- gh#785: a truncated file is rejected, not silently defaulted ----
8979    //
8980    // `WITH_CON_SUFFIX` is a complete, well-formed file, so cutting it at a
8981    // segment boundary is exactly the failure the issue reports: an
8982    // interrupted write. Each cut loses a different first segment and each
8983    // is caught by a different check, so all three are asserted — and the
8984    // untruncated text parsing cleanly (`parses_con_int_suffix` above) is
8985    // what keeps these from passing against a parser that rejects
8986    // everything.
8987
8988    /// Everything from `at` (a segment header line) onward is gone.
8989    fn truncate_before(txt: &str, at: &str) -> String {
8990        let cut = txt
8991            .find(at)
8992            .unwrap_or_else(|| panic!("fixture has no {at:?} segment"));
8993        txt[..cut].to_string()
8994    }
8995
8996    #[test]
8997    fn truncation_before_the_row_bounds_is_a_parse_error() {
8998        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nr\n"))
8999            .expect_err("truncated file must not parse");
9000        assert!(
9001            err.contains("`r` (constraint-bounds) segment"),
9002            "error should name the missing segment: {err}"
9003        );
9004    }
9005
9006    #[test]
9007    fn truncation_before_the_variable_bounds_is_a_parse_error() {
9008        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nb\n"))
9009            .expect_err("truncated file must not parse");
9010        assert!(
9011            err.contains("`b` (variable-bounds) segment"),
9012            "error should name the missing segment: {err}"
9013        );
9014    }
9015
9016    /// The cut that leaves every bound in place and takes only the
9017    /// coefficients. Neither presence check can see it; the declared-vs-
9018    /// parsed nonzero count is the whole of the evidence.
9019    #[test]
9020    fn truncation_before_the_jacobian_is_a_parse_error() {
9021        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nk1\n"))
9022            .expect_err("truncated file must not parse");
9023        assert!(
9024            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 0"),
9025            "error should report the mismatch: {err}"
9026        );
9027    }
9028
9029    /// The mismatch is an equality, not a floor: a file supplying *more*
9030    /// entries than it declares is as corrupt as one supplying fewer, and a
9031    /// `>=` check would wave it through.
9032    #[test]
9033    fn more_jacobian_entries_than_declared_is_also_a_parse_error() {
9034        let extra = WITH_CON_SUFFIX.replace("J1 2\n0 1\n1 -1\n", "J1 2\n0 1\n1 -1\nJ0 1\n0 5\n");
9035        let err = parse_nl_text(&extra).expect_err("over-full file must not parse");
9036        assert!(
9037            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 5"),
9038            "error should report the mismatch: {err}"
9039        );
9040    }
9041
9042    /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
9043    /// (n = m = 2) and hand back everything the engine would see.
9044    fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
9045        let mut obj = 1.0;
9046        let mut use_x = false;
9047        let mut x = vec![0.0; 2];
9048        let mut use_g = false;
9049        let mut g = vec![0.0; 2];
9050        let ok = tnlp.get_scaling_parameters(ScalingRequest {
9051            obj_scaling: &mut obj,
9052            use_x_scaling: &mut use_x,
9053            x_scaling: &mut x,
9054            use_g_scaling: &mut use_g,
9055            g_scaling: &mut g,
9056        });
9057        (ok, obj, use_x, x, use_g, g)
9058    }
9059
9060    /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
9061    /// the objective (`S6`) and one constraint (`S5`) reaches the
9062    /// engine's `user-scaling` pathway. The untagged second row is
9063    /// unscaled — its AMPL suffix default is 0, which is not a usable
9064    /// scale factor and reads as "not tagged".
9065    #[test]
9066    fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
9067        let nl = WITH_CON_SUFFIX.to_string()
9068            + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
9069        let p = parse_nl_text(&nl).expect("parse");
9070        let mut tnlp = NlTnlp::new(p);
9071        let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
9072        assert!(ok, "a tagged model must supply scaling");
9073        assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
9074        assert!(use_g);
9075        assert_eq!(g, vec![10.0, 1.0]);
9076        assert!(!use_x, "no variable suffix was declared");
9077    }
9078
9079    /// Variable-level `scaling_factor` entries are passed through, not
9080    /// dropped on the floor: `OrigIpoptNlp` does not model them and
9081    /// refuses the solve, which is the whole point of gh#483.
9082    #[test]
9083    fn scaling_factor_suffix_forwards_variable_factors() {
9084        let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
9085        let p = parse_nl_text(&nl).expect("parse");
9086        let mut tnlp = NlTnlp::new(p);
9087        let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
9088        assert!(ok);
9089        assert!(use_x, "variable factors must reach the engine");
9090        assert_eq!(x, vec![1.0, 3.0]);
9091    }
9092
9093    /// gh #703: with curvature-based scaling switched on, the computed
9094    /// factors are the base and a `scaling_factor` suffix the model
9095    /// actually carries wins **component by component** — an explicit
9096    /// factor from the modeller beats one inferred from the coefficients,
9097    /// and the components they did not tag keep the inferred one rather
9098    /// than snapping back to 1.
9099    #[test]
9100    fn a_user_suffix_overrides_the_computed_factor_component_wise() {
9101        let baseline = {
9102            let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9103            let mut t = NlTnlp::new(p);
9104            assert!(t.enable_curvature_scaling(), "an LP is degree ≤ 2");
9105            scaling_of(&mut t).5
9106        };
9107        assert!(
9108            baseline.iter().all(|v| *v > 0.0),
9109            "curvature scaling should produce usable row factors, got {baseline:?}"
9110        );
9111
9112        // Tag row 0 only. AMPL's untagged default is 0, which must read as
9113        // "not tagged" and leave row 1 on the computed factor.
9114        let nl = WITH_CON_SUFFIX.to_string() + "S5 1 scaling_factor\n0 7.0\n";
9115        let p = parse_nl_text(&nl).expect("parse");
9116        let mut t = NlTnlp::new(p);
9117        assert!(t.enable_curvature_scaling());
9118        let (ok, _obj, use_x, _x, use_g, g) = scaling_of(&mut t);
9119        assert!(ok);
9120        assert!(use_g && use_x);
9121        assert_eq!(g[0], 7.0, "the tagged row takes the user's factor");
9122        assert_eq!(
9123            g[1], baseline[1],
9124            "the untagged row keeps the computed one, not 1.0"
9125        );
9126    }
9127
9128    /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
9129    /// same answer the default `TNLP` impl gives, so `user-scaling`
9130    /// falls back to no scaling instead of a bogus all-zero vector.
9131    #[test]
9132    fn no_scaling_factor_suffix_declines() {
9133        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9134        let mut tnlp = NlTnlp::new(p);
9135        let (ok, ..) = scaling_of(&mut tnlp);
9136        assert!(!ok);
9137    }
9138
9139    #[test]
9140    fn rejects_suffix_with_out_of_range_index() {
9141        let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
9142        let err = parse_nl_text(&bad).expect_err("must reject");
9143        assert!(
9144            err.contains("out of range"),
9145            "expected out-of-range error, got: {err}"
9146        );
9147    }
9148
9149    #[test]
9150    fn tnlp_round_trip_solves() {
9151        let p = parse_nl_text(SIMPLE).expect("parse");
9152        let mut tnlp = NlTnlp::new(p);
9153        let info = tnlp.get_nlp_info().unwrap();
9154        assert_eq!(info.n, 2);
9155        assert_eq!(info.m, 0);
9156        let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
9157        assert!((f0 - 5.0).abs() < 1e-12);
9158        let mut g = [0.0_f64; 2];
9159        tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
9160        // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
9161        assert!((g[0] - (-2.0)).abs() < 1e-12);
9162        assert!((g[1] - (-4.0)).abs() < 1e-12);
9163    }
9164
9165    // ---- Sibling `.col` / `.row` name-file capture --------------------
9166    //
9167    // Names let diagnostics name the offending equation instead of "row 3"
9168    // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
9169    // the read path and the documented fallback-to-empty behavior.
9170
9171    use pounce_nlp::expression_provider::ExpressionProvider;
9172    use std::sync::atomic::{AtomicUsize, Ordering};
9173
9174    /// Unique scratch dir for one test (no `tempfile` dev-dep available).
9175    fn scratch_dir(tag: &str) -> std::path::PathBuf {
9176        static N: AtomicUsize = AtomicUsize::new(0);
9177        let seq = N.fetch_add(1, Ordering::Relaxed);
9178        let dir = std::env::temp_dir().join(format!(
9179            "pounce_nlnames_{}_{}_{}",
9180            std::process::id(),
9181            tag,
9182            seq
9183        ));
9184        std::fs::create_dir_all(&dir).expect("create scratch dir");
9185        dir
9186    }
9187
9188    #[test]
9189    fn read_name_file_reads_in_order() {
9190        let dir = scratch_dir("col_order");
9191        let p = dir.join("m.col");
9192        std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
9193        assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
9194    }
9195
9196    #[test]
9197    fn read_name_file_truncates_extra_lines() {
9198        // `.row` conventionally appends the objective name after the m
9199        // constraint names; `.take(expected)` must drop it so names stay
9200        // 1:1 with `g`.
9201        let dir = scratch_dir("row_obj");
9202        let p = dir.join("m.row");
9203        std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
9204        assert_eq!(
9205            read_name_file(&p, 2),
9206            vec!["mass_balance", "energy_balance"]
9207        );
9208    }
9209
9210    #[test]
9211    fn read_name_file_empty_on_short_or_missing() {
9212        let dir = scratch_dir("short");
9213        let short = dir.join("m.col");
9214        std::fs::write(&short, "only_one\n").unwrap();
9215        // Fewer lines than expected ⇒ empty (never a partial mapping).
9216        assert!(read_name_file(&short, 3).is_empty());
9217        // Missing file ⇒ empty, no error.
9218        assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
9219    }
9220
9221    #[test]
9222    fn read_nl_file_captures_sibling_names() {
9223        // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
9224        // names ride through onto the TNLP's ExpressionProvider.
9225        let dir = scratch_dir("sibling");
9226        let nl = dir.join("m.nl");
9227        std::fs::write(&nl, SIMPLE).unwrap();
9228        std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
9229
9230        let prob = read_nl_file(&nl).expect("parse + name capture");
9231        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9232        assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
9233
9234        let tnlp = NlTnlp::new(prob);
9235        assert_eq!(tnlp.variable_name(0), Some("alpha"));
9236        assert_eq!(tnlp.variable_name(1), Some("beta"));
9237        assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
9238    }
9239
9240    #[test]
9241    fn read_nl_file_without_names_yields_empty() {
9242        let dir = scratch_dir("noname");
9243        let nl = dir.join("m.nl");
9244        std::fs::write(&nl, SIMPLE).unwrap();
9245        let prob = read_nl_file(&nl).expect("parse");
9246        assert!(prob.var_names.is_empty());
9247        assert!(prob.con_names.is_empty());
9248        let tnlp = NlTnlp::new(prob);
9249        assert_eq!(tnlp.variable_name(0), None);
9250    }
9251
9252    #[test]
9253    fn read_nl_file_resolves_extensionless_ampl_stub() {
9254        // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
9255        // the `.nl` extension; the solver must read `mystub.nl`. Code
9256        // review 2026-06 item M15.
9257        let dir = scratch_dir("stub");
9258        std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
9259        // Pass the extensionless stub — the file `mystub` does not exist.
9260        let stub = dir.join("mystub");
9261        assert!(!stub.exists(), "stub must be extensionless / absent");
9262        let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
9263        assert_eq!(prob.n, 2);
9264        assert_eq!(prob.m, 0);
9265
9266        // Sibling name files are still found off the resolved stem.
9267        std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
9268        let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
9269        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9270    }
9271
9272    #[test]
9273    fn read_nl_file_prefers_exact_path_over_nl_sibling() {
9274        // An existing path is read verbatim — the `.nl` fallback only
9275        // kicks in when the literal path is missing, so a caller passing a
9276        // real file is never silently redirected to a `<file>.nl` sibling.
9277        let dir = scratch_dir("exact");
9278        // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
9279        std::fs::write(dir.join("data"), SIMPLE).unwrap();
9280        std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
9281        let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
9282        assert_eq!(prob.n, 2);
9283    }
9284
9285    #[test]
9286    fn append_extension_appends_rather_than_replaces() {
9287        use std::path::Path;
9288        assert_eq!(
9289            append_extension(Path::new("mystub"), "nl"),
9290            Path::new("mystub.nl")
9291        );
9292        // A stub that itself contains a dot keeps its stem (AMPL names it
9293        // `my.model.nl`, not `my.nl`).
9294        assert_eq!(
9295            append_extension(Path::new("my.model"), "nl"),
9296            Path::new("my.model.nl")
9297        );
9298    }
9299
9300    // ---- equation rendering (`print equation`) ----
9301
9302    fn names(v: &[&str]) -> Vec<String> {
9303        v.iter().map(|s| s.to_string()).collect()
9304    }
9305
9306    #[test]
9307    fn render_uses_variable_names_when_present() {
9308        let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9309        assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
9310        // Falls back to x[i] when names are absent.
9311        assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
9312    }
9313
9314    #[test]
9315    fn render_parenthesizes_by_precedence() {
9316        // (x0 + x1) * x2  must keep the parens around the sum.
9317        let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9318        let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
9319        assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
9320
9321        // x0 + x1 * x2  needs no parens (mul binds tighter).
9322        let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9323        let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
9324        assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
9325    }
9326
9327    #[test]
9328    fn render_subtraction_right_assoc_parens() {
9329        // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
9330        let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9331        let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
9332        assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
9333    }
9334
9335    #[test]
9336    fn render_functions_and_pow() {
9337        let sq = Expr::Binary(
9338            BinOp::Pow,
9339            Box::new(Expr::Var(0)),
9340            Box::new(Expr::Const(2.0)),
9341        );
9342        let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
9343        assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
9344    }
9345
9346    #[test]
9347    fn render_linear_signs_are_tidy() {
9348        // 1*a - 2*b + c  (coef +1 omits the multiplier).
9349        let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
9350        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
9351    }
9352
9353    #[test]
9354    fn render_linear_skips_zero_coefficients() {
9355        // A 0 coefficient (a variable present only in the nonlinear part)
9356        // is dropped, not rendered as `0*x`.
9357        let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
9358        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
9359        // Leading term zero ⇒ the first emitted term still has no ` + `.
9360        let lin = vec![(0usize, 0.0), (1, 2.0)];
9361        assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
9362    }
9363
9364    #[test]
9365    fn render_sum_folds_negative_terms() {
9366        // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
9367        let sq = |i| {
9368            Expr::Binary(
9369                BinOp::Pow,
9370                Box::new(Expr::Var(i)),
9371                Box::new(Expr::Const(2.0)),
9372            )
9373        };
9374        let neg = |i| {
9375            Expr::Binary(
9376                BinOp::Mul,
9377                Box::new(Expr::Const(-1.0)),
9378                Box::new(Expr::Var(i)),
9379            )
9380        };
9381        let e = Expr::Sum(vec![
9382            sq(0),
9383            neg(1),
9384            Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
9385        ]);
9386        assert_eq!(
9387            render_expr(&e, &names(&["a", "b", "c"]), &[]),
9388            "a^2 - 1*b - c"
9389        );
9390    }
9391
9392    #[test]
9393    fn render_constraint_equation_forms() {
9394        // Build a 2-constraint problem by hand: an equality and a range.
9395        let mut prob = parse_nl_text(SIMPLE).unwrap();
9396        // Overwrite to a known small shape: 1 var, 2 cons.
9397        prob.n = 2;
9398        prob.m = 2;
9399        prob.var_names = names(&["mass_in", "mass_out"]);
9400        prob.con_names = names(&["balance", "window"]);
9401        prob.con_linear = vec![
9402            vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
9403            vec![(0, 1.0)],            // mass_in
9404        ];
9405        prob.con_nonlinear = vec![
9406            NlBody::Tree(Expr::Const(0.0)),
9407            NlBody::Tree(Expr::Const(0.0)),
9408        ];
9409        prob.g_l = vec![0.0, 0.0];
9410        prob.g_u = vec![0.0, 500.0];
9411
9412        assert_eq!(
9413            render_constraint_equation(&prob, 0),
9414            "mass_in - mass_out = 0"
9415        );
9416        assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
9417
9418        let all = render_all_constraint_equations(&prob);
9419        assert_eq!(all.len(), 2);
9420        assert_eq!(all[1], "0 <= mass_in <= 500");
9421    }
9422
9423    #[test]
9424    fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
9425        let mut prob = parse_nl_text(SIMPLE).unwrap();
9426        prob.n = 3;
9427        prob.m = 2;
9428        // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
9429        // Row 1: linear in x2 only → support {2}.
9430        prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
9431        prob.con_nonlinear = vec![
9432            NlBody::Tree(Expr::Binary(
9433                BinOp::Mul,
9434                Box::new(Expr::Var(0)),
9435                Box::new(Expr::Var(2)),
9436            )),
9437            NlBody::Tree(Expr::Const(0.0)),
9438        ];
9439        prob.g_l = vec![0.0, 0.0];
9440        prob.g_u = vec![0.0, 0.0];
9441
9442        let (irow, jcol) = constraint_jacobian_sparsity(&prob);
9443        // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
9444        assert_eq!(irow, vec![0, 0, 0, 1]);
9445        assert_eq!(jcol, vec![0, 1, 2, 2]);
9446    }
9447
9448    #[test]
9449    fn funcall_string_arg_with_hash_is_not_truncated() {
9450        // Code review L31: an AMPL string argument is a Hollerith literal
9451        // `h<len>:<chars>` whose content is exactly <len> bytes and may
9452        // legitimately contain '#' (e.g. a parameters-directory path). The
9453        // old parser ran strip_comment() over the line first, truncating
9454        // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
9455        let mut p = Parser::new("h3:a#b\n", false);
9456        match p.parse_funcall_arg().expect("parse hollerith arg") {
9457            FuncallArg::Str(s) => assert_eq!(s, "a#b"),
9458            other => panic!("expected Str, got {other:?}"),
9459        }
9460    }
9461
9462    #[test]
9463    fn funcall_string_arg_honors_declared_length() {
9464        // The declared `<len>` is authoritative: exactly that many bytes
9465        // after the ':' form the string; trailing content (here a real
9466        // ` # comment`) is not part of it.
9467        let mut p = Parser::new("h3:abc # trailing comment\n", false);
9468        match p.parse_funcall_arg().expect("parse hollerith arg") {
9469            FuncallArg::Str(s) => assert_eq!(s, "abc"),
9470            other => panic!("expected Str, got {other:?}"),
9471        }
9472    }
9473
9474    // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
9475    //
9476    // AMPL emits these in place of the general `o5` (OPPOW) when one operand
9477    // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
9478    // negative-base-safe constant-power lowering applies. The eval points below
9479    // are chosen to pin down BOTH the arity and the operand order: a swapped
9480    // `base`/`exp` (or treating `o82` as a different unary op) gives a
9481    // different number at these points, so each assertion is discriminating.
9482
9483    /// Parse a single expression `expr_src` with `n` variables in scope,
9484    /// driving the real `parse_opcode` path through `parse_expr`.
9485    fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
9486        let mut p = Parser::new(expr_src, false);
9487        p.n = n;
9488        p.parse_expr().expect("parse expression")
9489    }
9490
9491    #[test]
9492    fn opcode_o82_square_is_unary_pow_of_two() {
9493        // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
9494        let e = parse_one_expr(1, "o82\nv0\n");
9495        match &e {
9496            Expr::Binary(BinOp::Pow, base, exp) => {
9497                assert!(matches!(**base, Expr::Var(0)));
9498                match **exp {
9499                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
9500                    ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
9501                }
9502            }
9503            other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
9504        }
9505        // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
9506        // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
9507        assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
9508        assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
9509        // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
9510        let mut g = [0.0_f64; 1];
9511        grad_expr(&e, &[3.0], 1.0, &mut g);
9512        assert!((g[0] - 6.0).abs() < 1e-9, "grad at 3 = {}", g[0]);
9513        g[0] = 0.0;
9514        grad_expr(&e, &[-3.0], 1.0, &mut g);
9515        assert!((g[0] + 6.0).abs() < 1e-9, "grad at -3 = {}", g[0]);
9516    }
9517
9518    #[test]
9519    fn opcode_o81_const_exponent_is_base_pow_const() {
9520        // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
9521        let e = parse_one_expr(1, "o81\nv0\nn3\n");
9522        match &e {
9523            Expr::Binary(BinOp::Pow, base, exp) => {
9524                assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
9525                match **exp {
9526                    Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
9527                    ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
9528                }
9529            }
9530            other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
9531        }
9532        // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
9533        assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
9534        // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
9535        // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
9536        assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
9537        // gradient d/dx x^3 = 3x^2 = 12 at x=2.
9538        let mut g = [0.0_f64; 1];
9539        grad_expr(&e, &[2.0], 1.0, &mut g);
9540        assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
9541    }
9542
9543    #[test]
9544    fn opcode_o83_const_base_is_const_pow_exp() {
9545        // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
9546        let e = parse_one_expr(1, "o83\nn2\nv0\n");
9547        match &e {
9548            Expr::Binary(BinOp::Pow, base, exp) => {
9549                match **base {
9550                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
9551                    ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
9552                }
9553                assert!(
9554                    matches!(**exp, Expr::Var(0)),
9555                    "exponent must be the variable"
9556                );
9557            }
9558            other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
9559        }
9560        // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
9561        assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
9562        assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
9563        // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
9564        let mut g = [0.0_f64; 1];
9565        grad_expr(&e, &[3.0], 1.0, &mut g);
9566        assert!(
9567            (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
9568            "grad at 3 = {} (want {})",
9569            g[0],
9570            8.0 * 2.0_f64.ln()
9571        );
9572    }
9573
9574    #[test]
9575    fn power_specializations_agree_with_general_o5() {
9576        // Where both are defined, o81/o82/o83 must be numerically identical to
9577        // the general `o5` pow on the same operands — they are only routing
9578        // hints, not different math.
9579        let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
9580        let o82 = parse_one_expr(1, "o82\nv0\n");
9581        let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
9582        let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
9583        let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
9584        let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
9585        for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
9586            assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
9587            assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
9588            // 2^x is real for all x; compare across the same points.
9589            assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
9590        }
9591    }
9592
9593    #[test]
9594    fn power_opcodes_round_trip_through_parse_nl_text() {
9595        // End-to-end through the public entry point: `min x0^2 + x1^2` written
9596        // with o82 (square) parses and evaluates like its o5 twin. Reuses the
9597        // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
9598        let nl = SIMPLE.replace(
9599            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9600            "o0\no82\nv0\no82\nv1\n",
9601        );
9602        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9603        let p = parse_nl_text(&nl).expect("parse o82 objective");
9604        // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
9605        assert!((eval_expr(&p.obj_expr(), &[3.0, 4.0]) - 25.0).abs() < 1e-12);
9606        assert!((eval_expr(&p.obj_expr(), &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
9607    }
9608
9609    #[test]
9610    fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
9611        // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
9612        // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
9613        // (the negative-base-safe path) rather than a generic `powf`. The check
9614        // at a NEGATIVE base is the one that would break if o81 wrongly routed
9615        // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
9616        let nl = SIMPLE.replace(
9617            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9618            "o0\no81\nv0\nn3\no81\nv1\nn3\n",
9619        );
9620        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9621        let p = parse_nl_text(&nl).expect("parse o81 objective");
9622        let mut tnlp = NlTnlp::new(p);
9623        tnlp.get_nlp_info().unwrap();
9624        // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
9625        let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
9626        assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
9627        // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
9628        let mut g = [0.0_f64; 2];
9629        assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
9630        assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
9631        assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
9632    }
9633
9634    // ---- Shared-CSE constraint tape (issue #476) ----------------------
9635
9636    /// Three constraints over one `V` segment (a `.nl` *defined variable*),
9637    /// `V3 = 2*x0 + 3*x1`, referenced by all three:
9638    ///   C0: V3^2        C1: V3^3 + x2        C2: V3*x2
9639    /// `{BODY2}` is a substitution point so a variant can drop an opcode the
9640    /// hybrid path rejects into C2.
9641    const SHARED_CSE: &str = "g3 1 1 0
9642 3 3 1 0 0
9643 3 0
9644 0 0
9645 3 0 0
9646 0 0 0 1
9647 0 0 0 0 0
9648 8 3
9649 0 0
9650 0 1 0 0 0
9651V3 2 0
96520 2.0
96531 3.0
9654n0
9655C0
9656o5
9657v3
9658n2
9659C1
9660o0
9661o5
9662v3
9663n3
9664v2
9665C2
9666{BODY2}
9667O0 0
9668n0
9669r
96702 0
96712 0
96722 0
9673b
96743
96753
96763
9677k2
96783
96796
9680J0 2
96810 0
96821 0
9683J1 3
96840 0
96851 0
96862 0
9687J2 3
96880 0
96891 0
96902 0
9691G0 3
96920 1.0
96931 1.0
96942 1.0
9695";
9696
9697    fn shared_cse_nl(body2: &str) -> String {
9698        SHARED_CSE.replace("{BODY2}", body2)
9699    }
9700
9701    /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
9702    /// per-summand tapes return — it is a different traversal of the same
9703    /// DAG, not a different derivative. Forced on here regardless of
9704    /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
9705    /// the size heuristic that decides when to use it.
9706    #[test]
9707    fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
9708        let nl = shared_cse_nl("o2\nv3\nv2");
9709        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9710
9711        let mut hybrid = NlTnlp::new(p.clone());
9712        let info = hybrid.get_nlp_info().unwrap();
9713        let nnz = info.nnz_jac_g as usize;
9714        hybrid
9715            .con_hybrid
9716            .as_mut()
9717            .expect("CSE shared by 3 constraints must build the hybrid tape")
9718            .use_for_jac = true;
9719
9720        let mut flat = NlTnlp::new(p);
9721        flat.get_nlp_info().unwrap();
9722        flat.con_hybrid = None;
9723
9724        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9725            let mut jh = vec![0.0_f64; nnz];
9726            let mut jf = vec![0.0_f64; nnz];
9727            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9728            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9729            assert_eq!(
9730                jh, jf,
9731                "hybrid Jacobian differs from the flat tape at {x:?}"
9732            );
9733
9734            // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
9735            let s = 2.0 * x[0] + 3.0 * x[1];
9736            let want = [
9737                4.0 * s,
9738                6.0 * s,
9739                6.0 * s * s,
9740                9.0 * s * s,
9741                1.0,
9742                2.0 * x[2],
9743                3.0 * x[2],
9744                s,
9745            ];
9746            assert_eq!(nnz, want.len());
9747            for k in 0..nnz {
9748                assert!(
9749                    (jh[k] - want[k]).abs() < 1e-9,
9750                    "entry {k} at {x:?}: got {}, want {}",
9751                    jh[k],
9752                    want[k]
9753                );
9754            }
9755        }
9756    }
9757
9758    /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
9759    /// CSE `S = body(x0, x1)` (the caller passes the body's expression
9760    /// text, which must reference exactly `v0` and `v1`). A deep shared
9761    /// body over few local ops per row is the regime the op-ratio gates
9762    /// are meant to catch: no repository fixture has a CSE shared across
9763    /// constraints at all, so without this the gate-on paths have no
9764    /// natural coverage.
9765    fn shared_body_chain_nl(m: usize, body: &str) -> String {
9766        let n = m + 2;
9767        let nzc = 3 * m;
9768        let mut s = String::new();
9769        s.push_str("g3 1 1 0\n");
9770        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
9771        s.push_str(&format!(" {m} 0\n 0 0\n"));
9772        s.push_str(&format!(" {n} 0 0\n"));
9773        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
9774        s.push_str(&format!(" {nzc} {n}\n"));
9775        s.push_str(" 0 0\n 0 1 0 0 0\n");
9776        // The shared body.
9777        s.push_str(&format!("V{n} 0 0\n"));
9778        s.push_str(body);
9779        // Rows: S * x_{i+2}.
9780        for i in 0..m {
9781            s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
9782        }
9783        s.push_str("O0 0\nn0\n");
9784        s.push_str(&format!("x{n}\n"));
9785        for j in 0..n {
9786            s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
9787        }
9788        s.push_str("r\n");
9789        for _ in 0..m {
9790            s.push_str("2 0\n");
9791        }
9792        s.push_str("b\n");
9793        for _ in 0..n {
9794            s.push_str("3\n");
9795        }
9796        // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
9797        s.push_str(&format!("k{}\n", n - 1));
9798        let mut acc = 0;
9799        for j in 0..n - 1 {
9800            acc += if j < 2 { m } else { 1 };
9801            s.push_str(&format!("{acc}\n"));
9802        }
9803        for i in 0..m {
9804            s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
9805        }
9806        s.push_str(&format!("G0 {n}\n"));
9807        for j in 0..n {
9808            s.push_str(&format!("{j} 0.0\n"));
9809        }
9810        s
9811    }
9812
9813    /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
9814    /// The forward value saturates to `inf` past depth ≈ 5, which the
9815    /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
9816    /// bounded variant below instead, whose second-order terms would
9817    /// otherwise mix `inf`s of both signs into `NaN`.
9818    fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
9819        let mut body = String::new();
9820        for _ in 0..depth {
9821            body.push_str("o44\n");
9822        }
9823        body.push_str("o2\nn0.01\no0\nv0\nv1\n");
9824        shared_body_chain_nl(m, &body)
9825    }
9826
9827    /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
9828    /// (x0 + x1))` — mathematically the identity chain, so the value
9829    /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
9830    /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
9831    /// through both second-order sweeps. `2 * pairs` body ops drive the
9832    /// flat/shared op ratio as high as the Hessian gate tests need.
9833    fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
9834        let mut body = String::new();
9835        for _ in 0..pairs {
9836            body.push_str("o43\no44\n");
9837        }
9838        body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
9839        shared_body_chain_nl(m, &body)
9840    }
9841
9842    /// With a deep shared body the gate turns itself on, and the path it
9843    /// turns on must still agree with the flat tapes exactly.
9844    #[test]
9845    fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
9846        let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
9847
9848        let mut hybrid = NlTnlp::new(p.clone());
9849        let info = hybrid.get_nlp_info().unwrap();
9850        let nnz = info.nnz_jac_g as usize;
9851        assert!(
9852            hybrid
9853                .con_hybrid
9854                .as_ref()
9855                .expect("shared CSE must build the hybrid tape")
9856                .use_for_jac,
9857            "a 40-deep body shared by 16 rows is well past the op-ratio gate"
9858        );
9859
9860        let mut flat = NlTnlp::new(p);
9861        flat.get_nlp_info().unwrap();
9862        flat.con_hybrid = None;
9863
9864        for scale in [1.0_f64, -0.7, 2.5] {
9865            let x: Vec<f64> = (0..info.n as usize)
9866                .map(|j| scale * (0.2 + 0.03 * j as f64))
9867                .collect();
9868            let mut jh = vec![0.0_f64; nnz];
9869            let mut jf = vec![0.0_f64; nnz];
9870            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9871            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9872            assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
9873            assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
9874        }
9875    }
9876
9877    /// The Jacobian gate is off for a model whose shared bodies are small,
9878    /// because there the hybrid traversal's per-op overhead outweighs the
9879    /// halved forward sweep. `eval_g` still takes the hybrid path — that
9880    /// one is a win at any ratio.
9881    #[test]
9882    fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
9883        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
9884        let mut t = NlTnlp::new(p);
9885        t.get_nlp_info().unwrap();
9886        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
9887        assert!(
9888            !h.use_for_jac,
9889            "a 3-row model with a 2-term CSE is far below the op-ratio gate"
9890        );
9891    }
9892
9893    /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
9894    /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
9895    /// adjoints and running one prelude sweep reassociates floating-point
9896    /// products, so the two paths agree only to rounding on general inputs —
9897    /// but here every operation in both traversals is exact (dyadic values,
9898    /// small-integer coefficients, polynomial ops), so the results must be
9899    /// bit-identical, pinning the arithmetic itself and not just its
9900    /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
9901    /// path is covered independently of the size heuristic.
9902    #[test]
9903    fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
9904        let nl = shared_cse_nl("o2\nv3\nv2");
9905        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9906
9907        let mut hybrid = NlTnlp::new(p.clone());
9908        let info = hybrid.get_nlp_info().unwrap();
9909        let nnz = info.nnz_h_lag as usize;
9910        hybrid
9911            .con_hybrid
9912            .as_mut()
9913            .expect("CSE shared by 3 constraints must build the hybrid tape")
9914            .use_for_hess = true;
9915
9916        let mut flat = NlTnlp::new(p);
9917        flat.get_nlp_info().unwrap();
9918        flat.con_hybrid = None;
9919
9920        let pairs: Vec<(usize, usize)> = hybrid
9921            .h_irow
9922            .iter()
9923            .zip(&hybrid.h_jcol)
9924            .map(|(&i, &j)| (i as usize, j as usize))
9925            .collect();
9926
9927        // Two multiplier sets: one all-live, one with a dead row so the
9928        // λ == 0 skip is exercised on the hybrid path too.
9929        for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
9930            for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9931                let mut hh = vec![0.0_f64; nnz];
9932                let mut hf = vec![0.0_f64; nnz];
9933                assert!(hybrid.eval_h(
9934                    Some(&x),
9935                    true,
9936                    1.0,
9937                    Some(&lam),
9938                    true,
9939                    SparsityRequest::Values { values: &mut hh }
9940                ));
9941                assert!(flat.eval_h(
9942                    Some(&x),
9943                    true,
9944                    1.0,
9945                    Some(&lam),
9946                    true,
9947                    SparsityRequest::Values { values: &mut hf }
9948                ));
9949                assert_eq!(
9950                    hh, hf,
9951                    "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
9952                );
9953
9954                // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
9955                // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
9956                // objective is constant, so the Lagrangian Hessian is
9957                //   (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
9958                let s = 2.0 * x[0] + 3.0 * x[1];
9959                let q = 2.0 * lam[0] + 6.0 * s * lam[1];
9960                let dv = [2.0, 3.0, 0.0];
9961                for (k, &(i, j)) in pairs.iter().enumerate() {
9962                    let mut want = q * dv[i] * dv[j];
9963                    if i == 2 {
9964                        want += lam[2] * dv[j];
9965                    }
9966                    if j == 2 {
9967                        want += lam[2] * dv[i];
9968                    }
9969                    assert!(
9970                        (hh[k] - want).abs() < 1e-9,
9971                        "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
9972                        hh[k]
9973                    );
9974                }
9975            }
9976        }
9977    }
9978
9979    /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
9980    /// turns the Hessian gate on by itself, and the path it turns on must
9981    /// agree with the flat tapes. Not bitwise here: the shared prelude
9982    /// reverse sweep runs once over the `λ_k`-folded adjoints of all
9983    /// summands where the flat path runs per summand, and that
9984    /// reassociation moves transcendental results by rounding — so the bar
9985    /// is a relative few-ULP band, with the exact-arithmetic case pinned
9986    /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
9987    #[test]
9988    fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
9989        let m = 16;
9990        let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
9991
9992        let mut hybrid = NlTnlp::new(p.clone());
9993        let info = hybrid.get_nlp_info().unwrap();
9994        let nnz = info.nnz_h_lag as usize;
9995        assert!(
9996            hybrid
9997                .con_hybrid
9998                .as_ref()
9999                .expect("shared CSE must build the hybrid tape")
10000                .use_for_hess,
10001            "a 40-op body shared by 16 rows is well past the op-ratio gate"
10002        );
10003
10004        let mut flat = NlTnlp::new(p);
10005        flat.get_nlp_info().unwrap();
10006        flat.con_hybrid = None;
10007
10008        let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
10009        for scale in [1.0_f64, -0.7, 2.5] {
10010            let x: Vec<f64> = (0..info.n as usize)
10011                .map(|j| scale * (0.2 + 0.03 * j as f64))
10012                .collect();
10013            // Run the hybrid Jacobian first, the order a real solve
10014            // iteration uses. Its `gradient_summand` sweeps leave the
10015            // *Jacobian's* prelude adjoint arena dirty; the Hessian's
10016            // accumulators must be its own buffers with the all-zero-
10017            // between-colors invariant, or this seeds `eval_h` with a
10018            // stale row gradient (caught here).
10019            let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
10020            assert!(hybrid.eval_jac_g(
10021                Some(&x),
10022                true,
10023                SparsityRequest::Values { values: &mut jac }
10024            ));
10025            let mut hh = vec![0.0_f64; nnz];
10026            let mut hf = vec![0.0_f64; nnz];
10027            assert!(hybrid.eval_h(
10028                Some(&x),
10029                true,
10030                1.0,
10031                Some(&lam),
10032                true,
10033                SparsityRequest::Values { values: &mut hh }
10034            ));
10035            assert!(flat.eval_h(
10036                Some(&x),
10037                true,
10038                1.0,
10039                Some(&lam),
10040                true,
10041                SparsityRequest::Values { values: &mut hf }
10042            ));
10043            for k in 0..nnz {
10044                assert!(
10045                    hh[k].is_finite() && hf[k].is_finite(),
10046                    "non-finite Hessian entry {k} defeats the comparison"
10047                );
10048                let tol = 1e-12 * hf[k].abs().max(1.0);
10049                assert!(
10050                    (hh[k] - hf[k]).abs() <= tol,
10051                    "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
10052                    hh[k],
10053                    hf[k]
10054                );
10055            }
10056            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10057        }
10058    }
10059
10060    /// `.nl` text for two independent shared-CSE blocks of different widths:
10061    /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
10062    /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
10063    /// of the form `body * x_r`.
10064    ///
10065    /// The width difference is the point. A body summing `w` variables gives
10066    /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
10067    /// conflict and the coloring must spend `w` colors on them; the narrower
10068    /// block reuses the low colors. The surplus colors therefore belong to
10069    /// block A alone, and a per-color prelude walk over *both* bodies would
10070    /// be doing work for a body that color cannot reach.
10071    fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
10072        let nvars = wide + narrow;
10073        let m = 2 * rows;
10074        let n = nvars + m;
10075        // Block A rows touch `wide` body vars + 1 row var; block B rows
10076        // touch `narrow` + 1.
10077        let nzc = rows * (wide + 1) + rows * (narrow + 1);
10078        let mut s = String::new();
10079        s.push_str("g3 1 1 0\n");
10080        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
10081        s.push_str(&format!(" {m} 0\n 0 0\n"));
10082        s.push_str(&format!(" {n} 0 0\n"));
10083        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
10084        s.push_str(&format!(" {nzc} {n}\n"));
10085        s.push_str(" 0 0\n 0 2 0 0 0\n");
10086        // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
10087        // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
10088        for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
10089            s.push_str(&format!("V{} 0 0\n", n + b));
10090            s.push_str("o41\n");
10091            for _ in 0..count - 1 {
10092                s.push_str("o0\n");
10093            }
10094            for j in 0..*count {
10095                s.push_str(&format!("v{}\n", base + j));
10096            }
10097        }
10098        // Rows: body_b * x_{rowvar}.
10099        for i in 0..m {
10100            let b = i / rows;
10101            s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
10102        }
10103        s.push_str("O0 0\nn0\n");
10104        s.push_str(&format!("x{n}\n"));
10105        for j in 0..n {
10106            s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
10107        }
10108        s.push_str("r\n");
10109        for _ in 0..m {
10110            s.push_str("2 0\n");
10111        }
10112        s.push_str("b\n");
10113        for _ in 0..n {
10114            s.push_str("3\n");
10115        }
10116        // Cumulative Jacobian column counts for the first n-1 columns.
10117        s.push_str(&format!("k{}\n", n - 1));
10118        let mut acc = 0;
10119        for j in 0..n - 1 {
10120            acc += if j < nvars { rows } else { 1 };
10121            s.push_str(&format!("{acc}\n"));
10122        }
10123        for i in 0..m {
10124            let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
10125            s.push_str(&format!("J{i} {}\n", count + 1));
10126            let mut cols: Vec<usize> = (base..base + count).collect();
10127            cols.push(nvars + i);
10128            cols.sort_unstable();
10129            for c in cols {
10130                s.push_str(&format!("{c} 0.0\n"));
10131            }
10132        }
10133        s.push_str(&format!("G0 {n}\n"));
10134        for j in 0..n {
10135            s.push_str(&format!("{j} 0.0\n"));
10136        }
10137        s
10138    }
10139
10140    /// Both prelude sweeps run once per color, so iterating the whole prelude
10141    /// each time would cost `n_colors × |prelude|` where the op-ratio gate
10142    /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
10143    /// `eval_h` instead walks the union of that color's summands'
10144    /// `prelude_reach`.
10145    ///
10146    /// What this can and cannot pin is worth being exact about, because the
10147    /// change is pure performance: walking the whole prelude per color
10148    /// computes the *same* Hessian, so no assertion on output values can
10149    /// detect it, and a timing assertion would be flaky. So this asserts the
10150    /// two things that are checkable — that the table the sweeps iterate is
10151    /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
10152    /// where colors genuinely reach different bodies, and that each reach list
10153    /// is ascending and operand-closed, the invariants that make the narrowed
10154    /// walk safe — plus agreement with the flat tapes under narrowing.
10155    #[test]
10156    fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
10157        let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
10158        let mut hybrid = NlTnlp::new(p.clone());
10159        let info = hybrid.get_nlp_info().unwrap();
10160        let nnz = info.nnz_h_lag as usize;
10161        let m = info.m as usize;
10162
10163        {
10164            let h = hybrid
10165                .con_hybrid
10166                .as_mut()
10167                .expect("two shared CSE bodies must build the hybrid tape");
10168            h.use_for_hess = true;
10169
10170            let np = h.tape.n_prelude_ops();
10171            let n_colors = h.hess_color_reach_off.len() - 1;
10172            let total: usize = h.hess_color_reach.len();
10173            assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
10174            assert!(
10175                total < n_colors * np,
10176                "per-color reach must be strictly smaller than walking the whole \
10177                 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
10178                n_colors * np
10179            );
10180            // Every reach list must be ascending and operand-closed, which is
10181            // what makes the narrowed walk safe.
10182            for c in 0..n_colors {
10183                let r =
10184                    &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
10185                assert!(
10186                    r.windows(2).all(|w| w[0] < w[1]),
10187                    "color {c} reach is not strictly ascending"
10188                );
10189                let member: std::collections::HashSet<u32> = r.iter().copied().collect();
10190                for &i in r {
10191                    let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
10192                    for opnd in [a, b].into_iter().flatten() {
10193                        assert!(
10194                            member.contains(&(opnd as u32)),
10195                            "color {c}: slot {i}'s operand {opnd} is missing from its reach"
10196                        );
10197                    }
10198                }
10199            }
10200        }
10201
10202        let mut flat = NlTnlp::new(p);
10203        flat.get_nlp_info().unwrap();
10204        flat.con_hybrid = None;
10205
10206        let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
10207        for scale in [1.0_f64, -0.6] {
10208            let x: Vec<f64> = (0..info.n as usize)
10209                .map(|j| scale * (0.15 + 0.02 * j as f64))
10210                .collect();
10211            let mut hh = vec![0.0_f64; nnz];
10212            let mut hf = vec![0.0_f64; nnz];
10213            assert!(hybrid.eval_h(
10214                Some(&x),
10215                true,
10216                1.0,
10217                Some(&lam),
10218                true,
10219                SparsityRequest::Values { values: &mut hh }
10220            ));
10221            assert!(flat.eval_h(
10222                Some(&x),
10223                true,
10224                1.0,
10225                Some(&lam),
10226                true,
10227                SparsityRequest::Values { values: &mut hf }
10228            ));
10229            for k in 0..nnz {
10230                let tol = 1e-12 * hf[k].abs().max(1.0);
10231                assert!(
10232                    (hh[k] - hf[k]).abs() <= tol,
10233                    "narrowed-reach Hessian entry {k} at scale {scale}: \
10234                     hybrid {} vs flat {}",
10235                    hh[k],
10236                    hf[k]
10237                );
10238            }
10239            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10240        }
10241    }
10242
10243    /// The Hessian gate is off for a model whose shared bodies are small —
10244    /// below the ratio where the shared prelude sweeps pay for the hybrid
10245    /// traversal's per-op overhead — leaving `eval_h` on the flat path
10246    /// (which the gate keeps bit-identical for such models by definition).
10247    #[test]
10248    fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
10249        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10250        let mut t = NlTnlp::new(p);
10251        t.get_nlp_info().unwrap();
10252        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10253        assert!(
10254            !h.use_for_hess,
10255            "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
10256        );
10257    }
10258
10259    /// A CSE referenced from several constraints is evaluated once per
10260    /// `eval_g` via the shared prelude instead of once per reference. The
10261    /// values must be bit-identical to the flat per-summand `Tape` path,
10262    /// which is what makes the optimization safe to apply unconditionally.
10263    #[test]
10264    fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
10265        let nl = shared_cse_nl("o2\nv3\nv2");
10266        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10267        let mut hybrid = NlTnlp::new(p.clone());
10268        hybrid.get_nlp_info().unwrap();
10269        let h = hybrid
10270            .con_hybrid
10271            .as_ref()
10272            .expect("CSE shared by 3 constraints must take the hybrid path");
10273        assert!(
10274            h.tape.n_prelude_ops() > 0,
10275            "shared CSE body must land in the prelude"
10276        );
10277
10278        // Same model with the hybrid path switched off: the reference.
10279        let mut flat = NlTnlp::new(p);
10280        flat.get_nlp_info().unwrap();
10281        flat.con_hybrid = None;
10282
10283        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10284            let mut gh = [0.0_f64; 3];
10285            let mut gf = [0.0_f64; 3];
10286            assert!(hybrid.eval_g(&x, true, &mut gh));
10287            assert!(flat.eval_g(&x, true, &mut gf));
10288            // V3 = 2 x0 + 3 x1.
10289            let s = 2.0 * x[0] + 3.0 * x[1];
10290            let want = [s * s, s * s * s + x[2], s * x[2]];
10291            for i in 0..3 {
10292                assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
10293                assert!(
10294                    (gh[i] - want[i]).abs() < 1e-9,
10295                    "row {i}: got {}, want {}",
10296                    gh[i],
10297                    want[i]
10298                );
10299            }
10300        }
10301    }
10302
10303    /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
10304    /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
10305    /// take that path after `hybrid_supported` clears the model. Here a
10306    /// min-list in one constraint has to disable it for the whole block —
10307    /// falling back, not panicking.
10308    #[test]
10309    fn unsupported_opcode_falls_back_to_the_flat_tape() {
10310        let nl = shared_cse_nl("o11\n2\nv3\nv2");
10311        let p = parse_nl_text(&nl).expect("parse min-list model");
10312        let mut tnlp = NlTnlp::new(p);
10313        tnlp.get_nlp_info().unwrap();
10314        assert!(
10315            tnlp.con_hybrid.is_none(),
10316            "a min-list anywhere in the constraint block must disable the hybrid path"
10317        );
10318        let mut g = [0.0_f64; 3];
10319        assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
10320        let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
10321        assert!((g[0] - s * s).abs() < 1e-9);
10322        assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
10323    }
10324
10325    // ---- In-memory construction + HVP (issue #469) --------------------
10326
10327    fn v(i: usize) -> Expr {
10328        Expr::Var(i)
10329    }
10330
10331    fn c(x: Number) -> Expr {
10332        Expr::Const(x)
10333    }
10334
10335    fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
10336        Expr::Binary(op, Box::new(a), Box::new(b))
10337    }
10338
10339    fn un(op: UnaryOp, a: Expr) -> Expr {
10340        Expr::Unary(op, Box::new(a))
10341    }
10342
10343    /// `NlProblemParts` for an `n`-variable, unbounded model.
10344    fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
10345        let m = constraints.len();
10346        NlProblemParts {
10347            minimize: true,
10348            objective,
10349            obj_constant: 0.0,
10350            constraints,
10351            x_l: vec![-1e19; n],
10352            x_u: vec![1e19; n],
10353            x0: vec![0.0; n],
10354            g_l: vec![-1e19; m],
10355            g_u: vec![1e19; m],
10356            var_names: Vec::new(),
10357            con_names: Vec::new(),
10358        }
10359    }
10360
10361    /// A model built from expressions evaluates exactly like a parsed one:
10362    /// objective, gradient, constraints, and Jacobian all come from the
10363    /// same tape, with no `.nl` text in the loop.
10364    #[test]
10365    fn from_expressions_builds_evaluable_problem() {
10366        // min (1-x0)^2 + 100*(x1 - x0^2)^2   s.t.  x0^2 + x1^2 <= 2
10367        let rosen = bin(
10368            BinOp::Add,
10369            bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
10370            bin(
10371                BinOp::Mul,
10372                c(100.0),
10373                bin(
10374                    BinOp::Pow,
10375                    bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
10376                    c(2.0),
10377                ),
10378            ),
10379        );
10380        let circle = bin(
10381            BinOp::Add,
10382            bin(BinOp::Pow, v(0), c(2.0)),
10383            bin(BinOp::Pow, v(1), c(2.0)),
10384        );
10385
10386        let mut p = parts(2, rosen, vec![circle]);
10387        p.g_l = vec![0.0];
10388        p.g_u = vec![2.0];
10389        p.x0 = vec![-1.2, 1.0];
10390        p.var_names = names(&["x", "y"]);
10391        p.con_names = names(&["circle"]);
10392
10393        let prob = NlProblem::from_expressions(p).expect("build");
10394        assert_eq!((prob.n, prob.m), (2, 1));
10395        assert_eq!(prob.var_names, names(&["x", "y"]));
10396
10397        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10398        t.get_nlp_info().unwrap();
10399
10400        // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
10401        let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
10402        assert!((f - 24.2).abs() < 1e-10, "f = {f}");
10403
10404        // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
10405        //    = (4.4 + 480*(-0.44)... ) — computed below rather than
10406        //      transcribed, so the check is the formula, not an editor.
10407        let (x0, x1) = (-1.2, 1.0);
10408        let want = [
10409            -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
10410            200.0 * (x1 - x0 * x0),
10411        ];
10412        let mut g = [0.0_f64; 2];
10413        assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
10414        for j in 0..2 {
10415            assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
10416        }
10417
10418        // g(x) = x0^2 + x1^2 = 2.44
10419        let mut gv = [0.0_f64; 1];
10420        assert!(t.eval_g(&[x0, x1], true, &mut gv));
10421        assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
10422    }
10423
10424    /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
10425    /// this path. None of the three survives a `.nl` round trip in a
10426    /// typical frontend — `atan2` has no two-argument funcall path,
10427    /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
10428    /// at all — which is the reason the in-memory door exists.
10429    #[test]
10430    fn from_expressions_carries_ops_nl_cannot_express() {
10431        let obj = Expr::Sum(vec![
10432            bin(BinOp::Atan2, v(0), v(1)),
10433            Expr::MinList(vec![v(0), v(1)]),
10434            Expr::MaxList(vec![v(0), v(1)]),
10435            un(UnaryOp::Erf, v(0)),
10436        ]);
10437        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10438        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10439        t.get_nlp_info().unwrap();
10440
10441        let x: [Number; 2] = [0.8, 1.5];
10442        // atan2 + min + max + erf; min+max == x0+x1 for any pair.
10443        let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
10444        let f = t.eval_f(&x, true).unwrap();
10445        assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
10446    }
10447
10448    /// A `Var` index past `n` would be an out-of-bounds read in the
10449    /// tape's forward sweep. It has to be caught at construction, while
10450    /// it is still a diagnosable user error.
10451    #[test]
10452    fn from_expressions_rejects_out_of_range_var() {
10453        let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
10454            .expect_err("Var(5) with n = 2 must be rejected");
10455        assert!(err.contains("Var(5)"), "{err}");
10456
10457        let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
10458            .expect_err("constraint Var(2) with n = 2 must be rejected");
10459        assert!(err.contains("constraint 0"), "{err}");
10460
10461        // Length mismatches are errors too, not panics.
10462        let mut p = parts(2, c(0.0), Vec::new());
10463        p.x0 = vec![0.0; 3];
10464        let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
10465        assert!(err.contains("x0"), "{err}");
10466    }
10467
10468    /// An out-of-range `Var` must be caught wherever it hides, not just at
10469    /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
10470    /// branch, and a funcall argument all reach the same forward sweep.
10471    #[test]
10472    fn from_expressions_finds_out_of_range_vars_in_every_position() {
10473        let inner_cse = Arc::new(v(7));
10474        let cases: Vec<(&str, Expr)> = vec![
10475            ("bare", v(7)),
10476            ("cse", Expr::Cse(Arc::new(v(7)))),
10477            ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
10478            (
10479                "cond branch",
10480                Expr::Cond {
10481                    cond: Box::new(c(1.0)),
10482                    then_: Box::new(v(7)),
10483                    else_: Box::new(c(0.0)),
10484                },
10485            ),
10486            ("min list", Expr::MinList(vec![c(0.0), v(7)])),
10487            (
10488                "sum",
10489                Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
10490            ),
10491        ];
10492        for (label, e) in cases {
10493            let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
10494                .err()
10495                .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
10496            assert!(err.contains("Var(7)"), "{label}: {err}");
10497        }
10498    }
10499
10500    /// A balanced share-DAG: each level is one `Cse` whose body
10501    /// references the level below twice. Depth `d` is `d` distinct nodes
10502    /// but `2^d` paths, so any walk that re-enters a shared body per
10503    /// occurrence is Θ(2^d).
10504    fn share_dag(depth: usize) -> Expr {
10505        let mut e = v(0);
10506        for _ in 0..depth {
10507            let shared = Arc::new(e);
10508            e = bin(
10509                BinOp::Add,
10510                Expr::Cse(Arc::clone(&shared)),
10511                Expr::Cse(shared),
10512            );
10513        }
10514        e
10515    }
10516
10517    /// The walks over a shared DAG must be memoized, not exponential.
10518    ///
10519    /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
10520    /// does not "fail" so much as never finish, which is exactly the
10521    /// signal. `from_expressions` is the door that makes such a DAG
10522    /// trivially constructible, but the blowup was reachable through
10523    /// `collect_vars` (which presolve calls on every solve, via
10524    /// `get_variables_linearity`) and `collect_funcall_ids` (which
10525    /// `NlTnlp::try_new` runs over every row).
10526    #[test]
10527    fn shared_dag_walks_are_memoized_not_exponential() {
10528        const DEPTH: usize = 30;
10529        let e = share_dag(DEPTH);
10530
10531        let mut vars = BTreeSet::new();
10532        collect_vars(&e, &mut vars);
10533        assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
10534
10535        let mut ids = BTreeSet::new();
10536        super::super::nl_external::collect_funcall_ids(&e, &mut ids);
10537        assert!(ids.is_empty());
10538
10539        // The whole build path, end to end: validation, tape construction,
10540        // and the linearity metadata presolve consumes.
10541        let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
10542        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10543        t.get_nlp_info().unwrap();
10544        let mut lin = vec![Linearity::Linear; 1];
10545        assert!(t.get_variables_linearity(&mut lin));
10546    }
10547
10548    /// `from_expressions` cannot carry AMPL imported functions — there is
10549    /// nowhere to put the `F`-segment declarations that bind a funcall id
10550    /// to a library — so a `Funcall` must be refused up front. Accepting it
10551    /// produces "AMPLFUNC is not set", which the user cannot act on:
10552    /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
10553    #[test]
10554    fn from_expressions_rejects_imported_function_calls() {
10555        let call = Expr::Funcall {
10556            id: 0,
10557            args: vec![FuncallArg::Real(v(0))],
10558        };
10559        let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
10560            .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
10561        assert!(err.contains("imported function"), "{err}");
10562        assert!(
10563            err.contains("read_nl") || err.contains("parse_nl_text"),
10564            "the error must point at the paths that do support externals: {err}"
10565        );
10566
10567        // Also when buried in a constraint, behind a Cse.
10568        let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
10569        let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
10570            .expect_err("a buried Funcall must be rejected too");
10571        assert!(err.contains("constraint 0"), "{err}");
10572    }
10573
10574    /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
10575    /// same tapes, same weights, one seed instead of a color sweep. The
10576    /// objective and both constraints are chosen with cross terms so the
10577    /// off-diagonal blocks actually carry signal.
10578    #[test]
10579    fn hessian_vector_product_matches_dense_hessian() {
10580        let obj = Expr::Sum(vec![
10581            bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
10582            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10583            un(UnaryOp::Erf, v(2)),
10584        ]);
10585        let cons = vec![
10586            bin(
10587                BinOp::Add,
10588                bin(BinOp::Pow, v(0), c(2.0)),
10589                un(UnaryOp::Sin, v(2)),
10590            ),
10591            bin(BinOp::Mul, v(1), v(2)),
10592        ];
10593        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10594        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10595        let info = t.get_nlp_info().unwrap();
10596
10597        let x = [0.3, -0.7, 1.1];
10598        let lam = [0.5, -1.25];
10599        let obj_factor = 2.0;
10600
10601        // Dense Hessian from the sparse lower triangle.
10602        let nnz = info.nnz_h_lag as usize;
10603        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10604        assert!(t.eval_h(
10605            None,
10606            false,
10607            1.0,
10608            None,
10609            false,
10610            SparsityRequest::Structure {
10611                irow: &mut irow,
10612                jcol: &mut jcol
10613            }
10614        ));
10615        let mut hvals = vec![0.0_f64; nnz];
10616        assert!(t.eval_h(
10617            Some(&x),
10618            true,
10619            obj_factor,
10620            Some(&lam),
10621            true,
10622            SparsityRequest::Values { values: &mut hvals }
10623        ));
10624        let mut dense = [[0.0_f64; 3]; 3];
10625        for k in 0..nnz {
10626            let (i, j) = (irow[k] as usize, jcol[k] as usize);
10627            dense[i][j] += hvals[k];
10628            if i != j {
10629                dense[j][i] += hvals[k];
10630            }
10631        }
10632
10633        // Each unit seed recovers a column; a mixed seed catches an HVP
10634        // that only happens to be right on the basis vectors.
10635        let seeds: [[Number; 3]; 4] = [
10636            [1.0, 0.0, 0.0],
10637            [0.0, 1.0, 0.0],
10638            [0.0, 0.0, 1.0],
10639            [0.4, -1.3, 2.0],
10640        ];
10641        let mut out = vec![0.0; 3];
10642        for s in &seeds {
10643            t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
10644                .expect("hvp");
10645            for i in 0..3 {
10646                let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
10647                assert!(
10648                    (out[i] - want).abs() < 1e-9,
10649                    "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
10650                    out[i]
10651                );
10652            }
10653        }
10654    }
10655
10656    /// `lam = None` is the objective block alone, and `out` is
10657    /// overwritten (not accumulated) so a reused buffer is safe.
10658    #[test]
10659    fn hessian_vector_product_defaults_and_validation() {
10660        // f = x0^2 + 3 x0 x1  ->  ∇²f = [[2, 3], [3, 0]]
10661        let obj = bin(
10662            BinOp::Add,
10663            bin(BinOp::Pow, v(0), c(2.0)),
10664            bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
10665        );
10666        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10667        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10668        t.get_nlp_info().unwrap();
10669
10670        let mut out = vec![7.0, -7.0]; // dirty buffer
10671        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
10672            .expect("hvp");
10673        assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
10674        assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
10675
10676        // obj_factor scales linearly.
10677        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
10678            .expect("hvp");
10679        assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
10680
10681        // Length mismatches are errors, not panics or silent truncation.
10682        let mut short = vec![0.0; 1];
10683        assert!(
10684            t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
10685                .is_err()
10686        );
10687        assert!(
10688            t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
10689                .is_err()
10690        );
10691        assert!(
10692            t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
10693                .is_err()
10694        );
10695    }
10696
10697    /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
10698    /// Hessian — the sparse shape an IPM actually meets. The block HVP has
10699    /// to reproduce it column for column, including the structural zeros:
10700    /// a bug that leaked coupling between non-adjacent variables would
10701    /// show up here and nowhere in a small dense test.
10702    #[test]
10703    fn hessian_vector_products_on_a_sparse_hessian() {
10704        const N: usize = 8;
10705        let mut terms = Vec::new();
10706        for i in 0..N - 1 {
10707            terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
10708        }
10709        for i in 0..N {
10710            terms.push(un(UnaryOp::Exp, v(i)));
10711        }
10712        let prob =
10713            NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
10714        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10715        let info = t.get_nlp_info().unwrap();
10716
10717        // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
10718        assert_eq!(
10719            info.nnz_h_lag as usize,
10720            2 * N - 1,
10721            "chain objective should give a tridiagonal Hessian, not a dense one"
10722        );
10723
10724        let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
10725
10726        // Densify the sparse triangle for the reference.
10727        let nnz = info.nnz_h_lag as usize;
10728        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10729        assert!(t.eval_h(
10730            None,
10731            false,
10732            1.0,
10733            None,
10734            false,
10735            SparsityRequest::Structure {
10736                irow: &mut irow,
10737                jcol: &mut jcol
10738            }
10739        ));
10740        let mut hvals = vec![0.0; nnz];
10741        assert!(t.eval_h(
10742            Some(&x),
10743            true,
10744            1.0,
10745            None,
10746            true,
10747            SparsityRequest::Values { values: &mut hvals }
10748        ));
10749        let mut dense = vec![vec![0.0; N]; N];
10750        for k in 0..nnz {
10751            let (i, j) = (irow[k] as usize, jcol[k] as usize);
10752            dense[i][j] += hvals[k];
10753            if i != j {
10754                dense[j][i] += hvals[k];
10755            }
10756        }
10757
10758        // One block of N unit seeds recovers the whole matrix — the
10759        // "densify via HVPs" path, in one call.
10760        let mut seeds = vec![0.0; N * N];
10761        for cc in 0..N {
10762            seeds[cc * N + cc] = 1.0;
10763        }
10764        let mut out = vec![0.0; N * N];
10765        t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
10766            .expect("block hvp");
10767        for cc in 0..N {
10768            for i in 0..N {
10769                assert!(
10770                    (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
10771                    "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
10772                    out[cc * N + i],
10773                    dense[i][cc]
10774                );
10775            }
10776        }
10777    }
10778
10779    /// The block form must agree with `k` separate single-vector calls
10780    /// (it shares one forward sweep across directions, so a bug there
10781    /// would show as a per-direction discrepancy), and must skip an
10782    /// all-zero direction without disturbing its neighbours.
10783    #[test]
10784    fn hessian_vector_products_match_repeated_single_calls() {
10785        let obj = Expr::Sum(vec![
10786            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10787            bin(BinOp::Pow, v(2), c(4.0)),
10788            bin(BinOp::Mul, v(0), v(2)),
10789        ]);
10790        let cons = vec![bin(BinOp::Mul, v(1), v(2))];
10791        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10792        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10793        t.get_nlp_info().unwrap();
10794
10795        let x = [0.4, -0.6, 1.3];
10796        let lam = [0.75];
10797        let cols: [[Number; 3]; 4] = [
10798            [1.0, 2.0, -3.0],
10799            [0.0, 0.0, 0.0], // the skipped direction
10800            [0.5, 0.0, 0.0],
10801            [-1.0, 1.0, 1.0],
10802        ];
10803
10804        let mut block = vec![0.0; 3 * cols.len()];
10805        let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
10806        t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
10807            .expect("block hvp");
10808
10809        for (c, col) in cols.iter().enumerate() {
10810            let mut single = vec![0.0; 3];
10811            t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
10812                .expect("single hvp");
10813            for i in 0..3 {
10814                assert!(
10815                    (block[c * 3 + i] - single[i]).abs() < 1e-12,
10816                    "direction {c} row {i}: block={:.12e} single={:.12e}",
10817                    block[c * 3 + i],
10818                    single[i]
10819                );
10820            }
10821        }
10822        // The zero direction really is zero, not stale scratch.
10823        assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
10824    }
10825
10826    /// `k = 0` is a legal empty block, and the length checks scale with
10827    /// `k` rather than assuming a single direction.
10828    #[test]
10829    fn hessian_vector_products_validate_block_shape() {
10830        let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
10831            .expect("build");
10832        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10833        t.get_nlp_info().unwrap();
10834
10835        let mut empty: Vec<Number> = Vec::new();
10836        assert!(
10837            t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
10838                .is_ok()
10839        );
10840
10841        // v sized for one direction while k says two.
10842        let mut out = vec![0.0; 4];
10843        assert!(
10844            t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
10845                .is_err()
10846        );
10847        // out sized for one direction while k says two.
10848        let mut short = vec![0.0; 2];
10849        assert!(
10850            t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
10851                .is_err()
10852        );
10853    }
10854
10855    /// A `maximize` model's objective is negated by the evaluator, and
10856    /// the HVP has to agree with `eval_h` about that — otherwise a
10857    /// Hessian-free step would climb where the sparse path descends.
10858    #[test]
10859    fn hessian_vector_product_respects_maximize_sign() {
10860        let obj = bin(BinOp::Pow, v(0), c(2.0));
10861        let mut p = parts(1, obj, Vec::new());
10862        p.minimize = false;
10863        let prob = NlProblem::from_expressions(p).expect("build");
10864        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10865        t.get_nlp_info().unwrap();
10866
10867        // max x0^2 is minimized as -x0^2, so ∇² = -2.
10868        let mut out = vec![0.0; 1];
10869        t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
10870            .expect("hvp");
10871        assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
10872    }
10873
10874    // ---- gh#1042 R3: `|e|^p · |e|^q` merged at parse time ----------------
10875
10876    /// An unconstrained `.nl` model over `n` variables whose objective is
10877    /// the given expression-token body (one token per line).
10878    fn abs_pow_nl(n: usize, body: &[&str]) -> String {
10879        let mut s = format!(
10880            "g3 0 1 0\n {n} 0 1 0 0\n 0 1\n 0 0\n 0 {n} 0\n 0 0 0 1\n 0 0 0 0 0\n 0 0\n 0 0\n 0 0 0 0 0\nO0 0\n"
10881        );
10882        for t in body {
10883            s.push_str(t);
10884            s.push('\n');
10885        }
10886        s.push_str("b\n");
10887        for _ in 0..n {
10888            s.push_str("3\n");
10889        }
10890        s
10891    }
10892
10893    /// `(3·|x|^2)·|x|^0.85` as Vanderbei's `dallass` writes its head-loss
10894    /// terms. Read as written, the tape's product rule forms `0·∞` at
10895    /// `x = 0` and the Hessian is NaN there (measured; the gradient reads
10896    /// `0`), which fails the
10897    /// KKT factorization and forces a restoration entry (11 of `dallass`'s
10898    /// 12 on 0.13.0). Merged into `3·|x|^2.85` every value is unchanged and
10899    /// the derivatives at the kink are the finite limits, `0` and `0`.
10900    #[test]
10901    fn abs_pow_product_is_merged_and_its_derivatives_are_finite_at_the_kink() {
10902        let txt = abs_pow_nl(
10903            1,
10904            &[
10905                "o2", "o2", "n3", "o5", "o15", "v0", "n2", "o5", "o15", "v0", "n0.85",
10906            ],
10907        );
10908        let p = parse_nl_text(&txt).expect("parse");
10909        // The tree is `Mul(3, Pow(Abs(x0), 2.85))`.
10910        match &*p.obj_expr() {
10911            Expr::Binary(BinOp::Mul, k, f) => {
10912                assert!(matches!(**k, Expr::Const(c) if c == 3.0), "{k:?}");
10913                assert!(
10914                    matches!(abs_pow_parts(f), Some((Expr::Var(0), q)) if q == 2.85),
10915                    "{f:?}"
10916                );
10917            }
10918            other => panic!("not merged: {other:?}"),
10919        }
10920
10921        let mut t = NlTnlp::new(p);
10922        let info = t.get_nlp_info().unwrap();
10923        assert_eq!(info.nnz_h_lag, 1);
10924        let f = |t: &mut NlTnlp, x: f64| t.eval_f(&[x], true).unwrap();
10925        let g = |t: &mut NlTnlp, x: f64| {
10926            let mut g = [0.0];
10927            assert!(t.eval_grad_f(&[x], true, &mut g));
10928            g[0]
10929        };
10930        let h = |t: &mut NlTnlp, x: f64| {
10931            let mut v = [0.0];
10932            assert!(t.eval_h(
10933                Some(&[x]),
10934                true,
10935                1.0,
10936                None,
10937                true,
10938                SparsityRequest::Values { values: &mut v }
10939            ));
10940            v[0]
10941        };
10942        let (pw, k) = (2.85_f64, 3.0_f64);
10943        for &x in &[-2.0, -0.7, -1e-3, 0.0, 1e-3, 0.7, 2.0, 1e3] {
10944            let a = f64::abs(x);
10945            // The value is the product's: `k·|x|^2·|x|^0.85`.
10946            let want_f = k * a.powi(2) * a.powf(0.85);
10947            let want_g = k * pw * a.powf(pw - 1.0) * x.signum() * f64::from(u8::from(x != 0.0));
10948            let want_h = k * pw * (pw - 1.0) * a.powf(pw - 2.0);
10949            let (gf, gg, gh) = (f(&mut t, x), g(&mut t, x), h(&mut t, x));
10950            assert!(
10951                gf.is_finite() && gg.is_finite() && gh.is_finite(),
10952                "x={x}: {gf} {gg} {gh}"
10953            );
10954            let tol = |w: f64| 1e-13 * w.abs().max(1e-300);
10955            assert!(
10956                (gf - want_f).abs() <= tol(want_f),
10957                "f({x}) = {gf}, want {want_f}"
10958            );
10959            assert!(
10960                (gg - want_g).abs() <= tol(want_g),
10961                "g({x}) = {gg}, want {want_g}"
10962            );
10963            assert!(
10964                (gh - want_h).abs() <= tol(want_h),
10965                "h({x}) = {gh}, want {want_h}"
10966            );
10967        }
10968        // At the kink: exactly zero, not NaN.
10969        assert_eq!(
10970            (f(&mut t, 0.0), g(&mut t, 0.0), h(&mut t, 0.0)),
10971            (0.0, 0.0, 0.0)
10972        );
10973        // The Hessian matches a central difference of the gradient on both
10974        // sides of the kink.
10975        for &x in &[-1e-3, 1e-3] {
10976            let e = 1e-8;
10977            let fd = (g(&mut t, x + e) - g(&mut t, x - e)) / (2.0 * e);
10978            let hx = h(&mut t, x);
10979            assert!(
10980                (fd - hx).abs() <= 1e-6 * hx.abs(),
10981                "x={x}: fd {fd} vs h {hx}"
10982            );
10983        }
10984    }
10985
10986    /// Every near miss keeps the tree it always had: `Mul` of the two
10987    /// operands exactly as read. Different operands, a non-positive power, a
10988    /// bare (signed) base and a variable exponent are each a case where the
10989    /// merge would change a value, not only a derivative, or is not known to
10990    /// be the same function.
10991    #[test]
10992    fn abs_pow_near_misses_build_the_unmerged_tree() {
10993        let cases: [(&[&str], &[&str]); 5] = [
10994            // different operands
10995            (&["o5", "o15", "v0", "n2"], &["o5", "o15", "v1", "n0.85"]),
10996            // a negative power: `|0|^-1·|0|^2` is NaN, `|0|^1` is not
10997            (&["o5", "o15", "v0", "n-1"], &["o5", "o15", "v0", "n2"]),
10998            // a zero power
10999            (&["o5", "o15", "v0", "n0"], &["o5", "o15", "v0", "n2"]),
11000            // no abs: `x^0.5·x^0.5` is NaN for x < 0, `x^1` is not
11001            (&["o5", "v0", "n0.5"], &["o5", "v0", "n0.5"]),
11002            // a variable exponent
11003            (&["o5", "o15", "v0", "v1"], &["o5", "o15", "v0", "n2"]),
11004        ];
11005        for (a, b) in cases {
11006            let mut body = vec!["o2"];
11007            body.extend_from_slice(a);
11008            body.extend_from_slice(b);
11009            let p = parse_nl_text(&abs_pow_nl(2, &body)).expect("parse");
11010            let frag = |toks: &[&str]| {
11011                let txt: String = toks.iter().map(|t| format!("{t}\n")).collect();
11012                parse_body_fragment(&txt, 2, &[]).expect("fragment")
11013            };
11014            let want = Expr::Binary(BinOp::Mul, Box::new(frag(a)), Box::new(frag(b)));
11015            assert_eq!(
11016                format!("{:?}", p.obj_expr()),
11017                format!("{want:?}"),
11018                "near miss {body:?} was rewritten"
11019            );
11020        }
11021    }
11022
11023    /// The merge reaches into one `Mul` level on either side and keeps the
11024    /// remaining factors, so `|x|^2·(|x|^0.85·y)` and `(|x|^0.5·y)·|x|^1.5`
11025    /// become `|x|^2.85·y` and `|x|^2·y` — the latter lowered to a mul chain
11026    /// by the tape — with values unchanged.
11027    #[test]
11028    fn abs_pow_merge_keeps_the_other_factors() {
11029        for (body, pw) in [
11030            (
11031                &[
11032                    "o2", "o5", "o15", "v0", "n2", "o2", "o5", "o15", "v0", "n0.85", "v1",
11033                ][..],
11034                2.85,
11035            ),
11036            (
11037                &[
11038                    "o2", "o2", "o5", "o15", "v0", "n0.5", "v1", "o5", "o15", "v0", "n1.5",
11039                ][..],
11040                2.0,
11041            ),
11042        ] {
11043            let p = parse_nl_text(&abs_pow_nl(2, body)).expect("parse");
11044            match &*p.obj_expr() {
11045                Expr::Binary(BinOp::Mul, l, r) => {
11046                    assert!(
11047                        matches!(abs_pow_parts(l), Some((Expr::Var(0), q)) if q == pw),
11048                        "{l:?}"
11049                    );
11050                    assert!(matches!(**r, Expr::Var(1)), "{r:?}");
11051                }
11052                other => panic!("not merged: {other:?}"),
11053            }
11054            let mut t = NlTnlp::new(p);
11055            for &(x, y) in &[(-1.3, 0.4), (0.0, 2.0), (2.5, -1.0)] {
11056                let want = f64::abs(x).powf(pw) * y;
11057                let got = t.eval_f(&[x, y], true).unwrap();
11058                assert!(
11059                    (got - want).abs() <= 1e-13 * want.abs(),
11060                    "{body:?} at ({x},{y}): {got} vs {want}"
11061                );
11062            }
11063        }
11064    }
11065}