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    /// Complementarity rows (`r`-segment kind 5, AMPL `complements`), in
760    /// row order. Each row's bounds are set to the side its body must lie
761    /// on (`[0, inf)` for a variable bounded below, `(-inf, 0]` for one
762    /// bounded above); the pairing itself is lowered by
763    /// `pounce_nlp::mpcc::MpccTnlp`, which a solve of a model with entries
764    /// here must go through. Empty for every model without complementarity.
765    pub complementarity: Vec<NlComplementarity>,
766}
767
768/// One `.nl` complementarity row: `body(row) complements x[var]`.
769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
770pub struct NlComplementarity {
771    /// 0-based constraint row.
772    pub row: usize,
773    /// 0-based variable.
774    pub var: usize,
775    /// The `.nl` flag: which of the variable's bounds are finite (1 lower,
776    /// 2 upper, 3 both).
777    pub flags: u8,
778}
779
780/// The pieces of a model built in memory, as handed to
781/// [`NlProblem::from_expressions`].
782///
783/// Everything is expressed as [`Expr`] trees — there is no linear/nonlinear
784/// split to fill in, because the AD tape treats a linear term exactly like
785/// any other subexpression (`.nl`'s `J`/`G` segments are a file-format
786/// optimization, not an evaluator requirement). `n` is taken from the length
787/// of `x_l`; `m` from the length of `constraints`.
788///
789/// One cost to that simplification, in *metadata* rather than values: with
790/// `con_linear` empty, `get_constraints_linearity` tags a row `Linear` only
791/// when its expression is literally `Const(0.0)`, so a genuinely linear row
792/// built here reports `NonLinear`. Presolve consumes that tag, and the
793/// direction is the safe one — it loses tightening it could have done, and
794/// never asserts linearity that does not hold — but a frontend that cares
795/// about presolve strength on linear rows should know the tag is
796/// pessimistic on this path.
797#[derive(Debug, Clone)]
798pub struct NlProblemParts {
799    /// `true` to minimize `objective`, `false` to maximize it. Matches
800    /// [`NlProblem::minimize`]: the evaluator negates a maximize objective
801    /// so callers always see the minimization form.
802    pub minimize: bool,
803    /// Objective expression.
804    pub objective: Expr,
805    /// Constant offset added to the objective.
806    pub obj_constant: Number,
807    /// One expression per constraint row; row `i` is bounded by
808    /// `g_l[i] <= constraints[i](x) <= g_u[i]`.
809    pub constraints: Vec<Expr>,
810    /// Variable bounds and starting point, each length `n`. Use `±1e19`
811    /// for "unbounded", the same sentinel the `.nl` reader emits.
812    pub x_l: Vec<Number>,
813    pub x_u: Vec<Number>,
814    pub x0: Vec<Number>,
815    /// Constraint bounds, each length `m`. `g_l[i] == g_u[i]` is an
816    /// equality row.
817    pub g_l: Vec<Number>,
818    pub g_u: Vec<Number>,
819    /// Optional names, index-aligned to `x` / `g`. Empty is fine — every
820    /// consumer falls back to indices (see [`NlProblem::var_names`]).
821    pub var_names: Vec<String>,
822    pub con_names: Vec<String>,
823}
824
825impl NlProblem {
826    /// Assemble a problem from expression trees, with no `.nl` file
827    /// anywhere in the loop (issue #469).
828    ///
829    /// A modeling frontend that already has its own expression DAG should
830    /// come in here rather than serialize to `.nl` and re-parse: the round
831    /// trip is not only slower, it is *lossy*, because `.nl` writers
832    /// routinely refuse operators this tape supports natively (`atan2`,
833    /// `min`/`max`, and — with no `.nl` opcode at all — [`UnaryOp::Erf`]).
834    ///
835    /// The result is an ordinary [`NlProblem`], so it feeds
836    /// [`NlTnlp::try_new`] and gets exactly the evaluators a parsed model
837    /// does: objective, gradient, constraints, Jacobian + structure,
838    /// Lagrangian Hessian + structure, and
839    /// [`NlTnlp::hessian_vector_product`].
840    ///
841    /// Errors on a length mismatch or on a `Var(i)` index at or beyond `n`
842    /// — the latter would otherwise be an out-of-bounds read in the tape's
843    /// forward sweep, so it must be caught while it is still a diagnosable
844    /// user error.
845    pub fn from_expressions(parts: NlProblemParts) -> Result<NlProblem, String> {
846        let NlProblemParts {
847            minimize,
848            objective,
849            obj_constant,
850            constraints,
851            x_l,
852            x_u,
853            x0,
854            g_l,
855            g_u,
856            var_names,
857            con_names,
858        } = parts;
859
860        let n = x_l.len();
861        let m = constraints.len();
862        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
863            if got == want {
864                Ok(())
865            } else {
866                Err(format!(
867                    "from_expressions: {name} has length {got}, expected {want}"
868                ))
869            }
870        };
871        check("x_u", x_u.len(), n)?;
872        check("x0", x0.len(), n)?;
873        check("g_l", g_l.len(), m)?;
874        check("g_u", g_u.len(), m)?;
875        if !var_names.is_empty() {
876            check("var_names", var_names.len(), n)?;
877        }
878        if !con_names.is_empty() {
879            check("con_names", con_names.len(), m)?;
880        }
881
882        // Numeric validation, the same screen the `.nl` reader applies
883        // (gh #847). `lower_bound_present` / `upper_bound_present` are
884        // `is_finite() && ...`, so a caller that builds a model here and hands
885        // in a non-finite bound gets it silently dropped -- read as "no bound
886        // declared" -- exactly as a file containing `1e400` was. `-inf` on a
887        // lower side and `+inf` on an upper one are the sentinel said a
888        // different way and are normalized; everything else, `NaN` included,
889        // is refused.
890        let mut x_l = x_l;
891        let mut x_u = x_u;
892        let mut g_l = g_l;
893        let mut g_u = g_u;
894        for (i, v) in x_l.iter_mut().enumerate() {
895            *v = finite_bound_or_err(&format!("x_l[{i}]"), *v, true)
896                .map_err(|e| format!("from_expressions: {e}"))?;
897        }
898        for (i, v) in x_u.iter_mut().enumerate() {
899            *v = finite_bound_or_err(&format!("x_u[{i}]"), *v, false)
900                .map_err(|e| format!("from_expressions: {e}"))?;
901        }
902        for (i, v) in g_l.iter_mut().enumerate() {
903            *v = finite_bound_or_err(&format!("g_l[{i}]"), *v, true)
904                .map_err(|e| format!("from_expressions: {e}"))?;
905        }
906        for (i, v) in g_u.iter_mut().enumerate() {
907            *v = finite_bound_or_err(&format!("g_u[{i}]"), *v, false)
908                .map_err(|e| format!("from_expressions: {e}"))?;
909        }
910        for (i, v) in x0.iter().enumerate() {
911            finite_or_err(&format!("x0[{i}]"), *v).map_err(|e| format!("from_expressions: {e}"))?;
912        }
913        finite_or_err("obj_constant", obj_constant)
914            .map_err(|e| format!("from_expressions: {e}"))?;
915
916        // Structural validation. Memoized on `Cse` pointer identity so a
917        // heavily-shared DAG costs O(nodes) rather than O(inlined tree).
918        let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
919        validate_expr(&objective, n, &mut seen).map_err(|e| format!("objective {e}"))?;
920        for (i, c) in constraints.iter().enumerate() {
921            validate_expr(c, n, &mut seen).map_err(|e| format!("constraint {i} {e}"))?;
922        }
923
924        Ok(NlProblem {
925            n,
926            m,
927            num_obj: 1,
928            minimize,
929            obj_nonlinear: NlBody::Tree(objective),
930            obj_linear: Vec::new(),
931            obj_constant,
932            con_nonlinear: constraints.into_iter().map(NlBody::Tree).collect(),
933            con_linear: vec![Vec::new(); m],
934            x_l,
935            x_u,
936            g_l,
937            g_u,
938            x0,
939            lambda0: vec![0.0; m],
940            suffixes: NlSuffixes::default(),
941            imported_funcs: Vec::new(),
942            ampl_options: Vec::new(),
943            // No header was read, so there is no census to report. Consumers
944            // fall back to walking the trees, which is what they would have
945            // to do here anyway.
946            nl_counts: None,
947            n_discrete: None,
948            var_names,
949            con_names,
950            // Built from trees, so every body has one and there is nothing
951            // to rebuild from.
952            src: None,
953            cse_bodies: Vec::new(),
954            complementarity: Vec::new(),
955        })
956    }
957
958    /// The objective's nonlinear body as an [`Expr`].
959    ///
960    /// Borrowed when the tree is resident, rebuilt when the parser
961    /// recognized the body and skipped building it. The rebuild re-parses
962    /// the body's own bytes with the same parser that produced the rest of
963    /// the model, so the result is the tree a non-recognizing parse would
964    /// have produced — structurally identical, coefficient bit patterns
965    /// included, and sharing the same `Cse` allocations.
966    ///
967    /// It is not free: it allocates the nodes the recognizer avoided. Reach
968    /// for [`NlBody::quad`] first if the coefficients are what you want.
969    pub fn obj_expr(&self) -> std::borrow::Cow<'_, Expr> {
970        self.body_expr(&self.obj_nonlinear, "objective")
971    }
972
973    /// Row `k`'s nonlinear body as an [`Expr`]. See [`Self::obj_expr`].
974    ///
975    /// # Panics
976    ///
977    /// Panics if `k >= m`, or if re-parsing a recognized body fails — the
978    /// latter cannot happen for a problem this crate produced (the bytes
979    /// parsed once already, by this code) and means the `NlProblem` was
980    /// assembled by hand with a `src` that does not match its bodies.
981    pub fn con_expr(&self, k: usize) -> std::borrow::Cow<'_, Expr> {
982        self.body_expr(&self.con_nonlinear[k], "constraint")
983    }
984
985    fn body_expr<'a>(&'a self, body: &'a NlBody, what: &str) -> std::borrow::Cow<'a, Expr> {
986        match body {
987            NlBody::Tree(e) => std::borrow::Cow::Borrowed(e),
988            NlBody::Quad(q) => {
989                let src = self
990                    .src
991                    .as_deref()
992                    .unwrap_or_else(|| panic!("{what} body was recognized but no source is kept"));
993                std::borrow::Cow::Owned(
994                    parse_body_fragment(&src[q.src.clone()], self.n, &self.cse_bodies)
995                        .unwrap_or_else(|e| panic!("re-parsing a recognized {what} body: {e}")),
996                )
997            }
998        }
999    }
1000}
1001
1002/// Structural check on an expression bound for [`NlProblem::from_expressions`]:
1003/// every `Var(i)` must satisfy `i < n`, and no `Expr::Funcall` may appear.
1004///
1005/// Both are things the tape cannot recover from later. An out-of-range
1006/// `Var` is an out-of-bounds read in the forward sweep. A `Funcall` is
1007/// worse-looking than it is fatal: `from_expressions` has nowhere to put
1008/// the `F`-segment declarations an AMPL imported function needs
1009/// (`NlProblemParts` has no field for them, and the built problem's
1010/// `imported_funcs` is necessarily empty), so *any* funcall on this path is
1011/// unresolvable. Accepting it would surface as "AMPLFUNC is not set" —
1012/// advice the user cannot act on, because setting `AMPLFUNC` just moves the
1013/// failure to "funcall id N has no F<N> declaration". Rejecting it here
1014/// says the true thing: this door does not carry external functions; go
1015/// through `read_nl` / `parse_nl_text` for a model that needs them.
1016///
1017/// `seen` memoizes `Cse` bodies by pointer identity across calls, so
1018/// passing one set through a whole problem keeps the walk linear in
1019/// distinct DAG nodes rather than exponential in sharing depth. Skipping a
1020/// repeat visit is sound because the caller aborts on the first violation:
1021/// reaching a body a second time proves the first visit found none.
1022fn validate_expr(
1023    e: &Expr,
1024    n: usize,
1025    seen: &mut std::collections::HashSet<*const Expr>,
1026) -> Result<(), String> {
1027    match e {
1028        Expr::Const(_) => Ok(()),
1029        Expr::Var(i) => {
1030            if *i < n {
1031                Ok(())
1032            } else {
1033                Err(format!("references Var({i}) but n = {n}"))
1034            }
1035        }
1036        Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
1037            validate_expr(a, n, seen)?;
1038            validate_expr(b, n, seen)
1039        }
1040        Expr::Unary(_, a) | Expr::Not(a) => validate_expr(a, n, seen),
1041        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
1042            for a in args {
1043                validate_expr(a, n, seen)?;
1044            }
1045            Ok(())
1046        }
1047        Expr::Cond { cond, then_, else_ } => {
1048            validate_expr(cond, n, seen)?;
1049            validate_expr(then_, n, seen)?;
1050            validate_expr(else_, n, seen)
1051        }
1052        Expr::Cse(body) => {
1053            if seen.insert(Arc::as_ptr(body)) {
1054                validate_expr(body, n, seen)
1055            } else {
1056                Ok(())
1057            }
1058        }
1059        Expr::Funcall { id, .. } => Err(format!(
1060            "references AMPL imported function id {id}, which this path cannot \
1061             resolve: a problem built from expressions has no F-segment \
1062             declarations to bind it to. Load such a model with read_nl or \
1063             parse_nl_text instead."
1064        )),
1065    }
1066}
1067
1068/// Suffix data parsed out of `S`-segments. Sparse entries are scattered
1069/// into dense vectors at problem load time so callers can index by
1070/// variable / constraint number directly. Empty maps when the `.nl`
1071/// file declared no suffixes.
1072#[derive(Debug, Clone, Default)]
1073pub struct NlSuffixes {
1074    /// Variable-level integer suffixes (kind = 0). Each vector has
1075    /// length `n_full` (problem variables).
1076    pub var_int: BTreeMap<String, Vec<Index>>,
1077    /// Constraint-level integer suffixes (kind = 1). Length `m_full`.
1078    pub con_int: BTreeMap<String, Vec<Index>>,
1079    /// Objective-level integer suffixes (kind = 2). Length `num_obj`.
1080    pub obj_int: BTreeMap<String, Vec<Index>>,
1081    /// Problem-level integer suffixes (kind = 3). Single value per name.
1082    pub problem_int: BTreeMap<String, Index>,
1083    /// Variable-level real suffixes (kind = 4). Length `n_full`.
1084    pub var_real: BTreeMap<String, Vec<Number>>,
1085    /// Constraint-level real suffixes (kind = 5). Length `m_full`.
1086    pub con_real: BTreeMap<String, Vec<Number>>,
1087    /// Objective-level real suffixes (kind = 6). Length `num_obj`.
1088    pub obj_real: BTreeMap<String, Vec<Number>>,
1089    /// Problem-level real suffixes (kind = 7). Single value per name.
1090    pub problem_real: BTreeMap<String, Number>,
1091}
1092
1093/// Deepest expression nesting the reader will build (gh#986). On a thread
1094/// whose stack is declared with [`set_parse_stack_budget`] the limit is
1095/// lowered to what that stack holds at the worst-case frame size; the CLI
1096/// runs on a 1 GiB thread and `pounce-py` on a 1 GiB worker (each falling
1097/// back to a smaller reservation, and a correspondingly lower limit, when the
1098/// OS refuses). A left-deep `o0` sum is read flat and does not count.
1099pub const MAX_PARSE_DEPTH: u32 = 40_000;
1100
1101thread_local! {
1102    /// Stack bytes the current thread offers the recursive reader
1103    /// (gh#986), set by whoever spawned it with a known stack. `None` (the
1104    /// default) means "unknown": [`DEFAULT_ASSUMED_STACK`] is assumed.
1105    static PARSE_STACK_BUDGET: std::cell::Cell<Option<usize>> =
1106        const { std::cell::Cell::new(None) };
1107}
1108
1109/// Tell the reader how much stack this thread has, so its depth guard fires
1110/// before the stack does at *any* build profile (gh#986). Call it first thing
1111/// on a thread created with an explicit stack size (the CLI's main thread, the
1112/// Python worker); `None` clears it.
1113pub fn set_parse_stack_budget(bytes: Option<usize>) {
1114    PARSE_STACK_BUDGET.with(|b| b.set(bytes));
1115}
1116
1117/// Stack the recursive reader (parse, tape build, drop glue) needs per
1118/// nesting level, as a worst case over build profiles. Measured on a
1119/// negation chain 39 900 deep: an unoptimised build (`opt-level = 0`, what
1120/// `cargo build` / `maturin develop` produce) fails at 512 MiB and passes at
1121/// 1 GiB, i.e. 13–26 KiB per level; optimised builds use well under 2 KiB.
1122const PARSE_LEVEL_BYTES: usize = if cfg!(debug_assertions) {
1123    32 * 1024
1124} else {
1125    4 * 1024
1126};
1127
1128/// Stack a thread is assumed to have when nobody declared one with
1129/// [`set_parse_stack_budget`] (gh#986 review). The first pass gave such a
1130/// thread the full [`MAX_PARSE_DEPTH`], sized for the CLI's 1 GiB thread, so a
1131/// library caller of [`read_nl_file`] on an ordinary thread, or the wasm
1132/// build, aborted on a stack overflow where the guard was meant to refuse.
1133/// The assumption is the smallest stack such a caller normally runs on: Rust's
1134/// default for a spawned thread (2 MiB, also what the test harness uses) on
1135/// native targets, and the 1 MiB `rustc` gives a wasm32 module.
1136const DEFAULT_ASSUMED_STACK: usize = if cfg!(target_arch = "wasm32") {
1137    1 << 20
1138} else {
1139    2 << 20
1140};
1141
1142/// The depth limit for the current thread: [`MAX_PARSE_DEPTH`], lowered to
1143/// what the thread's declared stack holds -- or, undeclared, what
1144/// [`DEFAULT_ASSUMED_STACK`] holds.
1145fn parse_depth_limit() -> u32 {
1146    let bytes = PARSE_STACK_BUDGET
1147        .with(|b| b.get())
1148        .unwrap_or(DEFAULT_ASSUMED_STACK);
1149    ((bytes / PARSE_LEVEL_BYTES).min(MAX_PARSE_DEPTH as usize)) as u32
1150}
1151
1152/// A run of binary `o0` longer than this is read as one n-ary `Sum`
1153/// (gh#986); shorter runs keep their binary tree.
1154const SUM_CHAIN_FLATTEN_MIN: usize = 64;
1155
1156/// Parse an `.nl` file from disk.
1157///
1158/// After parsing the `.nl` body, this also looks for AMPL's optional
1159/// sibling name files — `stub.col` (variable names) and `stub.row`
1160/// (constraint names), emitted only when the modeler sets
1161/// `option auxfiles rc;`. When present and well-formed they populate
1162/// [`NlProblem::var_names`] / [`NlProblem::con_names`]; when absent or
1163/// malformed the names stay empty and every downstream consumer falls
1164/// back to indices. Names are a diagnostic nicety, never load-blocking
1165/// (cf. Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
1166pub fn read_nl_file(path: &Path) -> Result<NlProblem, String> {
1167    // AMPL invokes a solver with an extensionless *stub* — e.g.
1168    // `pounce mymodel -AMPL` — and expects `mymodel.nl` to be read (and
1169    // the `.col`/`.row`/`.sol` siblings named off the same stem). If the
1170    // path as given is missing but appending `.nl` names an existing file,
1171    // resolve to that. This only ever *adds* a fallback: an existing path
1172    // is read verbatim, so nothing changes for callers that already pass a
1173    // full `.nl` path (Pyomo, `--nl-file`, the second-positional form).
1174    let resolved = if path.exists() {
1175        path.to_path_buf()
1176    } else {
1177        let with_nl = append_extension(path, "nl");
1178        if with_nl.exists() {
1179            with_nl
1180        } else {
1181            path.to_path_buf()
1182        }
1183    };
1184    let txt = std::fs::read_to_string(&resolved)
1185        .map_err(|e| format!("could not read {}: {}", resolved.display(), e))?;
1186    // By value: a recognized body keeps a byte range into this text rather
1187    // than a tree, so it is moved into the problem instead of copied.
1188    let mut prob = parse_nl_string(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())?;
1189    prob.var_names = read_name_file(&resolved.with_extension("col"), prob.n);
1190    prob.con_names = read_name_file(&resolved.with_extension("row"), prob.m);
1191    Ok(prob)
1192}
1193
1194/// Append `.ext` to `path`'s full file name (AMPL stub convention:
1195/// `mymodel` → `mymodel.nl`), as opposed to [`Path::with_extension`],
1196/// which would *replace* an existing extension. A stub that itself
1197/// contains a dot (`my.model` → `my.model.nl`) is therefore handled the
1198/// way AMPL names it.
1199fn append_extension(path: &Path, ext: &str) -> std::path::PathBuf {
1200    let mut name = path.as_os_str().to_os_string();
1201    name.push(".");
1202    name.push(ext);
1203    std::path::PathBuf::from(name)
1204}
1205
1206/// Read an AMPL name file (`.col` / `.row`): one name per line, in index
1207/// order. Returns the first `expected` names, or an empty vector when the
1208/// file is missing, unreadable, or has fewer than `expected` lines.
1209///
1210/// Returning empty (rather than erroring) on any mismatch is deliberate:
1211/// names are an optional diagnostic aid, so a missing or truncated file
1212/// must never block a solve. The `.take(expected)` also drops AMPL's
1213/// convention of appending the objective name after the constraint names
1214/// in `.row`, keeping the result aligned 1:1 with `g`.
1215fn read_name_file(path: &Path, expected: usize) -> Vec<String> {
1216    let Ok(txt) = std::fs::read_to_string(path) else {
1217        return Vec::new();
1218    };
1219    let names: Vec<String> = txt.lines().take(expected).map(str::to_owned).collect();
1220    if names.len() == expected {
1221        names
1222    } else {
1223        Vec::new()
1224    }
1225}
1226
1227/// The value of a constraint's nonlinear part when that part is a
1228/// constant, else `None`. Drives the constant-row-body fold in
1229/// [`parse_nl_text`].
1230///
1231/// "Constant" is decided by *evaluation*, not by syntax: a literal
1232/// `Expr::Const` is the common case, but `o0 n1 n2` is just as constant
1233/// and is folded too.
1234///
1235/// Declines in two cases, both of which would make the fold unsound:
1236/// * The expression calls an AMPL imported function. Its value depends on
1237///   a shared library resolved much later (`nl_external::ExternalResolver`),
1238///   so it is not a parse-time constant even with constant arguments — and
1239///   [`eval_expr`] panics on `Funcall` rather than guess.
1240/// * The value is not finite (`n0 / n0`, `log(-1)`, an overflow). Pushing
1241///   a NaN or infinity into a bound would corrupt a row that is merely
1242///   infeasible; leaving the expression in place keeps it a solver-time
1243///   fact.
1244fn row_constant_value(e: &Expr) -> Option<Number> {
1245    // The identity zero the parser preallocates for every untouched row is
1246    // by far the most common input; settle it without walking anything.
1247    if let Expr::Const(c) = e {
1248        return c.is_finite().then_some(*c);
1249    }
1250    let mut vars: BTreeSet<usize> = BTreeSet::new();
1251    collect_vars(e, &mut vars);
1252    if !vars.is_empty() {
1253        return None;
1254    }
1255    let mut funcs: BTreeSet<usize> = BTreeSet::new();
1256    crate::nl_external::collect_funcall_ids(e, &mut funcs);
1257    if !funcs.is_empty() {
1258        return None;
1259    }
1260    // Variable-free, so no `Expr::Var` can index into the (empty) point.
1261    let v = eval_expr(e, &[]);
1262    v.is_finite().then_some(v)
1263}
1264
1265/// Parse `.nl` text content. Public so tests can use string literals.
1266///
1267/// Degree-2 bodies are recognized as the token stream is read, so their
1268/// `Expr` trees are never built (gh #588, Q5); `POUNCE_DBG_NO_QUAD=1`
1269/// turns that off and restores the pre-Q5 parse exactly, which is what the
1270/// A/B reference and gh #540's guard test need. See
1271/// [`parse_nl_text_with_quadratic`] for the same switch as a parameter.
1272pub fn parse_nl_text(txt: &str) -> Result<NlProblem, String> {
1273    parse_nl_text_with_quadratic(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
1274}
1275
1276/// [`parse_nl_text`] with parse-time quadratic recognition explicitly on
1277/// or off.
1278///
1279/// The env var the plain entry point reads is process-global, which is
1280/// exactly wrong for a differential test that has to parse the *same* text
1281/// both ways in one process and compare the results — so the knob is a
1282/// parameter here, mirroring [`NlTnlp::try_new_with_quadratic`].
1283pub fn parse_nl_text_with_quadratic(txt: &str, use_quadratic: bool) -> Result<NlProblem, String> {
1284    parse_nl_string(txt.to_string(), use_quadratic)
1285}
1286
1287/// [`parse_nl_text_with_quadratic`], taking the text by value.
1288///
1289/// A recognized body keeps a byte range into the source rather than a tree,
1290/// so the source outlives the parse. Taking ownership means the file
1291/// [`read_nl_file`] already read is *moved* into the problem instead of
1292/// copied: the text is resident during parsing either way, so this costs
1293/// nothing at the peak the phase is about.
1294pub fn parse_nl_string(txt: String, use_quadratic: bool) -> Result<NlProblem, String> {
1295    let src = Arc::new(txt);
1296    let mut p = Parser::new(&src, use_quadratic);
1297    p.parse_header()?;
1298    let n = p.n;
1299    let m = p.m;
1300    let num_obj = p.num_obj;
1301
1302    let mut con_nonlinear: Vec<NlBody> = (0..m).map(|_| NlBody::Tree(Expr::Const(0.0))).collect();
1303    let mut obj_nonlinear = NlBody::Tree(Expr::Const(0.0));
1304    let mut minimize = true;
1305    let mut obj_linear: Vec<(usize, Number)> = Vec::new();
1306    let mut con_linear: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
1307    let mut x_l = vec![-1e19; n];
1308    let mut x_u = vec![1e19; n];
1309    let mut g_l = vec![-1e19; m];
1310    let mut g_u = vec![1e19; m];
1311    let mut x0 = vec![0.0; n];
1312    let mut lambda0 = vec![0.0; m];
1313    let mut suffixes = NlSuffixes::default();
1314    let mut imported_funcs: Vec<ImportedFunc> = Vec::new();
1315    // Segment presence, for the truncation check after the loop (gh#785).
1316    let mut saw_r = false;
1317    let mut complementarity: Vec<NlComplementarity> = Vec::new();
1318    let mut saw_b = false;
1319
1320    while let Some(line) = p.peek_segment_line() {
1321        let tag = line
1322            .trim_start()
1323            .chars()
1324            .next()
1325            .ok_or("unexpected blank segment header")?;
1326        match tag {
1327            'C' => {
1328                let (_hdr, rest) = p.eat_segment_header()?;
1329                let _ = rest;
1330                let idx = parse_segment_index(_hdr, 'C')?;
1331                if idx >= m {
1332                    return Err(format!("C{idx} out of range; m={m}"));
1333                }
1334                con_nonlinear[idx] = p.parse_body()?;
1335            }
1336            'O' => {
1337                let (hdr, _rest) = p.eat_segment_header()?;
1338                let parts: Vec<&str> = hdr.split_whitespace().collect();
1339                if parts.len() < 2 {
1340                    return Err(format!("malformed O-segment header: {hdr}"));
1341                }
1342                let idx = parse_segment_index(parts[0], 'O')?;
1343                let kind: i32 = parts[1].parse().map_err(|e| format!("O kind: {e}"))?;
1344                if idx == 0 {
1345                    minimize = kind == 0;
1346                    obj_nonlinear = p.parse_body()?;
1347                } else {
1348                    // Extra objectives are read but ignored.
1349                    let _ = p.parse_expr()?;
1350                }
1351            }
1352            'r' => {
1353                p.eat_segment_header()?;
1354                saw_r = true;
1355                for i in 0..m {
1356                    let line = p.next_data_line()?;
1357                    if let Some(cc) = parse_complementarity_line(line)? {
1358                        // `5 k j`: body complements x_j. The body lies on the
1359                        // side the variable's finite bound implies; a free
1360                        // variable (k = 0) leaves the body an equation.
1361                        let (lo, hi) = match cc.flags {
1362                            0 => (0.0, 0.0),
1363                            1 => (0.0, 1e19),
1364                            2 => (-1e19, 0.0),
1365                            _ => (-1e19, 1e19),
1366                        };
1367                        g_l[i] = lo;
1368                        g_u[i] = hi;
1369                        if cc.flags != 0 {
1370                            complementarity.push(NlComplementarity { row: i, ..cc });
1371                        }
1372                        continue;
1373                    }
1374                    let (lo, hi) = parse_bound_line(line)?;
1375                    g_l[i] = lo;
1376                    g_u[i] = hi;
1377                }
1378            }
1379            'b' => {
1380                p.eat_segment_header()?;
1381                saw_b = true;
1382                for i in 0..n {
1383                    let line = p.next_data_line()?;
1384                    let (lo, hi) = parse_bound_line(line)?;
1385                    x_l[i] = lo;
1386                    x_u[i] = hi;
1387                }
1388            }
1389            'k' => {
1390                // Column counts in the Jacobian; we don't need their
1391                // values for evaluation (the J segments give explicit
1392                // lists), but we must consume exactly as many data lines
1393                // as follow or the segment stream desyncs. The `.nl`
1394                // format writes that line count in the header itself
1395                // (`k<count>`), and the standard value is `n-1`. Read the
1396                // declared count rather than assuming it: a file with a
1397                // nonstandard count would otherwise leave us reading the
1398                // wrong number of lines, swallowing a later segment header
1399                // (or stopping short) and failing with a confusing,
1400                // far-removed error. Validate against the expected `n-1`
1401                // so a mismatch surfaces here, clearly, at its source.
1402                let (hdr, _) = p.eat_segment_header()?;
1403                let declared = parse_segment_index(hdr, 'k')?;
1404                let expected = if n == 0 { 0 } else { n - 1 };
1405                if declared != expected {
1406                    return Err(format!(
1407                        "k-segment declares {declared} column-count lines but \
1408                         the standard count for n={n} variables is {expected}"
1409                    ));
1410                }
1411                for _ in 0..declared {
1412                    p.next_data_line()?;
1413                }
1414            }
1415            'J' => {
1416                let (hdr, _) = p.eat_segment_header()?;
1417                let parts: Vec<&str> = hdr.split_whitespace().collect();
1418                if parts.len() < 2 {
1419                    return Err(format!("malformed J-segment header: {hdr}"));
1420                }
1421                let row = parse_segment_index(parts[0], 'J')?;
1422                let nz: usize = parts[1].parse().map_err(|e| format!("J nz: {e}"))?;
1423                if row >= m {
1424                    return Err(format!("J{row} out of range"));
1425                }
1426                for _ in 0..nz {
1427                    let line = p.next_data_line()?;
1428                    let (var, coef) = parse_var_coef(line)?;
1429                    // Validate the column index here: an out-of-range `var`
1430                    // would otherwise be stored and panic as a slice OOB
1431                    // (`x[var]`) during constraint evaluation. Mirror the
1432                    // clean parse error used for the row index above.
1433                    if var >= n {
1434                        return Err(format!(
1435                            "J{row} entry variable index {var} out of range (n={n})"
1436                        ));
1437                    }
1438                    con_linear[row].push((var, coef));
1439                }
1440            }
1441            'G' => {
1442                let (hdr, _) = p.eat_segment_header()?;
1443                let parts: Vec<&str> = hdr.split_whitespace().collect();
1444                if parts.len() < 2 {
1445                    return Err(format!("malformed G-segment header: {hdr}"));
1446                }
1447                let idx = parse_segment_index(parts[0], 'G')?;
1448                let nz: usize = parts[1].parse().map_err(|e| format!("G nz: {e}"))?;
1449                let mut acc = Vec::with_capacity(nz);
1450                for _ in 0..nz {
1451                    let line = p.next_data_line()?;
1452                    let (var, coef) = parse_var_coef(line)?;
1453                    // Same as J: reject an out-of-range gradient column index
1454                    // up front rather than letting it panic on `x[var]` later.
1455                    if var >= n {
1456                        return Err(format!(
1457                            "G{idx} entry variable index {var} out of range (n={n})"
1458                        ));
1459                    }
1460                    acc.push((var, coef));
1461                }
1462                if idx == 0 {
1463                    obj_linear = acc;
1464                }
1465            }
1466            'x' => {
1467                let (hdr, _) = p.eat_segment_header()?;
1468                let parts: Vec<&str> = hdr.split_whitespace().collect();
1469                let nx: usize = parts
1470                    .first()
1471                    .and_then(|s| s.trim_start_matches('x').parse().ok())
1472                    .ok_or_else(|| format!("malformed x-segment header: {hdr}"))?;
1473                for _ in 0..nx {
1474                    let line = p.next_data_line()?;
1475                    let (idx, val) = parse_var_coef(line)?;
1476                    // Reject out-of-range indices as a parse error, matching
1477                    // J/G strictness, rather than silently dropping the entry
1478                    // (which hides a corrupt initial-primal segment).
1479                    if idx >= n {
1480                        return Err(format!(
1481                            "x-segment variable index {idx} out of range (n={n})"
1482                        ));
1483                    }
1484                    x0[idx] = val;
1485                }
1486            }
1487            'd' => {
1488                let (hdr, _) = p.eat_segment_header()?;
1489                let parts: Vec<&str> = hdr.split_whitespace().collect();
1490                let nd: usize = parts
1491                    .first()
1492                    .and_then(|s| s.trim_start_matches('d').parse().ok())
1493                    .ok_or_else(|| format!("malformed d-segment header: {hdr}"))?;
1494                for _ in 0..nd {
1495                    let line = p.next_data_line()?;
1496                    let (idx, val) = parse_var_coef(line)?;
1497                    // Reject out-of-range indices as a parse error, matching
1498                    // J/G strictness, rather than silently dropping the entry
1499                    // (which hides a corrupt initial-dual segment).
1500                    if idx >= m {
1501                        return Err(format!(
1502                            "d-segment constraint index {idx} out of range (m={m})"
1503                        ));
1504                    }
1505                    // A `d` entry is in AMPL's dual sign (`d obj / d b`, the
1506                    // sign a `.sol` dual block carries), and pounce's
1507                    // multiplier is that of `L = f + lambda' g`, i.e. `-dual`.
1508                    // `sol_writer` negates on the way out; negate on the way
1509                    // in so a `.nl` start and a `.sol` result share one sign
1510                    // (gh#1033; matches Ipopt's AMPL driver).
1511                    lambda0[idx] = -val;
1512                }
1513            }
1514            'V' => p.parse_v_segment()?,
1515            'S' => {
1516                parse_suffix_segment(&mut p, n, m, num_obj, &mut suffixes)?;
1517            }
1518            'F' => {
1519                // AMPL imported (external) function declaration:
1520                // `F<k> <type> <nargs> <name>`.
1521                let (hdr, _rest) = p.eat_segment_header()?;
1522                let parts: Vec<&str> = hdr.split_whitespace().collect();
1523                if parts.is_empty() {
1524                    return Err(format!("malformed F-segment header: '{hdr}'"));
1525                }
1526                let id = parse_segment_index(parts[0], 'F')?;
1527                let kind: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1528                let nargs: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
1529                let name = parts.get(3).copied().unwrap_or("").to_string();
1530                imported_funcs.push(ImportedFunc {
1531                    id,
1532                    kind,
1533                    nargs,
1534                    name,
1535                });
1536            }
1537            other => return Err(format!("unknown .nl segment tag '{other}'")),
1538        }
1539    }
1540
1541    // A `.nl` file that ends early is not a smaller model — it is a corrupt
1542    // one, and every segment the truncation ate is a piece of the problem
1543    // that silently reverts to a default: a dropped `r` leaves every row at
1544    // ±1e19 (i.e. unconstrained), a dropped `b` leaves every variable free,
1545    // a dropped `J` leaves every row's linear part empty. The segment loop
1546    // cannot tell those defaults from a legitimately absent segment, so the
1547    // truncated model solves, and reports `SolveSucceeded` with a
1548    // confidently wrong objective — which is strictly worse than the parse
1549    // error every other malformed input already gets (gh#785). The header
1550    // declares enough to tell the two apart; check it here, once, against
1551    // what the segments actually delivered.
1552    //
1553    // `r` and `b` are unconditional in the format: AMPL writes both whenever
1554    // the model has rows / columns at all, free rows and free variables
1555    // included — those get the `3` ("no bounds") code, not an omitted line.
1556    if m > 0 && !saw_r {
1557        return Err(format!(
1558            "missing `r` (constraint-bounds) segment for a model declaring {m} \
1559             constraint(s): the .nl file is truncated or corrupt"
1560        ));
1561    }
1562    if n > 0 && !saw_b {
1563        return Err(format!(
1564            "missing `b` (variable-bounds) segment for a model declaring {n} \
1565             variable(s): the .nl file is truncated or corrupt"
1566        ));
1567    }
1568    // The `J` segments must deliver exactly the Jacobian nonzero count the
1569    // header declares — `nzc` is the sum of their lengths by construction,
1570    // nonlinear-only columns included (they are written with a zero
1571    // coefficient, not omitted). This is the check that catches a truncation
1572    // landing *after* `r` and `b`, where the bounds are all present and only
1573    // the coefficients are gone.
1574    if let Some(declared) = p.declared_jac_nnz {
1575        let parsed: usize = con_linear.iter().map(Vec::len).sum();
1576        if parsed != declared {
1577            return Err(format!(
1578                "header declares {declared} Jacobian nonzero(s) but the J \
1579                 segments supply {parsed}: the .nl file is truncated or corrupt"
1580            ));
1581        }
1582    }
1583
1584    // Normalize constant row bodies into the row bounds, so that
1585    // "the nonlinear part is the identity zero" and "this row's body is
1586    // Σaⱼxⱼ" mean the same thing for every downstream consumer.
1587    //
1588    // A `C<i>` segment holding a variable-free expression (`n3.0`, or
1589    // anything that evaluates to a constant such as `o0 n1 n2`) is an
1590    // affine row — but it arrives with a non-zero `con_nonlinear[i]`,
1591    // which every consumer reads as "nonlinear": the linearity predicate
1592    // (`get_constraints_linearity`), which is what makes presolve's
1593    // linear-equality reduction decline the row; the CLI problem
1594    // classifier (`is_trivially_zero` in `dispatch.rs`, whose fallback
1595    // polynomial walk absorbs a bare literal but not a constant it has to
1596    // compute — so an otherwise plain LP carrying a `sqrt(9)` classified
1597    // NLP and never reached the convex path); and the FBBT translator.
1598    // Folding here fixes all of them at once, with no per-consumer audit.
1599    //
1600    // The shift is exact and invisible from outside: the body drops by `c`
1601    // and each bound drops by `c` with it, so the feasible set, the active
1602    // set, and the duals are unchanged (`gh #492`). This is the same
1603    // normalization `qp_extract::analyze_quadratic_full` already performs
1604    // ad hoc via `const_shift`, promoted to the parse boundary.
1605    for i in 0..m {
1606        // A recognized body is degree 2, so it is not a constant row and
1607        // this fold has nothing to do with it.
1608        let Some(tree) = con_nonlinear[i].tree() else {
1609            continue;
1610        };
1611        let Some(c) = row_constant_value(tree) else {
1612            continue;
1613        };
1614        // Presence is directional (gh #401): shifting an *absent* bound
1615        // would turn the ±1e19 sentinel into a real bound for `c < 0`
1616        // (lower) or `c > 0` (upper), inventing a constraint. Leave the
1617        // sentinels alone.
1618        if lower_bound_present(g_l[i]) {
1619            g_l[i] -= c;
1620        }
1621        if upper_bound_present(g_u[i]) {
1622            g_u[i] -= c;
1623        }
1624        con_nonlinear[i] = NlBody::Tree(Expr::Const(0.0));
1625    }
1626
1627    // The source is kept only when something in it has no tree of its own.
1628    // A model with nothing recognized is byte-for-byte the pre-Q5 problem,
1629    // extra field included.
1630    let any_recognized =
1631        obj_nonlinear.quad().is_some() || con_nonlinear.iter().any(|b| b.quad().is_some());
1632    let kept_src = any_recognized.then(|| Arc::clone(&src));
1633
1634    // A `d` entry was stored as `-dual` above, which is the engine's
1635    // multiplier only for a minimize model. A maximize model is solved as
1636    // `min -f`, and the CLI writes its `.sol` dual as `-sigma * lambda`
1637    // (sigma = -1, gh#959), so the inverse is `lambda = -sigma * dual`.
1638    // Applied here because the `O` segment fixing the sense may follow `d`.
1639    if !minimize {
1640        for v in lambda0.iter_mut() {
1641            *v = -*v;
1642        }
1643    }
1644    Ok(NlProblem {
1645        n,
1646        m,
1647        num_obj,
1648        minimize,
1649        obj_nonlinear,
1650        obj_linear,
1651        obj_constant: 0.0,
1652        con_nonlinear,
1653        con_linear,
1654        x_l,
1655        x_u,
1656        g_l,
1657        g_u,
1658        x0,
1659        lambda0,
1660        suffixes,
1661        ampl_options: p.ampl_options.clone(),
1662        nl_counts: p.nl_counts,
1663        n_discrete: p.n_discrete,
1664        imported_funcs,
1665        // `.nl` text carries no names; `read_nl_file` fills these from the
1666        // sibling `.col`/`.row` files when present.
1667        var_names: Vec::new(),
1668        con_names: Vec::new(),
1669        src: kept_src,
1670        cse_bodies: p.cses.clone(),
1671        complementarity,
1672    })
1673}
1674
1675/// Parse a single `S`-segment. Format (Gay 2005, "Hooking Your Solver
1676/// to AMPL", §6, and `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`):
1677///
1678/// ```text
1679/// S<kind> <nentries> <suffix_name>
1680/// <idx> <value>      ... nentries lines
1681/// ```
1682///
1683/// `<kind>` is a 3-bit encoding:
1684/// * Bits 0-1 select the suffix target: 0 = variables, 1 = constraints,
1685///   2 = objectives, 3 = problem-level.
1686/// * Bit 2 (`0x4`) selects the value type: 0 = integer, 1 = real.
1687///
1688/// Sparse entries scatter into a freshly-allocated dense vector (zero
1689/// default), sized for the target dimension. Problem-level suffixes
1690/// (kind = 3 / 7) carry a single value.
1691fn parse_suffix_segment(
1692    p: &mut Parser,
1693    n: usize,
1694    m: usize,
1695    num_obj: usize,
1696    out: &mut NlSuffixes,
1697) -> Result<(), String> {
1698    let (hdr, _) = p.eat_segment_header()?;
1699    let parts: Vec<&str> = hdr.split_whitespace().collect();
1700    if parts.len() < 3 {
1701        return Err(format!(
1702            "malformed S-segment header: '{hdr}' (expected `S<kind> <n> <name>`)"
1703        ));
1704    }
1705    let kind_str = parts[0].trim_start_matches('S');
1706    let kind: u32 = kind_str
1707        .parse()
1708        .map_err(|e| format!("S kind '{kind_str}': {e}"))?;
1709    let nentries: usize = parts[1].parse().map_err(|e| format!("S nentries: {e}"))?;
1710    let name = parts[2].to_string();
1711
1712    let is_real = (kind & 0x4) != 0;
1713    let target = kind & 0x3;
1714    let target_dim = match target {
1715        0 => n,
1716        1 => m,
1717        2 => num_obj,
1718        3 => 0, // problem-level — entries are single-valued (idx=0)
1719        _ => unreachable!("kind & 0x3 is in 0..=3"),
1720    };
1721
1722    // Pre-allocate dense buffers (default zero). Problem-level kinds
1723    // (3 / 7) hold a single scalar — we still read the (idx, value)
1724    // pairs but only the value field is meaningful.
1725    let mut int_buf: Vec<Index> = if !is_real && target != 3 {
1726        vec![0; target_dim]
1727    } else {
1728        Vec::new()
1729    };
1730    let mut real_buf: Vec<Number> = if is_real && target != 3 {
1731        vec![0.0; target_dim]
1732    } else {
1733        Vec::new()
1734    };
1735    let mut problem_int: Index = 0;
1736    let mut problem_real: Number = 0.0;
1737
1738    for _ in 0..nentries {
1739        let line = p.next_data_line()?;
1740        let parts: Vec<&str> = line.split_whitespace().collect();
1741        if parts.len() < 2 {
1742            return Err(format!(
1743                "malformed S-segment entry '{line}' (expected `<idx> <value>`)"
1744            ));
1745        }
1746        let idx: usize = parts[0]
1747            .parse()
1748            .map_err(|e| format!("S entry idx '{}': {e}", parts[0]))?;
1749        if target != 3 && idx >= target_dim {
1750            return Err(format!(
1751                "S-suffix '{name}' index {idx} out of range for target dim {target_dim}"
1752            ));
1753        }
1754        if is_real {
1755            let v: Number = parts[1]
1756                .parse()
1757                .map_err(|e| format!("S real entry value '{}': {e}", parts[1]))?;
1758            if target == 3 {
1759                problem_real = v;
1760            } else {
1761                real_buf[idx] = v;
1762            }
1763        } else {
1764            let v: Index = parts[1]
1765                .parse()
1766                .map_err(|e| format!("S int entry value '{}': {e}", parts[1]))?;
1767            if target == 3 {
1768                problem_int = v;
1769            } else {
1770                int_buf[idx] = v;
1771            }
1772        }
1773    }
1774
1775    match (target, is_real) {
1776        (0, false) => {
1777            out.var_int.insert(name, int_buf);
1778        }
1779        (1, false) => {
1780            out.con_int.insert(name, int_buf);
1781        }
1782        (2, false) => {
1783            out.obj_int.insert(name, int_buf);
1784        }
1785        (3, false) => {
1786            out.problem_int.insert(name, problem_int);
1787        }
1788        (0, true) => {
1789            out.var_real.insert(name, real_buf);
1790        }
1791        (1, true) => {
1792            out.con_real.insert(name, real_buf);
1793        }
1794        (2, true) => {
1795            out.obj_real.insert(name, real_buf);
1796        }
1797        (3, true) => {
1798            out.problem_real.insert(name, problem_real);
1799        }
1800        _ => unreachable!(),
1801    }
1802    Ok(())
1803}
1804
1805fn parse_segment_index(s: &str, tag: char) -> Result<usize, String> {
1806    let trimmed = s.trim_start_matches(tag);
1807    trimmed
1808        .parse()
1809        .map_err(|e| format!("malformed {tag}-segment index '{s}': {e}"))
1810}
1811
1812// `parse_bound_line` and `parse_var_coef` run once per `r` / `b` / `J` /
1813// `G` / `x` / `d` data line, so between them they see every Jacobian and
1814// gradient nonzero in the file. Both walk the whitespace iterator
1815// directly instead of collecting a `Vec<&str>` first — that collect was
1816// a heap allocation per line on top of the one the reader used to make
1817// handing the line over.
1818/// Refuse a non-finite number read out of a `.nl` file (gh #847).
1819///
1820/// `str::parse::<f64>()` accepts `inf`, `-inf` and `nan`, and it also *returns*
1821/// `inf` for any literal that overflows the type — `1e400` is a plausible thing
1822/// for a model generator to write. Nothing downstream treats such a value as an
1823/// error, and in one place it is actively misread: `lower_bound_present` /
1824/// `upper_bound_present` are `is_finite() && ...`, so a non-finite bound is
1825/// indistinguishable from a bound that was never declared, and is silently
1826/// dropped. On a model that a lower bound of `1e300` makes infeasible, the same
1827/// bound written `1e400` returned `EXIT: Optimal Solution Found.` with exit code
1828/// 0. A `nan` is worse: it propagates into the answer, and the solve reports
1829/// `Objective: nan` under `Solve_Succeeded`.
1830///
1831/// Ipopt refuses this input ("Invalid number"), POUNCE's own NLP arm refuses it,
1832/// and `pounce.solve_qp` refuses a non-finite bound with a bespoke `ValueError`.
1833/// There is no reading on which `Optimal` is the intended answer, so the reader
1834/// refuses it too.
1835fn finite_or_err(what: &str, v: Number) -> Result<Number, String> {
1836    if v.is_finite() {
1837        Ok(v)
1838    } else {
1839        Err(format!(
1840            "invalid number: {what} is {v}, which is not finite"
1841        ))
1842    }
1843}
1844
1845/// The same screen for a *bound* slot, where one non-finite value has an
1846/// unambiguous meaning and is normalized instead of refused.
1847///
1848/// `.nl` states "no bound" with a bound **kind** (1 = upper only, 2 = lower
1849/// only, 3 = free), so a non-finite number in a bound slot is a corrupt value
1850/// rather than a notation — with one exception per side. `-inf` in a *lower*
1851/// slot and `+inf` in an *upper* slot say precisely what the `±1e19` sentinel
1852/// says, and a writer that emits them means it, so they map to the sentinel.
1853///
1854/// Everything else is refused, and the asymmetry is the whole point: `+inf` as
1855/// a *lower* bound is the gh #847 case. It is not "unbounded below" — it is an
1856/// empty box, and reading it as "absent" is what turned an infeasible model
1857/// into an `Optimal` one. `NaN` is refused on either side, having no meaning at
1858/// all.
1859fn finite_bound_or_err(what: &str, v: Number, lower: bool) -> Result<Number, String> {
1860    if v.is_finite() {
1861        return Ok(v);
1862    }
1863    if lower && v == Number::NEG_INFINITY {
1864        return Ok(-1e19);
1865    }
1866    if !lower && v == Number::INFINITY {
1867        return Ok(1e19);
1868    }
1869    Err(format!(
1870        "invalid number: {what} is {v}, which is not finite (a `.nl` file \
1871         states an absent bound with a bound kind of 1, 2 or 3, not with a \
1872         non-finite value)"
1873    ))
1874}
1875
1876/// `Some` for an `r`-segment complementarity line `5 k j` (`k` the variable's
1877/// finite-bound flags, `j` the 1-based variable), `None` for any other kind.
1878/// The row index is filled in by the caller.
1879fn parse_complementarity_line(line: &str) -> Result<Option<NlComplementarity>, String> {
1880    let mut parts = line.split_whitespace();
1881    if parts.next() != Some("5") {
1882        return Ok(None);
1883    }
1884    let (Some(k), Some(j)) = (parts.next(), parts.next()) else {
1885        return Err(format!("complementarity line needs `5 k var`: '{line}'"));
1886    };
1887    let flags: u8 = k
1888        .parse()
1889        .map_err(|e| format!("complementarity flags: {e}"))?;
1890    if flags > 3 {
1891        return Err(format!(
1892            "complementarity flags {flags} not in 0..=3: '{line}'"
1893        ));
1894    }
1895    let var: usize = j
1896        .parse()
1897        .map_err(|e| format!("complementarity variable: {e}"))?;
1898    if var == 0 {
1899        return Err(format!(
1900            "complementarity variable index is 1-based, got 0: '{line}'"
1901        ));
1902    }
1903    Ok(Some(NlComplementarity {
1904        row: 0,
1905        var: var - 1,
1906        flags,
1907    }))
1908}
1909
1910fn parse_bound_line(line: &str) -> Result<(Number, Number), String> {
1911    let mut parts = line.split_whitespace();
1912    let kind: i32 = parts
1913        .next()
1914        .ok_or("empty bound line")?
1915        .parse()
1916        .map_err(|e| format!("bound kind: {e}"))?;
1917    let lo;
1918    let hi;
1919    match kind {
1920        0 => {
1921            // 0  lo  hi
1922            let (l, h) = (parts.next(), parts.next());
1923            let (Some(l), Some(h)) = (l, h) else {
1924                return Err(format!("bound kind 0 needs 2 values: '{line}'"));
1925            };
1926            lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1927            hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1928        }
1929        1 => {
1930            // 1  hi
1931            let Some(h) = parts.next() else {
1932                return Err(format!("bound kind 1 needs 1 value: '{line}'"));
1933            };
1934            lo = -1e19;
1935            hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1936        }
1937        2 => {
1938            // 2  lo
1939            let Some(l) = parts.next() else {
1940                return Err(format!("bound kind 2 needs 1 value: '{line}'"));
1941            };
1942            lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1943            hi = 1e19;
1944        }
1945        3 => {
1946            // 3  (free)
1947            lo = -1e19;
1948            hi = 1e19;
1949        }
1950        4 => {
1951            // 4  eq
1952            let Some(v) = parts.next() else {
1953                return Err(format!("bound kind 4 needs 1 value: '{line}'"));
1954            };
1955            // An equality has no "absent" side, so neither infinity is a
1956            // notation here and both are refused.
1957            let v: Number = finite_or_err("eq bound", v.parse().map_err(|e| format!("eq: {e}"))?)?;
1958            lo = v;
1959            hi = v;
1960        }
1961        5 => return Err("complementarity (kind 5) bounds are not supported".into()),
1962        other => return Err(format!("unknown bound kind {other}")),
1963    }
1964    Ok((lo, hi))
1965}
1966
1967fn parse_var_coef(line: &str) -> Result<(usize, Number), String> {
1968    let mut parts = line.split_whitespace();
1969    let (Some(v), Some(c)) = (parts.next(), parts.next()) else {
1970        return Err(format!("malformed var/coef line: '{line}'"));
1971    };
1972    let v: usize = v.parse().map_err(|e| format!("var idx: {e}"))?;
1973    let c: Number = finite_or_err("coefficient", c.parse().map_err(|e| format!("coef: {e}"))?)?;
1974    Ok((v, c))
1975}
1976
1977/// Build an [`NlCounts`] from `.nl` header lines 3 (`nlc nlo`) and 5
1978/// (`nlvc nlvo nlvb`).
1979///
1980/// Returns `None` unless both lines carry the full complement of
1981/// non-negative integers, so a truncated or non-conforming header reads as
1982/// "unknown" rather than as zeros — "no nonlinear variables" is a claim, and
1983/// a header that failed to parse has not made it.
1984fn parse_nl_counts(line3: &str, line5: &str) -> Option<NlCounts> {
1985    let nums = |line: &str, want: usize| -> Option<Vec<usize>> {
1986        let v: Vec<usize> = line
1987            .split_whitespace()
1988            .take(want)
1989            .map(str::parse)
1990            .collect::<Result<_, _>>()
1991            .ok()?;
1992        (v.len() == want).then_some(v)
1993    };
1994    let cons_objs = nums(line3, 2)?;
1995    let vars = nums(line5, 3)?;
1996    Some(NlCounts {
1997        nl_cons: cons_objs[0],
1998        nl_objs: cons_objs[1],
1999        nl_vars_cons: vars[0],
2000        nl_vars_objs: vars[1],
2001        nl_vars_both: vars[2],
2002    })
2003}
2004
2005/// Header line 7: `nbv niv nlvbi nlvci nlvoi` (gh#987).
2006///
2007/// In AMPL's convention `nbv` and `niv` count only the discrete variables
2008/// that appear **linearly**; a discrete variable that appears nonlinearly is
2009/// counted in `nlvbi` / `nlvci` / `nlvoi` instead, *not* in `nbv + niv`. So a
2010/// MINLP whose integers all appear nonlinearly has `nbv = niv = 0` — reading
2011/// only those two fields reports it as continuous.
2012#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2013pub struct DiscreteCensus {
2014    /// `nbv`: binary variables appearing only linearly.
2015    pub binary: usize,
2016    /// `niv`: other integer variables appearing only linearly.
2017    pub integer: usize,
2018    /// `nlvbi`: integer variables nonlinear in both constraints and objectives.
2019    pub nl_both: usize,
2020    /// `nlvci`: integer variables nonlinear in constraints only.
2021    pub nl_cons: usize,
2022    /// `nlvoi`: integer variables nonlinear in objectives only.
2023    pub nl_objs: usize,
2024}
2025
2026impl DiscreteCensus {
2027    /// Total declared discrete variables, linear and nonlinear
2028    /// (`nbv + niv + nlvbi + nlvci + nlvoi`).
2029    pub fn total(&self) -> usize {
2030        self.binary + self.integer + self.nonlinear_discrete()
2031    }
2032
2033    /// Discrete variables that appear nonlinearly (`nlvbi + nlvci + nlvoi`).
2034    pub fn nonlinear_discrete(&self) -> usize {
2035        self.nl_both + self.nl_cons + self.nl_objs
2036    }
2037
2038    /// The 0-based columns of every discrete variable, from Gay's variable
2039    /// ordering ("Writing .nl Files", table 4):
2040    ///
2041    /// 1. nonlinear in both constraints and objectives — `nlvb` columns, the
2042    ///    last `nlvbi` of them integer;
2043    /// 2. nonlinear in constraints only — `nlvc − nlvb` columns, the last
2044    ///    `nlvci` integer;
2045    /// 3. nonlinear in objectives only — `nlvo − nlvc` columns (when
2046    ///    positive), the last `nlvoi` integer;
2047    /// 4. linear variables, ending in `nbv` binary then `niv` integer, which
2048    ///    are therefore the last `nbv + niv` columns of the model.
2049    ///
2050    /// `None` when the columns cannot be identified: a nonlinear integer
2051    /// count is non-zero but the header's line-5 census is missing, or the
2052    /// counts are inconsistent with the block sizes. Callers must report that
2053    /// as *not checked*, never as passed.
2054    pub fn integer_columns(&self, n: usize, counts: Option<NlCounts>) -> Option<Vec<usize>> {
2055        let lin = self.binary + self.integer;
2056        if lin > n {
2057            return None;
2058        }
2059        let mut cols = Vec::with_capacity(self.total());
2060        if self.nonlinear_discrete() > 0 {
2061            let c = counts?;
2062            let nlvb = c.nl_vars_both;
2063            let nlvc = c.nl_vars_cons;
2064            let nlvo = c.nl_vars_objs;
2065            if nlvc < nlvb {
2066                return None;
2067            }
2068            let b3 = nlvo.saturating_sub(nlvc);
2069            let blocks = [
2070                (0, nlvb, self.nl_both),
2071                (nlvb, nlvc - nlvb, self.nl_cons),
2072                (nlvc, b3, self.nl_objs),
2073            ];
2074            for (start, len, ints) in blocks {
2075                if ints > len || start + len > n - lin {
2076                    return None;
2077                }
2078                cols.extend(start + len - ints..start + len);
2079            }
2080        }
2081        cols.extend(n - lin..n);
2082        Some(cols)
2083    }
2084}
2085
2086struct Parser<'a> {
2087    lines: Vec<&'a str>,
2088    /// Start address and length of the source text. A recognized body
2089    /// records the byte *range* it consumed so it can be re-parsed later
2090    /// (see [`QuadBody::src`]), and every line borrows from this buffer, so
2091    /// one subtraction per line recovers its offset.
2092    ///
2093    /// Deliberately not a per-line offset table: on a 119 MB generated
2094    /// `qcqp500-3c` the file is ~17 M lines, and a `Vec<usize>` beside the
2095    /// existing `Vec<&str>` would add 136 MB to the peak this phase exists
2096    /// to reduce.
2097    txt_base: usize,
2098    txt_len: usize,
2099    pos: usize,
2100    n: usize,
2101    m: usize,
2102    num_obj: usize,
2103    /// Number of AMPL imported (external) functions declared in the header.
2104    n_funcs: usize,
2105    /// Header lines 3 and 5, when both parsed. See [`NlCounts`].
2106    nl_counts: Option<NlCounts>,
2107    /// `(nbv, niv)` from header line 7.
2108    n_discrete: Option<DiscreteCensus>,
2109    /// `nzc` from header line 8: the number of Jacobian nonzeros the file
2110    /// *declares*. [`parse_nl_string`] cross-checks it against the number the
2111    /// `J` segments actually deliver, which is how a file truncated before
2112    /// them is told from a model that genuinely has none (gh#785). `None`
2113    /// when the header does not carry it in the documented shape.
2114    declared_jac_nnz: Option<usize>,
2115    ampl_options: Vec<i64>,
2116    /// Common subexpressions (`V` segments). Index in this vec is the
2117    /// CSE-local index, i.e. the global `.nl` index minus `n`.
2118    cses: Vec<Arc<Expr>>,
2119    /// Recognize degree-2 bodies from the token stream instead of building
2120    /// their trees (gh #588, Q5). Off restores the pre-Q5 parse exactly.
2121    quad_enabled: bool,
2122    /// Per-CSE answers the streaming recognizer needs about a `V` body it
2123    /// may be handed a reference to, all keyed by CSE-local index: the
2124    /// body's own degree-≤2 form, whether it is legal on a sum spine,
2125    /// whether it is legal *inside* a monomial, and its variable support.
2126    ///
2127    /// These are computed from the built `V` body with the same functions
2128    /// `NlTnlp` would apply to a whole row, so a reference costs a lookup
2129    /// and cannot drift from what the tree walk would have said. `V` bodies
2130    /// keep their trees regardless — they are shared, so the memory is
2131    /// amortized over every reference, and dropping them would mean
2132    /// rebuilding them for the rows that are *not* recognized.
2133    cse_quad: Vec<Option<Quad2>>,
2134    cse_sum_ok: Vec<bool>,
2135    cse_mono_ok: Vec<bool>,
2136    cse_vars: Vec<Vec<u32>>,
2137    cse_depth: Vec<u32>,
2138    /// gh#986 review: the depth of every `V` body *through* the defined
2139    /// variables it references (a `Cse` counting as its body), keyed by the
2140    /// body's `Arc` pointer. Always kept, whatever `quad_enabled` says: it is
2141    /// what bounds the recursion of every later pass that walks through a
2142    /// `Cse` (tape build, `collect_vars`, evaluation, drop glue), and it makes
2143    /// [`expr_tree_depth`] linear in the body rather than in the whole chain.
2144    cse_depth_memo: std::collections::HashMap<*const Expr, u32>,
2145    /// `V` body pointer -> its index in `cses` (and in the `cse_*` caches),
2146    /// so the parse-time passes can read an earlier body's verdict instead of
2147    /// re-walking it (gh#986 review).
2148    cse_index: std::collections::HashMap<*const Expr, usize>,
2149    /// Current recursion depth of `parse_expr` (gh#986).
2150    parse_depth: u32,
2151    /// Depth at which `parse_expr` refuses (gh#986); see [`parse_depth_limit`].
2152    max_depth: u32,
2153}
2154
2155/// One pending operator on the streaming recognizer's stack.
2156struct QFrame {
2157    op: QOp,
2158    /// Operands still to be read.
2159    remaining: usize,
2160    /// Read this frame's operands in monomial mode — no `+`/`-` may appear
2161    /// below it. See [`crate::nl_quadratic::is_expanded_quadratic`].
2162    mono: bool,
2163}
2164
2165/// The operators the streaming recognizer accepts. One variant per shape
2166/// that [`crate::nl_quadratic::recognize_expr`] handles; everything else
2167/// makes it bail.
2168enum QOp {
2169    Neg,
2170    Add,
2171    Sub,
2172    Mul,
2173    Div,
2174    /// `o5`/`o81`/`o83`: base and exponent both read.
2175    Pow,
2176    /// `o82`: square, exponent implicit.
2177    Square,
2178    Sum(usize),
2179}
2180
2181impl<'a> Parser<'a> {
2182    fn new(txt: &'a str, quad_enabled: bool) -> Self {
2183        let lines: Vec<&str> = txt.lines().collect();
2184        Self {
2185            lines,
2186            txt_base: txt.as_ptr() as usize,
2187            txt_len: txt.len(),
2188            pos: 0,
2189            n: 0,
2190            m: 0,
2191            num_obj: 0,
2192            n_funcs: 0,
2193            nl_counts: None,
2194            n_discrete: None,
2195            declared_jac_nnz: None,
2196            ampl_options: Vec::new(),
2197            cses: Vec::new(),
2198            quad_enabled,
2199            cse_quad: Vec::new(),
2200            cse_sum_ok: Vec::new(),
2201            cse_mono_ok: Vec::new(),
2202            cse_vars: Vec::new(),
2203            cse_depth: Vec::new(),
2204            cse_depth_memo: std::collections::HashMap::new(),
2205            cse_index: std::collections::HashMap::new(),
2206            parse_depth: 0,
2207            max_depth: parse_depth_limit(),
2208        }
2209    }
2210
2211    /// Byte offset in the source text of the start of line `line`, or the
2212    /// end of the text when the cursor has run off it.
2213    ///
2214    /// Pointer arithmetic against the same allocation, never a deref: every
2215    /// line in `lines` borrows from the source buffer, so the difference is
2216    /// its offset.
2217    fn byte_at(&self, line: usize) -> usize {
2218        match self.lines.get(line) {
2219            Some(l) => l.as_ptr() as usize - self.txt_base,
2220            None => self.txt_len,
2221        }
2222    }
2223
2224    fn next_line(&mut self) -> Option<&'a str> {
2225        while self.pos < self.lines.len() {
2226            let l = self.lines[self.pos];
2227            self.pos += 1;
2228            // Strip comment after '#' for header / data lines (but
2229            // leave the segment-tag tokens untouched — they are the
2230            // first token on the line).
2231            let trimmed = strip_comment(l).trim();
2232            if !trimmed.is_empty() {
2233                return Some(l);
2234            }
2235        }
2236        None
2237    }
2238
2239    /// Next non-blank line, comment stripped and trimmed.
2240    ///
2241    /// Borrows from the source text rather than allocating: the result
2242    /// is `&'a str`, tied to the `.nl` buffer and not to `self`, so it
2243    /// outlives the `&mut self` this took. A large `.nl` is mostly data
2244    /// lines — 620k of them for a 20k-variable model — and returning an
2245    /// owned `String` put one heap allocation on every single one.
2246    fn next_data_line(&mut self) -> Result<&'a str, String> {
2247        while self.pos < self.lines.len() {
2248            let l = self.lines[self.pos];
2249            self.pos += 1;
2250            let trimmed = strip_comment(l).trim();
2251            if !trimmed.is_empty() {
2252                return Ok(trimmed);
2253            }
2254        }
2255        Err("unexpected end of file in data line".to_string())
2256    }
2257
2258    fn parse_header(&mut self) -> Result<(), String> {
2259        let line0 = self.next_line().ok_or("empty .nl file")?;
2260        let trimmed = strip_comment(line0).trim();
2261        let first = trimmed.chars().next().ok_or("empty header line")?;
2262        if first != 'g' {
2263            return Err(format!(
2264                "only ASCII (g-) .nl files supported; got header '{trimmed}'"
2265            ));
2266        }
2267        // Line 0 is `g<count> <opt0> <opt1> ...`: the digits glued to the
2268        // `g` say how many AMPL option words follow on the same line.
2269        // A solver echoes them back in the `.sol` `Options` block, so
2270        // keep them verbatim. Malformed or truncated option lists are not
2271        // fatal — the writer falls back to a generic block.
2272        let mut words = trimmed.split_whitespace();
2273        let n_opts: usize = words.next().and_then(|w| w[1..].parse().ok()).unwrap_or(0);
2274        let opts: Vec<i64> = words.filter_map(|w| w.parse().ok()).collect();
2275        if opts.len() >= n_opts {
2276            self.ampl_options = opts[..n_opts].to_vec();
2277        }
2278
2279        // Header line 2: n_vars n_cons n_objs ranges eqns
2280        let l2 = self.next_data_line()?;
2281        let nums: Vec<&str> = l2.split_whitespace().collect();
2282        if nums.len() < 3 {
2283            return Err(format!("malformed line 2: '{l2}'"));
2284        }
2285        self.n = nums[0].parse().map_err(|e| format!("n: {e}"))?;
2286        self.m = nums[1].parse().map_err(|e| format!("m: {e}"))?;
2287        self.num_obj = nums[2].parse().map_err(|e| format!("num_obj: {e}"))?;
2288
2289        // Header line 3: `nlc nlo`. Line 4: the network-constraint census,
2290        // which pounce has no use for. Line 5: `nlvc nlvo nlvb`. Together
2291        // these are the model's nonlinearity census — see [`NlCounts`].
2292        //
2293        // A header that does not carry them in the documented shape leaves
2294        // `nl_counts` at `None` rather than at a guess: every consumer has a
2295        // walk-the-trees fallback, and a fabricated count is worse than an
2296        // absent one. The rest of the header stays tolerant in the same way
2297        // the `nfunc` read below is.
2298        let l3 = self.next_data_line()?;
2299        let _l4_network = self.next_data_line()?;
2300        let l5 = self.next_data_line()?;
2301        self.nl_counts = parse_nl_counts(l3, l5);
2302        // Line 5 (0-indexed from `g`-header): `nwv nfunc arith flags`
2303        let l6 = self.next_data_line()?;
2304        let nums5: Vec<&str> = l6.split_whitespace().collect();
2305        self.n_funcs = nums5.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
2306        // Lines 6..10 are metadata we mostly don't need. Line 7 is the
2307        // discrete-variable census; line 8 is `nzc nzo` — the Jacobian and
2308        // objective-gradient nonzero counts, kept because `nzc` is what
2309        // catches a file truncated before its `J` segments (gh#785); lines
2310        // 9 and 10 are the maximum name lengths and the common-expression
2311        // census.
2312        let l7_discrete = self.next_data_line()?;
2313        {
2314            let v: Vec<usize> = l7_discrete
2315                .split_whitespace()
2316                .map_while(|w| w.parse::<usize>().ok())
2317                .collect();
2318            if v.len() >= 2 {
2319                self.n_discrete = Some(DiscreteCensus {
2320                    binary: v[0],
2321                    integer: v[1],
2322                    // `nlvbi nlvci nlvoi`; absent in a short header.
2323                    nl_both: v.get(2).copied().unwrap_or(0),
2324                    nl_cons: v.get(3).copied().unwrap_or(0),
2325                    nl_objs: v.get(4).copied().unwrap_or(0),
2326                });
2327            }
2328        }
2329        let l8 = self.next_data_line()?;
2330        // Tolerant like the `nfunc` read above: a header that does not
2331        // carry the count in the documented shape leaves it `None`, and
2332        // the cross-check that reads it is skipped rather than fabricated.
2333        self.declared_jac_nnz = l8.split_whitespace().next().and_then(|s| s.parse().ok());
2334        let _l9_name_lens = self.next_data_line()?;
2335        let _l10_common_exprs = self.next_data_line()?;
2336        Ok(())
2337    }
2338
2339    fn peek_segment_line(&mut self) -> Option<&'a str> {
2340        let saved = self.pos;
2341        let l = self.next_line()?;
2342        self.pos = saved;
2343        Some(l)
2344    }
2345
2346    /// Eat the next non-blank line as a segment header. Returns the
2347    /// whole header (after stripping comments) and the comment text.
2348    fn eat_segment_header(&mut self) -> Result<(&'a str, &'a str), String> {
2349        let raw = self
2350            .next_line()
2351            .ok_or_else(|| "expected segment header".to_string())?;
2352        let (hdr, comment) = split_comment(raw);
2353        Ok((hdr.trim(), comment.trim()))
2354    }
2355
2356    /// Parse one `C`/`O` body, recognizing it from the token stream when
2357    /// that is possible instead of building its tree (gh #588, Q5).
2358    ///
2359    /// The cursor is the whole mechanism. `Parser` is line-based, so a
2360    /// failed recognition costs one assignment to rewind — the same trick
2361    /// `parse_funcall_arg` already uses to route a Hollerith literal — and
2362    /// the fallback then produces exactly the tree this file has always
2363    /// produced. Nothing downstream can tell which arm ran except by
2364    /// measuring memory.
2365    ///
2366    /// Only **degree-2** forms are kept. A body that recognizes as constant
2367    /// or linear is re-parsed as a tree and stored as one: the constant-row
2368    /// fold (gh #492), the linearity contract and the classifier's LP fast
2369    /// path all key on the identity zero and on trees, they are cheap
2370    /// already, and the memory that motivates this phase is entirely in
2371    /// degree-2 rows. Refusing to touch them keeps the change confined to
2372    /// the rows it is for.
2373    fn parse_body(&mut self) -> Result<NlBody, String> {
2374        if !self.quad_enabled {
2375            let e = self.parse_expr()?;
2376            self.check_effective_depth(&e)?;
2377            return Ok(NlBody::Tree(e));
2378        }
2379        let saved = self.pos;
2380        if let Some((form, vars, depth)) = self.parse_expr_quadratic() {
2381            if !form.quadratic().is_empty() {
2382                let src = self.byte_at(saved)..self.byte_at(self.pos);
2383                return Ok(NlBody::Quad(Box::new(QuadBody {
2384                    form,
2385                    vars,
2386                    src,
2387                    depth,
2388                })));
2389            }
2390        }
2391        self.pos = saved;
2392        let e = self.parse_expr()?;
2393        self.check_effective_depth(&e)?;
2394        Ok(NlBody::Tree(e))
2395    }
2396
2397    /// gh#986 review: refuse a tree whose depth *through the defined
2398    /// variables it references* exceeds the guard. `parse_expr` counts only
2399    /// the syntactic nesting of the text it reads, and a `v<i>` reference to a
2400    /// `V` segment is one token there; a chain of `V` segments each referencing
2401    /// the previous is shallow to the parser and as deep as the chain to every
2402    /// pass that walks through a `Cse` (the tape builder, `collect_vars`, drop
2403    /// glue), so the stack the guard exists to protect was unguarded.
2404    fn check_effective_depth(&self, e: &Expr) -> Result<u32, String> {
2405        let d = expr_tree_depth(e, &self.cse_depth_memo);
2406        if d > self.max_depth {
2407            return Err(format!(
2408                "the model nests an expression {d} levels deep counting the defined \
2409                 variables (V segments) it references, more than the {} levels the \
2410                 recursive reader accepts; a chain of defined variables each \
2411                 referencing the previous counts once per link",
2412                self.max_depth
2413            ));
2414        }
2415        Ok(d)
2416    }
2417
2418    /// Read one expression off the token stream as a degree-≤2 form,
2419    /// **without building it**, or give up.
2420    ///
2421    /// This is the same computation as
2422    /// `is_expanded_quadratic(e) && recognize_expr(e)` run on the tree these
2423    /// tokens parse to — the accuracy gate and the algebra, interleaved so
2424    /// neither needs the tree. That equivalence is the phase's correctness
2425    /// claim and is asserted directly, bit for bit, over every body of every
2426    /// `.nl` file in the repository by
2427    /// `pounce-cli/tests/quad_parse_differential.rs`.
2428    ///
2429    /// Two things it must reproduce and not merely approximate:
2430    ///
2431    /// * **The exactness rule.** `is_expanded_quadratic` admits a body only
2432    ///   when reading it as `½xᵀHx + aᵀx + c` repeats the additions the
2433    ///   writer already wrote — expanding a *factored* form cancels, which
2434    ///   is what took `airport.nl` from 16 to 300 iterations in Q4. Here
2435    ///   that rule is structural rather than a separate pass: `+`/`-` are
2436    ///   the spine, everything under a `*`, `/` or `^` is read in monomial
2437    ///   mode, and a `+` seen in monomial mode gives up.
2438    /// * **The association.** `recognize_expr` folds an `o54` sumlist from
2439    ///   the *first* operand to the last, which is also the order the AD
2440    ///   tape sums in, so this folds the same way — operands arrive here in
2441    ///   file order and are folded as they arrive. Summation order is not
2442    ///   observable on distinct monomials and is exactly observable on
2443    ///   repeated ones.
2444    ///
2445    /// On `None` the cursor is left wherever it stopped; the caller rewinds.
2446    fn parse_expr_quadratic(&mut self) -> Option<(Quad2, Vec<u32>, u32)> {
2447        let mut frames: Vec<QFrame> = Vec::new();
2448        let mut vals: Vec<Quad2> = Vec::new();
2449        let mut vars: Vec<u32> = Vec::new();
2450        // Deepest node of the tree these tokens describe. A leaf sits under
2451        // `frames.len()` operators, and a CSE reference carries its body's
2452        // depth under it — the same convention `pounce-py`'s `expr_depth`
2453        // uses, because that is who reads the answer.
2454        let mut depth: u32 = 0;
2455        let note_leaf = |frames: &[QFrame], depth: &mut u32, below: u32| {
2456            let d = u32::try_from(frames.len()).unwrap_or(u32::MAX);
2457            *depth = (*depth).max(d.saturating_add(1).saturating_add(below));
2458        };
2459
2460        loop {
2461            let mono = frames.last().is_some_and(|f| f.mono);
2462            // A `^` base must be atomic — `Const`, `Var` or a `Neg` — or the
2463            // body is a factored form dressed as a monomial. `is_monomial`
2464            // applies the same test to `Pow`'s left operand.
2465            let pow_base = matches!(
2466                frames.last(),
2467                Some(QFrame {
2468                    op: QOp::Pow,
2469                    remaining: 2,
2470                    ..
2471                }) | Some(QFrame {
2472                    op: QOp::Square,
2473                    remaining: 1,
2474                    ..
2475                })
2476            );
2477
2478            let raw = self.next_line()?;
2479            let tok = strip_comment(raw).trim();
2480            let first = tok.chars().next()?;
2481            match first {
2482                'n' => {
2483                    // A constant base is atomic; `is_monomial` accepts it.
2484                    // A non-finite literal bails out of the fast path so the
2485                    // general parser reaches it and reports the error rather
2486                    // than folding it into a quadratic (gh #847).
2487                    let v: Number = tok[1..]
2488                        .trim()
2489                        .parse()
2490                        .ok()
2491                        .filter(|v: &Number| v.is_finite())?;
2492                    note_leaf(&frames, &mut depth, 0);
2493                    vals.push(Quad2::of_constant(v));
2494                }
2495                'v' => {
2496                    let i: usize = tok[1..].trim().parse().ok()?;
2497                    if i < self.n {
2498                        note_leaf(&frames, &mut depth, 0);
2499                        vars.push(u32::try_from(i).ok()?);
2500                        vals.push(Quad2::of_var(i));
2501                    } else {
2502                        // A CSE reference lowers to `Expr::Cse`, which is
2503                        // *not* one of the shapes `is_monomial` accepts as a
2504                        // power's base.
2505                        if pow_base {
2506                            return None;
2507                        }
2508                        let local = i.checked_sub(self.n)?;
2509                        let ok = if mono {
2510                            *self.cse_mono_ok.get(local)?
2511                        } else {
2512                            *self.cse_sum_ok.get(local)?
2513                        };
2514                        if !ok {
2515                            return None;
2516                        }
2517                        let form = self.cse_quad.get(local)?.clone()?;
2518                        note_leaf(&frames, &mut depth, *self.cse_depth.get(local)?);
2519                        vars.extend_from_slice(self.cse_vars.get(local)?);
2520                        vals.push(form);
2521                    }
2522                }
2523                'o' => {
2524                    let code: i32 = tok[1..].trim().parse().ok()?;
2525                    if pow_base && code != 16 {
2526                        return None;
2527                    }
2528                    let (op, arity, child_mono) = match code {
2529                        // The sum spine. Inside a monomial there is no such
2530                        // thing, and the body is a factored form.
2531                        0 if !mono => (QOp::Add, 2, false),
2532                        1 if !mono => (QOp::Sub, 2, false),
2533                        16 => (QOp::Neg, 1, mono),
2534                        54 if !mono => {
2535                            let count_line = self.next_data_line().ok()?;
2536                            let count: usize =
2537                                count_line.split_whitespace().next()?.parse().ok()?;
2538                            (QOp::Sum(count), count, false)
2539                        }
2540                        // Monomial operators: everything below them is read
2541                        // in monomial mode.
2542                        2 => (QOp::Mul, 2, true),
2543                        3 => (QOp::Div, 2, true),
2544                        5 | 81 | 83 => (QOp::Pow, 2, true),
2545                        82 => (QOp::Square, 1, true),
2546                        // Transcendentals, comparisons, conditionals,
2547                        // min/max lists, and `+`/`-` under a monomial.
2548                        _ => return None,
2549                    };
2550                    if arity == 0 {
2551                        // An empty `o54` is the empty sum: zero.
2552                        note_leaf(&frames, &mut depth, 0);
2553                        vals.push(Quad2::default());
2554                    } else {
2555                        frames.push(QFrame {
2556                            op,
2557                            remaining: arity,
2558                            mono: child_mono,
2559                        });
2560                        continue;
2561                    }
2562                }
2563                // `f` (imported function call), `h`, and anything else.
2564                _ => return None,
2565            }
2566
2567            // A value has just been produced. Close every frame it completes.
2568            while let Some(f) = frames.last_mut() {
2569                f.remaining -= 1;
2570                if f.remaining > 0 {
2571                    break;
2572                }
2573                let f = frames.pop()?;
2574                let combined = apply_quad_op(f.op, &mut vals)?;
2575                vals.push(combined);
2576            }
2577            if frames.is_empty() {
2578                break;
2579            }
2580        }
2581
2582        if vals.len() != 1 {
2583            return None;
2584        }
2585        vars.sort_unstable();
2586        vars.dedup();
2587        Some((vals.pop()?, vars, depth))
2588    }
2589
2590    fn parse_expr(&mut self) -> Result<Expr, String> {
2591        // gh#986: a recursion guard that fires before the stack does. A
2592        // stack overflow is an abort, not an error return, so the reader
2593        // refuses past `MAX_PARSE_DEPTH` levels with a clean message at any
2594        // input depth instead of relying on the caller's stack size.
2595        if self.parse_depth >= self.max_depth {
2596            return Err(format!(
2597                "the model nests an expression more than {} levels deep, which the \
2598                 recursive reader refuses rather than overflow the stack (a long \
2599                 left-deep `o0` sum is read flat and does not count; deep unary or \
2600                 `o2`/`o3` nesting does)",
2601                self.max_depth
2602            ));
2603        }
2604        self.parse_depth += 1;
2605        let r = self.parse_expr_inner();
2606        self.parse_depth -= 1;
2607        r
2608    }
2609
2610    fn parse_expr_inner(&mut self) -> Result<Expr, String> {
2611        let raw = self
2612            .next_line()
2613            .ok_or_else(|| "expected expression token".to_string())?;
2614        // Borrowed, not owned: this runs once per node of every
2615        // expression tree in the file, so an owned `String` here is one
2616        // heap allocation per tape op in the whole model.
2617        let tok = strip_comment(raw).trim();
2618        if tok.is_empty() {
2619            return Err("empty expression token".into());
2620        }
2621        let first = tok.chars().next().ok_or("empty expression token")?;
2622        match first {
2623            'n' => {
2624                let v: Number = tok[1..]
2625                    .trim()
2626                    .parse()
2627                    .map_err(|e| format!("n value: {e}"))?;
2628                // A `nan` literal in the objective body reached the answer
2629                // itself: `Objective: nan` under `Solve_Succeeded` and exit
2630                // code 0 (gh #847).
2631                Ok(Expr::Const(finite_or_err("numeric literal", v)?))
2632            }
2633            'v' => {
2634                let i: usize = tok[1..]
2635                    .trim()
2636                    .parse()
2637                    .map_err(|e| format!("v index: {e}"))?;
2638                Ok(self.var_or_cse(i)?)
2639            }
2640            'o' => {
2641                let code: i32 = tok[1..]
2642                    .trim()
2643                    .parse()
2644                    .map_err(|e| format!("opcode: {e}"))?;
2645                self.parse_opcode(code)
2646            }
2647            'f' => {
2648                // AMPL imported (external) function call: `f<id> <nargs>`
2649                // followed by nargs child expressions (or string literals).
2650                let rest = &tok[1..];
2651                let mut parts = rest.split_whitespace();
2652                let id_str = parts
2653                    .next()
2654                    .ok_or_else(|| format!("missing function id in '{tok}'"))?;
2655                let nargs_str = parts
2656                    .next()
2657                    .ok_or_else(|| format!("missing nargs in '{tok}'"))?;
2658                let id: usize = id_str
2659                    .parse()
2660                    .map_err(|e| format!("bad function id '{id_str}': {e}"))?;
2661                let nargs: usize = nargs_str
2662                    .parse()
2663                    .map_err(|e| format!("bad funcall nargs '{nargs_str}': {e}"))?;
2664                let mut args: Vec<FuncallArg> = Vec::with_capacity(nargs);
2665                for _ in 0..nargs {
2666                    args.push(self.parse_funcall_arg()?);
2667                }
2668                Ok(Expr::Funcall { id, args })
2669            }
2670            't' | 'u' => Err(format!("unsupported expression token '{tok}'")),
2671            other => Err(format!(
2672                "unexpected expression token start '{other}': '{tok}'"
2673            )),
2674        }
2675    }
2676
2677    /// Parse one argument to an AMPL imported function. An argument
2678    /// is either a normal expression (real-valued) or a string literal
2679    /// in the form `h<len>:<chars>`. AMPL emits string args only when the
2680    /// function was declared `FUNCADD_STRING_ARGS` (e.g. component name
2681    /// or a parameters-directory path for IDAES Helmholtz functions).
2682    fn parse_funcall_arg(&mut self) -> Result<FuncallArg, String> {
2683        // Peek the next non-blank line so we can route `h...` differently.
2684        let saved = self.pos;
2685        let raw = self
2686            .next_line()
2687            .ok_or_else(|| "expected funcall argument".to_string())?;
2688        // A string arg is a Hollerith literal `h<len>:<chars>` where the
2689        // chars are *exactly* `<len>` bytes and may legitimately contain
2690        // '#'. We must NOT strip a trailing comment before extracting the
2691        // content (that would truncate e.g. a path like `a#b`), and we
2692        // honor the declared length rather than splitting loosely on ':'.
2693        // Detect the form from the leading non-blank char of the raw line;
2694        // no expression opcode (`o`/`v`/`n`/`f`) begins with 'h'.
2695        let lead = raw.trim_start();
2696        if let Some(after_h) = lead.strip_prefix('h') {
2697            let colon = after_h
2698                .find(':')
2699                .ok_or_else(|| format!("malformed Hollerith string arg (no ':'): {lead:?}"))?;
2700            let len: usize = after_h[..colon]
2701                .trim()
2702                .parse()
2703                .map_err(|e| format!("Hollerith length in {lead:?}: {e}"))?;
2704            let chars = &after_h[colon + 1..];
2705            if chars.len() < len {
2706                return Err(format!(
2707                    "Hollerith string shorter than declared length {len}: {chars:?}"
2708                ));
2709            }
2710            // Take exactly `len` bytes; anything past it (trailing
2711            // whitespace, a real comment) is not part of the string.
2712            if !chars.is_char_boundary(len) {
2713                return Err(format!(
2714                    "Hollerith length {len} splits a multibyte char in {chars:?}"
2715                ));
2716            }
2717            Ok(FuncallArg::Str(chars[..len].to_string()))
2718        } else {
2719            // Rewind: parse_expr re-consumes the line we just peeked.
2720            self.pos = saved;
2721            Ok(FuncallArg::Real(self.parse_expr()?))
2722        }
2723    }
2724
2725    fn parse_opcode(&mut self, code: i32) -> Result<Expr, String> {
2726        match code {
2727            0 => {
2728                // gh#986: a left-deep run of binary `o0` — `o0 o0 o0 … a b c d`,
2729                // what AMPL and discopt write for a long sum — is read as one
2730                // n-ary `Sum`, so the tree is one level deep however many terms
2731                // there are. Taken only past `SUM_CHAIN_FLATTEN_MIN` links, so
2732                // an ordinary model keeps exactly the tree it always had.
2733                // Each `o0`'s first child is the next line, so the `k + 1`
2734                // operands that follow the run are, in order, the leaves of
2735                // `((a + b) + c) + d`; `Sum` adds them left to right, the same
2736                // order of operations.
2737                let after_first = self.pos;
2738                let mut k = 1usize;
2739                loop {
2740                    let before = self.pos;
2741                    match self.next_line() {
2742                        Some(l) if strip_comment(l).trim() == "o0" => k += 1,
2743                        _ => {
2744                            self.pos = before;
2745                            break;
2746                        }
2747                    }
2748                }
2749                if k > SUM_CHAIN_FLATTEN_MIN {
2750                    let mut terms = Vec::with_capacity(k + 1);
2751                    for _ in 0..=k {
2752                        terms.push(self.parse_expr()?);
2753                    }
2754                    return Ok(Expr::Sum(terms));
2755                }
2756                self.pos = after_first;
2757                let a = self.parse_expr()?;
2758                let b = self.parse_expr()?;
2759                Ok(Expr::Binary(BinOp::Add, Box::new(a), Box::new(b)))
2760            }
2761            1 => {
2762                let a = self.parse_expr()?;
2763                let b = self.parse_expr()?;
2764                Ok(Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
2765            }
2766            2 => {
2767                let a = self.parse_expr()?;
2768                let b = self.parse_expr()?;
2769                Ok(merge_abs_pow_product(a, b))
2770            }
2771            3 => {
2772                let a = self.parse_expr()?;
2773                let b = self.parse_expr()?;
2774                Ok(Expr::Binary(BinOp::Div, Box::new(a), Box::new(b)))
2775            }
2776            5 => {
2777                let a = self.parse_expr()?;
2778                let b = self.parse_expr()?;
2779                Ok(Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b)))
2780            }
2781            15 => Ok(Expr::Unary(UnaryOp::Abs, Box::new(self.parse_expr()?))),
2782            16 => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.parse_expr()?))),
2783            39 => Ok(Expr::Unary(UnaryOp::Sqrt, Box::new(self.parse_expr()?))),
2784            41 => Ok(Expr::Unary(UnaryOp::Sin, Box::new(self.parse_expr()?))),
2785            42 => Ok(Expr::Unary(UnaryOp::Log10, Box::new(self.parse_expr()?))),
2786            43 => Ok(Expr::Unary(UnaryOp::Log, Box::new(self.parse_expr()?))),
2787            44 => Ok(Expr::Unary(UnaryOp::Exp, Box::new(self.parse_expr()?))),
2788            46 => Ok(Expr::Unary(UnaryOp::Cos, Box::new(self.parse_expr()?))),
2789            38 => Ok(Expr::Unary(UnaryOp::Tan, Box::new(self.parse_expr()?))),
2790            49 => Ok(Expr::Unary(UnaryOp::Atan, Box::new(self.parse_expr()?))),
2791            53 => Ok(Expr::Unary(UnaryOp::Acos, Box::new(self.parse_expr()?))),
2792            40 => Ok(Expr::Unary(UnaryOp::Sinh, Box::new(self.parse_expr()?))),
2793            45 => Ok(Expr::Unary(UnaryOp::Cosh, Box::new(self.parse_expr()?))),
2794            37 => Ok(Expr::Unary(UnaryOp::Tanh, Box::new(self.parse_expr()?))),
2795            51 => Ok(Expr::Unary(UnaryOp::Asin, Box::new(self.parse_expr()?))),
2796            52 => Ok(Expr::Unary(UnaryOp::Acosh, Box::new(self.parse_expr()?))),
2797            50 => Ok(Expr::Unary(UnaryOp::Asinh, Box::new(self.parse_expr()?))),
2798            47 => Ok(Expr::Unary(UnaryOp::Atanh, Box::new(self.parse_expr()?))),
2799            // atan2(y, x): binary, operand order `y` then `x`.
2800            48 => {
2801                let a = self.parse_expr()?;
2802                let b = self.parse_expr()?;
2803                Ok(Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b)))
2804            }
2805            // Relational comparisons (binary). Operand order is
2806            // `left OP right`.
2807            22 => self.parse_compare(CmpOp::Lt),
2808            23 => self.parse_compare(CmpOp::Le),
2809            24 => self.parse_compare(CmpOp::Eq),
2810            28 => self.parse_compare(CmpOp::Ge),
2811            29 => self.parse_compare(CmpOp::Gt),
2812            30 => self.parse_compare(CmpOp::Ne),
2813            // Logical connectives.
2814            20 => {
2815                let a = self.parse_expr()?;
2816                let b = self.parse_expr()?;
2817                Ok(Expr::Or(Box::new(a), Box::new(b)))
2818            }
2819            21 => {
2820                let a = self.parse_expr()?;
2821                let b = self.parse_expr()?;
2822                Ok(Expr::And(Box::new(a), Box::new(b)))
2823            }
2824            34 => Ok(Expr::Not(Box::new(self.parse_expr()?))),
2825            // if-then-else: condition, then-value, else-value.
2826            35 => {
2827                let cond = self.parse_expr()?;
2828                let then_ = self.parse_expr()?;
2829                let else_ = self.parse_expr()?;
2830                Ok(Expr::Cond {
2831                    cond: Box::new(cond),
2832                    then_: Box::new(then_),
2833                    else_: Box::new(else_),
2834                })
2835            }
2836            54 => {
2837                // Variadic sum: next data line gives the count.
2838                let count_line = self.next_data_line()?;
2839                let count: usize = count_line
2840                    .split_whitespace()
2841                    .next()
2842                    .ok_or_else(|| "missing variadic count".to_string())?
2843                    .parse()
2844                    .map_err(|e| format!("variadic count: {e}"))?;
2845                let mut args = Vec::with_capacity(count);
2846                for _ in 0..count {
2847                    args.push(self.parse_expr()?);
2848                }
2849                Ok(Expr::Sum(args))
2850            }
2851            // Variadic min (o11 MINLIST) / max (o12 MAXLIST): like o54,
2852            // a count data line followed by that many operands.
2853            11 | 12 => {
2854                let count_line = self.next_data_line()?;
2855                let count: usize = count_line
2856                    .split_whitespace()
2857                    .next()
2858                    .ok_or_else(|| "missing min/max list count".to_string())?
2859                    .parse()
2860                    .map_err(|e| format!("min/max list count: {e}"))?;
2861                let mut args = Vec::with_capacity(count);
2862                for _ in 0..count {
2863                    args.push(self.parse_expr()?);
2864                }
2865                if code == 11 {
2866                    Ok(Expr::MinList(args))
2867                } else {
2868                    Ok(Expr::MaxList(args))
2869                }
2870            }
2871            // AMPL power specializations (ASL `opcode.hd` 81/82/83). AMPL
2872            // emits these in place of the general `o5` (OPPOW) as a hint that
2873            // one operand is constant. The distinction exists because an
2874            // integer / half-integer constant power is evaluated by a
2875            // mul/sqrt chain that stays real for a negative base, whereas the
2876            // general `pow` (via `exp(c·ln x)`) returns NaN there. Structurally
2877            // they read exactly like `o5`, so they lower to the same `Pow` AST
2878            // and reuse the existing constant-power tape lowering (see
2879            // `nl_tape::try_emit_const_pow`). Arity/operand order confirmed
2880            // against the ASL reader and the `ampl/mp` opcode table:
2881            // POW_CONST_EXP / POW_CONST_BASE are binary `base, exp`; POW2 is
2882            // unary with an implicit exponent of 2.
2883            //
2884            // o81 OP1POW: `base ^ (const exponent)` — binary, operands
2885            // `base` then `exp` (the exponent is a numeric node here).
2886            81 => {
2887                let base = self.parse_expr()?;
2888                let exp = self.parse_expr()?;
2889                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2890            }
2891            // o82 OP2POW: square — unary, single operand; exponent 2 implicit.
2892            82 => {
2893                let base = self.parse_expr()?;
2894                Ok(Expr::Binary(
2895                    BinOp::Pow,
2896                    Box::new(base),
2897                    Box::new(Expr::Const(2.0)),
2898                ))
2899            }
2900            // o83 OPCPOW: `(const base) ^ exponent` — binary, operands `base`
2901            // (the numeric node) then `exp`.
2902            83 => {
2903                let base = self.parse_expr()?;
2904                let exp = self.parse_expr()?;
2905                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2906            }
2907            other => Err(format!("unsupported opcode o{other}")),
2908        }
2909    }
2910
2911    /// Parse the two operands of a relational opcode into an
2912    /// [`Expr::Compare`]. Operand order is `left OP right`.
2913    fn parse_compare(&mut self, op: CmpOp) -> Result<Expr, String> {
2914        let a = self.parse_expr()?;
2915        let b = self.parse_expr()?;
2916        Ok(Expr::Compare(op, Box::new(a), Box::new(b)))
2917    }
2918
2919    /// Resolve a `v<i>` token into either a plain variable reference
2920    /// (`i < n`) or a shared CSE reference (`i >= n`).
2921    fn var_or_cse(&self, i: usize) -> Result<Expr, String> {
2922        if i < self.n {
2923            Ok(Expr::Var(i))
2924        } else {
2925            let local = i - self.n;
2926            self.cses
2927                .get(local)
2928                .map(|rc| Expr::Cse(rc.clone()))
2929                .ok_or_else(|| {
2930                    format!(
2931                        "v{i} references CSE {local} but only {} have been defined",
2932                        self.cses.len()
2933                    )
2934                })
2935        }
2936    }
2937
2938    /// Parse a `V<k> <nlin> <type>` common-subexpression segment. The
2939    /// CSE evaluates to `nonlinear_expr + sum_i coef_i * v_{var_i}`.
2940    /// CSEs are numbered starting at `n` and must appear in order.
2941    fn parse_v_segment(&mut self) -> Result<(), String> {
2942        let (hdr, _) = self.eat_segment_header()?;
2943        let parts: Vec<&str> = hdr.split_whitespace().collect();
2944        if parts.len() < 2 {
2945            return Err(format!("malformed V-segment header: {hdr}"));
2946        }
2947        let cse_idx = parse_segment_index(parts[0], 'V')?;
2948        let nlin: usize = parts[1].parse().map_err(|e| format!("V nlin: {e}"))?;
2949        // parts[2] (type) is ignored; values >0 just mark special-purpose CSEs.
2950        let mut linear: Vec<(usize, Number)> = Vec::with_capacity(nlin);
2951        for _ in 0..nlin {
2952            let line = self.next_data_line()?;
2953            let (var, coef) = parse_var_coef(line)?;
2954            linear.push((var, coef));
2955        }
2956        let nonlin = self.parse_expr()?;
2957        // Build `nonlin + sum coef_i * v_{var_i}`. Linear terms can
2958        // reference earlier CSEs as well as plain variables.
2959        let mut combined = nonlin;
2960        for (var, coef) in linear {
2961            let v_expr = self.var_or_cse(var)?;
2962            let term = if coef == 1.0 {
2963                v_expr
2964            } else {
2965                Expr::Binary(BinOp::Mul, Box::new(Expr::Const(coef)), Box::new(v_expr))
2966            };
2967            combined = Expr::Binary(BinOp::Add, Box::new(combined), Box::new(term));
2968        }
2969        if cse_idx < self.n {
2970            return Err(format!("V{cse_idx} below n={}", self.n));
2971        }
2972        let local = cse_idx - self.n;
2973        if local != self.cses.len() {
2974            return Err(format!(
2975                "V-segment index V{cse_idx} out of order; expected V{}",
2976                self.n + self.cses.len()
2977            ));
2978        }
2979        // What a reference to this body would mean to the streaming
2980        // recognizer, answered once here rather than per reference. The
2981        // `V` tree exists at this point, so these are the *same* functions
2982        // `NlTnlp` applies to a whole row — the parse-time recognizer never
2983        // gets a second opinion about a CSE.
2984        // gh#986 review: bound the chain before any pass walks through it.
2985        let depth = self.check_effective_depth(&combined)?;
2986        if self.quad_enabled {
2987            // gh#986 review: every earlier body's verdicts are already
2988            // known, so each pass reads them instead of re-walking the chain
2989            // of defined variables below this one (quadratic over a long
2990            // chain otherwise).
2991            let idx = &self.cse_index;
2992            let (quad, sum_ok, mono_ok) = (&self.cse_quad, &self.cse_sum_ok, &self.cse_mono_ok);
2993            let quad_seed = |k: *const Expr| idx.get(&k).map(|&i| quad[i].clone());
2994            let shape_seed = |k: *const Expr, mono: bool| {
2995                idx.get(&k)
2996                    .map(|&i| if mono { mono_ok[i] } else { sum_ok[i] })
2997            };
2998            let q = crate::nl_quadratic::recognize_expr_seeded(&combined, &quad_seed);
2999            let s_ok = crate::nl_quadratic::is_expanded_quadratic_seeded(&combined, &shape_seed);
3000            let m_ok = crate::nl_quadratic::is_monomial_expr_seeded(&combined, &shape_seed);
3001            let mut vars: BTreeSet<usize> = BTreeSet::new();
3002            collect_vars_seeded(&combined, &mut vars, &self.cse_index, &self.cse_vars);
3003            self.cse_quad.push(q);
3004            self.cse_sum_ok.push(s_ok);
3005            self.cse_mono_ok.push(m_ok);
3006            self.cse_vars
3007                .push(vars.into_iter().map(|v| v as u32).collect());
3008            self.cse_depth.push(depth);
3009        }
3010        let body = Arc::new(combined);
3011        self.cse_index.insert(Arc::as_ptr(&body), self.cses.len());
3012        self.cse_depth_memo.insert(Arc::as_ptr(&body), depth);
3013        self.cses.push(body);
3014        Ok(())
3015    }
3016}
3017
3018/// Combine the operands of one recognized operator, mirroring
3019/// [`crate::nl_quadratic::recognize_expr`]'s `Apply` arm term for term.
3020///
3021/// Every difference from that function is a difference in the coefficients
3022/// this parser stores, so there is nothing here that is "equivalent but
3023/// tidier": the division scales by the reciprocal because that is what the
3024/// tree walk does, and the sumlist folds back to front for the same reason.
3025/// Nesting depth of a `V`-segment body, leaf = 1, a `Cse` reference
3026/// counting one level above its body — the convention `pounce-py`'s
3027/// `expr_depth` uses, since that is the guard the answer feeds.
3028///
3029/// Recursive, and safe to be: it recurses only through the syntax of `e`,
3030/// which [`Parser::parse_expr`] has just built *recursively* on this same
3031/// stack, so a frame that fits the parser fits this. A `Cse` reference is
3032/// read from `memo` (every `V` body is memoized when its segment is parsed,
3033/// and a body can only reference earlier ones), so the walk never descends a
3034/// chain of defined variables: linear in `e`, where it used to be linear in
3035/// the whole chain on every `V` segment -- quadratic over a long chain, and
3036/// as deep as it (gh#986 review).
3037fn expr_tree_depth(e: &Expr, memo: &std::collections::HashMap<*const Expr, u32>) -> u32 {
3038    let deepest = |kids: &mut dyn Iterator<Item = &Expr>| {
3039        kids.fold(0u32, |acc, k| acc.max(expr_tree_depth(k, memo)))
3040    };
3041    1 + match e {
3042        Expr::Const(_) | Expr::Var(_) => 0,
3043        Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3044            deepest(&mut [&**a, &**b].into_iter())
3045        }
3046        Expr::Unary(_, a) | Expr::Not(a) => expr_tree_depth(a, memo),
3047        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => deepest(&mut args.iter()),
3048        Expr::Cond { cond, then_, else_ } => {
3049            deepest(&mut [&**cond, &**then_, &**else_].into_iter())
3050        }
3051        Expr::Funcall { args, .. } => deepest(&mut args.iter().filter_map(|a| match a {
3052            FuncallArg::Real(inner) => Some(inner),
3053            FuncallArg::Str(_) => None,
3054        })),
3055        Expr::Cse(body) => match memo.get(&Arc::as_ptr(body)) {
3056            Some(&d) => d,
3057            None => expr_tree_depth(body, memo),
3058        },
3059    }
3060}
3061
3062fn apply_quad_op(op: QOp, vals: &mut Vec<Quad2>) -> Option<Quad2> {
3063    let pop2 = |vals: &mut Vec<Quad2>| -> Option<(Quad2, Quad2)> {
3064        let b = vals.pop()?;
3065        let a = vals.pop()?;
3066        Some((a, b))
3067    };
3068    Some(match op {
3069        QOp::Sum(n) => {
3070            let at = vals.len().checked_sub(n)?;
3071            let mut acc = Quad2::default();
3072            // Operands arrive in file order, so `drain` already yields them
3073            // front to back — the order `recognize_expr` folds in, and the
3074            // order the AD tape sums in. Do not reverse this: floating-point
3075            // addition is not associative, and on repeated monomials the two
3076            // orders do not agree bit for bit.
3077            for p in vals.drain(at..) {
3078                acc = Quad2::add(acc, p);
3079            }
3080            acc
3081        }
3082        QOp::Neg => vals.pop()?.neg(),
3083        QOp::Add => {
3084            let (a, b) = pop2(vals)?;
3085            Quad2::add(a, b)
3086        }
3087        QOp::Sub => {
3088            let (a, b) = pop2(vals)?;
3089            Quad2::add(a, b.neg())
3090        }
3091        QOp::Mul => {
3092            let (a, b) = pop2(vals)?;
3093            a.mul(&b)?
3094        }
3095        QOp::Div => {
3096            let (a, b) = pop2(vals)?;
3097            let d = b.as_constant()?;
3098            if d == 0.0 {
3099                return None;
3100            }
3101            let mut out = a.div_by_constant(d);
3102            out.absorb_flags(&b);
3103            out
3104        }
3105        QOp::Pow => {
3106            let (a, b) = pop2(vals)?;
3107            let exp = b.as_constant()?;
3108            let mut out = if exp == 0.0 {
3109                Quad2::of_constant(1.0)
3110            } else if exp == 1.0 {
3111                a
3112            } else if exp == 2.0 {
3113                a.mul(&a)?
3114            } else {
3115                return None;
3116            };
3117            // The exponent is read out of a form with `as_constant`, which
3118            // leaves its flags behind; `Div` above does the same.
3119            out.absorb_flags(&b);
3120            out
3121        }
3122        // `o82` is `Pow(base, Const(2.0))` with the exponent left implicit,
3123        // so it takes the `exp == 2.0` branch above.
3124        QOp::Square => {
3125            let a = vals.pop()?;
3126            a.mul(&a)?
3127        }
3128    })
3129}
3130
3131/// Re-parse one recognized body from the bytes it was recognized from.
3132///
3133/// The point is that this is the *same* function the original parse ran, on
3134/// the *same* bytes, with the same `n` and the same `V`-segment bodies — so
3135/// the tree it returns is the tree that parse would have built, down to the
3136/// `Arc` identities that `HybridTape::build_multi` keys CSE sharing on.
3137/// Deriving an equivalent tree from the stored coefficients instead would
3138/// be a different tree, evaluated by a different tape, and this phase would
3139/// stop being invisible from outside.
3140fn parse_body_fragment(txt: &str, n: usize, cses: &[Arc<Expr>]) -> Result<Expr, String> {
3141    let mut p = Parser::new(txt, false);
3142    p.n = n;
3143    p.cses = cses.to_vec();
3144    p.parse_expr()
3145}
3146
3147/// `a·b` for an `o2` node, with one rewrite: when a factor `|e|^p` on the
3148/// left meets a factor `|e|^q` on the right (same operand `e`, constant
3149/// `p, q > 0`), the two become the single factor `|e|^(p+q)` (gh#1042 R3).
3150///
3151/// **Why.** Vanderbei's `dallas*` models write a head-loss term as
3152/// `(k·|x|^2)·|x|^0.85`. Differentiated as written, the product rule meets
3153/// `|x|^0.85` at `x = 0`, where its slope `0.85·|x|^-0.15` is `∞` and its
3154/// second derivative `-0.1275·|x|^-1.15` is `-∞`, against co-factors
3155/// `k·|x|^2 = 0` and `2k·|x| = 0`: the Hessian of the term is `0·∞ = NaN`
3156/// (the gradient survives, measured `0`), though the term is `k·|x|^2.85`,
3157/// whose second derivative at `0` is `0`. The NaN Hessian fails the KKT
3158/// factorization and forces a restoration entry each time a flow lands on
3159/// exactly zero — 11 of `dallass`'s 12 restoration calls on 0.13.0. Merged,
3160/// the tape differentiates `|x|^2.85` directly and every derivative at the
3161/// kink is the finite, correct one.
3162///
3163/// **Why it is exact.** `|e| ≥ 0`, so `|e|^p·|e|^q = |e|^(p+q)` holds for
3164/// every real `e` and every `p, q > 0` — including `e = 0` (`0·0 = 0`),
3165/// `|e| = ∞` (`∞·∞ = ∞`) and `e = NaN` (NaN either way); the two sides
3166/// differ by rounding only. The restrictions are what keep it exact:
3167///
3168/// * the base must be `abs(·)` — for a bare `e < 0` and non-integer
3169///   powers `e^0.5·e^0.5` is `NaN·NaN` while `e^1` is `e`;
3170/// * both exponents must be positive literals — `|e|^-1·|e|^2` is
3171///   `∞·0 = NaN` at `e = 0` but `|e|^1 = 0`, so a negative or zero power
3172///   would change a value, not only a derivative;
3173/// * the operands must be the *same* expression (same variable, same
3174///   shared `Cse` body, or structurally identical) — see [`same_operand`].
3175///
3176/// Away from `e = 0` the derivatives are unchanged mathematically:
3177/// `d/de |e|^(p+q)` and the product rule on `|e|^p·|e|^q` are the same
3178/// function there. Only the evaluation at the kink changes, from `NaN` to
3179/// the limit.
3180///
3181/// **Scope.** Only the two operands of this `o2` and, at most, one `Mul`
3182/// level inside each are inspected, which is what an AMPL left-associated
3183/// `k*abs(x)^2*abs(x)^0.85` produces; a pair inside one operand was already
3184/// merged when that operand was read. When nothing matches, the node is
3185/// built exactly as before (`Mul(a, b)`), so a model without the pattern
3186/// gets an identical tree and tape.
3187fn merge_abs_pow_product(a: Expr, b: Expr) -> Expr {
3188    fn factors(e: &Expr) -> Vec<&Expr> {
3189        match e {
3190            Expr::Binary(BinOp::Mul, x, y) => vec![&**x, &**y],
3191            other => vec![other],
3192        }
3193    }
3194    fn into_factors(e: Expr) -> Vec<Expr> {
3195        match e {
3196            Expr::Binary(BinOp::Mul, x, y) => vec![*x, *y],
3197            other => vec![other],
3198        }
3199    }
3200    let hit = {
3201        let (fa, fb) = (factors(&a), factors(&b));
3202        let mut hit = None;
3203        'outer: for (i, x) in fa.iter().enumerate() {
3204            let Some((ex, px)) = abs_pow_parts(x) else {
3205                continue;
3206            };
3207            for (j, y) in fb.iter().enumerate() {
3208                if let Some((ey, py)) = abs_pow_parts(y)
3209                    && (px + py).is_finite()
3210                    && same_operand(ex, ey)
3211                {
3212                    hit = Some((i, j, px + py));
3213                    break 'outer;
3214                }
3215            }
3216        }
3217        hit
3218    };
3219    let Some((i, j, p)) = hit else {
3220        return Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b));
3221    };
3222    let mut fa = into_factors(a);
3223    let mut fb = into_factors(b);
3224    fb.remove(j);
3225    let Expr::Binary(BinOp::Pow, base, _) = std::mem::replace(&mut fa[i], Expr::Const(0.0)) else {
3226        unreachable!("abs_pow_parts matched a Pow node");
3227    };
3228    fa[i] = Expr::Binary(BinOp::Pow, base, Box::new(Expr::Const(p)));
3229    let mut it = fa.into_iter().chain(fb);
3230    let first = it.next().expect("at least the merged factor");
3231    it.fold(first, |acc, f| {
3232        Expr::Binary(BinOp::Mul, Box::new(acc), Box::new(f))
3233    })
3234}
3235
3236/// `Some((e, p))` when `x` is `|e|^p` with a finite literal `p > 0`.
3237fn abs_pow_parts(x: &Expr) -> Option<(&Expr, f64)> {
3238    match x {
3239        Expr::Binary(BinOp::Pow, base, exp) => match (&**base, &**exp) {
3240            (Expr::Unary(UnaryOp::Abs, e), Expr::Const(p)) if p.is_finite() && *p > 0.0 => {
3241                Some((&**e, *p))
3242            }
3243            _ => None,
3244        },
3245        _ => None,
3246    }
3247}
3248
3249/// Conservative structural identity for [`merge_abs_pow_product`]: `true`
3250/// only when `x` and `y` are certainly the same function of the variables —
3251/// the same variable, the same shared `Cse` body (pointer identity), a
3252/// bit-identical constant, or the same unary/binary operator over operands
3253/// that are themselves identical. Anything else (sums, calls, logic,
3254/// conditionals) answers `false`, which only ever declines a merge.
3255fn same_operand(x: &Expr, y: &Expr) -> bool {
3256    match (x, y) {
3257        (Expr::Var(i), Expr::Var(j)) => i == j,
3258        (Expr::Const(a), Expr::Const(b)) => a.to_bits() == b.to_bits(),
3259        (Expr::Cse(a), Expr::Cse(b)) => Arc::ptr_eq(a, b),
3260        (Expr::Unary(oa, a), Expr::Unary(ob, b)) => oa == ob && same_operand(a, b),
3261        (Expr::Binary(oa, a1, a2), Expr::Binary(ob, b1, b2)) => {
3262            oa == ob && same_operand(a1, b1) && same_operand(a2, b2)
3263        }
3264        _ => false,
3265    }
3266}
3267
3268fn strip_comment(s: &str) -> &str {
3269    match s.find('#') {
3270        Some(i) => &s[..i],
3271        None => s,
3272    }
3273}
3274
3275fn split_comment(s: &str) -> (&str, &str) {
3276    match s.find('#') {
3277        Some(i) => (&s[..i], &s[i + 1..]),
3278        None => (s, ""),
3279    }
3280}
3281
3282// --------------------------------------------------------------------
3283// Expression evaluation and gradient (tree walkers, kept for tests).
3284// The hot paths in `NlTnlp` use the flat `Tape` AD in `nl_tape.rs`
3285// instead — see `Tape::gradient_seed` / `Tape::hessian_accumulate`.
3286// --------------------------------------------------------------------
3287
3288/// Forward-mode value evaluation.
3289pub fn eval_expr(e: &Expr, x: &[Number]) -> Number {
3290    match e {
3291        Expr::Const(c) => *c,
3292        Expr::Var(i) => x[*i],
3293        Expr::Binary(op, a, b) => {
3294            let va = eval_expr(a, x);
3295            let vb = eval_expr(b, x);
3296            match op {
3297                BinOp::Add => va + vb,
3298                BinOp::Sub => va - vb,
3299                BinOp::Mul => va * vb,
3300                BinOp::Div => va / vb,
3301                BinOp::Pow => va.powf(vb),
3302                BinOp::Atan2 => va.atan2(vb),
3303                BinOp::CEntropy => crate::nl_tape::centropy(va, vb),
3304            }
3305        }
3306        Expr::Unary(op, a) => {
3307            let va = eval_expr(a, x);
3308            match op {
3309                UnaryOp::Neg => -va,
3310                UnaryOp::Sqrt => va.sqrt(),
3311                UnaryOp::Log => va.ln(),
3312                UnaryOp::Log10 => va.log10(),
3313                UnaryOp::Exp => va.exp(),
3314                UnaryOp::Abs => va.abs(),
3315                UnaryOp::Sin => va.sin(),
3316                UnaryOp::Cos => va.cos(),
3317                UnaryOp::Tan => va.tan(),
3318                UnaryOp::Atan => va.atan(),
3319                UnaryOp::Acos => va.acos(),
3320                UnaryOp::Sinh => va.sinh(),
3321                UnaryOp::Cosh => va.cosh(),
3322                UnaryOp::Tanh => va.tanh(),
3323                UnaryOp::Asin => va.asin(),
3324                UnaryOp::Acosh => va.acosh(),
3325                UnaryOp::Asinh => va.asinh(),
3326                UnaryOp::Atanh => crate::nl_tape::atanh(va),
3327                UnaryOp::Erf => crate::nl_tape::erf(va),
3328                UnaryOp::XLogX => crate::nl_tape::xlogx(va),
3329            }
3330        }
3331        Expr::Sum(args) => args.iter().map(|a| eval_expr(a, x)).sum(),
3332        Expr::MinList(args) => args
3333            .iter()
3334            .map(|a| eval_expr(a, x))
3335            .fold(Number::INFINITY, Number::min),
3336        Expr::MaxList(args) => args
3337            .iter()
3338            .map(|a| eval_expr(a, x))
3339            .fold(Number::NEG_INFINITY, Number::max),
3340        Expr::Compare(op, a, b) => {
3341            let va = eval_expr(a, x);
3342            let vb = eval_expr(b, x);
3343            let truth = match op {
3344                CmpOp::Lt => va < vb,
3345                CmpOp::Le => va <= vb,
3346                CmpOp::Eq => va == vb,
3347                CmpOp::Ge => va >= vb,
3348                CmpOp::Gt => va > vb,
3349                CmpOp::Ne => va != vb,
3350            };
3351            if truth { 1.0 } else { 0.0 }
3352        }
3353        Expr::And(a, b) => {
3354            if eval_expr(a, x) != 0.0 && eval_expr(b, x) != 0.0 {
3355                1.0
3356            } else {
3357                0.0
3358            }
3359        }
3360        Expr::Or(a, b) => {
3361            if eval_expr(a, x) != 0.0 || eval_expr(b, x) != 0.0 {
3362                1.0
3363            } else {
3364                0.0
3365            }
3366        }
3367        Expr::Not(a) => {
3368            if eval_expr(a, x) == 0.0 {
3369                1.0
3370            } else {
3371                0.0
3372            }
3373        }
3374        Expr::Cond { cond, then_, else_ } => {
3375            if eval_expr(cond, x) != 0.0 {
3376                eval_expr(then_, x)
3377            } else {
3378                eval_expr(else_, x)
3379            }
3380        }
3381        Expr::Cse(body) => eval_expr(body, x),
3382        Expr::Funcall { .. } => panic!(
3383            "eval_expr: AMPL imported function called without an external resolver; \
3384             evaluate through the tape AD path (Tape::build_with_externals) instead"
3385        ),
3386    }
3387}
3388
3389/// Index of the active operand of an n-ary min (`want_min = true`) or
3390/// max (`want_min = false`) list at point `x`: the smallest / largest
3391/// value, with ties resolved to the first such operand (the
3392/// conventional subgradient choice). Returns `None` for an empty list.
3393fn argmin_argmax(args: &[Expr], x: &[Number], want_min: bool) -> Option<usize> {
3394    let mut best: Option<(usize, Number)> = None;
3395    for (i, a) in args.iter().enumerate() {
3396        let v = eval_expr(a, x);
3397        match best {
3398            None => best = Some((i, v)),
3399            Some((_, bv)) => {
3400                // Strict comparison keeps the FIRST extremal operand on
3401                // ties, matching the subgradient convention used by Abs
3402                // and Select elsewhere in the tape.
3403                if (want_min && v < bv) || (!want_min && v > bv) {
3404                    best = Some((i, v));
3405                }
3406            }
3407        }
3408    }
3409    best.map(|(i, _)| i)
3410}
3411
3412/// Reverse-mode gradient: accumulates `seed * d(expr)/dx_i` into `grad`.
3413pub fn grad_expr(e: &Expr, x: &[Number], seed: Number, grad: &mut [Number]) {
3414    match e {
3415        Expr::Const(_) => {}
3416        Expr::Var(i) => grad[*i] += seed,
3417        Expr::Binary(op, a, b) => {
3418            let va = eval_expr(a, x);
3419            let vb = eval_expr(b, x);
3420            match op {
3421                BinOp::Add => {
3422                    grad_expr(a, x, seed, grad);
3423                    grad_expr(b, x, seed, grad);
3424                }
3425                BinOp::Sub => {
3426                    grad_expr(a, x, seed, grad);
3427                    grad_expr(b, x, -seed, grad);
3428                }
3429                BinOp::Mul => {
3430                    grad_expr(a, x, seed * vb, grad);
3431                    grad_expr(b, x, seed * va, grad);
3432                }
3433                BinOp::Div => {
3434                    grad_expr(a, x, seed / vb, grad);
3435                    grad_expr(b, x, -seed * va / (vb * vb), grad);
3436                }
3437                BinOp::Pow => {
3438                    // d/da: b * a^(b-1)
3439                    let dpa = vb * va.powf(vb - 1.0);
3440                    grad_expr(a, x, seed * dpa, grad);
3441                    // d/db: a^b * ln(a) (only valid for a>0; simple branch)
3442                    if va > 0.0 {
3443                        let dpb = va.powf(vb) * va.ln();
3444                        grad_expr(b, x, seed * dpb, grad);
3445                    }
3446                }
3447                BinOp::Atan2 => {
3448                    // atan2(y=a, x=b): d/dy = x/(x²+y²), d/dx = -y/(x²+y²)
3449                    let (fa, fb) = crate::nl_tape::atan2_d1(va, vb);
3450                    grad_expr(a, x, seed * fa, grad);
3451                    grad_expr(b, x, seed * fb, grad);
3452                }
3453                BinOp::CEntropy => {
3454                    grad_expr(a, x, seed * crate::nl_tape::centropy_da(va, vb), grad);
3455                    grad_expr(b, x, seed * crate::nl_tape::centropy_db(va, vb), grad);
3456                }
3457            }
3458        }
3459        Expr::Unary(op, a) => {
3460            let va = eval_expr(a, x);
3461            let d = match op {
3462                UnaryOp::Neg => -1.0,
3463                UnaryOp::Sqrt => 0.5 / va.sqrt(),
3464                UnaryOp::Log => 1.0 / va,
3465                UnaryOp::Log10 => crate::nl_tape::div_u_ln10(1.0, va),
3466                UnaryOp::Exp => va.exp(),
3467                UnaryOp::Abs => {
3468                    if va > 0.0 {
3469                        1.0
3470                    } else if va < 0.0 {
3471                        -1.0
3472                    } else {
3473                        0.0
3474                    }
3475                }
3476                UnaryOp::Sin => va.cos(),
3477                UnaryOp::Cos => -va.sin(),
3478                UnaryOp::Tan => {
3479                    let t = va.tan();
3480                    1.0 + t * t
3481                }
3482                UnaryOp::Atan => crate::nl_tape::atan_d1(va),
3483                UnaryOp::Acos => crate::nl_tape::acos_d1(va),
3484                UnaryOp::Sinh => va.cosh(),
3485                UnaryOp::Cosh => va.sinh(),
3486                UnaryOp::Tanh => crate::nl_tape::tanh_d1(va),
3487                UnaryOp::Asin => crate::nl_tape::asin_d1(va),
3488                UnaryOp::Acosh => crate::nl_tape::acosh_d1(va),
3489                UnaryOp::Asinh => crate::nl_tape::asinh_d1(va),
3490                UnaryOp::Atanh => crate::nl_tape::atanh_d1(va),
3491                UnaryOp::Erf => crate::nl_tape::erf_d1(va),
3492                UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
3493            };
3494            grad_expr(a, x, seed * d, grad);
3495        }
3496        Expr::Sum(args) => {
3497            for arg in args {
3498                grad_expr(arg, x, seed, grad);
3499            }
3500        }
3501        // min/max are piecewise linear: the seed flows only through the
3502        // currently-active (smallest / largest) operand — a subgradient.
3503        // Ties resolve to the first such operand. Empty list: no operand,
3504        // no derivative (matches the ±inf eval fold).
3505        Expr::MinList(args) => {
3506            if let Some(k) = argmin_argmax(args, x, true) {
3507                grad_expr(&args[k], x, seed, grad);
3508            }
3509        }
3510        Expr::MaxList(args) => {
3511            if let Some(k) = argmin_argmax(args, x, false) {
3512                grad_expr(&args[k], x, seed, grad);
3513            }
3514        }
3515        // Comparisons and logical connectives are piecewise constant:
3516        // zero derivative, so no seed propagates into their operands.
3517        Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
3518        // if-then-else: differentiate only the active branch. The
3519        // branch-switch discontinuity contributes no derivative.
3520        Expr::Cond { cond, then_, else_ } => {
3521            if eval_expr(cond, x) != 0.0 {
3522                grad_expr(then_, x, seed, grad);
3523            } else {
3524                grad_expr(else_, x, seed, grad);
3525            }
3526        }
3527        Expr::Cse(body) => grad_expr(body, x, seed, grad),
3528        Expr::Funcall { .. } => {
3529            panic!("grad_expr: AMPL imported function called without an external resolver")
3530        }
3531    }
3532}
3533
3534/// Walk `e` and insert every `Var(i)` index into `out`.
3535///
3536/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
3537/// identity. Without that this is Θ(2^depth) on a DAG that shares
3538/// subexpressions — each reference re-walks the whole body — and presolve
3539/// calls this on every solve (`get_variables_linearity`). Skipping a
3540/// repeat visit cannot change the answer: `out` is a set, and a second
3541/// walk of the same body inserts exactly the indices the first already did.
3542/// [`collect_vars`] over a `V` body whose earlier `Cse` references already
3543/// have their variable sets in `cse_vars` (indexed through `index`): those are
3544/// read, not re-walked (gh#986 review). The recursion is through `e`'s own
3545/// syntax only, which the parser has just built on this stack.
3546fn collect_vars_seeded(
3547    e: &Expr,
3548    out: &mut BTreeSet<usize>,
3549    index: &std::collections::HashMap<*const Expr, usize>,
3550    cse_vars: &[Vec<u32>],
3551) {
3552    let mut kids: Vec<&Expr> = vec![e];
3553    while let Some(e) = kids.pop() {
3554        match e {
3555            Expr::Const(_) => {}
3556            Expr::Var(i) => {
3557                out.insert(*i);
3558            }
3559            Expr::Cse(body) => match index.get(&Arc::as_ptr(body)) {
3560                Some(&k) => out.extend(cse_vars[k].iter().map(|&v| v as usize)),
3561                None => collect_vars(body, out),
3562            },
3563            Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3564                kids.push(a);
3565                kids.push(b);
3566            }
3567            Expr::Unary(_, a) | Expr::Not(a) => kids.push(a),
3568            Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => kids.extend(args.iter()),
3569            Expr::Cond { cond, then_, else_ } => {
3570                kids.push(cond);
3571                kids.push(then_);
3572                kids.push(else_);
3573            }
3574            Expr::Funcall { args, .. } => {
3575                for a in args {
3576                    if let FuncallArg::Real(e) = a {
3577                        kids.push(e);
3578                    }
3579                }
3580            }
3581        }
3582    }
3583}
3584
3585pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
3586    // `HashSet::new` does not allocate until the first insert, so an
3587    // expression with no CSEs pays nothing for the memo.
3588    let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
3589    collect_vars_memo(e, out, &mut seen);
3590}
3591
3592fn collect_vars_memo(
3593    e: &Expr,
3594    out: &mut BTreeSet<usize>,
3595    seen: &mut std::collections::HashSet<*const Expr>,
3596) {
3597    match e {
3598        Expr::Const(_) => {}
3599        Expr::Var(i) => {
3600            out.insert(*i);
3601        }
3602        Expr::Binary(_, a, b) => {
3603            collect_vars_memo(a, out, seen);
3604            collect_vars_memo(b, out, seen);
3605        }
3606        Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
3607        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
3608            for a in args {
3609                collect_vars_memo(a, out, seen);
3610            }
3611        }
3612        // Collect from every child, including the condition: even
3613        // though the comparison/branch-test contributes no derivative,
3614        // the variables it reads are genuinely "used" by the problem,
3615        // and being conservative here only ever adds structural zeros
3616        // to the Jacobian/Hessian (never drops a real nonzero).
3617        Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3618            collect_vars_memo(a, out, seen);
3619            collect_vars_memo(b, out, seen);
3620        }
3621        Expr::Not(a) => collect_vars_memo(a, out, seen),
3622        Expr::Cond { cond, then_, else_ } => {
3623            collect_vars_memo(cond, out, seen);
3624            collect_vars_memo(then_, out, seen);
3625            collect_vars_memo(else_, out, seen);
3626        }
3627        Expr::Cse(body) => {
3628            if seen.insert(Arc::as_ptr(body)) {
3629                collect_vars_memo(body, out, seen);
3630            }
3631        }
3632        Expr::Funcall { args, .. } => {
3633            for a in args {
3634                if let FuncallArg::Real(e) = a {
3635                    collect_vars_memo(e, out, seen);
3636                }
3637            }
3638        }
3639    }
3640}
3641
3642// --------------------------------------------------------------------
3643// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
3644// Jacobian, and Hessian. Built once at construction; every solve-time
3645// callback is a tape sweep, no expression-tree recursion.
3646// --------------------------------------------------------------------
3647
3648/// Per-color decoding instruction for `eval_h` Hessian-coloring.
3649/// After a directional Hessian-vector product `compressed = H · s_c`,
3650/// the entry at row `row` came uniquely from column `col` (because
3651/// no two columns of color `c` share any nonzero row), so we
3652/// scatter `compressed[row]` into `values[hess_idx]`.
3653#[derive(Debug, Clone)]
3654struct ColorWrite {
3655    row: u32,
3656    hess_idx: u32,
3657}
3658
3659/// Constraint-block [`HybridTape`]: one local op list per summand plus a
3660/// **shared prelude** holding every CSE body referenced by two or more
3661/// summands, evaluated once per sweep.
3662///
3663/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
3664/// defined variable (`V` segment) referenced from many rows is re-emitted —
3665/// and re-evaluated — once per reference. That is invisible on most models
3666/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
3667/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
3668/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
3669/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
3670/// search. See pounce#476.
3671///
3672/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
3673/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
3674/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
3675/// overhead the flat tapes do not, so each derivative order has to earn
3676/// its switch.
3677#[derive(Debug, Clone)]
3678struct ConHybrid {
3679    tape: HybridTape,
3680    /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
3681    /// Length `m + 1`.
3682    row_start: Vec<usize>,
3683    /// Prelude forward values, sized to `tape.n_prelude_ops()`.
3684    prelude_vals: Vec<f64>,
3685    /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
3686    local_vals: Vec<f64>,
3687    /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
3688    /// value arenas above. `gradient_summand` zeroes only the slots a
3689    /// summand actually reaches, so these are allocated once and reused.
3690    local_adj: Vec<f64>,
3691    prelude_adj: Vec<f64>,
3692    /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
3693    /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
3694    /// unlike `eval_g`, the hybrid Jacobian is not a free win.
3695    use_for_jac: bool,
3696    /// Whether `eval_h` routes the constraint block through the shared
3697    /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
3698    use_for_hess: bool,
3699    // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
3700    // `try_new` after the Hessian coloring exists; cheap enough (a few
3701    // index tables plus one f64 per local op) to build whenever the
3702    // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
3703    /// Forward values of every summand, packed at
3704    /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
3705    /// fills it; every color then reuses the values, mirroring the flat
3706    /// path's forward-once-per-tape structure.
3707    local_vals_all: Vec<f64>,
3708    /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
3709    local_off: Vec<usize>,
3710    /// Constraint row of each summand (the inverse of `row_start`), for
3711    /// the `λ[row]` weight lookup.
3712    summand_row: Vec<u32>,
3713    /// Per color: the summands whose variables fall in that color — the
3714    /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
3715    /// exactly the live (color, summand) pairs.
3716    hess_color_summands: Vec<Vec<u32>>,
3717    /// Per color: the prelude slots that color's summands actually reach,
3718    /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
3719    /// Both prelude sweeps run once per color, so walking the whole
3720    /// prelude each time would cost `n_colors × |prelude|` where the
3721    /// op-ratio gate assumes `|prelude|`; iterating the union of the
3722    /// color's `prelude_reach` sets makes the cost proportional to what
3723    /// is used, so the gate does not need an `n_colors` term. Stored as
3724    /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
3725    /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
3726    /// this struct is the one #552 made O(n²) by holding per-color dense
3727    /// arrays.
3728    hess_color_reach: Vec<u32>,
3729    hess_color_reach_off: Vec<usize>,
3730    /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
3731    prelude_dot: Vec<f64>,
3732    /// First-/second-order prelude adjoint accumulators. NOT shared
3733    /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
3734    /// between-colors invariant (`prelude_reverse_directional`'s
3735    /// consume-and-zero contract), while `gradient_summand` zeroes only
3736    /// the slots it is about to use and leaves them dirty afterwards —
3737    /// sharing the buffer would seed a later `eval_h` with a stale
3738    /// Jacobian adjoint.
3739    hess_prelude_adj: Vec<f64>,
3740    prelude_adj_dot: Vec<f64>,
3741    /// Local tangent / second-order adjoint arenas, sized to
3742    /// `tape.max_summand_ops()`.
3743    local_dot: Vec<f64>,
3744    local_adj_dot: Vec<f64>,
3745}
3746
3747/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
3748/// shared-CSE prelude.
3749///
3750/// `eval_g` takes the hybrid path unconditionally because it only needs
3751/// *values*: the prelude is swept once for the whole constraint block and
3752/// the saving is the full op-count ratio. The Jacobian is different. Each
3753/// row needs its own gradient, so only the forward sweep can be shared —
3754/// the reverse sweep still walks each summand's `prelude_reach`
3755/// separately, and it pays a per-op cost the flat tape does not: a nested
3756/// `SummandOp` dispatch and an indirected walk over a reach list instead
3757/// of a straight loop over a contiguous `Vec<TapeOp>`.
3758///
3759/// So the hybrid Jacobian wins only when the shared bodies are large
3760/// enough for the halved forward sweep to outweigh that overhead.
3761/// Measured on chain models at CSE redundancy 40, varying the body size
3762/// (`eval_jac_g`, flat → hybrid):
3763///
3764/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
3765/// |---|---|---|---|---|---|---|---|---|
3766/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
3767///
3768/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
3769/// a model that does not clearly benefit stays on the flat path. For
3770/// reference `robot_a` (#476) measures 4.03×.
3771const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
3772
3773/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
3774/// block through the shared-CSE prelude (issue #557).
3775///
3776/// The Hessian shares **both** second-order sweeps of the prelude, not just
3777/// the forward one: the coloring hands every summand of a color the same
3778/// seed vector, so the prelude forward tangent runs once per color, and —
3779/// because reverse-over-tangent is linear in its adjoint seeds — the
3780/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
3781/// unit-weight prelude reverse sweep per color. That is why its crossover
3782/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
3783/// Jacobian can only share the forward half, and per-row gradients forbid
3784/// batching its reverse sweeps at all.
3785///
3786/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
3787/// bodies), varying the body size — the same protocol as the Jacobian
3788/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
3789/// flat/hybrid pairs per point**, with the observed range, because
3790/// single runs on a shared machine are not reproducible to the precision
3791/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
3792/// single ratio:
3793///
3794/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
3795/// |---|---|---|---|---|---|---|---|---|
3796/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
3797/// | 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 |
3798///
3799/// The gate sits at 3.0: that is the lowest ratio where **every** sample
3800/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
3801/// break-even and individual runs lose. Setting it there costs only a
3802/// marginal forgone gain — below the gate `eval_h` stays on the flat path
3803/// bit-identically, so a gate placed too high is merely conservative while
3804/// one placed too low risks a real regression. For reference `robot_a`
3805/// (#476) measures 4.03×.
3806const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
3807
3808// `Clone` supports the batched-solve path (pounce#126): one parsed
3809// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
3810// the clone is cheap relative to a solve) and each clone gets its own
3811// bound / starting-point overrides via [`NlTnlp::variant`].
3812#[derive(Debug, Clone)]
3813pub struct NlTnlp {
3814    prob: NlProblem,
3815    /// Per-summand objective tapes (one `Tape` per top-level
3816    /// summand after `split_top_sums`).
3817    obj_tapes: Vec<Tape>,
3818    /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
3819    /// one `Tape` per summand of constraint `i`.
3820    con_tapes: Vec<Vec<Tape>>,
3821    /// Constraint-block tape with a **shared** CSE prelude, used by
3822    /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
3823    /// `eval_g` on the per-summand `con_tapes` above.
3824    con_hybrid: Option<ConHybrid>,
3825    /// The degree-≤2 objective and rows, evaluated from their constant
3826    /// matrices instead of from a tape (gh #588, Q4). A row with a form here
3827    /// has an **empty** `con_tapes` entry, so every loop over the tapes
3828    /// naturally contributes nothing for it and only the sites that consult
3829    /// `quad` add it back: `eval_f`, `eval_grad_f`, `eval_g`, `eval_jac_g`,
3830    /// `eval_h`, and `hessian_vector_products` — the last of which the design
3831    /// note's list of five omitted, and which is the one where an omission
3832    /// would be silent. Empty for a model with nothing recognized, in which
3833    /// case everything below behaves bit for bit as it did before this
3834    /// existed.
3835    quad: QuadraticStructure,
3836    /// Which entries of `h_irow`/`h_jcol` some *tape* can contribute to.
3837    /// Empty when `quad` is — the pattern is then all tape. The coloring is
3838    /// built over this subset alone, which is what stops one dense quadratic
3839    /// block from forcing the color count to `n` for the benefit of tapes
3840    /// that no longer exist.
3841    h_tape_mask: Vec<bool>,
3842    /// Lower-triangle Hessian sparsity (row >= col), one entry per
3843    /// structurally nonzero second derivative in the Lagrangian.
3844    h_irow: Vec<i32>,
3845    h_jcol: Vec<i32>,
3846    /// Per-row sorted variable indices for the constraint Jacobian.
3847    jac_cols: Vec<Vec<usize>>,
3848    jac_nnz: usize,
3849    /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
3850    /// is in color `c`, else `0.0`. Each color is a set of
3851    /// variables whose Hessian columns have pairwise-disjoint
3852    /// nonzero rows; one directional H·s product per color
3853    /// recovers all those columns simultaneously. Dense for
3854    /// O(1) lookup in the per-op forward tangent.
3855    seeds: Vec<Vec<f64>>,
3856    /// Per-color decoding table: for each `(row, hess_idx)` entry,
3857    /// scatter `compressed_c[row] -> values[hess_idx]` after the
3858    /// per-color directional product.
3859    decoding: Vec<Vec<ColorWrite>>,
3860    /// For each objective tape: the distinct colors of vars it
3861    /// references. Lets us skip tape × color pairs where the tape
3862    /// has zero overlap with the color's seed.
3863    obj_tape_colors: Vec<Vec<u32>>,
3864    /// Same as `obj_tape_colors` but per constraint × summand.
3865    con_tape_colors: Vec<Vec<Vec<u32>>>,
3866    /// The same work, color-major, for the parallel flat Hessian path.
3867    hess_color_index: HessColorIndex,
3868    /// Offset of each row's run in the Jacobian `values` array, length
3869    /// `m + 1`. Lets a parallel row group take one contiguous slice.
3870    jac_row_off: Vec<u32>,
3871    /// Per-worker scratch for the parallel Jacobian, kept across evaluations:
3872    /// each holds a length-`n` gradient row, too big to allocate per call.
3873    #[cfg(not(target_arch = "wasm32"))]
3874    jac_scratch_pool: Vec<JacScratch>,
3875    /// Color of each variable's Hessian column, `u32::MAX` for a column
3876    /// that needs no pass of its own. Kept so
3877    /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
3878    /// pass in `compressed`.
3879    var_color: Vec<u32>,
3880    /// Columns peeled out of the conflict structure and given singleton
3881    /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
3882    peeled_cols: Vec<u32>,
3883    final_x: Option<Vec<Number>>,
3884    final_obj: Number,
3885    /// Converged constraint multipliers (length `m`, original `.nl` row
3886    /// order, user convention), captured from the same `finalize_solution`
3887    /// call as `final_x`. Kept so a frontend can write the `.sol` dual
3888    /// block without re-deriving it from the algorithm's internal `y_c` /
3889    /// `y_d` split and scaling.
3890    final_lambda: Option<Vec<Number>>,
3891    /// Converged bound multipliers (length `n` each, Ipopt's internal
3892    /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
3893    /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
3894    /// reads for reduced costs.
3895    final_z_l: Option<Vec<Number>>,
3896    final_z_u: Option<Vec<Number>>,
3897    /// Per-row Jacobian accumulator (length n).
3898    scratch_row_grad: Vec<f64>,
3899    /// Scratch buffers for `Tape::hessian_directional` (each sized
3900    /// to `max_tape_n`).
3901    vals_scratch: Vec<f64>,
3902    dot_scratch: Vec<f64>,
3903    adj_scratch: Vec<f64>,
3904    adj_dot_scratch: Vec<f64>,
3905    /// Per-color compressed Hessian-vector results, sized to
3906    /// `prob.n`. Reused across `eval_h` calls but allocated once.
3907    compressed: Vec<Vec<f64>>,
3908    /// Per-direction "carries signal" mask for `hessian_vector_products`,
3909    /// kept here rather than allocated per call. This crate holds an
3910    /// explicit no-per-call-allocation line on the tape sweeps (see
3911    /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
3912    /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
3913    /// Krylov iteration.
3914    hvp_live: Vec<bool>,
3915    /// Model-derived scaling factors (gh #703), computed on demand by
3916    /// [`NlTnlp::enable_curvature_scaling`] and served through
3917    /// [`TNLP::get_scaling_parameters`]. `None` until asked for, which is
3918    /// the state every solve that does not select `curvature-based` stays
3919    /// in — computing them costs a pass over every stored Hessian entry
3920    /// and nothing else reads them.
3921    curvature_scaling: Option<crate::nl_scaling::CurvatureScaling>,
3922}
3923
3924// ---------------------------------------------------------------------
3925// Human-readable equation rendering (`print equation` in the debugger).
3926//
3927// Turns a parsed constraint back into infix text using the model's
3928// variable / constraint names, so the debugger can show the actual
3929// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
3930// bare row index. This is the "print the specific equation, with
3931// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
3932// argue makes equation-oriented model diagnostics actionable.
3933//
3934// The renderer is intentionally separate from the evaluation `Tape`:
3935// tapes are lossy for display (CSEs flattened, externals opaque),
3936// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
3937// ---------------------------------------------------------------------
3938
3939/// Binding strength for parenthesization. Higher binds tighter.
3940const P_ADD: u8 = 10;
3941const P_MUL: u8 = 20;
3942const P_NEG: u8 = 30;
3943const P_POW: u8 = 40;
3944const P_ATOM: u8 = 100;
3945
3946/// Format a numeric literal compactly: integers without a trailing `.0`,
3947/// everything else via the shortest round-tripping `f64` form.
3948fn fmt_num(x: Number) -> String {
3949    if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
3950        format!("{}", x as i64)
3951    } else {
3952        format!("{x}")
3953    }
3954}
3955
3956/// Display label for variable `i`: its `.col` name when present, else
3957/// `x[i]`.
3958fn var_label(i: usize, var_names: &[String]) -> String {
3959    match var_names.get(i) {
3960        Some(s) if !s.is_empty() => s.clone(),
3961        _ => format!("x[{i}]"),
3962    }
3963}
3964
3965/// Precedence of an expression's top operator (for child wrapping).
3966fn expr_prec(e: &Expr) -> u8 {
3967    match e {
3968        Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
3969        Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
3970        Expr::Unary(UnaryOp::Neg, _) => P_NEG,
3971        Expr::Binary(BinOp::Pow, ..) => P_POW,
3972        Expr::Cse(inner) => expr_prec(inner),
3973        // Everything else renders as an atom / `f(...)` form.
3974        _ => P_ATOM,
3975    }
3976}
3977
3978/// Render an expression as infix text, using `var_names` for variable
3979/// labels where available (`x[i]` otherwise).
3980///
3981/// The debugger reaches the renderer through the constraint/objective
3982/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
3983/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
3984/// able to *see* the expression you just built is most of the debugging
3985/// story.
3986///
3987/// `Cse` bodies are inlined at every occurrence, so the output of a
3988/// heavily-shared DAG can be far larger than the DAG itself. Callers
3989/// rendering user-built expressions should bound the input first.
3990pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
3991    render_expr(e, var_names, &[])
3992}
3993
3994/// Render `e`, wrapping in parentheses iff its precedence is looser than
3995/// `min_prec`.
3996fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
3997    let s = render_expr(e, vn, funcs);
3998    if expr_prec(e) < min_prec {
3999        format!("({s})")
4000    } else {
4001        s
4002    }
4003}
4004
4005fn unary_name(op: UnaryOp) -> &'static str {
4006    match op {
4007        UnaryOp::Neg => "-",
4008        UnaryOp::Sqrt => "sqrt",
4009        UnaryOp::Log => "log",
4010        UnaryOp::Exp => "exp",
4011        UnaryOp::Abs => "abs",
4012        UnaryOp::Sin => "sin",
4013        UnaryOp::Cos => "cos",
4014        UnaryOp::Log10 => "log10",
4015        UnaryOp::Tan => "tan",
4016        UnaryOp::Atan => "atan",
4017        UnaryOp::Acos => "acos",
4018        UnaryOp::Sinh => "sinh",
4019        UnaryOp::Cosh => "cosh",
4020        UnaryOp::Tanh => "tanh",
4021        UnaryOp::Asin => "asin",
4022        UnaryOp::Acosh => "acosh",
4023        UnaryOp::Asinh => "asinh",
4024        UnaryOp::Atanh => "atanh",
4025        UnaryOp::Erf => "erf",
4026        // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
4027        // the rendered text is read by humans debugging a model and must not
4028        // imply a sign the op does not have.
4029        UnaryOp::XLogX => "xlogx",
4030    }
4031}
4032
4033fn cmp_sym(op: CmpOp) -> &'static str {
4034    match op {
4035        CmpOp::Lt => "<",
4036        CmpOp::Le => "<=",
4037        CmpOp::Eq => "==",
4038        CmpOp::Ge => ">=",
4039        CmpOp::Gt => ">",
4040        CmpOp::Ne => "!=",
4041    }
4042}
4043
4044/// Append an additive sub-term with a tidy sign: a rendered term that
4045/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
4046/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
4047/// the term is itself a sum. The first term is emitted verbatim.
4048fn push_additive(out: &mut String, rendered: &str, first: bool) {
4049    if first {
4050        out.push_str(rendered);
4051    } else if let Some(rest) = rendered.strip_prefix('-') {
4052        out.push_str(" - ");
4053        out.push_str(rest);
4054    } else {
4055        out.push_str(" + ");
4056        out.push_str(rendered);
4057    }
4058}
4059
4060/// Render an [`Expr`] DAG to infix text using model names.
4061fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
4062    match e {
4063        Expr::Const(c) => fmt_num(*c),
4064        Expr::Var(i) => var_label(*i, vn),
4065        Expr::Binary(op, l, r) => match op {
4066            BinOp::Add => {
4067                let mut s = render_prec(l, P_ADD, vn, funcs);
4068                push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
4069                s
4070            }
4071            // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
4072            BinOp::Sub => format!(
4073                "{} - {}",
4074                render_prec(l, P_ADD, vn, funcs),
4075                render_prec(r, P_ADD + 1, vn, funcs)
4076            ),
4077            BinOp::Mul => format!(
4078                "{}*{}",
4079                render_prec(l, P_MUL, vn, funcs),
4080                render_prec(r, P_MUL, vn, funcs)
4081            ),
4082            BinOp::Div => format!(
4083                "{}/{}",
4084                render_prec(l, P_MUL, vn, funcs),
4085                render_prec(r, P_MUL + 1, vn, funcs)
4086            ),
4087            // Pow is right-associative: tighten the left operand instead.
4088            BinOp::Pow => format!(
4089                "{}^{}",
4090                render_prec(l, P_POW + 1, vn, funcs),
4091                render_prec(r, P_POW, vn, funcs)
4092            ),
4093            BinOp::Atan2 => format!(
4094                "atan2({}, {})",
4095                render_expr(l, vn, funcs),
4096                render_expr(r, vn, funcs)
4097            ),
4098            BinOp::CEntropy => format!(
4099                "centropy({}, {})",
4100                render_expr(l, vn, funcs),
4101                render_expr(r, vn, funcs)
4102            ),
4103        },
4104        Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
4105        Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
4106        Expr::Sum(xs) => {
4107            if xs.is_empty() {
4108                "0".to_string()
4109            } else {
4110                let mut s = String::new();
4111                for (k, x) in xs.iter().enumerate() {
4112                    push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
4113                }
4114                s
4115            }
4116        }
4117        Expr::Cse(inner) => render_expr(inner, vn, funcs),
4118        Expr::Funcall { id, args } => {
4119            let name = funcs
4120                .iter()
4121                .find(|f| f.id == *id)
4122                .map(|f| f.name.clone())
4123                .unwrap_or_else(|| format!("extern#{id}"));
4124            let parts: Vec<String> = args
4125                .iter()
4126                .map(|a| match a {
4127                    FuncallArg::Real(x) => render_expr(x, vn, funcs),
4128                    FuncallArg::Str(s) => format!("{s:?}"),
4129                })
4130                .collect();
4131            format!("{name}({})", parts.join(", "))
4132        }
4133        Expr::Compare(op, a, b) => format!(
4134            "({} {} {})",
4135            render_expr(a, vn, funcs),
4136            cmp_sym(*op),
4137            render_expr(b, vn, funcs)
4138        ),
4139        Expr::And(a, b) => format!(
4140            "({} && {})",
4141            render_expr(a, vn, funcs),
4142            render_expr(b, vn, funcs)
4143        ),
4144        Expr::Or(a, b) => format!(
4145            "({} || {})",
4146            render_expr(a, vn, funcs),
4147            render_expr(b, vn, funcs)
4148        ),
4149        Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
4150        Expr::Cond { cond, then_, else_ } => format!(
4151            "if({}, {}, {})",
4152            render_expr(cond, vn, funcs),
4153            render_expr(then_, vn, funcs),
4154            render_expr(else_, vn, funcs)
4155        ),
4156        Expr::MinList(xs) => format!(
4157            "min({})",
4158            xs.iter()
4159                .map(|x| render_expr(x, vn, funcs))
4160                .collect::<Vec<_>>()
4161                .join(", ")
4162        ),
4163        Expr::MaxList(xs) => format!(
4164            "max({})",
4165            xs.iter()
4166                .map(|x| render_expr(x, vn, funcs))
4167                .collect::<Vec<_>>()
4168                .join(", ")
4169        ),
4170    }
4171}
4172
4173/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
4174/// `a + -2*b`). Returns `""` when there are no linear terms.
4175fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
4176    let mut out = String::new();
4177    // The `.nl` linear part carries an entry for every variable in the
4178    // row's Jacobian, including a 0 coefficient for variables that appear
4179    // only *nonlinearly* (they're rendered in the nonlinear part). Skip
4180    // those zeros so the equation reads as written, not as a sparsity map.
4181    let mut first = true;
4182    for (var, coef) in linear {
4183        if *coef == 0.0 {
4184            continue;
4185        }
4186        let neg = *coef < 0.0;
4187        let mag = coef.abs();
4188        let term = if mag == 1.0 {
4189            var_label(*var, vn)
4190        } else {
4191            format!("{}*{}", fmt_num(mag), var_label(*var, vn))
4192        };
4193        if first {
4194            if neg {
4195                out.push('-');
4196            }
4197            out.push_str(&term);
4198            first = false;
4199        } else {
4200            out.push_str(if neg { " - " } else { " + " });
4201            out.push_str(&term);
4202        }
4203    }
4204    out
4205}
4206
4207/// Render the constraint body (linear + nonlinear parts combined).
4208fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
4209    let mut s = render_linear(linear, &prob.var_names);
4210    let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
4211    if !nl_is_zero {
4212        let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
4213        if s.is_empty() {
4214            s = nl;
4215        } else {
4216            push_additive(&mut s, &nl, false);
4217        }
4218    }
4219    if s.is_empty() {
4220        s = "0".to_string();
4221    }
4222    s
4223}
4224
4225/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
4226/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
4227/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
4228pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
4229    // Diagnostic path: a recognized body has no tree, so this is one of
4230    // the places that pays to rebuild one. It runs once per rendered row.
4231    let body = render_body(&prob.con_linear[k], &prob.con_expr(k), prob);
4232    let lo = prob.g_l[k];
4233    let hi = prob.g_u[k];
4234    const INF: Number = 1.0e19;
4235    let has_lo = lo > -INF;
4236    let has_hi = hi < INF;
4237    match (has_lo, has_hi) {
4238        (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
4239        (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
4240        (true, false) => format!("{body} >= {}", fmt_num(lo)),
4241        (false, true) => format!("{body} <= {}", fmt_num(hi)),
4242        (false, false) => format!("{body}  (free)"),
4243    }
4244}
4245
4246/// Render every constraint to text, index-aligned to `g` (original `.nl`
4247/// row order). Used to build the debugger's static equation book.
4248pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
4249    (0..prob.m)
4250        .map(|k| render_constraint_equation(prob, k))
4251        .collect()
4252}
4253
4254/// Structural sparsity of the constraint Jacobian as flat 0-based
4255/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
4256/// structurally depends on — the union of its linear support and the
4257/// `Var(i)` indices appearing anywhere in its nonlinear tree
4258/// ([`collect_vars`]). Sorted and deduplicated within each row.
4259///
4260/// This is the input to the debugger's Dulmage–Mendelsohn
4261/// structural-rank check (`diagnose`), which names the over-determined
4262/// (candidate redundant / inconsistent) equations and under-determined
4263/// variables. Naming the dependent rows — rather than reporting
4264/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
4265/// equation-oriented model debugging. See
4266/// <https://doi.org/10.69997/sct.147875>.
4267pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
4268    let mut irow: Vec<Index> = Vec::new();
4269    let mut jcol: Vec<Index> = Vec::new();
4270    let mut support: BTreeSet<usize> = BTreeSet::new();
4271    for k in 0..prob.m {
4272        support.clear();
4273        for &(j, _coef) in &prob.con_linear[k] {
4274            support.insert(j);
4275        }
4276        prob.con_nonlinear[k].collect_vars(&mut support);
4277        for &j in &support {
4278            irow.push(k as Index);
4279            jcol.push(j as Index);
4280        }
4281    }
4282    (irow, jcol)
4283}
4284
4285/// Flatten an additive expression tree into independent summand
4286/// expressions, each of which becomes its own Hessian tape.
4287///
4288/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
4289/// each summand tape once *per color the summand touches*, so the
4290/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
4291/// small (few variables → few colors) is what makes a sparse Hessian
4292/// cheap. A single fused tape spanning all `n` variables, by
4293/// contrast, is walked once per color → `O(n · tape_len)`, which on a
4294/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
4295/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
4296///
4297/// We therefore descend through the *affine* envelope of the sum, not
4298/// just `+`/`Sum`:
4299///
4300///   * `Neg(x)`            → split `x`, negate each summand
4301///   * `Sub(l, r)`         → split `l`; split `r`, negate each summand
4302///   * `c * x` / `x * c`   → split `x`, scale each summand by `c`
4303///   * `x / c`             → split `x`, scale each summand by `1/c`
4304///
4305/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
4306/// least-squares / max-entropy shapes) still decomposes to its leaf
4307/// terms instead of collapsing into one giant tape. The carried
4308/// `factor` is materialised onto each leaf only when it differs from
4309/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
4310/// is unchanged and the per-summand op count grows by at most one.
4311fn split_top_sums(expr: &Expr) -> Vec<Expr> {
4312    let mut out = Vec::new();
4313    fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4314        if factor == 1.0 {
4315            out.push(e.clone());
4316        } else if factor == -1.0 {
4317            out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
4318        } else {
4319            out.push(Expr::Binary(
4320                BinOp::Mul,
4321                Box::new(Expr::Const(factor)),
4322                Box::new(e.clone()),
4323            ));
4324        }
4325    }
4326    fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4327        match e {
4328            Expr::Sum(terms) => {
4329                for t in terms {
4330                    go(t, factor, out);
4331                }
4332            }
4333            Expr::Binary(BinOp::Add, l, r) => {
4334                go(l, factor, out);
4335                go(r, factor, out);
4336            }
4337            Expr::Binary(BinOp::Sub, l, r) => {
4338                go(l, factor, out);
4339                go(r, -factor, out);
4340            }
4341            Expr::Unary(UnaryOp::Neg, x) => {
4342                go(x, -factor, out);
4343            }
4344            // Affine scaling: distribute a constant coefficient into
4345            // the summands so a leading `c·(Σ …)` still splits.
4346            Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
4347                (Expr::Const(c), _) => go(r, factor * c, out),
4348                (_, Expr::Const(c)) => go(l, factor * c, out),
4349                _ => push_leaf(e, factor, out),
4350            },
4351            Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
4352                Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
4353                _ => push_leaf(e, factor, out),
4354            },
4355            _ => push_leaf(e, factor, out),
4356        }
4357    }
4358    go(expr, 1.0, &mut out);
4359    if out.is_empty() {
4360        out.push(Expr::Const(0.0));
4361    }
4362    out
4363}
4364
4365/// Greedy column coloring of a symmetric sparsity pattern stored
4366/// as lower-triangle pairs.
4367///
4368/// Builds the column-intersection graph: columns `c1` and `c2` are
4369/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
4370/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
4371/// satisfies the direct-recovery condition for symmetric Hessians
4372/// (Coleman-Moré): for any color, the columns it contains have
4373/// pairwise disjoint row supports, so a single H·s product
4374/// recovers them all unambiguously.
4375///
4376/// Returns `(var_color, n_colors)` where `var_color[k]` is the
4377/// color assigned to variable `k`, or `u32::MAX` for variables
4378/// not in any Hessian pair (they contribute nothing and don't
4379/// need a color).
4380/// A column is treated as **dense** — and peeled out of the coloring —
4381/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
4382/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
4383/// factor keeps uniformly-dense Hessians (where every column looks like
4384/// every other) on the plain coloring path, and the absolute floor stops
4385/// a very sparse average from declaring a 10-entry column "dense".
4386const DENSE_COL_FACTOR: usize = 16;
4387const DENSE_COL_MIN: usize = 32;
4388/// Largest relative error a peeled column may inflict on the smallest
4389/// entry recovered from its pass before
4390/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
4391///
4392/// The ratio is a worst-case bound — the pass's roundoff floor over the
4393/// smallest entry read out of it — and it is a *loose* one, by an amount
4394/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
4395/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
4396/// measures 4e-16. The cut is therefore calibrated on the corpus, not
4397/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
4398/// models that peel anything, the highest bound on a column that recovers
4399/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
4400/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
4401/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
4402///
4403/// 1e-8 sits below both, which deliberately buys correctness with speed:
4404/// the errors are asymmetric, since a false veto costs one model a coloring
4405/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
4406/// while a missed veto costs a solve its certificate. Five of the 56 take a
4407/// veto they do not need; none takes a wrong answer. Raising this constant
4408/// to buy those five back would put the cut inside a 2.8x window measured
4409/// on two model families, which is not a margin worth trading a certificate
4410/// for.
4411const PEEL_MAX_REL_ERR: f64 = 1e-8;
4412/// Hard cap on how many columns get peeled, applied on top of the
4413/// pay-for-itself rule in [`select_peeled_cols`].
4414const MAX_PEELED_COLS: usize = 256;
4415
4416/// Lower bound on the color count that results from peeling `peeled`.
4417///
4418/// Peeling costs one color per peeled column. On what remains, any row
4419/// with `d` surviving entries makes those `d` columns pairwise
4420/// conflicting, so the greedy walk needs at least `d` colors. Hence
4421/// `|peeled| + max surviving row degree` is a lower bound on the total,
4422/// computable in one O(nnz) pass — no coloring required.
4423///
4424/// The bound is what makes the choice decidable at all: the plain
4425/// coloring cannot be run as a comparison baseline, because on the
4426/// one-dense-row case the plain walk is itself O(n^2) — precisely the
4427/// blowup peeling exists to avoid.
4428fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
4429    let mut deg = vec![0usize; n];
4430    for &(i, j) in lower_pairs {
4431        if peeled[i] || peeled[j] {
4432            continue;
4433        }
4434        deg[j] += 1;
4435        if i != j {
4436            deg[i] += 1;
4437        }
4438    }
4439    let n_peeled = peeled.iter().filter(|&&p| p).count();
4440    n_peeled + deg.iter().copied().max().unwrap_or(0)
4441}
4442
4443/// Choose which of the candidate dense columns to actually peel.
4444///
4445/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
4446/// top `k` candidates by degree, over a doubling ladder of `k` up to
4447/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
4448/// so peeling has to earn its colors.
4449///
4450/// **Why not just truncate an over-long candidate list.** Cutting the
4451/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
4452/// columns that miss the cut stay in the conflict structure, so the base
4453/// color count is untouched and the singleton colors are pure addition.
4454/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
4455/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
4456/// regression in exactly the quantity peeling exists to reduce. The
4457/// bound above sees it: peeling `k` of several thousand equal-degree
4458/// columns leaves the surviving max degree unchanged, so every `k > 0`
4459/// scores strictly worse than peeling nothing.
4460///
4461/// **Why not a simple degree rule.** "Peel only columns denser than some
4462/// fraction of `n`" would refuse three rows of degree 10,000 in a
4463/// 200,000-variable model, where peeling three columns takes the
4464/// coloring from >= 10,000 down to a handful. The win depends on what
4465/// peeling leaves behind, not on the peeled column's degree alone.
4466fn select_peeled_cols(
4467    n: usize,
4468    lower_pairs: &[(usize, usize)],
4469    deg: &[usize],
4470    mut candidates: Vec<usize>,
4471) -> Vec<usize> {
4472    if candidates.is_empty() {
4473        return candidates;
4474    }
4475    // Worst offenders first; they remove the most conflict per color spent.
4476    candidates.sort_unstable_by(|&a, &b| deg[b].cmp(&deg[a]).then(a.cmp(&b)));
4477    candidates.truncate(MAX_PEELED_COLS);
4478
4479    let mut mask = vec![false; n];
4480    let mut best_k = 0usize;
4481    // Peeling nothing: the bound is just the largest row degree.
4482    let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
4483
4484    // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
4485    // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
4486    // mask only ever gains entries, so each step just marks the new slice.
4487    let mut marked = 0usize;
4488    let mut k = 1usize;
4489    loop {
4490        let k_now = k.min(candidates.len());
4491        for &j in &candidates[marked..k_now] {
4492            mask[j] = true;
4493        }
4494        marked = k_now;
4495        let bound = peel_color_bound(n, lower_pairs, &mask);
4496        if bound < best_bound {
4497            best_bound = bound;
4498            best_k = k_now;
4499        }
4500        if k_now == candidates.len() {
4501            break;
4502        }
4503        k *= 2;
4504    }
4505
4506    candidates.truncate(best_k);
4507    candidates
4508}
4509
4510/// Greedy distance-1 coloring of the Hessian's column-intersection
4511/// graph, with **dense columns peeled out**.
4512///
4513/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
4514/// marks a column that needs no directional product of its own: either
4515/// it has no Hessian entries at all, or every entry it has is recovered
4516/// from a peeled column's pass (see below).
4517///
4518/// # Why peeling
4519///
4520/// The plain coloring rule is "two columns may share a color when they
4521/// have no common nonzero row". A single **dense row** — one variable
4522/// multiplying a sum over all the others, a total-cost variable, a
4523/// shared design parameter — puts a nonzero in *every* column at that
4524/// row, so every pair of columns conflicts and the greedy walk hands out
4525/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
4526/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
4527/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
4528/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
4529///
4530/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
4531/// own singleton color: one directional product with seed `e_d` recovers
4532/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
4533/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
4534/// at row `j` of that same pass. So row `d` no longer constrains any
4535/// other column's color and is dropped from the conflict structure, and
4536/// the remaining columns colour on their genuine sparsity. On the
4537/// one-dense-row case above this takes `n_colors` from `n` to a handful.
4538///
4539/// # What peeling costs, and `peel_veto`
4540///
4541/// Recovering `H[d, j]` from column `d`'s pass is exact in real
4542/// arithmetic but not in floating point: the pass is accumulated at the
4543/// scale of the whole dense column, so every entry read out of it carries
4544/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
4545/// ordinary path — column `j`'s own pass — would have left a floor of
4546/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
4547/// dense column is well scaled, and they do on every peel-firing model in
4548/// the benchmark corpus but one. Where a peeled column spans a wide
4549/// dynamic range, though, that floor swamps its small entries: a column
4550/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
4551///
4552/// Structure cannot see this — it is a property of the values — so
4553/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
4554/// and passes the offenders back here in `peel_veto`, which bars them
4555/// from being peeled again. A vetoed column is colored normally, its row
4556/// returns to the conflict structure, and its entries go back to the
4557/// accurate path.
4558fn greedy_hessian_coloring(
4559    n: usize,
4560    lower_pairs: &[(usize, usize)],
4561    peel_veto: &[bool],
4562) -> (Vec<u32>, usize, Vec<bool>) {
4563    if n == 0 {
4564        return (Vec::new(), 0, Vec::new());
4565    }
4566
4567    // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
4568    // i >= j contributes row i to column j and row j to column i; a
4569    // diagonal contributes once.
4570    let mut deg = vec![0usize; n];
4571    for &(i, j) in lower_pairs {
4572        deg[j] += 1;
4573        if i != j {
4574            deg[i] += 1;
4575        }
4576    }
4577
4578    // Pick the dense columns to peel.
4579    let total: usize = deg.iter().sum();
4580    let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
4581    let mut peeled = vec![false; n];
4582    let candidates: Vec<usize> = (0..n)
4583        .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
4584        .collect();
4585    let dense = select_peeled_cols(n, lower_pairs, &deg, candidates);
4586    for &j in &dense {
4587        peeled[j] = true;
4588    }
4589
4590    // Conflict structure over the *non-peeled* columns only. Pairs with
4591    // a peeled endpoint are recovered from that endpoint's own pass, so
4592    // they neither need a color nor constrain one.
4593    let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
4594    let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
4595    for &(i, j) in lower_pairs {
4596        if peeled[i] || peeled[j] {
4597            continue;
4598        }
4599        col_rows[j].push(i as u32);
4600        row_cols[i].push(j as u32);
4601        if i != j {
4602            col_rows[i].push(j as u32);
4603            row_cols[j].push(i as u32);
4604        }
4605    }
4606
4607    let mut var_color = vec![u32::MAX; n];
4608    let mut forbidden = vec![u32::MAX; n + 1];
4609    let mut n_colors: u32 = 0;
4610
4611    for j in 0..n {
4612        // Peeled columns are colored below; a column with no surviving
4613        // Hessian entries needs no color at all.
4614        if peeled[j] || col_rows[j].is_empty() {
4615            continue;
4616        }
4617        // Mark colors used by any column sharing a row with `j`.
4618        // Row-of-col -> col-in-row visit pattern collects all
4619        // distance-1 neighbors in the column-intersection graph.
4620        for &r in &col_rows[j] {
4621            for &c in &row_cols[r as usize] {
4622                if c as usize == j {
4623                    continue;
4624                }
4625                let cc = var_color[c as usize];
4626                if cc != u32::MAX {
4627                    forbidden[cc as usize] = j as u32;
4628                }
4629            }
4630        }
4631        // First color not stamped with `j as u32`.
4632        let mut chosen: u32 = 0;
4633        while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
4634            chosen += 1;
4635        }
4636        var_color[j] = chosen;
4637        if chosen + 1 > n_colors {
4638            n_colors = chosen + 1;
4639        }
4640    }
4641
4642    // One singleton color per peeled column, appended after the shared
4643    // ones so the non-peeled numbering is untouched.
4644    for &j in &dense {
4645        var_color[j] = n_colors;
4646        n_colors += 1;
4647    }
4648
4649    (var_color, n_colors as usize, peeled)
4650}
4651
4652/// Everything downstream of the Hessian coloring: seed vectors, the
4653/// per-color decode table, the per-tape color sets, and the shared-CSE
4654/// per-color summand / prelude-reach tables.
4655///
4656/// Split out of [`NlTnlp::new`] because
4657/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
4658/// time, with a peel veto in hand, once it has seen real Hessian values.
4659fn build_color_tables(
4660    n: usize,
4661    m: usize,
4662    lower_pairs: &[(usize, usize)],
4663    tape_mask: &[bool],
4664    peel_veto: &[bool],
4665    obj_tapes: &[Tape],
4666    con_tapes: &[Vec<Tape>],
4667    con_hybrid: Option<&mut ConHybrid>,
4668) -> ColorTables {
4669    // Hessian column coloring. The chromatic number of the
4670    // column-intersection graph bounds how many directional
4671    // Hessian-vector products we need per `eval_h` call —
4672    // typically O(stencil) for PDE-mesh problems.
4673    //
4674    // Only the entries a *tape* can write take part. `tape_mask` is empty
4675    // when every entry is a tape entry (no quadratic structure), which is
4676    // the pre-#588 behaviour bit for bit; otherwise the quadratic forms'
4677    // entries are excluded, since they are scattered from their stored
4678    // values and never read out of a directional product. A variable that
4679    // appears only in quadratic rows then has no color at all
4680    // (`u32::MAX`) — `greedy_hessian_coloring` already declines to color a
4681    // column with no surviving entries.
4682    let colored_pairs: Vec<(usize, usize)>;
4683    let color_input: &[(usize, usize)] = if tape_mask.is_empty() {
4684        lower_pairs
4685    } else {
4686        colored_pairs = lower_pairs
4687            .iter()
4688            .zip(tape_mask)
4689            .filter_map(|(p, &t)| t.then_some(*p))
4690            .collect();
4691        &colored_pairs
4692    };
4693    let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, color_input, peel_veto);
4694
4695    // Per-color seed vectors (dense for O(1) Var lookup in
4696    // `Tape::hessian_directional`).
4697    let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
4698    for (k, &c) in var_color.iter().enumerate() {
4699        if c != u32::MAX {
4700            seeds[c as usize][k] = 1.0;
4701        }
4702    }
4703
4704    // Per-color decoding table. For each lower-tri pair (i, j)
4705    // with i >= j, the entry belongs to column j's color: after
4706    // computing compressed_{c_j} = (H · s_{c_j}), the value at
4707    // row i is exactly H[i, j] (coloring guarantees no other
4708    // column in c_j has a nonzero at row i).
4709    // Built straight from `lower_pairs`, which is sorted, so each
4710    // color's table is in ascending `hess_idx` order and the decode
4711    // scatter walks `values` forward instead of hopping (the old
4712    // build drained a `HashMap`, whose iteration order is arbitrary).
4713    let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
4714    for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
4715        // An entry no tape writes has no pass to be decoded out of; the
4716        // quadratic scatter already put its value there.
4717        if !tape_mask.is_empty() && !tape_mask[idx] {
4718            continue;
4719        }
4720        // Which directional product recovers H[i, j]? Column `j`'s,
4721        // read at row `i` — except when `i` is a peeled column and
4722        // `j` is not: then `j` may have no color of its own, and the
4723        // entry is already in column `i`'s pass at row `j`, since
4724        // H[i, j] == H[j, i].
4725        let (c, row) = if peeled[i] && !peeled[j] {
4726            (var_color[i], j)
4727        } else {
4728            (var_color[j], i)
4729        };
4730        debug_assert!(
4731            c != u32::MAX,
4732            "Hessian pair ({i}, {j}) at index {idx} has no color"
4733        );
4734        decoding[c as usize].push(ColorWrite {
4735            row: row as u32,
4736            hess_idx: idx as u32,
4737        });
4738    }
4739
4740    // Per-tape distinct color set: for each tape, the colors
4741    // its variables fall into. `eval_h` loops over only these
4742    // (tape, color) pairs instead of n_tapes × n_colors.
4743    let tape_colors = |t: &Tape| -> Vec<u32> {
4744        let mut s: Vec<u32> = t
4745            .variables()
4746            .into_iter()
4747            .map(|v| var_color[v])
4748            .filter(|&c| c != u32::MAX)
4749            .collect();
4750        s.sort_unstable();
4751        s.dedup();
4752        s
4753    };
4754    let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
4755    let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
4756        .iter()
4757        .map(|row| row.iter().map(tape_colors).collect())
4758        .collect();
4759
4760    // Shared-CSE Hessian tables (issue #557): the per-color summand
4761    // lists, row lookup, and packed forward-value arena `eval_h`'s
4762    // hybrid path walks. Built whenever the hybrid tape is — not just
4763    // above the gate — so flipping `use_for_hess` on (tests, the
4764    // force env var) needs no extra setup; the cost is one f64 per
4765    // local op plus small index tables.
4766    if let Some(h) = con_hybrid {
4767        let n_sum = h.tape.n_summands();
4768        let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
4769        let mut acc = 0usize;
4770        for s in &h.tape.summands {
4771            local_off.push(acc);
4772            acc += s.ops.len();
4773        }
4774        local_off.push(acc);
4775        h.local_vals_all = vec![0.0; acc];
4776        h.local_off = local_off;
4777
4778        let mut summand_row = vec![0u32; n_sum];
4779        for i in 0..m {
4780            for si in h.row_start[i]..h.row_start[i + 1] {
4781                summand_row[si] = i as u32;
4782            }
4783        }
4784        h.summand_row = summand_row;
4785
4786        // A summand's variable set (`all_vars`) equals its flat tape's,
4787        // so this is `con_tape_colors` inverted to color-major order —
4788        // the loop `eval_h` actually runs.
4789        let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
4790        for (si, s) in h.tape.summands.iter().enumerate() {
4791            let mut cs: Vec<u32> = s
4792                .all_vars
4793                .iter()
4794                .map(|&v| var_color[v])
4795                .filter(|&c| c != u32::MAX)
4796                .collect();
4797            cs.sort_unstable();
4798            cs.dedup();
4799            for c in cs {
4800                by_color[c as usize].push(si as u32);
4801            }
4802        }
4803        // Per-color prelude reach: the union of `prelude_reach` over the
4804        // color's summands, ascending. A union of operand-closed
4805        // ascending sets is itself operand-closed and ascending, which is
4806        // exactly what the two prelude sweeps require. Deduped with an
4807        // epoch-tagged buffer so the build costs
4808        // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
4809        // it saves — rather than `n_colors × |prelude|`.
4810        let np = h.tape.n_prelude_ops();
4811        let mut seen: Vec<u32> = vec![0; np];
4812        let mut epoch: u32 = 0;
4813        let mut reach: Vec<u32> = Vec::new();
4814        let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
4815        for list in &by_color {
4816            reach_off.push(reach.len());
4817            epoch += 1;
4818            let start = reach.len();
4819            for &si in list {
4820                for &p in &h.tape.summands[si as usize].prelude_reach {
4821                    if seen[p] != epoch {
4822                        seen[p] = epoch;
4823                        reach.push(p as u32);
4824                    }
4825                }
4826            }
4827            reach[start..].sort_unstable();
4828        }
4829        reach_off.push(reach.len());
4830        h.hess_color_reach = reach;
4831        h.hess_color_reach_off = reach_off;
4832
4833        h.hess_color_summands = by_color;
4834        h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
4835        h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
4836        h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
4837        h.local_dot = vec![0.0; h.tape.max_summand_ops()];
4838        h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
4839    }
4840
4841    ColorTables {
4842        var_color,
4843        n_colors,
4844        peeled_cols: peeled
4845            .iter()
4846            .enumerate()
4847            .filter(|(_, p)| **p)
4848            .map(|(j, _)| j as u32)
4849            .collect(),
4850        seeds,
4851        decoding,
4852        hess_color_index: HessColorIndex::build(n_colors, &con_tape_colors),
4853        obj_tape_colors,
4854        con_tape_colors,
4855    }
4856}
4857
4858/// The color-dependent half of an [`NlTnlp`], as built by
4859/// [`build_color_tables`].
4860struct ColorTables {
4861    var_color: Vec<u32>,
4862    n_colors: usize,
4863    /// Columns given a singleton color and dropped from the conflict
4864    /// structure. Small by construction (`MAX_PEELED_COLS`).
4865    peeled_cols: Vec<u32>,
4866    seeds: Vec<Vec<f64>>,
4867    decoding: Vec<Vec<ColorWrite>>,
4868    obj_tape_colors: Vec<Vec<u32>>,
4869    con_tape_colors: Vec<Vec<Vec<u32>>>,
4870    /// `con_tape_colors` inverted to color-major, CSR-packed: the (row, tape)
4871    /// pairs whose tape touches each color, and the objective tapes likewise.
4872    ///
4873    /// The flat `eval_h` walks tapes and, inside each, the colors it touches,
4874    /// so several rows accumulate into one `compressed[c]` — fine serially,
4875    /// unshareable across threads. Walking color-major makes each color's
4876    /// accumulator private to its task, at the cost of forwarding a tape once
4877    /// per color it touches instead of once (measured at 2.9 colors per tape
4878    /// on an AC-SCOPF model, against a directional sweep that costs several
4879    /// forwards, so ~20% more serial work for 28-way parallelism).
4880    ///
4881    /// Within a color the pairs stay in ascending (row, tape) order, which is
4882    /// the order the tape-major loop contributes them in, so the two walks
4883    /// accumulate identical sums in identical order — bit-for-bit, not merely
4884    /// within tolerance. `parallel_matches_serial_bit_for_bit` pins that.
4885    hess_color_index: HessColorIndex,
4886}
4887
4888/// Color-major (row, tape) work lists for the flat Hessian path. See
4889/// [`ColorTables::hess_color_index`].
4890#[derive(Debug, Default, Clone)]
4891pub(crate) struct HessColorIndex {
4892    /// Constraint (row, tape) pairs per color, CSR. The objective's tapes are
4893    /// deliberately absent: `eval_h` accumulates them into `compressed`
4894    /// *before* the constraint block and keeps doing so serially, which is
4895    /// what makes the parallel walk's per-color ordering identical to the
4896    /// serial one — objective first, then rows ascending.
4897    con_off: Vec<u32>,
4898    con_row: Vec<u32>,
4899    con_tape: Vec<u32>,
4900}
4901
4902impl HessColorIndex {
4903    fn build(n_colors: usize, con_tape_colors: &[Vec<Vec<u32>>]) -> Self {
4904        // Rows ascending, then tapes ascending: the tape-major loop's order.
4905        let mut con: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_colors];
4906        for (k, row) in con_tape_colors.iter().enumerate() {
4907            for (ti, colors) in row.iter().enumerate() {
4908                for &c in colors {
4909                    con[c as usize].push((k as u32, ti as u32));
4910                }
4911            }
4912        }
4913        let mut out = HessColorIndex {
4914            con_off: Vec::with_capacity(n_colors + 1),
4915            ..Default::default()
4916        };
4917        out.con_off.push(0);
4918        for list in &con {
4919            for &(k, ti) in list {
4920                out.con_row.push(k);
4921                out.con_tape.push(ti);
4922            }
4923            out.con_off.push(out.con_row.len() as u32);
4924        }
4925        out
4926    }
4927
4928    /// The (row, tape) pairs of color `c`, as two parallel slices.
4929    fn con_pairs(&self, c: usize) -> (&[u32], &[u32]) {
4930        let (a, b) = (self.con_off[c] as usize, self.con_off[c + 1] as usize);
4931        (&self.con_row[a..b], &self.con_tape[a..b])
4932    }
4933}
4934
4935impl NlTnlp {
4936    /// Build the TNLP, panicking if AMPL external-function resolution fails.
4937    ///
4938    /// Kept for the many infallible call sites (tests) that operate on `.nl`
4939    /// models known to need no external libraries. Surfaces that can be
4940    /// handed an arbitrary user model — the CLI solve and `verify` paths, the
4941    /// Python `read_nl` binding — must call [`Self::try_new`] instead so a
4942    /// missing `$AMPLFUNC` library becomes an ordinary error rather than a
4943    /// panic (gh#1069: the CLI exited 101 with a backtrace hint).
4944    pub fn new(prob: NlProblem) -> Self {
4945        Self::try_new(prob)
4946            .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
4947    }
4948
4949    /// Build the TNLP, returning an error (instead of panicking) when AMPL
4950    /// imported functions named by the model can't be resolved — e.g.
4951    /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
4952    /// referenced function id isn't registered by any loaded library.
4953    pub fn try_new(prob: NlProblem) -> Result<Self, String> {
4954        // `POUNCE_DBG_NO_QUAD=1` forces the AD tape for every row and the
4955        // objective — the A/B reference for the constant-structure path,
4956        // mirroring `POUNCE_DBG_NO_HYBRID`. Diagnostic only: it is how the
4957        // fast path's derivatives are checked against the ones they replace
4958        // on a real model, and how a suspected fast-path bug is bisected
4959        // against a reference that computes the same numbers a different way.
4960        Self::try_new_with_quadratic(prob, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
4961    }
4962
4963    /// [`Self::try_new`] with the constant-structure fast path (gh #588, Q4)
4964    /// explicitly on or off.
4965    ///
4966    /// The env var `try_new` reads is process-global, which is exactly wrong
4967    /// for the differential test that has to build the *same* model both ways
4968    /// and compare the derivatives — so the knob is a parameter here and the
4969    /// env var only chooses its default.
4970    pub fn try_new_with_quadratic(prob: NlProblem, use_quadratic: bool) -> Result<Self, String> {
4971        // Resolve any AMPL imported (external) functions. Walk every
4972        // nonlinear expression to collect the funcall ids actually
4973        // referenced; load the libraries named in $AMPLFUNC and bind
4974        // each id to its (library, registered-name) pair so the tape
4975        // builder can emit live `TapeOp::Funcall` ops.
4976        let mut referenced: BTreeSet<usize> = BTreeSet::new();
4977        // Only trees can carry a `Funcall`: the recognizer refuses one, so
4978        // a recognized body provably contains none.
4979        for body in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
4980            if let Some(e) = body.tree() {
4981                super::nl_external::collect_funcall_ids(e, &mut referenced);
4982            }
4983        }
4984        let resolver = if referenced.is_empty() {
4985            super::nl_external::ExternalResolver::default()
4986        } else {
4987            super::nl_external::ExternalResolver::build_for_problem(
4988                &prob.imported_funcs,
4989                &referenced,
4990            )?
4991        };
4992
4993        // Recognize the degree-≤2 objective and rows *before* anything is
4994        // taped, because the win is not in evaluating the tape faster — it
4995        // is in never building it. On `qcqp500-3c` the ten quadratic rows
4996        // are 2.32 M monomials, one `Tape` each; recognizing them first
4997        // means those 2.32 M tapes, their color lists and their per-tape
4998        // sparsity sets are never allocated.
4999        //
5000        // A row that is trivially zero is left alone: it has no nonlinear
5001        // part to replace, and routing it through a (necessarily empty)
5002        // quadratic form would touch every model in the corpus to save
5003        // nothing.
5004        let mut quad = QuadraticStructure::new(prob.m);
5005        if use_quadratic {
5006            // `is_expanded_quadratic` is the accuracy gate, not an
5007            // optimization: it admits only forms whose read-out repeats the
5008            // additions the `.nl` writer already wrote. See its docs — and
5009            // note it is checked *before* recognition, so a factored form
5010            // costs one cheap structural walk rather than a full expansion.
5011            // Recognition is the parser's job now (gh #588, Q5) for the
5012            // bodies it could reach; `admitted_quad_form` reads its answer
5013            // back, and still walks a tree for the bodies that kept one —
5014            // a `from_expressions` model, a factored row rewound by the
5015            // parser, or any body at all under `POUNCE_DBG_NO_QUAD`.
5016            //
5017            // A form that gate refuses is offered the *factored* read-out
5018            // before it falls back to a tape (gh #673): the gate is a
5019            // verdict on the expansion, not on the body, and a sum of
5020            // squared residuals keeps its structure by keeping its squares.
5021            // `push_body_form` owns that order.
5022            if let Some(f) = push_body_form(&mut quad, &prob.obj_nonlinear) {
5023                quad.assign_objective(f);
5024            }
5025            for k in 0..prob.m {
5026                if let Some(f) = push_body_form(&mut quad, &prob.con_nonlinear[k]) {
5027                    quad.assign_row(k, f);
5028                }
5029            }
5030        }
5031
5032        // Flatten objective and each constraint into independent
5033        // summands. Each summand becomes its own `Tape` (CSE bodies
5034        // are deduplicated within a tape via Rc identity in
5035        // `Tape::build`; bodies shared across summands are
5036        // duplicated, which we accept as a simplicity tradeoff).
5037        let obj_tapes: Vec<Tape> = if quad.objective_form().is_some() {
5038            Vec::new()
5039        } else {
5040            split_top_sums(&prob.obj_expr())
5041                .iter()
5042                .map(|e| Tape::build_with_externals(e, &resolver))
5043                .collect()
5044        };
5045
5046        let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
5047        let mut con_roots: Vec<Expr> = Vec::new();
5048        let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
5049        for k in 0..prob.m {
5050            row_start.push(con_roots.len());
5051            // A row with a quadratic form contributes no tape and no
5052            // hybrid-tape root, so `con_tapes[k]` is empty and its
5053            // `row_start` range is empty too.
5054            if quad.row_form(k).is_some() {
5055                con_tapes.push(Vec::new());
5056                continue;
5057            }
5058            let summands = split_top_sums(&prob.con_expr(k));
5059            con_tapes.push(
5060                summands
5061                    .iter()
5062                    .map(|e| Tape::build_with_externals(e, &resolver))
5063                    .collect(),
5064            );
5065            // Move (not clone) the split summands into the root list: their
5066            // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
5067            // sharing on `Arc` pointer identity, so the roots must be the
5068            // same allocations the parse produced.
5069            con_roots.extend(summands);
5070        }
5071        row_start.push(con_roots.len());
5072
5073        // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
5074        // building only when some CSE body is actually referenced from two
5075        // or more summands — otherwise the prelude comes out empty and the
5076        // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
5077        // gates the opcodes `build_multi` would panic on.
5078        // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
5079        // whole constraint block. Diagnostic only: it is how the
5080        // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
5081        // on a real model, and how a suspected hybrid-path bug is bisected
5082        // against a reference that computes the same derivatives a
5083        // different way.
5084        let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
5085            None
5086        } else if hybrid_supported(&con_roots) {
5087            let tape = HybridTape::build_multi(&con_roots);
5088            (tape.n_prelude_ops() > 0).then(|| {
5089                let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5090                let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
5091                // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
5092                // regardless of the op ratio. Diagnostic only — it is how the
5093                // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
5094                // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
5095                // models that sit below the gate.
5096                let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
5097                ConHybrid {
5098                    prelude_vals: vec![0.0; tape.n_prelude_ops()],
5099                    local_vals: vec![0.0; tape.max_summand_ops()],
5100                    local_adj: vec![0.0; tape.max_summand_ops()],
5101                    prelude_adj: vec![0.0; tape.n_prelude_ops()],
5102                    use_for_jac: flat_ops as f64
5103                        >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
5104                    use_for_hess: force_hess
5105                        || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
5106                    local_vals_all: Vec::new(),
5107                    local_off: Vec::new(),
5108                    summand_row: Vec::new(),
5109                    hess_color_summands: Vec::new(),
5110                    hess_color_reach: Vec::new(),
5111                    hess_color_reach_off: Vec::new(),
5112                    prelude_dot: Vec::new(),
5113                    hess_prelude_adj: Vec::new(),
5114                    prelude_adj_dot: Vec::new(),
5115                    local_dot: Vec::new(),
5116                    local_adj_dot: Vec::new(),
5117                    row_start,
5118                    tape,
5119                }
5120            })
5121        } else {
5122            None
5123        };
5124        drop(con_roots);
5125
5126        // Hessian-of-Lagrangian sparsity: union of each tape's own
5127        // structural Hessian sparsity.
5128        // One flat `Vec`, sorted and deduped once, rather than a global
5129        // `BTreeSet` fed a single insert at a time across every summand
5130        // in the model: sort+dedup walks contiguous memory where the tree
5131        // chased a pointer and allocated a node per entry. The result is
5132        // exactly the ascending order the rest of this function wants, so
5133        // it doubles as `lower_pairs` instead of being copied into it.
5134        let mut tape_pairs: Vec<(usize, usize)> = Vec::new();
5135        for t in &obj_tapes {
5136            tape_pairs.extend(t.hessian_sparsity());
5137        }
5138        for row in &con_tapes {
5139            for t in row {
5140                tape_pairs.extend(t.hessian_sparsity());
5141            }
5142        }
5143        tape_pairs.sort_unstable();
5144        tape_pairs.dedup();
5145
5146        // The assembled pattern is the tapes' union *plus* the quadratic
5147        // forms'. They are kept apart because the coloring only ever needs
5148        // the tape half — a quadratic block's entries are scattered
5149        // directly, not recovered from a directional product — and coloring
5150        // a dense quadratic block would put the color count back at `n` to
5151        // pay for products nobody runs (`qcqp500-3c`: 500 colors → 0).
5152        let mut lower_pairs = tape_pairs.clone();
5153        if !quad.is_empty() {
5154            for f in quad
5155                .objective_form()
5156                .into_iter()
5157                .chain((0..prob.m).filter_map(|i| quad.row_form(i)))
5158            {
5159                lower_pairs.extend(
5160                    quad.lower_triangle(f)
5161                        .map(|(r, c, _)| (r as usize, c as usize)),
5162                );
5163            }
5164            lower_pairs.sort_unstable();
5165            lower_pairs.dedup();
5166        }
5167
5168        // Which assembled entries a tape can write. Both sides are sorted
5169        // and `tape_pairs ⊆ lower_pairs`, so this is a merge walk, and the
5170        // mask is left empty (meaning "all of them") when nothing was
5171        // recognized.
5172        let h_tape_mask: Vec<bool> = if quad.is_empty() {
5173            Vec::new()
5174        } else {
5175            let mut mask = vec![false; lower_pairs.len()];
5176            let mut t = 0usize;
5177            for (idx, pair) in lower_pairs.iter().enumerate() {
5178                if t < tape_pairs.len() && tape_pairs[t] == *pair {
5179                    mask[idx] = true;
5180                    t += 1;
5181                }
5182            }
5183            debug_assert_eq!(t, tape_pairs.len(), "every tape pair is in the union");
5184            mask
5185        };
5186        drop(tape_pairs);
5187
5188        let mut h_irow = Vec::with_capacity(lower_pairs.len());
5189        let mut h_jcol = Vec::with_capacity(lower_pairs.len());
5190        for &(hi, lo) in &lower_pairs {
5191            h_irow.push(hi as i32);
5192            h_jcol.push(lo as i32);
5193        }
5194
5195        // Bind each form's lower-triangle entries to their index in the
5196        // assembled pattern. `lower_pairs` is sorted, so the lookup is a
5197        // binary search — done once here, never again on the hot path.
5198        if !quad.is_empty() {
5199            quad.bind_slots(|r, c| {
5200                lower_pairs
5201                    .binary_search(&(r as usize, c as usize))
5202                    .unwrap_or_else(|_| {
5203                        unreachable!("quadratic entry ({r}, {c}) missing from the union pattern")
5204                    })
5205            });
5206        }
5207
5208        // Hessian column coloring and everything keyed off it. The
5209        // chromatic number of the column-intersection graph bounds how
5210        // many directional Hessian-vector products we need per `eval_h`
5211        // call — typically O(stencil) for PDE-mesh problems.
5212        let ColorTables {
5213            var_color,
5214            n_colors,
5215            peeled_cols,
5216            seeds,
5217            decoding,
5218            obj_tape_colors,
5219            con_tape_colors,
5220            hess_color_index,
5221        } = build_color_tables(
5222            prob.n,
5223            prob.m,
5224            &lower_pairs,
5225            &h_tape_mask,
5226            &vec![false; prob.n],
5227            &obj_tapes,
5228            &con_tapes,
5229            con_hybrid.as_mut(),
5230        );
5231
5232        // Per-row Jacobian sparsity = union of tape vars plus
5233        // linear-segment vars.
5234        let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
5235        let mut jac_nnz = 0;
5236        for (i, row_tapes) in con_tapes.iter().enumerate() {
5237            let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
5238            for t in row_tapes {
5239                cols.extend(t.variables());
5240            }
5241            // A quadratic row has no tape, so its Jacobian support comes
5242            // from the form: `Hx + a` is nonzero exactly on the union of the
5243            // Hessian's rows and the folded linear part.
5244            if let Some(f) = quad.row_form(i) {
5245                cols.extend(quad.gradient_support(f).iter().map(|&v| v as usize));
5246            }
5247            cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
5248            cols.sort_unstable();
5249            cols.dedup();
5250            cols.shrink_to_fit();
5251            jac_nnz += cols.len();
5252            jac_cols.push(cols);
5253        }
5254
5255        let mut max_tape_n: usize = 0;
5256        for t in &obj_tapes {
5257            max_tape_n = max_tape_n.max(t.ops.len());
5258        }
5259        for row in &con_tapes {
5260            for t in row {
5261                max_tape_n = max_tape_n.max(t.ops.len());
5262            }
5263        }
5264
5265        if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
5266            // Mean number of Hessian colors a single constraint tape touches.
5267            // It is the cost multiplier of visiting the work color-major
5268            // rather than tape-major: each (tape, color) pair needs the
5269            // tape's forward values, so a color-major walk forwards each tape
5270            // this many times instead of once.
5271            let (mut tape_color_pairs, mut n_live_tapes) = (0usize, 0usize);
5272            for row in &con_tape_colors {
5273                for cols in row {
5274                    if !cols.is_empty() {
5275                        tape_color_pairs += cols.len();
5276                        n_live_tapes += 1;
5277                    }
5278                }
5279            }
5280            let colors_per_tape = tape_color_pairs as f64 / n_live_tapes.max(1) as f64;
5281            let n_obj = obj_tapes.len();
5282            let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
5283            let total = n_obj + n_con;
5284            let mut sum_ops: usize = 0;
5285            for t in &obj_tapes {
5286                sum_ops += t.ops.len();
5287            }
5288            for row in &con_tapes {
5289                for t in row {
5290                    sum_ops += t.ops.len();
5291                }
5292            }
5293            let t = total.max(1);
5294            let nnz_h = h_irow.len();
5295            let avg_decode =
5296                decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
5297            eprintln!(
5298                "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
5299                 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
5300                 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h} \
5301                 colors_per_tape={colors_per_tape:.2}",
5302                sum_ops as f64 / t as f64,
5303            );
5304            // Flat vs shared-CSE op counts for the constraint block. The
5305            // ratio is how much duplicated CSE work `eval_g`'s hybrid path
5306            // avoids, and the ceiling on what routing the Jacobian /
5307            // Hessian through the same prelude could save.
5308            match &con_hybrid {
5309                Some(h) => {
5310                    let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5311                    let prelude = h.tape.n_prelude_ops();
5312                    let local = h.tape.total_local_ops();
5313                    eprintln!(
5314                        "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
5315                         local_ops={local} shared_total={} flat/shared={:.2}x \
5316                         jac_gate={} hess_gate={}",
5317                        prelude + local,
5318                        flat as f64 / (prelude + local).max(1) as f64,
5319                        if h.use_for_jac { "on" } else { "off" },
5320                        if h.use_for_hess { "on" } else { "off" },
5321                    );
5322                }
5323                None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
5324            }
5325            // What the constant-structure path took off the tape builder.
5326            // `forms` counts the objective too, which is why it can exceed
5327            // the row count by one.
5328            let quad_rows = (0..prob.m).filter(|&i| quad.row_form(i).is_some()).count();
5329            // How many of those forms the *parser* produced, i.e. how many
5330            // never cost an `Expr` (gh #588, Q5). The rest were recognized
5331            // from a tree that was built: a `from_expressions` model, or a
5332            // body the parser rewound because it was not degree 2.
5333            let parsed = std::iter::once(&prob.obj_nonlinear)
5334                .chain(prob.con_nonlinear.iter())
5335                .filter(|b| b.quad().is_some())
5336                .count();
5337            eprintln!(
5338                "[quad stats] forms={} (parse-time {parsed}) rows={quad_rows}/{} obj={} \
5339                 stored_h_entries={} colored_pairs={}/{}",
5340                quad.len(),
5341                prob.m,
5342                if quad.objective_form().is_some() {
5343                    "quadratic"
5344                } else {
5345                    "taped"
5346                },
5347                quad.stored_entries(),
5348                if h_tape_mask.is_empty() {
5349                    lower_pairs.len()
5350                } else {
5351                    h_tape_mask.iter().filter(|&&t| t).count()
5352                },
5353                lower_pairs.len(),
5354            );
5355        }
5356
5357        let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
5358
5359        let mut me = Self {
5360            prob,
5361            obj_tapes,
5362            con_tapes,
5363            con_hybrid,
5364            quad,
5365            h_tape_mask,
5366            h_irow,
5367            h_jcol,
5368            jac_cols,
5369            jac_nnz,
5370            seeds,
5371            decoding,
5372            obj_tape_colors,
5373            con_tape_colors,
5374            hess_color_index,
5375            jac_row_off: Vec::new(),
5376            #[cfg(not(target_arch = "wasm32"))]
5377            jac_scratch_pool: Vec::new(),
5378            var_color,
5379            peeled_cols,
5380            final_x: None,
5381            final_obj: 0.0,
5382            final_lambda: None,
5383            final_z_l: None,
5384            final_z_u: None,
5385            scratch_row_grad: Vec::new(),
5386            vals_scratch: vec![0.0; max_tape_n],
5387            dot_scratch: vec![0.0; max_tape_n],
5388            adj_scratch: vec![0.0; max_tape_n],
5389            adj_dot_scratch: vec![0.0; max_tape_n],
5390            compressed,
5391            hvp_live: Vec::new(),
5392            curvature_scaling: None,
5393        };
5394        me.veto_ill_conditioned_peels();
5395        Ok(me)
5396    }
5397
5398    /// Un-peel any dense column whose own pass is too ill-scaled to read
5399    /// its small entries out of, and re-color if that changes anything.
5400    ///
5401    /// `greedy_hessian_coloring` picks the peel set from structure alone,
5402    /// which is the right call for the memory and the color count but
5403    /// blind to the one thing that can go wrong: an entry recovered from
5404    /// column `d`'s pass inherits that pass's roundoff floor, about
5405    /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
5406    /// well-scaled dense column loses nothing to that — the recovered
5407    /// entries come back bit-identical to the uncompressed reference on
5408    /// every peel-firing model in the benchmark corpus but one. A column
5409    /// spanning many orders of magnitude, though, hands its small entries
5410    /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
5411    /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
5412    /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
5413    /// survives that, but the multipliers come out of the KKT system the
5414    /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
5415    /// the solve stalls short of `Optimal` on a problem it used to
5416    /// certify.
5417    ///
5418    /// Nothing structural distinguishes the two cases, so measure it: one
5419    /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
5420    /// column's exact pass sitting in `compressed`, and a column whose
5421    /// worst recovered entry would lose more than
5422    /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
5423    /// one `eval_h` — at the peeled color count, so cheap — and only for
5424    /// the ~3% of models that peel anything at all.
5425    fn veto_ill_conditioned_peels(&mut self) {
5426        if self.peeled_cols.is_empty() {
5427            return;
5428        }
5429
5430        let mut values = vec![0.0; self.h_irow.len()];
5431        let lambda = vec![1.0; self.prob.m];
5432        let x0 = self.prob.x0.clone();
5433        if !self.eval_h(
5434            Some(&x0),
5435            true,
5436            1.0,
5437            Some(&lambda),
5438            true,
5439            SparsityRequest::Values {
5440                values: &mut values,
5441            },
5442        ) {
5443            return;
5444        }
5445
5446        let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
5447        // A column whose whole pass is negligible against the Hessian as a
5448        // whole cannot move the KKT system no matter how badly its own
5449        // entries are rounded, and columns that are identically zero at
5450        // `x0` would otherwise veto on a ratio of pure noise.
5451        let h_scale = self
5452            .compressed
5453            .iter()
5454            .flat_map(|c| c.iter())
5455            .fold(0.0f64, |a, &v| a.max(v.abs()));
5456        let mut peel_veto = vec![false; self.prob.n];
5457        let mut vetoed = 0usize;
5458        for &d in &self.peeled_cols {
5459            let c = self.var_color[d as usize];
5460            if c == u32::MAX {
5461                continue;
5462            }
5463            let pass = &self.compressed[c as usize];
5464            // The floor the pass was accumulated at, against the smallest
5465            // entry actually read out of it.
5466            let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
5467            // Entries at or below the pass's own roundoff floor carry no
5468            // information to lose: `eps * scale` is the noise the pass was
5469            // accumulated at, so such an entry is already indistinguishable
5470            // from zero whether or not the column is peeled. Including them
5471            // would divide by that noise -- `orthregd` holds entries of
5472            // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
5473            // 4e-16 against an uncompressed reference.
5474            let noise = f64::EPSILON * scale;
5475            let smallest = self.decoding[c as usize]
5476                .iter()
5477                .map(|w| pass[w.row as usize].abs())
5478                .filter(|v| *v > noise)
5479                .fold(f64::INFINITY, f64::min);
5480            if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
5481                continue;
5482            }
5483            if scale <= h_scale * f64::EPSILON {
5484                continue;
5485            }
5486            let rel_err = f64::EPSILON * scale / smallest;
5487            if dbg {
5488                eprintln!(
5489                    "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
5490                     min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
5491                    if rel_err > PEEL_MAX_REL_ERR {
5492                        " VETO"
5493                    } else {
5494                        ""
5495                    }
5496                );
5497            }
5498            if rel_err > PEEL_MAX_REL_ERR {
5499                peel_veto[d as usize] = true;
5500                vetoed += 1;
5501            }
5502        }
5503
5504        if vetoed == 0 {
5505            return;
5506        }
5507        if dbg {
5508            eprintln!(
5509                "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
5510                self.peeled_cols.len()
5511            );
5512        }
5513        self.recolor(&peel_veto);
5514    }
5515
5516    /// Rebuild the coloring and everything keyed off it, barring
5517    /// `peel_veto` from the peel set.
5518    fn recolor(&mut self, peel_veto: &[bool]) {
5519        let lower_pairs: Vec<(usize, usize)> = self
5520            .h_irow
5521            .iter()
5522            .zip(&self.h_jcol)
5523            .map(|(&i, &j)| (i as usize, j as usize))
5524            .collect();
5525        let ColorTables {
5526            var_color,
5527            n_colors,
5528            peeled_cols,
5529            seeds,
5530            decoding,
5531            obj_tape_colors,
5532            con_tape_colors,
5533            hess_color_index,
5534        } = build_color_tables(
5535            self.prob.n,
5536            self.prob.m,
5537            &lower_pairs,
5538            &self.h_tape_mask,
5539            peel_veto,
5540            &self.obj_tapes,
5541            &self.con_tapes,
5542            self.con_hybrid.as_mut(),
5543        );
5544        self.var_color = var_color;
5545        self.peeled_cols = peeled_cols;
5546        self.seeds = seeds;
5547        self.decoding = decoding;
5548        self.obj_tape_colors = obj_tape_colors;
5549        self.con_tape_colors = con_tape_colors;
5550        // Recoloring invalidates the color-major index: its entries name
5551        // colors, so a stale one would send a row's contribution to whatever
5552        // color now holds that slot. Refreshed here, not rebuilt lazily,
5553        // because nothing downstream can detect the staleness.
5554        self.hess_color_index = hess_color_index;
5555        self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
5556    }
5557
5558    pub fn final_x(&self) -> Option<&[Number]> {
5559        self.final_x.as_deref()
5560    }
5561
5562    pub fn final_obj(&self) -> Number {
5563        self.final_obj
5564    }
5565
5566    /// Converged constraint multipliers from the last solve, in original
5567    /// `.nl` row order. `None` before a solve finishes. See
5568    /// [`Self::final_x`] for the primal counterpart.
5569    pub fn final_lambda(&self) -> Option<&[Number]> {
5570        self.final_lambda.as_deref()
5571    }
5572
5573    /// Converged lower / upper bound multipliers from the last solve, in
5574    /// original `.nl` variable order and Ipopt's internal convention (both
5575    /// `>= 0`). `None` before a solve finishes.
5576    pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
5577        Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
5578    }
5579
5580    /// The parsed problem this TNLP evaluates (bounds, starting point,
5581    /// names, suffixes). Read-only; per-instance overrides go through
5582    /// [`Self::variant`].
5583    pub fn problem(&self) -> &NlProblem {
5584        &self.prob
5585    }
5586
5587    /// Opt this model in to **curvature-based** scaling (gh #703): compute
5588    /// the per-variable and per-row factors of
5589    /// [`crate::nl_scaling::curvature_scaling`] and serve them from
5590    /// [`TNLP::get_scaling_parameters`], so `nlp_scaling_method` reaches
5591    /// them through the channel it already has for user factors.
5592    ///
5593    /// Returns `false` when the model is not one the scheme is defined for
5594    /// — some row or the objective is not degree ≤ 2, so no constant `Qᵢ`
5595    /// exists. The caller must surface that as an error rather than solving
5596    /// unscaled: an accepted scaling option that is then quietly not applied
5597    /// is exactly the gh #483 failure.
5598    ///
5599    /// Costs one pass over every stored Hessian entry plus `RUIZ_SWEEPS`
5600    /// passes over the magnitude surrogates, and nothing at all for a solve
5601    /// that never calls it.
5602    pub fn enable_curvature_scaling(&mut self) -> bool {
5603        match crate::nl_scaling::curvature_scaling(&self.prob) {
5604            Some(sc) => {
5605                self.curvature_scaling = Some(sc);
5606                true
5607            }
5608            None => false,
5609        }
5610    }
5611
5612    /// Whether [`Self::enable_curvature_scaling`] has been called and
5613    /// succeeded.
5614    pub fn curvature_scaling_enabled(&self) -> bool {
5615        self.curvature_scaling.is_some()
5616    }
5617
5618    /// Whether the enabled curvature scaling actually read any curvature —
5619    /// see [`crate::nl_scaling::CurvatureScaling::quadratic`]. `false` when
5620    /// scaling is not enabled, and `false` for a degree-≤2 model whose every
5621    /// `Q` is empty (an LP), where the scheme degenerates to plain Ruiz
5622    /// equilibration of `[A b]`.
5623    pub fn curvature_scaling_read_curvature(&self) -> bool {
5624        self.curvature_scaling
5625            .as_ref()
5626            .is_some_and(|sc| sc.quadratic)
5627    }
5628
5629    /// Is constraint row `i` evaluated from a constant quadratic form
5630    /// rather than from an AD tape (gh #588, Q4)?
5631    ///
5632    /// Structural, so it answers before any evaluation. Exposed for the
5633    /// differential test, which has to know which models exercise the fast
5634    /// path at all, and for `POUNCE_DBG_TAPE_STATS`.
5635    pub fn quadratic_row(&self, i: usize) -> bool {
5636        self.quad.row_form(i).is_some()
5637    }
5638
5639    /// As [`Self::quadratic_row`], for the objective.
5640    pub fn quadratic_objective(&self) -> bool {
5641        self.quad.objective_form().is_some()
5642    }
5643
5644    /// Structural set of variables that appear in *some* nonlinear part —
5645    /// the union of `collect_vars` over the objective and every constraint
5646    /// row. Shared by `get_variables_linearity` and the nonlinear-variable
5647    /// list below so the two can never disagree.
5648    fn nonlinear_var_set(&self) -> BTreeSet<usize> {
5649        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
5650        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
5651        for row in &self.prob.con_nonlinear {
5652            row.collect_vars(&mut nonlinear);
5653        }
5654        nonlinear
5655    }
5656
5657    /// The nonlinear-variable list published through the TNLP contract
5658    /// (`get_number_of_nonlinear_variables` / `get_list_of_nonlinear_variables`),
5659    /// ascending, in the C index style [`NlTnlp`] reports.
5660    ///
5661    /// The contract's asymmetry decides how this is computed. A consumer
5662    /// treats every variable *absent* from the list as linear — Ipopt's
5663    /// limited-memory Hessian skips the quasi-Newton update in that
5664    /// subspace — so naming too few variables is a wrong answer, while
5665    /// naming too many merely costs work. Hence:
5666    ///
5667    /// * When the `.nl` header says **every** variable is nonlinear, publish
5668    ///   that and skip the walk. This is the maximally conservative answer,
5669    ///   so it is sound whatever the header's provenance, and it is the
5670    ///   common case for the models this matters on (`eigena2`: 110 of 110).
5671    /// * Otherwise walk the trees. The header's prefix
5672    ///   (`nlvc + nlvo − nlvb` — see [`NlCounts`]) would also be an O(1)
5673    ///   answer, but it would be one that *trusts* the writer to have
5674    ///   ordered the variables as the format requires, and the walk is
5675    ///   already paid once per solve by `get_variables_linearity`. The
5676    ///   walk is also the only option for a model built through
5677    ///   [`NlProblem::from_expressions`], which has no header at all.
5678    ///
5679    /// The two disagree only in the safe direction, which
5680    /// `crates/pounce-cli/tests/nl_header_counts.rs` asserts over the
5681    /// fixture corpus: the walked set always sits inside the header's
5682    /// prefix, because `parse_nl_text` folds constant `C` bodies away
5683    /// (`gh #492`) and AMPL's own census predates that fold.
5684    fn nonlinear_variables(&self) -> Vec<Index> {
5685        if let Some(c) = self.prob.nl_counts
5686            && c.nonlinear_vars() >= self.prob.n
5687        {
5688            return (0..self.prob.n as Index).collect();
5689        }
5690        self.nonlinear_var_set()
5691            .into_iter()
5692            .map(|i| i as Index)
5693            .collect()
5694    }
5695
5696    /// Mutable access to that same problem, for a caller that owns this
5697    /// TNLP outright.
5698    ///
5699    /// The tapes were built from the expressions in [`Self::problem`] and
5700    /// are not rebuilt, so editing an expression here does **not** change
5701    /// what this TNLP evaluates. It exists for teardown: the Python
5702    /// binding takes the expression trees out through here so a deeply
5703    /// nested one is dropped on a stack chosen for it rather than
5704    /// recursively on whatever thread collected the object (pounce#472).
5705    pub fn problem_mut(&mut self) -> &mut NlProblem {
5706        &mut self.prob
5707    }
5708
5709    /// Hessian-vector product of the Lagrangian:
5710    /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
5711    ///
5712    /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
5713    /// [`Tape::hessian_directional`] pass *per color* and then decodes the
5714    /// compressed columns into the sparse lower triangle; here the seed is
5715    /// the caller's `v` directly, so it is a single forward-over-reverse
5716    /// pass per tape — O(tape ops), independent of `n` and of the coloring's
5717    /// chromatic number. That is what makes it usable on models where
5718    /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
5719    /// truncated-CG step only ever needs `∇²L · v`.
5720    ///
5721    /// Sign convention matches `eval_h` and the rest of this evaluator: a
5722    /// `maximize` model's objective is negated so the returned operator is
5723    /// the one that minimizing solves. `lambda` is `None` for the objective
5724    /// block alone.
5725    ///
5726    /// `out` is overwritten (not accumulated into). Errors on any length
5727    /// mismatch rather than panicking, since the Python binding hands this
5728    /// arbitrary user arrays.
5729    pub fn hessian_vector_product(
5730        &mut self,
5731        x: &[Number],
5732        v: &[Number],
5733        obj_factor: Number,
5734        lambda: Option<&[Number]>,
5735        out: &mut [Number],
5736    ) -> Result<(), String> {
5737        self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
5738    }
5739
5740    /// Block form of [`Self::hessian_vector_product`]: `k` directions at
5741    /// once, `out[:, c] = ∇²L · v[:, c]`.
5742    ///
5743    /// `v` and `out` are `n × k` in **column-major** order — direction `c`
5744    /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
5745    ///
5746    /// Worth having as its own entry point rather than a loop over the
5747    /// single-vector call: the forward sweep depends only on `x`, so a block
5748    /// runs it *once per tape* and reuses `vals` across all `k` directions,
5749    /// where `k` separate calls would redo it `k` times. Only the
5750    /// forward-tangent + reverse-over-tangent passes are per-direction. That
5751    /// is the shape a block-Krylov solve, a directional-derivative probe, or
5752    /// a densify-the-Hessian loop wants.
5753    ///
5754    /// An all-zero direction is skipped, so passing a sparse block whose
5755    /// columns are mostly empty costs only the columns that carry signal.
5756    /// (The sparsity that dominates is the model's own: each tape touches
5757    /// only its own variables, and `hessian_directional` is O(tape ops), not
5758    /// O(n).)
5759    pub fn hessian_vector_products(
5760        &mut self,
5761        x: &[Number],
5762        v: &[Number],
5763        k: usize,
5764        obj_factor: Number,
5765        lambda: Option<&[Number]>,
5766        out: &mut [Number],
5767    ) -> Result<(), String> {
5768        let (n, m) = (self.prob.n, self.prob.m);
5769        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5770            if got == want {
5771                Ok(())
5772            } else {
5773                Err(format!(
5774                    "hessian_vector_product: {name} has length {got}, expected {want}"
5775                ))
5776            }
5777        };
5778        check("x", x.len(), n)?;
5779        check("v", v.len(), n * k)?;
5780        check("out", out.len(), n * k)?;
5781        if let Some(lam) = lambda {
5782            check("lambda", lam.len(), m)?;
5783        }
5784
5785        out.fill(0.0);
5786        if k == 0 || n == 0 {
5787            return Ok(());
5788        }
5789
5790        // Which directions carry signal. Computed once, not per (tape,
5791        // direction) pair — with many small summand tapes the scan would
5792        // otherwise dominate the work it is meant to save. Reuses the
5793        // persistent mask so a Krylov loop allocates nothing per iteration.
5794        self.hvp_live.clear();
5795        self.hvp_live
5796            .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
5797        if !self.hvp_live.iter().any(|&l| l) {
5798            return Ok(());
5799        }
5800
5801        let obj_seed = if self.prob.minimize {
5802            obj_factor
5803        } else {
5804            -obj_factor
5805        };
5806        // The constant blocks. `H · v` for a quadratic form is a matvec
5807        // against stored values, so unlike a tape it needs no forward sweep
5808        // and no `x` at all. Easy to forget — a matrix-free solve that
5809        // silently dropped the quadratic part of `∇²L` would still converge,
5810        // just to a different point by a different route, which is the
5811        // failure mode this series is most exposed to.
5812        if !self.quad.is_empty() {
5813            if obj_seed != 0.0 {
5814                if let Some(f) = self.quad.objective_form() {
5815                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5816                        if self.hvp_live[c] {
5817                            self.quad.add_hessian_vector(
5818                                f,
5819                                &v[c * n..(c + 1) * n],
5820                                obj_seed,
5821                                out_col,
5822                            );
5823                        }
5824                    }
5825                }
5826            }
5827            if let Some(lam) = lambda {
5828                for (i, &w) in lam.iter().enumerate() {
5829                    if w == 0.0 {
5830                        continue;
5831                    }
5832                    let Some(f) = self.quad.row_form(i) else {
5833                        continue;
5834                    };
5835                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5836                        if self.hvp_live[c] {
5837                            self.quad
5838                                .add_hessian_vector(f, &v[c * n..(c + 1) * n], w, out_col);
5839                        }
5840                    }
5841                }
5842            }
5843        }
5844
5845        if obj_seed != 0.0 {
5846            for t in &self.obj_tapes {
5847                if t.ops.is_empty() {
5848                    continue;
5849                }
5850                // Once per tape, not once per direction — the whole point
5851                // of the block form.
5852                t.forward_into(x, &mut self.vals_scratch);
5853                for (c, out_col) in out.chunks_mut(n).enumerate() {
5854                    if !self.hvp_live[c] {
5855                        continue;
5856                    }
5857                    t.hessian_directional(
5858                        &self.vals_scratch,
5859                        &v[c * n..(c + 1) * n],
5860                        obj_seed,
5861                        out_col,
5862                        &mut self.dot_scratch,
5863                        &mut self.adj_scratch,
5864                        &mut self.adj_dot_scratch,
5865                    );
5866                }
5867            }
5868        }
5869
5870        if let Some(lam) = lambda {
5871            // `lam.len() == m` was checked above, so `con_tapes[k]` is in
5872            // range for every k.
5873            for (i, &w) in lam.iter().enumerate() {
5874                if w == 0.0 {
5875                    continue;
5876                }
5877                for t in &self.con_tapes[i] {
5878                    if t.ops.is_empty() {
5879                        continue;
5880                    }
5881                    t.forward_into(x, &mut self.vals_scratch);
5882                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5883                        if !self.hvp_live[c] {
5884                            continue;
5885                        }
5886                        t.hessian_directional(
5887                            &self.vals_scratch,
5888                            &v[c * n..(c + 1) * n],
5889                            w,
5890                            out_col,
5891                            &mut self.dot_scratch,
5892                            &mut self.adj_scratch,
5893                            &mut self.adj_dot_scratch,
5894                        );
5895                    }
5896                }
5897            }
5898        }
5899
5900        Ok(())
5901    }
5902
5903    /// Clone this TNLP with per-instance overrides applied — the
5904    /// "one structure, many bound / starting-point variations" case of
5905    /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
5906    /// coloring are reused via `Clone` (they depend only on the model
5907    /// structure, which a variation cannot change); only the values in
5908    /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
5909    /// are replaced. Any stale `final_x` from a previous solve of
5910    /// `self` is cleared on the clone.
5911    ///
5912    /// Errors when an override's length does not match the model
5913    /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
5914    pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
5915        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5916            if got == want {
5917                Ok(())
5918            } else {
5919                Err(format!(
5920                    "NlVariation.{name} has length {got}, expected {want}"
5921                ))
5922            }
5923        };
5924        let mut out = self.clone();
5925        out.final_x = None;
5926        out.final_obj = 0.0;
5927        out.final_lambda = None;
5928        out.final_z_l = None;
5929        out.final_z_u = None;
5930        if let Some(x0) = &v.x0 {
5931            check("x0", x0.len(), self.prob.n)?;
5932            out.prob.x0.clone_from(x0);
5933        }
5934        if let Some(x_l) = &v.x_l {
5935            check("x_l", x_l.len(), self.prob.n)?;
5936            out.prob.x_l.clone_from(x_l);
5937        }
5938        if let Some(x_u) = &v.x_u {
5939            check("x_u", x_u.len(), self.prob.n)?;
5940            out.prob.x_u.clone_from(x_u);
5941        }
5942        if let Some(g_l) = &v.g_l {
5943            check("g_l", g_l.len(), self.prob.m)?;
5944            out.prob.g_l.clone_from(g_l);
5945        }
5946        if let Some(g_u) = &v.g_u {
5947            check("g_u", g_u.len(), self.prob.m)?;
5948            out.prob.g_u.clone_from(g_u);
5949        }
5950        Ok(out)
5951    }
5952
5953    /// Build one [`NlTnlp`] per variation, sharing this instance's
5954    /// structure (see [`Self::variant`]). Returns instances in input
5955    /// order; errors on the first length-mismatched variation.
5956    pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
5957        vs.iter().map(|v| self.variant(v)).collect()
5958    }
5959}
5960
5961/// Per-instance overrides for building a family of related NLP
5962/// instances from one parsed `.nl` model (pounce#126): same structure
5963/// and tapes, different starting point and/or bounds — parametric
5964/// sweeps, multi-start, or branch-and-bound node relaxations where
5965/// each node only tightens variable bounds. `None` keeps the base
5966/// model's value.
5967#[derive(Debug, Clone, Default)]
5968pub struct NlVariation {
5969    pub x0: Option<Vec<Number>>,
5970    pub x_l: Option<Vec<Number>>,
5971    pub x_u: Option<Vec<Number>>,
5972    pub g_l: Option<Vec<Number>>,
5973    pub g_u: Option<Vec<Number>>,
5974}
5975
5976impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
5977    /// Per-`.nl`-row constraint expression tape, with the linear
5978    /// part folded in. Returns `None` for constraints that contribute
5979    /// neither a nonlinear expression nor any linear coefficients
5980    /// (so FBBT skips them — there's nothing to tighten).
5981    fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
5982        if i >= self.prob.con_nonlinear.len() {
5983            return None;
5984        }
5985        let nonlinear = self.prob.con_expr(i);
5986        let linear = self
5987            .prob
5988            .con_linear
5989            .get(i)
5990            .map(|v| v.as_slice())
5991            .unwrap_or(&[]);
5992        // FBBT needs the tree, so a recognized body is rebuilt here. It is
5993        // rebuilt once per row per presolve pass and dropped again, so the
5994        // DAG never all exists at once — which is the property the phase is
5995        // actually about. Translating the stored coefficients instead would
5996        // propagate bounds through a different association and change the
5997        // tightening; that is not a trade this phase makes.
5998        crate::nl_fbbt_translate::translate_constraint(&nonlinear, linear)
5999    }
6000
6001    /// Variable name from the sibling `.col` file, if one was loaded.
6002    /// Index is original `.nl` column order.
6003    fn variable_name(&self, i: usize) -> Option<&str> {
6004        self.prob.var_names.get(i).map(String::as_str)
6005    }
6006
6007    /// Constraint name from the sibling `.row` file, if one was loaded.
6008    /// Index is original `.nl` row order.
6009    fn constraint_name(&self, i: usize) -> Option<&str> {
6010        self.prob.con_names.get(i).map(String::as_str)
6011    }
6012}
6013
6014/// Per-worker scratch for the parallel Hessian walk. Four buffers of
6015/// `max_tape_n` — the largest tape's node count, tens of entries on the models
6016/// this path is for — so a worker's copy costs about a kilobyte.
6017#[cfg(not(target_arch = "wasm32"))]
6018struct HessScratch {
6019    vals: Vec<f64>,
6020    dot: Vec<f64>,
6021    adj: Vec<f64>,
6022    adj_dot: Vec<f64>,
6023}
6024
6025/// Per-worker scratch for the parallel Jacobian walk: one gradient row of
6026/// length `n` plus the two small tape arenas `gradient_seed_into` fills.
6027#[cfg(not(target_arch = "wasm32"))]
6028#[derive(Debug, Clone)]
6029struct JacScratch {
6030    row_grad: Vec<f64>,
6031    vals: Vec<f64>,
6032    adj: Vec<f64>,
6033}
6034
6035#[cfg(not(target_arch = "wasm32"))]
6036impl JacScratch {
6037    fn new(n: usize, max_tape_n: usize) -> Self {
6038        Self {
6039            row_grad: vec![0.0; n],
6040            vals: vec![0.0; max_tape_n],
6041            adj: vec![0.0; max_tape_n],
6042        }
6043    }
6044}
6045
6046#[cfg(not(target_arch = "wasm32"))]
6047impl HessScratch {
6048    fn new(n: usize) -> Self {
6049        Self {
6050            vals: vec![0.0; n],
6051            dot: vec![0.0; n],
6052            adj: vec![0.0; n],
6053            adj_dot: vec![0.0; n],
6054        }
6055    }
6056}
6057
6058impl NlTnlp {
6059    /// Smallest (row, tape) × color work list that earns a parallel walk.
6060    /// Below it the rayon dispatch costs more than the sweeps it splits, and
6061    /// the serial tape-major walk is also cheaper per unit work because it
6062    /// forwards each tape once rather than once per color.
6063    ///
6064    /// Measured on a banded synthetic (`sin(x_i·x_{i+1}) + x_{i+1}²·x_{i+2}`
6065    /// per row, the model in `parallel_hessian_matches_serial.rs`), serial ->
6066    /// parallel with the gate forced open:
6067    ///
6068    /// | pairs | serial | parallel | ratio |
6069    /// |---|---|---|---|
6070    /// | 400 | 0.028 ms | 0.036 ms | 0.79x |
6071    /// | 1 200 | 0.073 ms | 0.066 ms | 1.11x |
6072    /// | 4 000 | 0.179 ms | 0.112 ms | 1.60x |
6073    /// | 12 000 | 0.344 ms | 0.203 ms | 1.69x |
6074    ///
6075    /// The crossover sits between 400 and 1 200, hence this value.
6076    ///
6077    /// **What that table does not say is how big the win gets.** That model
6078    /// has three Hessian colors, so it has three tasks to hand out however
6079    /// many cores are free, and 1.7x is near its ceiling. The parallelism
6080    /// available here *is* the color count: an AC-SCOPF model with 28 colors
6081    /// runs the same walk 4.9x faster (108.3 ms -> 22.2 ms at 54 859
6082    /// variables, 421.0 ms -> 89.0 ms at 209 755). Gating on pair count alone
6083    /// is therefore conservative in the safe direction — at equal pairs, more
6084    /// colors can only help.
6085    const HESS_PAR_MIN_PAIRS: usize = 1024;
6086
6087    /// Whether the flat Hessian walk runs color-major and in parallel.
6088    ///
6089    /// `POUNCE_NL_PARALLEL_EVAL` overrides the size gate: `0` forces the
6090    /// serial walk, `1` forces the parallel one whatever the size. The two
6091    /// produce bit-identical values, so this is a lever for measurement and
6092    /// for pinning a suspected parallelism bug, not a correctness switch —
6093    /// and `1` is how the threshold below was measured, since the gate would
6094    /// otherwise hide the small end of the curve.
6095    #[cfg(not(target_arch = "wasm32"))]
6096    fn hess_flat_parallel(&self) -> bool {
6097        // More than one color is a hard requirement, not a heuristic: with one
6098        // color there is one task and the inversion is pure overhead.
6099        let multi_color = self.compressed.len() > 1;
6100        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6101            Ok("0") | Ok("no") | Ok("false") => false,
6102            Ok("1") | Ok("yes") | Ok("true") => multi_color,
6103            _ => multi_color && self.hess_color_index.con_row.len() >= Self::HESS_PAR_MIN_PAIRS,
6104        }
6105    }
6106
6107    /// Smallest Jacobian nonzero count that earns a parallel walk. Same shape
6108    /// of trade as [`Self::HESS_PAR_MIN_PAIRS`], but a cheaper one to cross:
6109    /// the row walk is *not* reordered or duplicated, so the only cost is the
6110    /// dispatch and one length-`n` scratch row per worker.
6111    #[cfg(not(target_arch = "wasm32"))]
6112    const JAC_PAR_MIN_NNZ: usize = 4096;
6113
6114    /// Smallest row count that earns a parallel constraint evaluation. This is
6115    /// the cheapest of the three walks per row — one forward sweep, no
6116    /// gradient, no directional derivative — so the dispatch is a larger share
6117    /// of it and the threshold is the highest of the three.
6118    ///
6119    /// Measured on the banded synthetic, serial -> parallel with the gate
6120    /// forced open: 500 rows 0.34x, 2 000 0.84x, 5 000 1.07x, 8 000 1.53x,
6121    /// 20 000 3.19x, 60 000 4.97x, 200 000 5.78x. The crossover is near 5 000,
6122    /// so the gate sits just under it. Unlike the Hessian, this walk has no
6123    /// color ceiling — every row is its own task — which is why it keeps
6124    /// climbing to ~6x where the Hessian's 3-color model stalls at 1.7x.
6125    #[cfg(not(target_arch = "wasm32"))]
6126    const EVAL_G_PAR_MIN_ROWS: usize = 4096;
6127
6128    /// Whether `eval_g` runs in parallel over rows. Shares
6129    /// `POUNCE_NL_PARALLEL_EVAL` with the other two walks.
6130    #[cfg(not(target_arch = "wasm32"))]
6131    fn eval_g_parallel(&self) -> bool {
6132        // The shared-CSE arm returns earlier; this gate only ever sees the
6133        // flat path.
6134        let big = self.prob.m >= Self::EVAL_G_PAR_MIN_ROWS;
6135        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6136            Ok("0") | Ok("no") | Ok("false") => false,
6137            Ok("1") | Ok("yes") | Ok("true") => true,
6138            _ => big,
6139        }
6140    }
6141
6142    /// Whether the flat Jacobian walk runs in parallel over row groups.
6143    /// Shares `POUNCE_NL_PARALLEL_EVAL` with the Hessian.
6144    #[cfg(not(target_arch = "wasm32"))]
6145    fn jac_flat_parallel(&self) -> bool {
6146        let big = self.jac_nnz >= Self::JAC_PAR_MIN_NNZ;
6147        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6148            Ok("0") | Ok("no") | Ok("false") => false,
6149            Ok("1") | Ok("yes") | Ok("true") => true,
6150            _ => big,
6151        }
6152    }
6153
6154    /// The flat Jacobian, walked in parallel over contiguous groups of rows.
6155    ///
6156    /// Unlike the Hessian this needs no inversion: rows are already
6157    /// independent — each zeroes only its own columns of the gradient scratch,
6158    /// accumulates into them, and writes a contiguous run of `values`. So the
6159    /// work is split by *rows*, each group keeping the serial order inside
6160    /// itself, and every row computes exactly what it computed serially. The
6161    /// results are bit-identical for the stronger reason that nothing was
6162    /// reordered at all.
6163    ///
6164    /// Two things are per-worker rather than per-call: the length-`n` gradient
6165    /// row (the reason this is not simply `par_iter`, since `n` can be tens of
6166    /// thousands and allocating one per row would dwarf the work) and the two
6167    /// small tape arenas. They are pooled on `self` and reused across
6168    /// evaluations.
6169    #[cfg(not(target_arch = "wasm32"))]
6170    fn jac_flat_rows_parallel(&mut self, x_arg: Option<&[Number]>, values: &mut [Number]) {
6171        use rayon::prelude::*;
6172        let n = self.prob.n;
6173        let m = self.prob.m;
6174        let groups = rayon::current_num_threads().clamp(1, m.max(1));
6175        let max_tape_n = self.vals_scratch.len();
6176
6177        // Row -> offset of its run in `values`, so a group can be hnaded one
6178        // contiguous slice. Rebuilt only when the shape changes.
6179        if self.jac_row_off.len() != m + 1 {
6180            self.jac_row_off = Vec::with_capacity(m + 1);
6181            let mut acc = 0u32;
6182            for cols in &self.jac_cols {
6183                self.jac_row_off.push(acc);
6184                acc += cols.len() as u32;
6185            }
6186            self.jac_row_off.push(acc);
6187        }
6188        if self.jac_scratch_pool.len() != groups {
6189            self.jac_scratch_pool = (0..groups)
6190                .map(|_| JacScratch::new(n, max_tape_n))
6191                .collect();
6192        }
6193
6194        // Equal-row groups. Rows differ in width, so this is not a perfect
6195        // balance; rayon is given more groups than threads only when that is
6196        // free, and the measurement below is taken with this split.
6197        let per = m.div_ceil(groups.max(1));
6198        let Self {
6199            prob,
6200            con_tapes,
6201            quad,
6202            jac_cols,
6203            jac_row_off,
6204            jac_scratch_pool,
6205            ..
6206        } = self;
6207        let xs = x_arg.unwrap_or(&prob.x0);
6208
6209        // Hand each group its own contiguous run of `values`.
6210        let mut rest: &mut [Number] = values;
6211        let mut slices: Vec<&mut [Number]> = Vec::with_capacity(groups);
6212        for g in 0..groups {
6213            let end_row = ((g + 1) * per).min(m);
6214            let start_row = (g * per).min(m);
6215            let len = (jac_row_off[end_row] - jac_row_off[start_row]) as usize;
6216            let (head, tail) = rest.split_at_mut(len);
6217            slices.push(head);
6218            rest = tail;
6219        }
6220
6221        jac_scratch_pool
6222            .par_iter_mut()
6223            .zip(slices.into_par_iter())
6224            .enumerate()
6225            .for_each(|(g, (sc, out))| {
6226                let (lo, hi) = ((g * per).min(m), ((g + 1) * per).min(m));
6227                let JacScratch {
6228                    row_grad,
6229                    vals,
6230                    adj,
6231                } = sc;
6232                let mut k = 0usize;
6233                for i in lo..hi {
6234                    for &j in &jac_cols[i] {
6235                        row_grad[j] = 0.0;
6236                    }
6237                    for t in &con_tapes[i] {
6238                        t.gradient_seed_into(xs, 1.0, row_grad, vals, adj);
6239                    }
6240                    if let Some(f) = quad.row_form(i) {
6241                        quad.add_gradient(f, xs, 1.0, row_grad);
6242                    }
6243                    for &(v, c) in &prob.con_linear[i] {
6244                        row_grad[v] += c;
6245                    }
6246                    for &j in &jac_cols[i] {
6247                        out[k] = row_grad[j];
6248                        k += 1;
6249                    }
6250                }
6251            });
6252    }
6253
6254    /// The flat Hessian's constraint block, walked color-major with one rayon
6255    /// task per color.
6256    ///
6257    /// Each task owns its color's `compressed[c]` outright, so nothing is
6258    /// shared mutably and no reduction is needed. Within a color the pairs are
6259    /// visited in ascending (row, tape) order — the order the serial walk
6260    /// contributes them in — so the sums are identical term by term and the
6261    /// two paths agree bit-for-bit, which
6262    /// `parallel_matches_serial_bit_for_bit` pins.
6263    ///
6264    /// The cost of the inversion is forwarding a tape once per color it
6265    /// touches instead of once for all of them (2.9 colors per tape measured
6266    /// on AC-SCOPF). That is paid back by the parallelism and only above
6267    /// `HESS_PAR_MIN_PAIRS`.
6268    #[cfg(not(target_arch = "wasm32"))]
6269    fn hess_flat_colors_parallel(&mut self, x_arg: Option<&[Number]>, lam: &[Number]) {
6270        use rayon::prelude::*;
6271        let Self {
6272            compressed,
6273            seeds,
6274            con_tapes,
6275            hess_color_index: idx,
6276            vals_scratch,
6277            prob,
6278            ..
6279        } = self;
6280        let x = x_arg.unwrap_or(&prob.x0);
6281        let max_tape_n = vals_scratch.len();
6282        compressed.par_iter_mut().enumerate().for_each_init(
6283            || HessScratch::new(max_tape_n),
6284            |sc, (c, out)| {
6285                let (rows, tapes) = idx.con_pairs(c);
6286                let seed = &seeds[c];
6287                let HessScratch {
6288                    vals,
6289                    dot,
6290                    adj,
6291                    adj_dot,
6292                } = sc;
6293                for (&k, &ti) in rows.iter().zip(tapes.iter()) {
6294                    let w = lam[k as usize];
6295                    if w == 0.0 {
6296                        continue;
6297                    }
6298                    let t = &con_tapes[k as usize][ti as usize];
6299                    if t.ops.is_empty() {
6300                        continue;
6301                    }
6302                    t.forward_into(x, vals);
6303                    t.hessian_directional(vals, seed, w, out, dot, adj, adj_dot);
6304                }
6305            },
6306        );
6307    }
6308}
6309
6310impl TNLP for NlTnlp {
6311    /// An `.nl` model carries no callback: the CLI and the browser build
6312    /// read the iteration log, not `IterStats` (gh#965).
6313    fn wants_intermediate_callback(&self) -> bool {
6314        false
6315    }
6316
6317    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
6318        Some(NlpInfo {
6319            n: self.prob.n as Index,
6320            m: self.prob.m as Index,
6321            nnz_jac_g: self.jac_nnz as Index,
6322            nnz_h_lag: self.h_irow.len() as Index,
6323            index_style: IndexStyle::C,
6324        })
6325    }
6326
6327    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
6328        b.x_l.copy_from_slice(&self.prob.x_l);
6329        b.x_u.copy_from_slice(&self.prob.x_u);
6330        if !self.prob.g_l.is_empty() {
6331            b.g_l.copy_from_slice(&self.prob.g_l);
6332            b.g_u.copy_from_slice(&self.prob.g_u);
6333        }
6334        true
6335    }
6336
6337    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
6338        sp.x.copy_from_slice(&self.prob.x0);
6339        // The `.nl` `d` segment supplies initial constraint multipliers
6340        // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
6341        // the engine when `warm_start_init_point yes` — by handing them
6342        // back; `OrigIpoptNlp::get_starting_point` then compresses them into
6343        // the algorithm-side y_c / y_d. Without this the warm start silently
6344        // began from zero multipliers, discarding the parsed duals. (Code
6345        // review 2026-06 item M19.)
6346        if sp.init_lambda {
6347            sp.lambda.copy_from_slice(&self.prob.lambda0);
6348        }
6349        // Bound multipliers come from the `ipopt_zL_in` / `ipopt_zU_in`
6350        // variable suffixes, as in Ipopt's `AmplTNLP::get_starting_point`:
6351        // `z_L = sigma * zL_in`, `z_U = -sigma * zU_in` (sigma = -1 for a
6352        // maximize model) — the inverse of the CLI's `ipopt_zL_out` /
6353        // `ipopt_zU_out`, so a `.sol` result fed back in round-trips. A
6354        // suffix the file does not carry leaves that block to the engine's
6355        // defaults, as before.
6356        if sp.init_z {
6357            let sigma = if self.prob.minimize { 1.0 } else { -1.0 };
6358            let sfx = &self.prob.suffixes;
6359            for (name, out, sign) in [
6360                ("ipopt_zL_in", &mut *sp.z_l, sigma),
6361                ("ipopt_zU_in", &mut *sp.z_u, -sigma),
6362            ] {
6363                // A writer may emit an all-integer suffix as integer-kind.
6364                let vals: Option<Vec<Number>> = sfx.var_real.get(name).cloned().or_else(|| {
6365                    sfx.var_int
6366                        .get(name)
6367                        .map(|v| v.iter().map(|&x| x as Number).collect())
6368                });
6369                if let Some(v) = vals.filter(|v| v.len() == out.len()) {
6370                    for (o, x) in out.iter_mut().zip(v) {
6371                        *o = sign * x;
6372                    }
6373                }
6374            }
6375        }
6376        true
6377    }
6378
6379    /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
6380    /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
6381    /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
6382    /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
6383    /// writes into. Before gh#483 nothing implemented this callback for
6384    /// `.nl` input, so a tagged model reached the solver with the option
6385    /// accepted and *no* scaling applied, silently.
6386    ///
6387    /// Returns `false` (engine falls back to no scaling) when the file
6388    /// declares no `scaling_factor` suffix at all — the same "user
6389    /// supplied nothing" answer as the default `TNLP` impl.
6390    ///
6391    /// AMPL suffix vectors default to **0** for components the model did
6392    /// not tag, and 0 is not a usable scale factor. A zero entry is
6393    /// therefore read as "not tagged" and becomes 1.0, which is what
6394    /// "unlisted components are unscaled" means. Per-variable factors
6395    /// are passed straight through: `OrigIpoptNlp` does not model them
6396    /// and refuses the solve with a message rather than dropping them.
6397    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
6398        const NAME: &str = "scaling_factor";
6399        let sfx = &self.prob.suffixes;
6400        let obj = sfx.obj_real.get(NAME);
6401        let var = sfx.var_real.get(NAME);
6402        let con = sfx.con_real.get(NAME);
6403        let computed = self.curvature_scaling.as_ref();
6404        if obj.is_none() && var.is_none() && con.is_none() && computed.is_none() {
6405            return false;
6406        }
6407        // Curvature-based factors (gh #703) are the *base*; a
6408        // `scaling_factor` suffix the model actually carries overrides
6409        // them component by component, because an explicit factor from the
6410        // modeller beats one inferred from the coefficients. A model with
6411        // no suffixes is the ordinary case and gets the computed vectors
6412        // whole.
6413        if let Some(sc) = computed {
6414            // The length guards are a `copy_from_slice` panic guard, not a
6415            // policy: `curvature_scaling` sizes both vectors from the same
6416            // `NlProblem` this callback is answering for, so a mismatch is a
6417            // bug upstream, not a model the scheme declines. Declining is the
6418            // *worst* available response to it — `use_*_scaling` stays false,
6419            // the engine reads that as "user supplied nothing", and the run
6420            // proceeds unscaled with the option accepted, which is gh #483
6421            // again. Assert it in debug builds so a mismatch is found here,
6422            // where it is one line, instead of as a slow solve later.
6423            debug_assert_eq!(
6424                sc.x.len(),
6425                req.x_scaling.len(),
6426                "curvature x-scaling sized {} for a {}-variable request",
6427                sc.x.len(),
6428                req.x_scaling.len()
6429            );
6430            debug_assert_eq!(
6431                sc.g.len(),
6432                req.g_scaling.len(),
6433                "curvature g-scaling sized {} for a {}-row request",
6434                sc.g.len(),
6435                req.g_scaling.len()
6436            );
6437            if sc.x.len() == req.x_scaling.len() {
6438                req.x_scaling.copy_from_slice(&sc.x);
6439                *req.use_x_scaling = true;
6440            }
6441            if sc.g.len() == req.g_scaling.len() {
6442                req.g_scaling.copy_from_slice(&sc.g);
6443                *req.use_g_scaling = true;
6444            }
6445        }
6446        // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
6447        // are parsed and ignored), so its entry is the objective scale.
6448        *req.obj_scaling = obj
6449            .and_then(|v| v.first().copied())
6450            .filter(|&s| s != 0.0)
6451            .unwrap_or(1.0);
6452        // A zero entry is AMPL's "untagged" default, not a scale factor.
6453        // An untagged component therefore falls back to the base: the
6454        // curvature factor when one was computed, and an explicit 1.0
6455        // otherwise — explicit because the callback's contract is to fill
6456        // the buffer, not to assume the caller pre-filled it with ones.
6457        if let Some(v) = var.filter(|v| v.len() == req.x_scaling.len()) {
6458            for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
6459                if s != 0.0 {
6460                    *slot = s;
6461                } else if computed.is_none() {
6462                    *slot = 1.0;
6463                }
6464            }
6465            *req.use_x_scaling = true;
6466        } else if computed.is_none() {
6467            *req.use_x_scaling = false;
6468        }
6469        if let Some(g) = con.filter(|g| g.len() == req.g_scaling.len()) {
6470            for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
6471                if s != 0.0 {
6472                    *slot = s;
6473                } else if computed.is_none() {
6474                    *slot = 1.0;
6475                }
6476            }
6477            *req.use_g_scaling = true;
6478        } else if computed.is_none() {
6479            *req.use_g_scaling = false;
6480        }
6481        true
6482    }
6483
6484    fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
6485        // Reuse the shared forward-value arena (sized to `max_tape_n`) so
6486        // each summand sweep allocates nothing — see `Tape::eval_into`.
6487        let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
6488        let mut nl: Number = 0.0;
6489        for t in obj_tapes {
6490            nl += t.eval_into(x, vals);
6491        }
6492        // A recognized objective has no tapes, so the loop above added
6493        // nothing and the form supplies the whole nonlinear part — including
6494        // the linear and constant terms AMPL folded into that tree, which is
6495        // why they are *not* also in `obj_linear` / `obj_constant`.
6496        if let Some(f) = self.quad.objective_form() {
6497            nl += self.quad.value(f, x);
6498        }
6499        let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
6500        let v = self.prob.obj_constant + nl + lin;
6501        let signed = if self.prob.minimize { v } else { -v };
6502        Some(signed)
6503    }
6504
6505    fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
6506        grad.fill(0.0);
6507        // Reuse the forward-value / adjoint scratch arenas (sized to
6508        // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
6509        // nothing — see `Tape::gradient_seed_into` (M18).
6510        for t in &self.obj_tapes {
6511            t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
6512        }
6513        if let Some(f) = self.quad.objective_form() {
6514            self.quad.add_gradient(f, x, 1.0, grad);
6515        }
6516        for (i, c) in &self.prob.obj_linear {
6517            grad[*i] += c;
6518        }
6519        if !self.prob.minimize {
6520            for g in grad.iter_mut() {
6521                *g = -*g;
6522            }
6523        }
6524        true
6525    }
6526
6527    fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
6528        // Constraint values are the line search's inner loop: on a
6529        // constraint-heavy model (m >> n) this runs ~10x per iteration over
6530        // every summand tape in the problem. Reuse the shared forward-value
6531        // arena so the sweep allocates nothing — the per-summand `Vec` the
6532        // allocating `Tape::eval` used to build was ~20% of `eval_g` on
6533        // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
6534        // `Tape::eval_into`.
6535        let m = self.prob.m;
6536        let con_linear = &self.prob.con_linear;
6537        let quad = &self.quad;
6538        if let Some(h) = &mut self.con_hybrid {
6539            // Shared CSE bodies once for the whole constraint block, then one
6540            // local sweep per summand (pounce#476).
6541            let ConHybrid {
6542                tape,
6543                row_start,
6544                prelude_vals,
6545                local_vals,
6546                ..
6547            } = h;
6548            tape.forward_prelude(x, prelude_vals);
6549            for i in 0..m {
6550                let mut nl: Number = 0.0;
6551                for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6552                    tape.forward_summand(s, x, prelude_vals, local_vals);
6553                    nl += tape.root_value(s, local_vals);
6554                }
6555                if let Some(f) = quad.row_form(i) {
6556                    nl += quad.value(f, x);
6557                }
6558                let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6559                g[i] = nl + lin;
6560            }
6561            return true;
6562        }
6563        // Row `i` reads only `x` and writes only `g[i]`, so this one is
6564        // parallel as written — no inversion, no row-offset bookkeeping, and
6565        // the per-row arithmetic is untouched, which is why the values are
6566        // bit-identical. Only the forward-value arena is per-worker.
6567        #[cfg(not(target_arch = "wasm32"))]
6568        if self.eval_g_parallel() {
6569            use rayon::prelude::*;
6570            let max_tape_n = self.vals_scratch.len();
6571            let con_tapes = &self.con_tapes;
6572            g.par_iter_mut().enumerate().for_each_init(
6573                || vec![0.0; max_tape_n],
6574                |vals, (i, gi)| {
6575                    let mut nl: Number = 0.0;
6576                    for t in &con_tapes[i] {
6577                        nl += t.eval_into(x, vals);
6578                    }
6579                    if let Some(f) = quad.row_form(i) {
6580                        nl += quad.value(f, x);
6581                    }
6582                    let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6583                    *gi = nl + lin;
6584                },
6585            );
6586            return true;
6587        }
6588        let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
6589        for i in 0..m {
6590            let mut nl: Number = 0.0;
6591            for t in &con_tapes[i] {
6592                nl += t.eval_into(x, vals);
6593            }
6594            // A quadratic row's summand range is empty above, so this is its
6595            // whole nonlinear part: one matvec against a constant matrix in
6596            // place of a walk over every monomial's tape.
6597            if let Some(f) = quad.row_form(i) {
6598                nl += quad.value(f, x);
6599            }
6600            let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6601            g[i] = nl + lin;
6602        }
6603        true
6604    }
6605
6606    fn eval_jac_g(
6607        &mut self,
6608        x: Option<&[Number]>,
6609        _new_x: bool,
6610        mode: SparsityRequest<'_>,
6611    ) -> bool {
6612        match mode {
6613            SparsityRequest::Structure { irow, jcol } => {
6614                let mut k = 0;
6615                for i in 0..self.prob.m {
6616                    for &j in &self.jac_cols[i] {
6617                        irow[k] = i as Index;
6618                        jcol[k] = j as Index;
6619                        k += 1;
6620                    }
6621                }
6622                true
6623            }
6624            SparsityRequest::Values { values } => {
6625                let n = self.prob.n;
6626                if self.scratch_row_grad.len() < n {
6627                    self.scratch_row_grad.resize(n, 0.0);
6628                }
6629                // Rows are independent and write disjoint runs of `values`, so
6630                // the parallel walk is the same work in the same order, split
6631                // by row group. Only the flat path: the shared-CSE arm below
6632                // forwards one prelude for the whole block, which is exactly
6633                // the sharing a row split would have to undo.
6634                #[cfg(not(target_arch = "wasm32"))]
6635                if self.con_hybrid.as_ref().filter(|h| h.use_for_jac).is_none()
6636                    && self.jac_flat_parallel()
6637                {
6638                    self.jac_flat_rows_parallel(x, values);
6639                    return true;
6640                }
6641                let Self {
6642                    prob,
6643                    con_tapes,
6644                    con_hybrid,
6645                    quad,
6646                    jac_cols,
6647                    scratch_row_grad,
6648                    vals_scratch,
6649                    adj_scratch,
6650                    ..
6651                } = self;
6652                let xs = x.unwrap_or(&prob.x0);
6653                let mut k = 0;
6654                // Shared-CSE path: the forward sweep over the CSE bodies runs
6655                // once for the whole constraint block instead of once per
6656                // referencing summand. The reverse sweep cannot be shared —
6657                // each row needs its own gradient — so a summand still walks
6658                // its own `prelude_reach` backwards.
6659                if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
6660                    let ConHybrid {
6661                        tape,
6662                        row_start,
6663                        prelude_vals,
6664                        local_vals,
6665                        local_adj,
6666                        prelude_adj,
6667                        ..
6668                    } = h;
6669                    tape.forward_prelude(xs, prelude_vals);
6670                    for i in 0..prob.m {
6671                        for &j in &jac_cols[i] {
6672                            scratch_row_grad[j] = 0.0;
6673                        }
6674                        for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6675                            tape.forward_summand(s, xs, prelude_vals, local_vals);
6676                            tape.gradient_summand(
6677                                s,
6678                                prelude_vals,
6679                                local_vals,
6680                                1.0,
6681                                scratch_row_grad,
6682                                local_adj,
6683                                prelude_adj,
6684                            );
6685                        }
6686                        if let Some(f) = quad.row_form(i) {
6687                            quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6688                        }
6689                        for &(v, c) in &prob.con_linear[i] {
6690                            scratch_row_grad[v] += c;
6691                        }
6692                        for &j in &jac_cols[i] {
6693                            values[k] = scratch_row_grad[j];
6694                            k += 1;
6695                        }
6696                    }
6697                    return true;
6698                }
6699                for i in 0..prob.m {
6700                    for &j in &jac_cols[i] {
6701                        scratch_row_grad[j] = 0.0;
6702                    }
6703                    for t in &con_tapes[i] {
6704                        // Allocation-free reverse-AD per summand tape (M18):
6705                        // reuse the shared forward/adjoint scratch arenas.
6706                        t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
6707                    }
6708                    // `Hx + a` for a quadratic row: one matvec over the
6709                    // row's support, in place of a reverse sweep per
6710                    // monomial. `jac_cols[i]` already covers the form's
6711                    // gradient support, so the scatter lands inside the
6712                    // window zeroed above.
6713                    if let Some(f) = quad.row_form(i) {
6714                        quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6715                    }
6716                    for &(v, c) in &prob.con_linear[i] {
6717                        scratch_row_grad[v] += c;
6718                    }
6719                    for &j in &jac_cols[i] {
6720                        values[k] = scratch_row_grad[j];
6721                        k += 1;
6722                    }
6723                }
6724                true
6725            }
6726        }
6727    }
6728
6729    fn eval_h(
6730        &mut self,
6731        x: Option<&[Number]>,
6732        _new_x: bool,
6733        obj_factor: Number,
6734        lambda: Option<&[Number]>,
6735        _new_lambda: bool,
6736        mode: SparsityRequest<'_>,
6737    ) -> bool {
6738        match mode {
6739            SparsityRequest::Structure { irow, jcol } => {
6740                irow.copy_from_slice(&self.h_irow);
6741                jcol.copy_from_slice(&self.h_jcol);
6742                true
6743            }
6744            SparsityRequest::Values { values } => {
6745                // Kept alongside the resolved slice: the parallel walk below
6746                // resolves it again *after* destructuring `self`, so `x` and
6747                // the buffers it writes borrow different fields and neither
6748                // has to be copied per evaluation.
6749                let x_arg = x;
6750                let x = x_arg.unwrap_or(&self.prob.x0);
6751                values.fill(0.0);
6752
6753                let obj_seed = if self.prob.minimize {
6754                    obj_factor
6755                } else {
6756                    -obj_factor
6757                };
6758                // Coloring path. For each (tape, weight) we do
6759                // one forward pass into `vals_scratch`, then one
6760                // forward-tangent+reverse-over-tangent per color
6761                // touched by that tape. Each pass accumulates a
6762                // weighted contribution of (H_tape · seed_c) into
6763                // `compressed[c]`. After all tapes done, we
6764                // decode each color's compressed vector into the
6765                // sparse `values` array.
6766                for buf in &mut self.compressed {
6767                    buf.fill(0.0);
6768                }
6769
6770                // The constant blocks first: no forward sweep, no
6771                // directional product, no decode — the multipliers are the
6772                // only thing that changed since the model was read, so this
6773                // is one `values[slot] += w · h` pass per live form. Skipped
6774                // wholesale on a model with nothing recognized, so such a
6775                // model does not pay an `O(m)` scan for a structure that is
6776                // empty.
6777                if !self.quad.is_empty() {
6778                    if obj_seed != 0.0 {
6779                        if let Some(f) = self.quad.objective_form() {
6780                            self.quad.accumulate_hessian(f, obj_seed, values);
6781                        }
6782                    }
6783                    if let Some(lam) = lambda {
6784                        for i in 0..self.prob.m {
6785                            let w = lam[i];
6786                            if w == 0.0 {
6787                                continue;
6788                            }
6789                            if let Some(f) = self.quad.row_form(i) {
6790                                self.quad.accumulate_hessian(f, w, values);
6791                            }
6792                        }
6793                    }
6794                }
6795
6796                if obj_seed != 0.0 {
6797                    for (ti, t) in self.obj_tapes.iter().enumerate() {
6798                        if t.ops.is_empty() {
6799                            continue;
6800                        }
6801                        t.forward_into(x, &mut self.vals_scratch);
6802                        for &c in &self.obj_tape_colors[ti] {
6803                            t.hessian_directional(
6804                                &self.vals_scratch,
6805                                &self.seeds[c as usize],
6806                                obj_seed,
6807                                &mut self.compressed[c as usize],
6808                                &mut self.dot_scratch,
6809                                &mut self.adj_scratch,
6810                                &mut self.adj_dot_scratch,
6811                            );
6812                        }
6813                    }
6814                }
6815
6816                // Computed before the match: its scrutinee takes `self` mutably,
6817                // so a guard cannot also read `self`. Absent on wasm32 along
6818                // with the arm it guards — the arm still has to *typecheck*
6819                // there, dead or not, and the method it calls does not exist.
6820                #[cfg(not(target_arch = "wasm32"))]
6821                let hess_flat_parallel = self.hess_flat_parallel();
6822                match (lambda, self.con_hybrid.as_mut()) {
6823                    // Shared-CSE path (issue #557). Per color the prelude's
6824                    // second-order work runs ONCE for the whole constraint
6825                    // block: one forward tangent, then — because
6826                    // reverse-over-tangent is linear in its adjoint seeds —
6827                    // one unit-weight reverse sweep over the λ-weighted
6828                    // adjoints accumulated by every summand of that color.
6829                    // The flat path below repeats both sweeps over the
6830                    // inlined CSE body once per referencing summand.
6831                    (Some(lam), Some(h)) if h.use_for_hess => {
6832                        let ConHybrid {
6833                            tape,
6834                            prelude_vals,
6835                            local_vals_all,
6836                            local_off,
6837                            summand_row,
6838                            hess_color_summands,
6839                            hess_color_reach,
6840                            hess_color_reach_off,
6841                            prelude_dot,
6842                            hess_prelude_adj,
6843                            prelude_adj_dot,
6844                            local_dot,
6845                            local_adj,
6846                            local_adj_dot,
6847                            ..
6848                        } = h;
6849                        // Forward once (values are color-independent):
6850                        // prelude for the block, then each summand of a row
6851                        // with a live multiplier into its packed slice.
6852                        tape.forward_prelude(x, prelude_vals);
6853                        for (si, s) in tape.summands.iter().enumerate() {
6854                            if lam[summand_row[si] as usize] == 0.0 {
6855                                continue;
6856                            }
6857                            tape.forward_summand(
6858                                s,
6859                                x,
6860                                prelude_vals,
6861                                &mut local_vals_all[local_off[si]..local_off[si + 1]],
6862                            );
6863                        }
6864                        for (c, list) in hess_color_summands.iter().enumerate() {
6865                            if !list
6866                                .iter()
6867                                .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
6868                            {
6869                                continue;
6870                            }
6871                            let seed = &self.seeds[c];
6872                            let out = &mut self.compressed[c];
6873                            let creach = &hess_color_reach
6874                                [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
6875                            tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
6876                            for &si in list {
6877                                let si = si as usize;
6878                                let w = lam[summand_row[si] as usize];
6879                                if w == 0.0 {
6880                                    continue;
6881                                }
6882                                tape.hessian_summand_directional(
6883                                    &tape.summands[si],
6884                                    &local_vals_all[local_off[si]..local_off[si + 1]],
6885                                    prelude_dot,
6886                                    seed,
6887                                    w,
6888                                    out,
6889                                    local_dot,
6890                                    local_adj,
6891                                    local_adj_dot,
6892                                    hess_prelude_adj,
6893                                    prelude_adj_dot,
6894                                );
6895                            }
6896                            tape.prelude_reverse_directional(
6897                                prelude_vals,
6898                                prelude_dot,
6899                                creach,
6900                                out,
6901                                hess_prelude_adj,
6902                                prelude_adj_dot,
6903                            );
6904                        }
6905                    }
6906                    // Color-major and parallel when the work is big enough
6907                    // to pay for the dispatch; bit-identical to the walk
6908                    // below, which stays the path every small model takes.
6909                    #[cfg(not(target_arch = "wasm32"))]
6910                    (Some(lam), _) if hess_flat_parallel => {
6911                        self.hess_flat_colors_parallel(x_arg, lam);
6912                    }
6913                    (Some(lam), _) => {
6914                        for k in 0..self.prob.m {
6915                            let w = lam[k];
6916                            if w == 0.0 {
6917                                continue;
6918                            }
6919                            for (ti, t) in self.con_tapes[k].iter().enumerate() {
6920                                if t.ops.is_empty() {
6921                                    continue;
6922                                }
6923                                t.forward_into(x, &mut self.vals_scratch);
6924                                for &c in &self.con_tape_colors[k][ti] {
6925                                    t.hessian_directional(
6926                                        &self.vals_scratch,
6927                                        &self.seeds[c as usize],
6928                                        w,
6929                                        &mut self.compressed[c as usize],
6930                                        &mut self.dot_scratch,
6931                                        &mut self.adj_scratch,
6932                                        &mut self.adj_dot_scratch,
6933                                    );
6934                                }
6935                            }
6936                        }
6937                    }
6938                    (None, _) => {}
6939                }
6940
6941                // Decode each color's compressed Hessian-vector
6942                // result into the lower-triangle `values` array.
6943                for (c, table) in self.decoding.iter().enumerate() {
6944                    let comp = &self.compressed[c];
6945                    for w in table {
6946                        values[w.hess_idx as usize] += comp[w.row as usize];
6947                    }
6948                }
6949                true
6950            }
6951        }
6952    }
6953
6954    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
6955        self.final_x = Some(sol.x.to_vec());
6956        self.final_obj = sol.obj_value;
6957        self.final_lambda = Some(sol.lambda.to_vec());
6958        self.final_z_l = Some(sol.z_l.to_vec());
6959        self.final_z_u = Some(sol.z_u.to_vec());
6960    }
6961
6962    /// Publish the `.col` / `.row` names (captured at load time) under the
6963    /// conventional `idx_names` metadata key, in original `.nl` order. The
6964    /// adapter permutes these into split space (see
6965    /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
6966    /// near-singular Jacobian row as the `mass_balance` equation rather
6967    /// than "row 3" — the model-vs-index gap Lee et al. (2024,
6968    /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
6969    /// model debugging. Declines (returns false) when the model shipped no
6970    /// name files so callers fall back to index labels.
6971    fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
6972        let mut any = false;
6973        if !self.prob.var_names.is_empty() {
6974            var.strings
6975                .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
6976            any = true;
6977        }
6978        if !self.prob.con_names.is_empty() {
6979            con.strings
6980                .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
6981            any = true;
6982        }
6983        any
6984    }
6985
6986    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
6987        // A row is linear iff its nonlinear-part expression is the
6988        // identity zero — either left over from initial allocation ("no
6989        // `C<idx>` segment touched this row") or installed by the
6990        // constant-row-body fold in `parse_nl_text`, which shifts a
6991        // variable-free `C<idx>` body into the row bounds precisely so
6992        // that this test is a genuine linearity test and not just an
6993        // identity check (`gh #492`).
6994        for (i, t) in types.iter_mut().enumerate() {
6995            *t = if self.prob.con_nonlinear[i].is_trivially_zero() {
6996                Linearity::Linear
6997            } else {
6998                Linearity::NonLinear
6999            };
7000        }
7001        true
7002    }
7003
7004    fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
7005        // Global linearity, per the upstream TNLP contract: a variable is
7006        // NonLinear iff it appears in the nonlinear part of the objective
7007        // or of any constraint; otherwise Linear. The parsed `.nl` splits
7008        // every row into a linear part (J/G coefficient list) and a
7009        // nonlinear expression, so the set of nonlinear variables is
7010        // exactly the structural union of `collect_vars` over
7011        // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
7012        // only by a linear part — or not referenced at all — is Linear.
7013        //
7014        let nonlinear = self.nonlinear_var_set();
7015        for (i, t) in types.iter_mut().enumerate() {
7016            *t = if nonlinear.contains(&i) {
7017                Linearity::NonLinear
7018            } else {
7019                Linearity::Linear
7020            };
7021        }
7022        true
7023    }
7024
7025    fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
7026        // Objective-scoped variant of `get_variables_linearity`: only
7027        // `obj_nonlinear` contributes. This is what engages the presolve
7028        // auxiliary-elimination safeguard (pounce-presolve H11): a variable
7029        // that is nonlinear in the objective but happens to have a zero
7030        // gradient at the single probe point (e.g. `f = (x - x0)^2`
7031        // warm-started at `x0`) is kept in the objective support instead of
7032        // being mis-classified objective-free and eliminated. A variable
7033        // that is nonlinear only in *constraints* stays `Linear` here, so
7034        // the guard does not block legitimate eliminations of
7035        // objective-free equality blocks (the gas-network case).
7036        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
7037        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
7038        for (i, t) in types.iter_mut().enumerate() {
7039            *t = if nonlinear.contains(&i) {
7040                Linearity::NonLinear
7041            } else {
7042                Linearity::Linear
7043            };
7044        }
7045        true
7046    }
7047
7048    fn get_number_of_nonlinear_variables(&mut self) -> Index {
7049        self.nonlinear_variables().len() as Index
7050    }
7051
7052    fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
7053        let list = self.nonlinear_variables();
7054        if pos_nonlin_vars.len() < list.len() {
7055            return false;
7056        }
7057        pos_nonlin_vars[..list.len()].copy_from_slice(&list);
7058        true
7059    }
7060
7061    fn derivative_proofs(&mut self) -> DerivativeProofs {
7062        // Degree is the whole argument (gh #588, Q6). A body proved
7063        // degree ≤ 1 has a constant gradient and no second derivative; a
7064        // body proved degree 2 has a nonzero second derivative, hence a
7065        // gradient that moves. A body the recognizer refuses is
7066        // `Unknown` — and `Unknown` must stay `Unknown`, because the
7067        // refusal is structural, not a finding of nonlinearity.
7068        fn proof(affine: Option<bool>) -> DerivativeProof {
7069            match affine {
7070                Some(true) => DerivativeProof::Constant,
7071                Some(false) => DerivativeProof::Varying,
7072                None => DerivativeProof::Unknown,
7073            }
7074        }
7075        let obj_affine = self.prob.obj_nonlinear.provably_affine();
7076        let jac: Vec<DerivativeProof> = self
7077            .prob
7078            .con_nonlinear
7079            .iter()
7080            .map(|b| proof(b.provably_affine()))
7081            .collect();
7082
7083        // `∇²L = σ·∇²f + Σᵢ λᵢ·∇²gᵢ`. One row proved genuinely quadratic
7084        // makes `∇²L` a non-constant function of `λ` — this is the QCQP
7085        // case, and it is exactly the assertion Ipopt would honour and
7086        // get wrong (§4 Lever 2). Otherwise every row must be *proved*
7087        // affine and the objective *proved* degree ≤ 2, at which point
7088        // `∇²L = σ·∇²f`, constant for a given `σ`; the caller keys its
7089        // reuse on `σ` because the restoration phase passes a different
7090        // one.
7091        let hessian = if jac.iter().any(|&p| p == DerivativeProof::Varying) {
7092            DerivativeProof::Varying
7093        } else if obj_affine.is_some() && jac.iter().all(|&p| p == DerivativeProof::Constant) {
7094            DerivativeProof::Constant
7095        } else {
7096            DerivativeProof::Unknown
7097        };
7098
7099        DerivativeProofs {
7100            grad_f: proof(obj_affine),
7101            hessian,
7102            jac,
7103        }
7104    }
7105}
7106
7107/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
7108pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
7109    let prob = read_nl_file(path)?;
7110    Ok(Rc::new(RefCell::new(NlTnlp::try_new(prob)?)))
7111}
7112
7113#[cfg(test)]
7114mod tests {
7115    /// `.nl` text for `min sum_i exp(x_i) + x_i^2` whose sum is a left-deep
7116    /// chain of `2n - 1` binary `o0`, wrapped in `wraps` pairs of unary `o16`
7117    /// (negation, an even count so the value is unchanged). The `o0` chain is
7118    /// read as one n-ary `Sum`; the negations are what stays genuinely deep.
7119    fn deep_sum_nl(n: usize, wraps: usize) -> String {
7120        let mut l = vec![
7121            "g3 1 1 0".to_string(),
7122            format!(" {n} 1 1 0 1"),
7123            " 0 1".into(),
7124            " 0 0".into(),
7125            format!(" 0 {n} 0"),
7126            " 0 0 0 1 0".into(),
7127            " 0 0 0 0 0".into(),
7128            format!(" {n} {n}"),
7129            " 0 0".into(),
7130            " 0 0 0 0 0".into(),
7131            "C0".into(),
7132            "n0".into(),
7133            "O0 0".into(),
7134        ];
7135        l.extend(std::iter::repeat_n("o16".to_string(), 2 * wraps));
7136        l.extend(std::iter::repeat_n("o0".to_string(), 2 * n - 1));
7137        for i in 0..n {
7138            l.extend([
7139                "o44".into(),
7140                format!("v{i}"),
7141                "o2".into(),
7142                format!("v{i}"),
7143                format!("v{i}"),
7144            ]);
7145        }
7146        l.extend(["r".into(), "4 0.5".into(), "b".into()]);
7147        l.extend((0..n).map(|_| "0 -1.0 1.0".to_string()));
7148        l.push(format!("k{}", n - 1));
7149        l.extend((0..n - 1).map(|i| (i + 1).to_string()));
7150        l.push(format!("J0 {n}"));
7151        l.extend((0..n).map(|i| format!("{i} 1.0")));
7152        l.push(format!("G0 {n}"));
7153        l.extend((0..n).map(|i| format!("{i} 0.0")));
7154        l.join("\n") + "\n"
7155    }
7156
7157    /// `ralph1` written as AMPL would: `min 2x - y, x >= 0, y >= 0,
7158    /// (y - x) complements y`. The complementarity row is `5 1 2` — flag 1
7159    /// (y has a finite lower bound), variable 2 (1-based).
7160    fn ralph1_nl(row_line: &str, y_bound: &str) -> String {
7161        [
7162            "g3 1 1 0",
7163            " 2 1 1 0 0", // vars, constraints, objectives, ranges, eqns
7164            " 0 0 1 0",   // nonlinear cons, objs; ccons: linear, nonlinear
7165            " 0 0",       // network constraints
7166            " 0 0 0",     // nonlinear vars in constraints, objectives, both
7167            " 0 0 0 1",   // linear network vars; functions; arith, flags
7168            " 0 0 0 0 0", // discrete variables
7169            " 2 2",       // Jacobian, gradient nonzeros
7170            " 0 0",       // max name lengths
7171            " 0 0 0 0 0", // common expressions
7172            "C0",
7173            "n0",
7174            "O0 0",
7175            "n0",
7176            "r",
7177            row_line,
7178            "b",
7179            "2 0",
7180            y_bound,
7181            "k1",
7182            "1",
7183            "J0 2",
7184            "0 -1",
7185            "1 1",
7186            "G0 2",
7187            "0 2",
7188            "1 -1",
7189        ]
7190        .join("\n")
7191            + "\n"
7192    }
7193
7194    #[test]
7195    fn complementarity_rows_are_read_not_refused() {
7196        let p = parse_nl_string(ralph1_nl("5 1 2", "2 0"), false).unwrap();
7197        assert_eq!(
7198            p.complementarity,
7199            vec![NlComplementarity {
7200                row: 0,
7201                var: 1,
7202                flags: 1
7203            }]
7204        );
7205        // Flag 1 (bounded below): the body must be >= 0.
7206        assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 1e19));
7207        assert_eq!((p.x_l[1], p.x_u[1]), (0.0, 1e19));
7208
7209        // Flag 2 (bounded above, y <= 0): the body must be <= 0.
7210        let p = parse_nl_string(ralph1_nl("5 2 2", "1 0"), false).unwrap();
7211        assert_eq!(p.complementarity[0].flags, 2);
7212        assert_eq!((p.g_l[0], p.g_u[0]), (-1e19, 0.0));
7213
7214        // Flag 0 (free variable): the body is an equation, and no pair.
7215        let p = parse_nl_string(ralph1_nl("5 0 2", "3"), false).unwrap();
7216        assert!(p.complementarity.is_empty());
7217        assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 0.0));
7218
7219        // Flag 3 (mixed) is recorded; the lowering is what refuses it.
7220        let p = parse_nl_string(ralph1_nl("5 3 2", "0 0 1"), false).unwrap();
7221        assert_eq!(p.complementarity[0].flags, 3);
7222
7223        // Malformed lines are errors, not silently dropped pairs.
7224        assert!(parse_nl_string(ralph1_nl("5 1", "2 0"), false).is_err());
7225        assert!(parse_nl_string(ralph1_nl("5 1 0", "2 0"), false).is_err());
7226        assert!(parse_nl_string(ralph1_nl("5 7 2", "2 0"), false).is_err());
7227    }
7228
7229    #[test]
7230    fn a_model_without_complementarity_reads_exactly_as_before() {
7231        // The same model with an ordinary `r` line: no pairs, the row's
7232        // bounds as written.
7233        let p = parse_nl_string(ralph1_nl("2 0", "2 0"), false).unwrap();
7234        assert!(p.complementarity.is_empty());
7235        assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 1e19));
7236    }
7237
7238    /// Run `f` on a thread with the stack the CLI and `pounce-py` provide,
7239    /// declared to the reader the way they declare it.
7240    fn on_big_stack(f: impl FnOnce() + Send + 'static) {
7241        std::thread::Builder::new()
7242            .stack_size(1 << 30)
7243            .spawn(move || {
7244                set_parse_stack_budget(Some(1 << 30));
7245                f();
7246                set_parse_stack_budget(None);
7247            })
7248            .unwrap()
7249            .join()
7250            .unwrap();
7251    }
7252
7253    /// A model whose objective is the last of `len` defined variables, each
7254    /// referencing the previous: `V_0 = x0^2`, `V_k = V_{k-1} + x0`, so the
7255    /// objective is `x0^2 + (len - 1) x0`. Every `V` segment is one level
7256    /// deep to the parser; the chain is `len` deep to anything that walks
7257    /// through it.
7258    fn v_chain_nl(len: usize) -> String {
7259        let mut l = vec![
7260            "g3 1 1 0".to_string(),
7261            " 1 1 1 0 1".into(),
7262            " 0 1".into(),
7263            " 0 0".into(),
7264            " 0 1 0".into(),
7265            " 0 0 0 1 0".into(),
7266            format!(" 0 0 {len} 0 0"),
7267            " 1 1".into(),
7268            " 0 0".into(),
7269            " 0 0 0 0 0".into(),
7270        ];
7271        l.extend(["V1 0 0".into(), "o5".into(), "v0".into(), "n2".into()]);
7272        for k in 1..len {
7273            l.extend([
7274                format!("V{} 0 0", k + 1),
7275                "o0".into(),
7276                format!("v{k}"),
7277                "v0".into(),
7278            ]);
7279        }
7280        l.extend(["C0".into(), "n0".into()]);
7281        l.extend(["O0 0".into(), format!("v{len}")]);
7282        l.extend(["r".into(), "4 0.5".into(), "b".into(), "0 -1.0 1.0".into()]);
7283        l.extend(["k0".into(), "J0 1".into(), "0 1.0".into()]);
7284        l.extend(["G0 1".into(), "0 0.0".into()]);
7285        l.join("\n") + "\n"
7286    }
7287
7288    /// gh#986 review item 9: a chain of defined variables is counted by the
7289    /// depth guard. Each `V` segment is shallow to the parser, so the first
7290    /// pass let a chain of any length through, and every pass that walks
7291    /// through a `Cse` (`collect_vars`, the tape builder, drop glue) then
7292    /// recursed once per link -- an abort on an ordinary stack. On a thread
7293    /// that declared no stack (the default budget) a chain far past the guard
7294    /// is now a clean `Err`.
7295    #[test]
7296    fn gh986_a_long_defined_variable_chain_is_refused_not_overflowed() {
7297        let limit = parse_depth_limit() as usize;
7298        let err = parse_nl_text(&v_chain_nl(limit + 50))
7299            .err()
7300            .expect("a V chain past the guard must be an Err");
7301        assert!(err.contains("defined") && err.contains("levels"), "{err}");
7302        // A short chain on the same thread is fine, and evaluates.
7303        let prob = parse_nl_text(&v_chain_nl(limit / 2)).expect("inside the guard");
7304        let x = [0.5];
7305        let want = 0.25 + (limit / 2 - 1) as f64 * 0.5;
7306        let got = eval_expr(&prob.obj_expr(), &x);
7307        assert!((got - want).abs() < 1e-9, "{got} vs {want}");
7308    }
7309
7310    /// gh#986 review item 9: the depth of a `V` body is memoized, so a long
7311    /// chain inside the guard costs time linear in the chain (the first
7312    /// pass's `expr_tree_depth` re-walked the whole chain on every segment).
7313    #[test]
7314    fn gh986_a_long_defined_variable_chain_inside_the_guard_parses() {
7315        on_big_stack(|| {
7316            // Two levels per link (the `o0` and the `Cse` reference), just
7317            // inside this thread's limit.
7318            let len = (parse_depth_limit() as usize - 100) / 2;
7319            let t = std::time::Instant::now();
7320            let prob = parse_nl_text(&v_chain_nl(len)).expect("inside the guard on a big stack");
7321            let got = eval_expr(&prob.obj_expr(), &[0.5]);
7322            let want = 0.25 + (len - 1) as f64 * 0.5;
7323            assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
7324            // Linear. With only `expr_tree_depth` memoized, the parse-time
7325            // quadratic passes still re-walked the chain per segment and this
7326            // ran past 60 s (measured, test profile); seeded, ~50 ms.
7327            let took = t.elapsed();
7328            eprintln!("v chain {len}: parsed and evaluated in {took:?}");
7329            assert!(took.as_secs() < 30, "{took:?}");
7330        });
7331    }
7332
7333    /// gh#986 review item 9: a thread that declares no stack is assumed to
7334    /// have Rust's default thread stack (wasm: 1 MiB), not the CLI's 1 GiB.
7335    #[test]
7336    fn gh986_an_undeclared_stack_gets_the_default_assumption() {
7337        std::thread::spawn(|| {
7338            set_parse_stack_budget(None);
7339            assert_eq!(
7340                parse_depth_limit() as usize,
7341                DEFAULT_ASSUMED_STACK / PARSE_LEVEL_BYTES
7342            );
7343            assert!((parse_depth_limit() as usize) < MAX_PARSE_DEPTH as usize);
7344        })
7345        .join()
7346        .unwrap();
7347    }
7348
7349    /// gh#986: a left-deep `o0` chain of any length is read as one n-ary
7350    /// `Sum` — one level deep — so the issue's n = 20 000 (39 999 nested
7351    /// `o0`) parses, and so does 100k, with the same value as the nested
7352    /// binary tree would have had.
7353    #[test]
7354    fn gh986_long_o0_chain_is_read_as_a_flat_sum() {
7355        on_big_stack(|| {
7356            for n in [5_000usize, 20_000, 100_000] {
7357                let prob = parse_nl_text(&deep_sum_nl(n, 0)).expect("a long o0 sum must parse");
7358                let x: Vec<f64> = (0..n).map(|i| (i % 7) as f64 * 0.1).collect();
7359                let want: f64 = x.iter().map(|v| v.exp() + v * v).sum();
7360                let tree = prob.obj_expr();
7361                assert!(expr_tree_depth(&tree, &Default::default()) < 16, "n = {n}");
7362                let got = eval_expr(&tree, &x);
7363                assert!((got - want).abs() <= 1e-9 * want.abs(), "{got} vs {want}");
7364            }
7365        });
7366    }
7367
7368    /// gh#986: the guard follows the stack the thread declares, so a thread
7369    /// with an ordinary 8 MiB stack refuses deep nesting cleanly — in an
7370    /// unoptimised build (the worst frame size) exactly as in an optimised
7371    /// one — instead of overflowing it. This is the reliability the
7372    /// `parse_nl_text` SIGBUS/SIGSEGV reports lacked.
7373    #[test]
7374    fn gh986_guard_follows_the_declared_stack() {
7375        let stack = 8 << 20;
7376        std::thread::Builder::new()
7377            .stack_size(stack)
7378            .spawn(move || {
7379                set_parse_stack_budget(Some(stack));
7380                assert_eq!(parse_depth_limit() as usize, stack / PARSE_LEVEL_BYTES);
7381                let msg = parse_nl_text(&deep_sum_nl(4, 20_000))
7382                    .err()
7383                    .expect("40 000 levels cannot fit 8 MiB");
7384                assert!(msg.contains("levels"), "{msg}");
7385                // and a shallow model on the same thread is fine
7386                assert!(parse_nl_text(&deep_sum_nl(4, 50)).is_ok());
7387                set_parse_stack_budget(None);
7388            })
7389            .unwrap()
7390            .join()
7391            .unwrap();
7392    }
7393
7394    /// gh#986: nesting that really is deep (negations) past the guard is
7395    /// refused with an `Err`, at any depth, instead of overflowing the stack;
7396    /// nesting just inside the guard still parses. Both on the stack the
7397    /// callers provide, in debug as in release — the guard's headroom is
7398    /// what this pins.
7399    #[test]
7400    fn gh986_deep_nesting_is_a_clean_error_past_the_guard_and_parses_inside_it() {
7401        on_big_stack(|| {
7402            let deep = parse_nl_text(&deep_sum_nl(4, 30_000));
7403            let msg = deep.err().expect("past the guard must be an Err");
7404            assert!(msg.contains("levels"), "{msg}");
7405            // Just inside the limit this thread's declared stack gives
7406            // (`MAX_PARSE_DEPTH` in an optimised build, 32 768 in an
7407            // unoptimised one, whose worst-case frame is 32 KiB).
7408            let wraps = (parse_depth_limit() as usize - 100) / 2;
7409            assert!(parse_nl_text(&deep_sum_nl(4, wraps)).is_ok());
7410        });
7411    }
7412
7413    use super::*;
7414
7415    /// Compile-time guarantee for the batched-solve path (pounce#126):
7416    /// a parsed problem and the TNLP built from it must be movable to a
7417    /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
7418    /// `!Send` state) into the `Expr` DAG / tape pipeline.
7419    #[test]
7420    fn nl_problem_and_tnlp_are_send() {
7421        fn assert_send<T: Send>() {}
7422        assert_send::<NlProblem>();
7423        assert_send::<NlTnlp>();
7424        assert_send::<Expr>();
7425    }
7426
7427    /// `variant()` patches starting point / bounds on a clone and
7428    /// validates override lengths; the base instance is untouched.
7429    #[test]
7430    fn variant_overrides_bounds_and_x0() {
7431        let p = parse_nl_text(SIMPLE).expect("parse");
7432        let base = NlTnlp::new(p);
7433        let var = base
7434            .variant(&NlVariation {
7435                x0: Some(vec![3.0, 4.0]),
7436                x_l: Some(vec![-1.0, -2.0]),
7437                x_u: Some(vec![5.0, 6.0]),
7438                ..Default::default()
7439            })
7440            .expect("variant");
7441        let mut var = var;
7442        let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
7443        let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
7444        assert!(var.get_bounds_info(BoundsInfo {
7445            x_l: &mut x_l,
7446            x_u: &mut x_u,
7447            g_l: &mut g_l,
7448            g_u: &mut g_u,
7449        }));
7450        assert_eq!(x_l, [-1.0, -2.0]);
7451        assert_eq!(x_u, [5.0, 6.0]);
7452        let mut x = [0.0; 2];
7453        let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
7454        assert!(var.get_starting_point(StartingPoint {
7455            init_x: true,
7456            x: &mut x,
7457            init_z: false,
7458            z_l: &mut zl,
7459            z_u: &mut zu,
7460            init_lambda: false,
7461            lambda: &mut lam,
7462        }));
7463        assert_eq!(x, [3.0, 4.0]);
7464        // Base keeps its parsed (free) bounds.
7465        assert!(base.problem().x_l[0] < -1.0e18);
7466        // Length mismatch is an error, not a panic.
7467        assert!(
7468            base.variant(&NlVariation {
7469                x0: Some(vec![1.0]),
7470                ..Default::default()
7471            })
7472            .is_err()
7473        );
7474    }
7475
7476    /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
7477    /// Header values:
7478    ///   line 2: n=2 m=0 num_obj=1 0 0
7479    ///   line 3: 0 1   (1 nonlinear objective)
7480    ///   line 4: 0 0
7481    ///   line 5: 0 2 0 (nonlinear vars in obj=2)
7482    ///   line 6: 0 0 0 1
7483    ///   line 7: 0 0 0 0 0
7484    ///   line 8: 0 0   (no Jacobian nonzeros, no linear obj)
7485    ///   line 9: 0 0
7486    ///   line 10: 0 0 0 0 0
7487    /// Then `O0 0` followed by an expression tree:
7488    /// `(x0 - 1)^2 + (x1 - 2)^2` =
7489    ///   o0
7490    ///     o5 (o1 v0 n1) n2
7491    ///     o5 (o1 v1 n2) n2
7492    /// Then `b` segment: free for both.
7493    /// gh#987: Gay's ordering places nonlinear integers at the END of each
7494    /// nonlinear block and linear ones at the end of the model.
7495    #[test]
7496    fn discrete_census_locates_nonlinear_and_linear_integer_columns() {
7497        // n = 10: nlvb = 2 (1 int), con-only 3 (1 int), obj-only 1 (1 int),
7498        // then linear, ending in nbv = 1, niv = 1.
7499        let d = DiscreteCensus {
7500            binary: 1,
7501            integer: 1,
7502            nl_both: 1,
7503            nl_cons: 1,
7504            nl_objs: 1,
7505        };
7506        let c = NlCounts {
7507            nl_cons: 1,
7508            nl_objs: 1,
7509            nl_vars_cons: 5,
7510            nl_vars_objs: 6,
7511            nl_vars_both: 2,
7512        };
7513        assert_eq!(d.total(), 5);
7514        assert_eq!(d.integer_columns(10, Some(c)), Some(vec![1, 4, 5, 8, 9]));
7515        // Without the line-5 census the nonlinear columns are unknowable.
7516        assert_eq!(d.integer_columns(10, None), None);
7517        // Linear-only integers need no census.
7518        let lin = DiscreteCensus {
7519            binary: 2,
7520            ..Default::default()
7521        };
7522        assert_eq!(lin.integer_columns(4, None), Some(vec![2, 3]));
7523        // Inconsistent counts: more nonlinear-in-both integers than columns.
7524        let bad = DiscreteCensus {
7525            nl_both: 3,
7526            ..Default::default()
7527        };
7528        assert_eq!(bad.integer_columns(10, Some(c)), None);
7529    }
7530
7531    const SIMPLE: &str = "g3 0 1 0
75322 0 1 0 0
75330 1
75340 0
75350 2 0
75360 0 0 1
75370 0 0 0 0
75380 0
75390 0
75400 0 0 0 0
7541O0 0
7542o0
7543o5
7544o1
7545v0
7546n1
7547n2
7548o5
7549o1
7550v1
7551n2
7552n2
7553b
75543
75553
7556";
7557
7558    #[test]
7559    fn parses_simple_quadratic() {
7560        let p = parse_nl_text(SIMPLE).expect("parse");
7561        assert_eq!(p.n, 2);
7562        assert_eq!(p.m, 0);
7563        assert_eq!(p.num_obj, 1);
7564        // f(0,0) = 1 + 4 = 5
7565        let f = eval_expr(&p.obj_expr(), &[0.0, 0.0]);
7566        assert!((f - 5.0).abs() < 1e-12);
7567        // f(1,2) = 0
7568        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
7569        assert!(f.abs() < 1e-12);
7570    }
7571
7572    #[test]
7573    fn gradient_matches_analytic() {
7574        let p = parse_nl_text(SIMPLE).expect("parse");
7575        let x = [0.5, 1.0];
7576        let mut g = [0.0_f64; 2];
7577        grad_expr(&p.obj_expr(), &x, 1.0, &mut g);
7578        // d/dx0 = 2*(x0-1) = -1.0
7579        // d/dx1 = 2*(x1-2) = -2.0
7580        assert!((g[0] - (-1.0)).abs() < 1e-12);
7581        assert!((g[1] - (-2.0)).abs() < 1e-12);
7582    }
7583
7584    /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
7585    /// with global semantics so the presolve auxiliary-elimination
7586    /// safeguard actually engages. Pre-fix the default trait stub returned
7587    /// `false` and left the slice untouched, so a variable that is
7588    /// nonlinear in the objective but zero-gradient at the probe point
7589    /// could be wrongly eliminated.
7590    ///
7591    /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
7592    /// of the objective (NonLinear); x1 appears only in the linear part
7593    /// (Linear).
7594    #[test]
7595    fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
7596        // (x0 - 1)^2
7597        let obj_nl = Expr::Binary(
7598            BinOp::Pow,
7599            Box::new(Expr::Binary(
7600                BinOp::Sub,
7601                Box::new(Expr::Var(0)),
7602                Box::new(Expr::Const(1.0)),
7603            )),
7604            Box::new(Expr::Const(2.0)),
7605        );
7606        let prob = NlProblem {
7607            src: None,
7608            cse_bodies: Vec::new(),
7609            complementarity: Vec::new(),
7610            n: 2,
7611            m: 0,
7612            num_obj: 1,
7613            minimize: true,
7614            obj_nonlinear: NlBody::Tree(obj_nl),
7615            obj_linear: vec![(1, 3.0)],
7616            obj_constant: 0.0,
7617            con_nonlinear: vec![],
7618            con_linear: vec![],
7619            x_l: vec![f64::NEG_INFINITY; 2],
7620            x_u: vec![f64::INFINITY; 2],
7621            g_l: vec![],
7622            g_u: vec![],
7623            x0: vec![0.0; 2],
7624            lambda0: vec![],
7625            suffixes: NlSuffixes::default(),
7626            imported_funcs: vec![],
7627            ampl_options: vec![],
7628            nl_counts: None,
7629            n_discrete: None,
7630            var_names: vec![],
7631            con_names: vec![],
7632        };
7633        let mut tnlp = NlTnlp::new(prob);
7634        let mut types = vec![Linearity::Linear; 2];
7635        let ok = tnlp.get_variables_linearity(&mut types);
7636        // Pre-fix: default stub returns false (slice untouched).
7637        assert!(
7638            ok,
7639            "get_variables_linearity must report it filled the slice"
7640        );
7641        assert!(
7642            matches!(types[0], Linearity::NonLinear),
7643            "x0 is nonlinear in the objective"
7644        );
7645        assert!(
7646            matches!(types[1], Linearity::Linear),
7647            "x1 appears only in the linear part"
7648        );
7649    }
7650
7651    /// Objective-scoped linearity must NOT inherit constraint
7652    /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
7653    /// (constraint tape) but linear w.r.t. the objective, so the presolve
7654    /// H11 guard must not treat it as objective-coupled — that was the CI
7655    /// regression where every gas-network variable (nonlinear in the flow
7656    /// equations, absent from the linear objective) blocked Phase-0
7657    /// elimination.
7658    #[test]
7659    fn objective_variables_linearity_ignores_constraint_nonlinearity() {
7660        // x0^2
7661        let con_nl = Expr::Binary(
7662            BinOp::Pow,
7663            Box::new(Expr::Var(0)),
7664            Box::new(Expr::Const(2.0)),
7665        );
7666        let prob = NlProblem {
7667            src: None,
7668            cse_bodies: Vec::new(),
7669            complementarity: Vec::new(),
7670            n: 2,
7671            m: 1,
7672            num_obj: 1,
7673            minimize: true,
7674            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
7675            obj_linear: vec![(1, 3.0)],
7676            obj_constant: 0.0,
7677            con_nonlinear: vec![NlBody::Tree(con_nl)],
7678            con_linear: vec![vec![]],
7679            x_l: vec![f64::NEG_INFINITY; 2],
7680            x_u: vec![f64::INFINITY; 2],
7681            g_l: vec![4.0],
7682            g_u: vec![4.0],
7683            x0: vec![0.0; 2],
7684            lambda0: vec![0.0],
7685            suffixes: NlSuffixes::default(),
7686            imported_funcs: vec![],
7687            ampl_options: vec![],
7688            nl_counts: None,
7689            n_discrete: None,
7690            var_names: vec![],
7691            con_names: vec![],
7692        };
7693        let mut tnlp = NlTnlp::new(prob);
7694
7695        let mut global = vec![Linearity::Linear; 2];
7696        assert!(tnlp.get_variables_linearity(&mut global));
7697        assert!(
7698            matches!(global[0], Linearity::NonLinear),
7699            "global tags see x0's constraint nonlinearity"
7700        );
7701
7702        let mut obj = vec![Linearity::NonLinear; 2];
7703        assert!(tnlp.get_objective_variables_linearity(&mut obj));
7704        assert!(
7705            matches!(obj[0], Linearity::Linear),
7706            "x0 is linear w.r.t. the objective despite the nonlinear constraint"
7707        );
7708        assert!(
7709            matches!(obj[1], Linearity::Linear),
7710            "x1 is linear everywhere"
7711        );
7712    }
7713
7714    /// Header lines 3 and 5 land in [`NlCounts`], with the field order the
7715    /// format documents: `nlc nlo` then `nlvc nlvo nlvb`. `SIMPLE` is
7716    /// `min (x0-1)^2 + (x1-2)^2`, so one nonlinear objective, no nonlinear
7717    /// constraints, and both variables nonlinear in the objective only.
7718    #[test]
7719    fn header_census_is_parsed() {
7720        let p = parse_nl_text(SIMPLE).expect("parse");
7721        let c = p.nl_counts.expect("SIMPLE has a well-formed header");
7722        assert_eq!(c.nl_cons, 0);
7723        assert_eq!(c.nl_objs, 1);
7724        assert_eq!((c.nl_vars_cons, c.nl_vars_objs, c.nl_vars_both), (0, 2, 0));
7725        assert_eq!(c.nonlinear_vars(), 2);
7726    }
7727
7728    /// `nlvb` is inside both `nlvc` and `nlvo`, so the total is
7729    /// `nlvc + nlvo − nlvb`. The two degenerate directions matter as much
7730    /// as the overlapping one: disjoint sets add, and `max` would be wrong
7731    /// for them.
7732    #[test]
7733    fn nonlinear_var_total_uses_inclusion_exclusion() {
7734        let c = |vc, vo, vb| NlCounts {
7735            nl_cons: 0,
7736            nl_objs: 0,
7737            nl_vars_cons: vc,
7738            nl_vars_objs: vo,
7739            nl_vars_both: vb,
7740        };
7741        // Fully shared: the same 5 variables in both.
7742        assert_eq!(c(5, 5, 5).nonlinear_vars(), 5);
7743        // Disjoint: `min x0^2 s.t. x1^2 <= 1` is two nonlinear variables,
7744        // not the `max(nlvc, nlvo) = 1` a naive reading gives.
7745        assert_eq!(c(1, 1, 0).nonlinear_vars(), 2);
7746        // Partial overlap: 4 + 3 - 2.
7747        assert_eq!(c(4, 3, 2).nonlinear_vars(), 5);
7748        // Nonsense header: saturates instead of underflowing.
7749        assert_eq!(c(1, 1, 9).nonlinear_vars(), 0);
7750    }
7751
7752    /// A header that does not carry the documented fields records no
7753    /// census at all rather than a guess of zero — "no nonlinear
7754    /// variables" is a claim, and a truncated header has not made it.
7755    #[test]
7756    fn short_header_line_records_no_census() {
7757        // Line 5 with two fields instead of `nlvc nlvo nlvb`.
7758        let txt = SIMPLE.replacen("0 2 0\n", "0 2\n", 1);
7759        assert_ne!(txt, SIMPLE, "the substitution must have applied");
7760        let p = parse_nl_text(&txt).expect("parse");
7761        assert!(p.nl_counts.is_none());
7762    }
7763
7764    /// `get_number_of_nonlinear_variables` used to be the trait default,
7765    /// `-1` ("assume everything is nonlinear"). It now answers from the
7766    /// trees, and `get_list_of_nonlinear_variables` agrees with it.
7767    ///
7768    /// `min (x0 - 1)^2 + 3*x1`: x0 is nonlinear, x1 is not.
7769    #[test]
7770    fn nonlinear_variable_list_excludes_linear_columns() {
7771        let obj_nl = Expr::Binary(
7772            BinOp::Pow,
7773            Box::new(Expr::Binary(
7774                BinOp::Sub,
7775                Box::new(Expr::Var(0)),
7776                Box::new(Expr::Const(1.0)),
7777            )),
7778            Box::new(Expr::Const(2.0)),
7779        );
7780        let parts = NlProblemParts {
7781            minimize: true,
7782            objective: obj_nl,
7783            obj_constant: 0.0,
7784            constraints: vec![],
7785            x_l: vec![-1e19; 2],
7786            x_u: vec![1e19; 2],
7787            x0: vec![0.0; 2],
7788            g_l: vec![],
7789            g_u: vec![],
7790            var_names: vec![],
7791            con_names: vec![],
7792        };
7793        let prob = NlProblem::from_expressions(parts).expect("build");
7794        assert!(
7795            prob.nl_counts.is_none(),
7796            "a model built in memory has no header to read"
7797        );
7798        let mut tnlp = NlTnlp::new(prob);
7799        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 1);
7800        let mut list = [-1 as Index; 2];
7801        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7802        assert_eq!(list[0], 0);
7803    }
7804
7805    /// When the header says every variable is nonlinear the walk is
7806    /// skipped, and the answer is the same one the walk would give for
7807    /// `SIMPLE` (both variables appear in the objective's nonlinear part).
7808    #[test]
7809    fn all_nonlinear_header_short_circuits_to_n() {
7810        let p = parse_nl_text(SIMPLE).expect("parse");
7811        assert_eq!(p.nl_counts.expect("census").nonlinear_vars(), p.n);
7812        let mut tnlp = NlTnlp::new(p);
7813        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 2);
7814        let mut list = [-1 as Index; 2];
7815        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7816        assert_eq!(list, [0, 1]);
7817    }
7818
7819    /// The list must not be written when the caller's slice is too small —
7820    /// the contract's `false` return, not a panic.
7821    #[test]
7822    fn nonlinear_variable_list_declines_a_short_slice() {
7823        let p = parse_nl_text(SIMPLE).expect("parse");
7824        let mut tnlp = NlTnlp::new(p);
7825        let mut list = [-1 as Index; 1];
7826        assert!(!tnlp.get_list_of_nonlinear_variables(&mut list));
7827        assert_eq!(list, [-1]);
7828    }
7829
7830    /// `min x0^2 + x1^2  s.t.  x0 + x1 = 1`.
7831    /// One equality constraint with a purely linear Jacobian — exercises
7832    /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
7833    /// kind 4).
7834    ///
7835    /// Header layout:
7836    ///   line 1: g3 0 1 0
7837    ///   line 2: 2 1 1 0 0   (n=2, m=1, num_obj=1)
7838    ///   line 3: 0 1         (1 nonlinear obj, 0 nonlinear cons)
7839    ///   line 4: 0 0
7840    ///   line 5: 0 2 0       (nonlinear vars in obj=2)
7841    ///   line 6: 0 0 0 1
7842    ///   line 7: 0 0 0 0 0
7843    ///   line 8: 2 0         (Jacobian nnz=2, no linear obj)
7844    ///   line 9: 0 0
7845    ///   line 10: 0 0 0 0 0
7846    /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
7847    /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
7848    const EQ_LIN: &str = "g3 0 1 0
78492 1 1 0 0
78500 1
78510 0
78520 2 0
78530 0 0 1
78540 0 0 0 0
78552 0
78560 0
78570 0 0 0 0
7858C0
7859n0
7860O0 0
7861o0
7862o5
7863v0
7864n2
7865o5
7866v1
7867n2
7868r
78694 1
7870b
78713
78723
7873k1
78742
7875J0 2
78760 1
78771 1
7878";
7879
7880    #[test]
7881    fn parses_constrained_problem() {
7882        let p = parse_nl_text(EQ_LIN).expect("parse");
7883        assert_eq!(p.n, 2);
7884        assert_eq!(p.m, 1);
7885        // r-segment kind 4 (equality with rhs=1).
7886        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7887        assert!((p.g_u[0] - 1.0).abs() < 1e-12);
7888        // J-row 0: x0 (coef 1), x1 (coef 1).
7889        assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
7890    }
7891
7892    #[test]
7893    fn malformed_j_variable_index_is_parse_error_not_panic() {
7894        // Code review L32: a J-segment entry's variable (column) index was
7895        // pushed into con_linear unchecked, so an out-of-range index (here 5
7896        // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
7897        // constraint evaluation. It must instead surface as a clean parse
7898        // error, consistent with the existing `J<row> out of range` check.
7899        let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
7900        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7901        let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
7902        assert!(err.contains("out of range"), "unexpected error: {err}");
7903    }
7904
7905    #[test]
7906    fn out_of_range_x_segment_index_is_parse_error() {
7907        // Same strictness for the initial-primal `x` segment: an index past
7908        // `n` used to be silently dropped; now it is a parse error, so the
7909        // four index-bearing segments (J/G/x/d) behave consistently.
7910        let bad = format!("{EQ_LIN}x1\n5 0.5\n");
7911        let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
7912        assert!(err.contains("out of range"), "unexpected error: {err}");
7913    }
7914
7915    // ---------------------------------------------------------------
7916    // gh #492 — a constant `C<i>` body folds into the row bounds.
7917    //
7918    // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
7919    // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
7920    // constant-body rows to fold.
7921    // ---------------------------------------------------------------
7922
7923    /// Linearity is a *linearity* test, not "did a `C` segment touch this
7924    /// row". A row whose body is the bare constant `3` is affine.
7925    #[test]
7926    fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
7927        // `x0 + x1 + 3 = 1`  ⇔  `x0 + x1 = -2`.
7928        let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7929        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7930        let p = parse_nl_text(&nl).expect("parse");
7931
7932        assert!(
7933            p.con_nonlinear[0].is_trivially_zero(),
7934            "the constant body must be replaced by the identity zero, got {:?}",
7935            p.con_nonlinear[0]
7936        );
7937        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7938        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7939
7940        let mut lin = [Linearity::NonLinear];
7941        let mut t = NlTnlp::new(p);
7942        assert!(t.get_constraints_linearity(&mut lin));
7943        assert_eq!(lin[0], Linearity::Linear);
7944    }
7945
7946    /// The shift must be exact, not merely "linear now": the folded model
7947    /// and the hand-folded one must be the same problem, row body for row
7948    /// body and bound for bound. That is what keeps feasibility, the
7949    /// active set, and the duals unchanged.
7950    #[test]
7951    fn folding_a_row_constant_gives_the_hand_folded_problem() {
7952        // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
7953        let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7954        let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
7955        assert_ne!(offset, EQ_LIN);
7956        assert_ne!(folded, EQ_LIN);
7957
7958        let a = parse_nl_text(&offset).expect("parse offset form");
7959        let b = parse_nl_text(&folded).expect("parse folded form");
7960        assert_eq!(a.g_l, b.g_l);
7961        assert_eq!(a.g_u, b.g_u);
7962        assert_eq!(a.con_linear, b.con_linear);
7963
7964        // And the row *values* agree pointwise, which is the property the
7965        // duals ride on: `g(x) - g_l` is the same residual either way.
7966        let mut ga = [0.0];
7967        let mut gb = [0.0];
7968        let x = [0.75, -1.25];
7969        assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
7970        assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
7971        assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
7972    }
7973
7974    /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
7975    /// constant as `n3`, and AMPL emits such trees when a expression
7976    /// collapses without being re-simplified.
7977    #[test]
7978    fn a_row_body_that_evaluates_to_a_constant_folds_too() {
7979        // `C0` = `1 + 2`.
7980        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
7981        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7982        let p = parse_nl_text(&nl).expect("parse");
7983        assert!(p.con_nonlinear[0].is_trivially_zero());
7984        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7985        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7986    }
7987
7988    /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
7989    /// "absent", not "a very large number". Shifting one turns it into a
7990    /// real bound and invents a constraint that is not in the model.
7991    ///
7992    /// The constants here are deliberately huge. An everyday `3` is
7993    /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
7994    /// presence guard would go unnoticed at ordinary magnitudes and then
7995    /// bite on a model that scales its rows. The guard is what makes the
7996    /// sentinel untouchable at *any* magnitude.
7997    #[test]
7998    fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
7999        // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
8000        // bound. Shifting the lower sentinel would leave `-9e18`, a real
8001        // bound, so the row would gain a floor the model never stated.
8002        let nl = EQ_LIN
8003            .replace("C0\nn0\n", "C0\nn-1e18\n")
8004            .replace("r\n4 1\n", "r\n1 1\n");
8005        let p = parse_nl_text(&nl).expect("parse");
8006        assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
8007        assert!(
8008            !lower_bound_present(p.g_l[0]),
8009            "the absent-lower sentinel became a real bound: {}",
8010            p.g_l[0]
8011        );
8012
8013        // The mirror case: a positive constant on a lower-bounded row is
8014        // the one that would pull the *upper* sentinel below 1e19.
8015        let nl = EQ_LIN
8016            .replace("C0\nn0\n", "C0\nn1e18\n")
8017            .replace("r\n4 1\n", "r\n2 1\n");
8018        let p = parse_nl_text(&nl).expect("parse");
8019        assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
8020        assert!(
8021            !upper_bound_present(p.g_u[0]),
8022            "the absent-upper sentinel became a real bound: {}",
8023            p.g_u[0]
8024        );
8025    }
8026
8027    /// A row body that mentions a variable is not a constant, however
8028    /// simple it looks. Folding it would delete the term.
8029    #[test]
8030    fn a_row_body_with_a_variable_is_not_folded() {
8031        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
8032        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8033        let p = parse_nl_text(&nl).expect("parse");
8034        assert!(
8035            !matches!(p.con_nonlinear[0].tree(), Some(Expr::Const(_))),
8036            "a row in x0 was folded away: {:?}",
8037            p.con_nonlinear[0]
8038        );
8039        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
8040        assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
8041    }
8042
8043    /// A variable-free body whose value is not finite is left in place. It
8044    /// makes the row infeasible (or ill-posed) and that is the solver's
8045    /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
8046    /// corrupt the bound pair and take every downstream presence test with
8047    /// it.
8048    #[test]
8049    fn a_non_finite_constant_row_body_is_not_folded() {
8050        // `C0` = `log(-1)` = NaN.
8051        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
8052        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8053        let p = parse_nl_text(&nl).expect("parse");
8054        assert!(
8055            !p.con_nonlinear[0].is_trivially_zero(),
8056            "a NaN body was folded into the bounds"
8057        );
8058        assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
8059        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
8060    }
8061
8062    /// An imported-function call is not a parse-time constant even with
8063    /// constant arguments — it is resolved to a shared library much later
8064    /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
8065    /// rather than guess. The fold must decline before it evaluates.
8066    #[test]
8067    fn a_constant_argument_funcall_row_body_is_not_folded() {
8068        // Declare one imported function and call it with a literal.
8069        let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
8070        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8071        let p = parse_nl_text(&nl).expect("parse");
8072        assert!(
8073            matches!(p.con_nonlinear[0].tree(), Some(Expr::Funcall { .. })),
8074            "expected the funcall to survive the fold, got {:?}",
8075            p.con_nonlinear[0]
8076        );
8077        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
8078    }
8079
8080    #[test]
8081    fn k_segment_nonstandard_count_is_parse_error_at_source() {
8082        // Code review L35: the `k` (Jacobian column-count) segment header
8083        // declares how many count lines follow — `k<count>` — and the
8084        // standard value is n-1. The parser used to *assume* n-1 and ignore
8085        // the header, so a file declaring a different count read the wrong
8086        // number of data lines, desynced the segment stream, and failed far
8087        // downstream with a confusing error (or silently mis-parsed). With
8088        // the declared count now read and validated, a nonstandard count is
8089        // a clear parse error at its source. Here EQ_LIN has n=2 (expected
8090        // count 1); rewrite its `k1` + one count line to `k0`.
8091        let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
8092        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
8093        let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
8094        assert!(
8095            err.contains("k-segment declares"),
8096            "expected a clear k-segment count error, got: {err}"
8097        );
8098    }
8099
8100    #[test]
8101    fn get_starting_point_returns_nl_initial_duals() {
8102        // Code review 2026-06 item M19: the `.nl` `d` segment supplies
8103        // initial constraint multipliers. They are parsed into `lambda0`,
8104        // but `get_starting_point` previously ignored them — so a
8105        // `warm_start_init_point yes` solve silently began from zero duals.
8106        // `get_starting_point` must hand the parsed duals back when the
8107        // engine requests them (`init_lambda`), and leave the buffer
8108        // untouched when it does not.
8109        let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
8110        let p = parse_nl_text(&nl).expect("parse");
8111        // gh#1033: a `d` entry is an AMPL dual, so it is stored negated.
8112        assert_eq!(p.lambda0, vec![-2.5], "the `d` segment fills lambda0");
8113
8114        let mut t = NlTnlp::new(p);
8115        let info = t.get_nlp_info().unwrap();
8116        let (n, m) = (info.n as usize, info.m as usize);
8117
8118        // Warm-start request: init_lambda = true → the parsed `.nl` duals
8119        // must be returned (pre-fix this stayed zero).
8120        let mut x = vec![0.0; n];
8121        let mut z_l = vec![0.0; n];
8122        let mut z_u = vec![0.0; n];
8123        let mut lambda = vec![0.0; m];
8124        assert!(t.get_starting_point(StartingPoint {
8125            init_x: true,
8126            x: &mut x,
8127            init_z: false,
8128            z_l: &mut z_l,
8129            z_u: &mut z_u,
8130            init_lambda: true,
8131            lambda: &mut lambda,
8132        }));
8133        assert_eq!(
8134            lambda,
8135            vec![-2.5],
8136            "a warm start must use the `.nl` initial duals, not zero"
8137        );
8138
8139        // No warm-start request: the multiplier buffer is left alone (the
8140        // engine owns its default), so honoring the flag does not clobber it.
8141        let mut lambda_untouched = vec![7.0; m];
8142        assert!(t.get_starting_point(StartingPoint {
8143            init_x: true,
8144            x: &mut x,
8145            init_z: false,
8146            z_l: &mut z_l,
8147            z_u: &mut z_u,
8148            init_lambda: false,
8149            lambda: &mut lambda_untouched,
8150        }));
8151        assert_eq!(
8152            lambda_untouched,
8153            vec![7.0],
8154            "without init_lambda the multiplier buffer must be untouched"
8155        );
8156    }
8157
8158    /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
8159    /// unconstrained, `n` variables. The trailing product puts a nonzero
8160    /// in row 0 of *every* Hessian column, which is the shape that used
8161    /// to force the greedy coloring to hand out one color per variable.
8162    fn dense_row_objective_nl(n: usize) -> String {
8163        let mut s = String::new();
8164        s.push_str("g3 1 1 0\n");
8165        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8166        s.push_str(" 0 1\n 0 0\n");
8167        s.push_str(&format!(" {n} {n} {n}\n"));
8168        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8169        s.push_str(&format!(" 0 {n}\n"));
8170        s.push_str(" 0 0\n 0 0 0 0 0\n");
8171        // objective
8172        s.push_str("O0 0\n");
8173        s.push_str(&format!("o54\n{}\n", n + 1));
8174        for j in 0..n {
8175            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8176        }
8177        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8178        for j in 0..n {
8179            s.push_str(&format!("v{j}\n"));
8180        }
8181        // start, bounds, gradient
8182        s.push_str(&format!("x{n}\n"));
8183        for j in 0..n {
8184            s.push_str(&format!("{j} 0.5\n"));
8185        }
8186        s.push_str("b\n");
8187        for _ in 0..n {
8188            s.push_str("3\n");
8189        }
8190        s.push_str(&format!("G0 {n}\n"));
8191        for j in 0..n {
8192            s.push_str(&format!("{j} 0.0\n"));
8193        }
8194        s
8195    }
8196
8197    /// Same shape as [`dense_row_objective_nl`], but the coupling term
8198    /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
8199    /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
8200    /// magnitude makes the dense column ill-scaled — the case where
8201    /// reading its entries out of its own pass costs real digits.
8202    fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
8203        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8204        let mut s = String::new();
8205        s.push_str("g3 1 1 0\n");
8206        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8207        s.push_str(" 0 1\n 0 0\n");
8208        s.push_str(&format!(" {n} {n} {n}\n"));
8209        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8210        s.push_str(&format!(" 0 {n}\n"));
8211        s.push_str(" 0 0\n 0 0 0 0 0\n");
8212        s.push_str("O0 0\n");
8213        s.push_str(&format!("o54\n{}\n", n + 1));
8214        for j in 0..n {
8215            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8216        }
8217        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8218        for j in 0..n {
8219            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8220        }
8221        s.push_str(&format!("x{n}\n"));
8222        for j in 0..n {
8223            s.push_str(&format!("{j} 0.5\n"));
8224        }
8225        s.push_str("b\n");
8226        for _ in 0..n {
8227            s.push_str("3\n");
8228        }
8229        s.push_str(&format!("G0 {n}\n"));
8230        for j in 0..n {
8231            s.push_str(&format!("{j} 0.0\n"));
8232        }
8233        s
8234    }
8235
8236    /// Locate a model in the benchmark corpus, or `None` if the corpus is
8237    /// not on this machine.
8238    ///
8239    /// The corpus is ~2 GB and deliberately outside the checkout (see
8240    /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
8241    /// no-op rather than fail. That is a real limitation — a check that
8242    /// silently does nothing is how the gap below went unnoticed in the
8243    /// first place — so anything using this must also be covered by a
8244    /// synthetic case that always runs.
8245    fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
8246        let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
8247        let p = std::path::PathBuf::from(root).join(rel);
8248        p.is_file().then_some(p)
8249    }
8250
8251    /// The corpus check the dense-column optimization never got.
8252    ///
8253    /// `cho_parmest` is the model whose certificate the optimization cost,
8254    /// and it could not have caught it: the model is 4.3 MB and lives in
8255    /// the benchmark data set, not in the repository, so validating
8256    /// against the in-repo `.nl` fixtures said nothing about it. This test
8257    /// closes that by checking the corpus directly wherever the corpus
8258    /// exists, which is every machine and CI job that runs the benchmarks.
8259    ///
8260    /// The assertion is the one that matters and the one that was never
8261    /// made: whatever the guard leaves peeled must decode to the same
8262    /// Hessian as peeling nothing. Against the pre-fix code this fails —
8263    /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
8264    #[test]
8265    fn cho_parmest_decodes_to_its_unpeeled_reference() {
8266        let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
8267            eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
8268            return;
8269        };
8270        let p = read_nl_file(&path).expect("read cho_parmest");
8271        let n = p.n;
8272        let mut t = NlTnlp::new(p);
8273        // The guard vetoes 7 of cho's 12 peeled columns, and putting those
8274        // seven dense rows back into the conflict graph costs the coloring
8275        // outright: it goes from 17 colors to 9010, and no column clears the
8276        // density threshold afterwards, so the model ends up fully unpeeled.
8277        // That is the price of the certificate on this model, and it is worth
8278        // knowing rather than assuming the other five survive.
8279        assert!(t.peeled_cols.is_empty());
8280
8281        let info = t.get_nlp_info().unwrap();
8282        let nnz = info.nnz_h_lag as usize;
8283        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8284        assert!(t.eval_h(
8285            None,
8286            true,
8287            1.0,
8288            None,
8289            true,
8290            SparsityRequest::Structure {
8291                irow: &mut irow,
8292                jcol: &mut jcol
8293            }
8294        ));
8295        // Evaluate away from `x0` with non-uniform multipliers. The damage
8296        // is point-dependent, and `x0` is close to where it is least
8297        // visible: only 5 entries move there, against 48,931 here. Note the
8298        // guard's own probe runs at `x0` — it fires anyway because it tests
8299        // a bound on what a pass *could* lose, not the damage it happens to
8300        // commit at one point. That is the property that makes it robust,
8301        // and this asymmetry is worth keeping in front of anyone who
8302        // retunes it.
8303        let x: Vec<f64> = t
8304            .prob
8305            .x0
8306            .iter()
8307            .enumerate()
8308            .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
8309            .collect();
8310        let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
8311
8312        let mut got = vec![0.0_f64; nnz];
8313        assert!(t.eval_h(
8314            Some(&x),
8315            true,
8316            1.0,
8317            Some(&lambda),
8318            true,
8319            SparsityRequest::Values { values: &mut got }
8320        ));
8321
8322        t.recolor(&vec![true; n]);
8323        assert!(t.peeled_cols.is_empty());
8324        let mut want = vec![0.0_f64; nnz];
8325        assert!(t.eval_h(
8326            Some(&x),
8327            true,
8328            1.0,
8329            Some(&lambda),
8330            true,
8331            SparsityRequest::Values { values: &mut want }
8332        ));
8333
8334        let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
8335        let mut worst = 0.0_f64;
8336        let mut at = 0usize;
8337        for k in 0..nnz {
8338            let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8339            if rel > worst {
8340                worst = rel;
8341                at = k;
8342            }
8343        }
8344        assert!(
8345            worst <= 1e-13,
8346            "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
8347             {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
8348             out of a pass it cannot be read out of.",
8349            irow[at],
8350            jcol[at],
8351            got[at],
8352            want[at]
8353        );
8354
8355        // The check above passes trivially on correct code, because the
8356        // guard leaves cho fully unpeeled and so compares a configuration
8357        // against itself. It only has teeth against a regression. So prove
8358        // the guard is doing necessary work rather than assuming it: put
8359        // the peels back the way the pre-fix reader had them and confirm
8360        // the Hessian really does come apart.
8361        t.recolor(&vec![false; n]);
8362        assert!(
8363            !t.peeled_cols.is_empty(),
8364            "restoring the unvetoed coloring must peel again"
8365        );
8366        let mut unguarded = vec![0.0_f64; nnz];
8367        assert!(t.eval_h(
8368            Some(&x),
8369            true,
8370            1.0,
8371            Some(&lambda),
8372            true,
8373            SparsityRequest::Values {
8374                values: &mut unguarded
8375            }
8376        ));
8377        let mut bad = 0usize;
8378        let mut worst_unguarded = 0.0_f64;
8379        for k in 0..nnz {
8380            let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8381            if rel > 1e-13 {
8382                bad += 1;
8383            }
8384            worst_unguarded = worst_unguarded.max(rel);
8385        }
8386        // Two different counts get quoted about this model and they measure
8387        // different things: 48,931 of the 96,000 entries differ from the
8388        // uncompressed reference *at all*, down to the last bit, while 88
8389        // exceed 1e-13 relative. The second is the one worth asserting on.
8390        assert!(
8391            bad >= 50 && worst_unguarded > 1e-11,
8392            "peeling cho_parmest unguarded should damage the entries the guard \
8393             exists to protect (measured: 88 entries past 1e-13 relative, \
8394             worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
8395             this fires, the model or the corpus changed and the guard's \
8396             calibration should be re-derived rather than the bound relaxed."
8397        );
8398        eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
8399    }
8400
8401    /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
8402    /// the dense variable is `x_{n-1}` instead of `x_0`:
8403    /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
8404    ///
8405    /// The index matters, and it is the whole reason this helper exists.
8406    /// With the dense variable at 0 every coupling entry is stored as
8407    /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
8408    /// out of column 0's own pass whether or not peeling is on, so the two
8409    /// paths are bit-identical and no test built on that shape can tell
8410    /// them apart. Putting the dense variable last stores them as
8411    /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
8412    /// back from column `n-1`'s pass by symmetry, carrying that pass's
8413    /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
8414    /// That is the category every one of `cho_parmest`'s 48,931 damaged
8415    /// entries fell into.
8416    fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
8417        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
8418        let d = n - 1;
8419        let mut s = String::new();
8420        s.push_str("g3 1 1 0\n");
8421        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8422        s.push_str(" 0 1\n 0 0\n");
8423        s.push_str(&format!(" {n} {n} {n}\n"));
8424        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8425        s.push_str(&format!(" 0 {n}\n"));
8426        s.push_str(" 0 0\n 0 0 0 0 0\n");
8427        s.push_str("O0 0\n");
8428        s.push_str(&format!("o54\n{}\n", n + 1));
8429        for j in 0..n {
8430            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8431        }
8432        s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
8433        for j in 0..d {
8434            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8435        }
8436        s.push_str(&format!("x{n}\n"));
8437        for j in 0..n {
8438            s.push_str(&format!("{j} 0.5\n"));
8439        }
8440        s.push_str("b\n");
8441        for _ in 0..n {
8442            s.push_str("3\n");
8443        }
8444        s.push_str(&format!("G0 {n}\n"));
8445        for j in 0..n {
8446            s.push_str(&format!("{j} 0.0\n"));
8447        }
8448        s
8449    }
8450
8451    /// A well-scaled dense column is still peeled, and the entries read
8452    /// out of its pass are exact — the property the peel guard must not
8453    /// cost us.
8454    #[test]
8455    fn a_well_scaled_dense_column_is_still_peeled() {
8456        let n = 200;
8457        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
8458        let t = NlTnlp::new(p);
8459        assert_eq!(
8460            t.peeled_cols,
8461            vec![0],
8462            "a dense column that costs no accuracy must stay peeled"
8463        );
8464        assert!(
8465            t.seeds.len() <= 4,
8466            "peeling should keep the color count at O(1), got {}",
8467            t.seeds.len()
8468        );
8469    }
8470
8471    /// An ill-scaled dense column must be un-peeled and colored the
8472    /// ordinary way, so its small entries come back to full precision.
8473    ///
8474    /// This test covers the *guard*, not the damage. It asserts that a
8475    /// column spanning 12 orders is un-peeled and that its entries are then
8476    /// exact — and it is worth being precise that it does not, and cannot,
8477    /// show what peeling would have cost, because on this model peeling
8478    /// costs nothing: force the peel through and every entry still comes
8479    /// back bit-identical to its analytic weight. The Hessian here is
8480    /// constant and each entry is a single product, so there is no
8481    /// accumulation for a large-magnitude pass to pollute.
8482    ///
8483    /// Reproducing the actual digit loss takes a model whose entries are
8484    /// summed through shared intermediates, which is why the demonstration
8485    /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
8486    /// real model rather than a synthetic one.
8487    #[test]
8488    fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
8489        let n = 200;
8490        let span = 12.0;
8491        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8492        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
8493        let mut t = NlTnlp::new(p);
8494
8495        assert!(
8496            t.peeled_cols.is_empty(),
8497            "a dense column spanning {span} orders must not be peeled; got {:?}",
8498            t.peeled_cols
8499        );
8500
8501        let info = t.get_nlp_info().unwrap();
8502        let nnz = info.nnz_h_lag as usize;
8503        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8504        assert!(t.eval_h(
8505            None,
8506            true,
8507            1.0,
8508            None,
8509            true,
8510            SparsityRequest::Structure {
8511                irow: &mut irow,
8512                jcol: &mut jcol
8513            }
8514        ));
8515        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8516        let mut vals = vec![0.0_f64; nnz];
8517        assert!(t.eval_h(
8518            Some(&x),
8519            true,
8520            1.0,
8521            None,
8522            true,
8523            SparsityRequest::Values { values: &mut vals }
8524        ));
8525
8526        // Every coupling entry must be its weight to full relative
8527        // precision. Peeled, the smallest of them came back with a
8528        // relative error near 1e-4.
8529        let mut checked = 0;
8530        for k in 0..nnz {
8531            let (i, j) = (irow[k] as usize, jcol[k] as usize);
8532            if i == j {
8533                continue;
8534            }
8535            assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8536            checked += 1;
8537            let want = w(i);
8538            assert!(
8539                (vals[k] - want).abs() <= 1e-13 * want,
8540                "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
8541                vals[k],
8542                (vals[k] - want).abs() / want
8543            );
8544        }
8545        assert_eq!(checked, n - 1);
8546    }
8547
8548    /// Whatever survives the peel guard must decode to the same Hessian
8549    /// that not peeling at all produces — every entry, not just the
8550    /// objective.
8551    ///
8552    /// This exists because the original dense-column optimization was
8553    /// validated by running the repository's `.nl` fixtures and comparing
8554    /// objective value and exit status, which was doubly blind: not one of
8555    /// the 60 fixtures has a Hessian row dense enough to peel anything, so
8556    /// the decode path under test never executed, and even had it executed,
8557    /// the defect cost digits in the multipliers while leaving the
8558    /// objective intact. So the instrument has to be the assembled Hessian
8559    /// and the input has to actually peel — hence `peeled_any` below, which
8560    /// fails if the sweep ever goes vacuous the way the fixture suite
8561    /// silently did.
8562    ///
8563    /// What this catches is a *decode* fault: an entry recovered from the
8564    /// wrong pass, which is wrong by O(1). It would not have caught the
8565    /// `cho_parmest` stall, because these synthetic models lose no
8566    /// precision under peeling at all (see
8567    /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
8568    /// precision half is covered against the real model in
8569    /// `cho_parmest_decodes_to_its_unpeeled_reference`.
8570    #[test]
8571    fn a_peeled_decode_matches_an_unpeeled_reference() {
8572        // `last = true` puts the dense variable at `n-1`, so the coupling
8573        // entries are stored with the peeled column as their *row* — the
8574        // only shape in which peeling and not peeling read an entry out of
8575        // different passes, and so the only shape that can detect a
8576        // difference at all. See `weighted_dense_last_col_nl`.
8577        let cases = [
8578            (200, 0.0, false),
8579            (200, 2.0, false),
8580            (200, 0.0, true),
8581            (200, 1.0, true),
8582            (200, 2.0, true),
8583            (400, 3.0, true),
8584            (600, 0.0, true),
8585        ];
8586        let mut peeled_any = false;
8587        let mut worst = 0.0_f64;
8588
8589        for &(n, span, last) in &cases {
8590            let text = if last {
8591                weighted_dense_last_col_nl(n, span)
8592            } else {
8593                weighted_dense_row_objective_nl(n, span)
8594            };
8595            let p = parse_nl_text(&text).expect("parse");
8596            let mut t = NlTnlp::new(p);
8597            let peeled = t.peeled_cols.clone();
8598            peeled_any |= !peeled.is_empty();
8599
8600            let info = t.get_nlp_info().unwrap();
8601            let nnz = info.nnz_h_lag as usize;
8602            let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8603            assert!(t.eval_h(
8604                None,
8605                true,
8606                1.0,
8607                None,
8608                true,
8609                SparsityRequest::Structure {
8610                    irow: &mut irow,
8611                    jcol: &mut jcol
8612                }
8613            ));
8614            let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
8615
8616            let mut got = vec![0.0_f64; nnz];
8617            assert!(t.eval_h(
8618                Some(&x),
8619                true,
8620                1.0,
8621                None,
8622                true,
8623                SparsityRequest::Values { values: &mut got }
8624            ));
8625
8626            // Same object, same tapes, same point — only the coloring
8627            // differs, so any disagreement is the decode path.
8628            t.recolor(&vec![true; n]);
8629            assert!(
8630                t.peeled_cols.is_empty(),
8631                "a fully vetoed model must peel nothing"
8632            );
8633            let mut want = vec![0.0_f64; nnz];
8634            assert!(t.eval_h(
8635                Some(&x),
8636                true,
8637                1.0,
8638                None,
8639                true,
8640                SparsityRequest::Values { values: &mut want }
8641            ));
8642
8643            for k in 0..nnz {
8644                let scale = want[k].abs().max(f64::MIN_POSITIVE);
8645                let rel = (got[k] - want[k]).abs() / scale;
8646                worst = worst.max(rel);
8647                assert!(
8648                    rel <= 1e-13,
8649                    "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
8650                     unpeeled reference {:e} (relative error {rel:e})",
8651                    irow[k],
8652                    jcol[k],
8653                    got[k],
8654                    want[k]
8655                );
8656            }
8657        }
8658
8659        assert!(
8660            peeled_any,
8661            "no case peeled anything, so this test proved nothing about the \
8662             decode path — the exact way the fixture suite missed the bug"
8663        );
8664        assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
8665    }
8666
8667    /// `peel_veto` bars a column from the peel set, and the row it puts
8668    /// back into the conflict structure costs the colors it used to save.
8669    #[test]
8670    fn a_vetoed_column_is_colored_the_ordinary_way() {
8671        let n = 300;
8672        // One dense row plus a diagonal: column 0 touches every row.
8673        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8674        pairs.extend((1..n).map(|i| (i, 0)));
8675        pairs.sort_unstable();
8676
8677        let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8678        assert!(peeled[0], "the dense column should peel by default");
8679        assert!(
8680            colors_peeled <= 4,
8681            "peeling should collapse the count, got {colors_peeled}"
8682        );
8683
8684        let mut veto = vec![false; n];
8685        veto[0] = true;
8686        let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
8687        assert!(!peeled[0], "a vetoed column must not be peeled");
8688        assert!(
8689            colors_vetoed > colors_peeled,
8690            "un-peeling restores row 0's conflicts, so colors must rise: \
8691             {colors_vetoed} vs {colors_peeled}"
8692        );
8693    }
8694
8695    /// A single dense Hessian row must not blow the coloring up to one
8696    /// color per variable. It used to: every column shares row 0, so no
8697    /// two columns could be colored alike, and `seeds` / `compressed`
8698    /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
8699    /// per `eval_h` on a Hessian holding only ~2n nonzeros.
8700    #[test]
8701    fn a_dense_hessian_row_does_not_explode_the_coloring() {
8702        let n = 200;
8703        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8704        let t = NlTnlp::new(p);
8705        // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
8706        // for j >= 1 from the coupling term.
8707        assert_eq!(t.h_irow.len(), 2 * n - 1);
8708        assert!(
8709            t.seeds.len() <= 4,
8710            "one dense row should cost one extra color, not n; got {} colors for n={n}",
8711            t.seeds.len()
8712        );
8713        assert_eq!(t.seeds.len(), t.compressed.len());
8714    }
8715
8716    /// Peeling changes *which* directional product recovers an entry, so
8717    /// the recovered Hessian must still be exactly right — including the
8718    /// entries read out of a peeled column's pass by symmetry.
8719    #[test]
8720    fn peeled_dense_column_still_recovers_the_exact_hessian() {
8721        let n = 200;
8722        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8723        let mut t = NlTnlp::new(p);
8724        let info = t.get_nlp_info().unwrap();
8725        let nnz = info.nnz_h_lag as usize;
8726
8727        let mut irow = vec![0_i32; nnz];
8728        let mut jcol = vec![0_i32; nnz];
8729        assert!(t.eval_h(
8730            None,
8731            true,
8732            1.0,
8733            None,
8734            true,
8735            SparsityRequest::Structure {
8736                irow: &mut irow,
8737                jcol: &mut jcol
8738            }
8739        ));
8740
8741        // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
8742        //   H[0,0] = 2 + 2 = 4;  H[j,j] = 2 (j >= 1);  H[j,0] = 1 (j >= 1).
8743        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8744        let obj_factor = 2.5;
8745        let mut vals = vec![0.0_f64; nnz];
8746        assert!(t.eval_h(
8747            Some(&x),
8748            true,
8749            obj_factor,
8750            None,
8751            true,
8752            SparsityRequest::Values { values: &mut vals }
8753        ));
8754
8755        let mut seen_diag = 0;
8756        let mut seen_coupling = 0;
8757        for k in 0..nnz {
8758            let (i, j) = (irow[k] as usize, jcol[k] as usize);
8759            let want = if i == 0 && j == 0 {
8760                4.0
8761            } else if i == j {
8762                seen_diag += 1;
8763                2.0
8764            } else {
8765                assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8766                seen_coupling += 1;
8767                1.0
8768            } * obj_factor;
8769            assert!(
8770                (vals[k] - want).abs() < 1e-12,
8771                "H[{i},{j}] = {}, want {want}",
8772                vals[k]
8773            );
8774        }
8775        assert_eq!(seen_diag, n - 1);
8776        assert_eq!(seen_coupling, n - 1);
8777    }
8778
8779    /// The peeling threshold must leave ordinary sparse models on
8780    /// exactly the coloring they had before: a banded Hessian colors by
8781    /// its bandwidth, with nothing peeled.
8782    #[test]
8783    fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
8784        let n = 400;
8785        let pairs: Vec<(usize, usize)> = (0..n)
8786            .flat_map(|j| {
8787                let mut v = vec![(j, j)];
8788                if j + 1 < n {
8789                    v.push((j + 1, j));
8790                }
8791                v
8792            })
8793            .collect();
8794        let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8795        assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
8796        assert!(
8797            n_colors <= 3,
8798            "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
8799        );
8800        assert!(var_color.iter().all(|&c| c != u32::MAX));
8801    }
8802
8803    /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
8804    /// scattered through an otherwise diagonal `n`-variable problem.
8805    /// A plain coloring needs exactly `size` colors no matter how many
8806    /// blocks there are, because the blocks share no rows.
8807    fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
8808        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8809        let stride = n / blocks;
8810        for b in 0..blocks {
8811            let base = b * stride;
8812            for i in 0..size {
8813                for j in 0..=i {
8814                    if i != j {
8815                        pairs.push((base + i, base + j));
8816                    }
8817                }
8818            }
8819        }
8820        pairs
8821    }
8822
8823    /// Many medium-degree columns must not be peeled.
8824    ///
8825    /// Regression: selecting candidates by "degree > 16x average" and then
8826    /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
8827    /// The columns that miss the cut stay in the conflict structure, so the
8828    /// base color count is untouched and the singleton colors are pure
8829    /// addition — these two patterns colored to 306 and 290 against a plain
8830    /// walk's 50 and 34.
8831    #[test]
8832    fn thousands_of_medium_degree_cols_are_not_peeled() {
8833        for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
8834            let pairs = disjoint_blocks(n, blocks, size);
8835            let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8836            let n_peeled = peeled.iter().filter(|&&p| p).count();
8837            assert_eq!(
8838                n_peeled, 0,
8839                "degree-{size} columns do not pay for a singleton color \
8840                 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
8841            );
8842            assert!(
8843                n_colors <= size + 1,
8844                "disjoint {size}x{size} blocks color by block size regardless \
8845                 of block count; got {n_colors} for n={n}, blocks={blocks}"
8846            );
8847        }
8848    }
8849
8850    /// The pay-for-itself rule must not throw away the case peeling exists
8851    /// for: a handful of genuinely dense rows still get peeled, and still
8852    /// collapse the coloring.
8853    #[test]
8854    fn a_few_truly_dense_rows_are_still_peeled() {
8855        let n = 5_000;
8856        let dense_rows = 4;
8857        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8858        for d in 0..dense_rows {
8859            for j in 0..n {
8860                if j != d {
8861                    pairs.push((j.max(d), j.min(d)));
8862                }
8863            }
8864        }
8865        let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8866        let n_peeled = peeled.iter().filter(|&&p| p).count();
8867        assert_eq!(n_peeled, dense_rows, "every full row should peel");
8868        assert!(
8869            n_colors <= dense_rows + 2,
8870            "peeling {dense_rows} full rows should leave a diagonal remainder, \
8871             got {n_colors} colors"
8872        );
8873    }
8874
8875    /// The cap still binds, and when it does the kept columns are the
8876    /// highest-degree ones.
8877    #[test]
8878    fn peeling_is_capped_and_keeps_the_worst_offenders() {
8879        let n = 20_000;
8880        let dense_rows = 400;
8881        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8882        for d in 0..dense_rows {
8883            // Row d touches the first (n - d) columns, so degree strictly
8884            // decreases with d and the ordering is unambiguous.
8885            for j in 0..(n - d) {
8886                if j != d {
8887                    pairs.push((j.max(d), j.min(d)));
8888                }
8889            }
8890        }
8891        let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8892        let n_peeled = peeled.iter().filter(|&&p| p).count();
8893        assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
8894        assert!(
8895            (0..MAX_PEELED_COLS).all(|d| peeled[d]),
8896            "the {MAX_PEELED_COLS} densest rows are the ones kept"
8897        );
8898    }
8899
8900    /// Header line 0 is `g<count> <opt0> ...`; the option words are the
8901    /// model's own and a solver echoes them into the `.sol` `Options`
8902    /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
8903    #[test]
8904    fn header_option_words_are_kept_verbatim() {
8905        let p = parse_nl_text(EQ_LIN).expect("parse");
8906        assert_eq!(p.ampl_options, vec![0, 1, 0]);
8907    }
8908
8909    /// A count that does not match the words present must not be
8910    /// guessed at — the writer falls back rather than emit a wrong block.
8911    #[test]
8912    fn a_truncated_option_list_is_dropped_not_padded() {
8913        let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
8914        let p = parse_nl_text(&text).expect("parse");
8915        assert!(
8916            p.ampl_options.is_empty(),
8917            "9 declared but 3 present: {:?}",
8918            p.ampl_options
8919        );
8920    }
8921
8922    #[test]
8923    fn constrained_tnlp_eval_g_jac_h() {
8924        let p = parse_nl_text(EQ_LIN).expect("parse");
8925        let mut t = NlTnlp::new(p);
8926        let info = t.get_nlp_info().unwrap();
8927        assert_eq!(info.m, 1);
8928        assert_eq!(info.nnz_jac_g, 2);
8929
8930        // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
8931        let mut g = [0.0_f64; 1];
8932        assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
8933        assert!((g[0] - 0.7).abs() < 1e-12);
8934
8935        // Jacobian structure: row 0, cols [0, 1].
8936        let mut irow = [0_i32; 2];
8937        let mut jcol = [0_i32; 2];
8938        assert!(t.eval_jac_g(
8939            None,
8940            true,
8941            SparsityRequest::Structure {
8942                irow: &mut irow,
8943                jcol: &mut jcol
8944            }
8945        ));
8946        assert_eq!(irow, [0, 0]);
8947        assert_eq!(jcol, [0, 1]);
8948
8949        // Jacobian values: both 1.0.
8950        let mut vals = [0.0_f64; 2];
8951        assert!(t.eval_jac_g(
8952            Some(&[0.3, 0.4]),
8953            true,
8954            SparsityRequest::Values { values: &mut vals }
8955        ));
8956        assert!((vals[0] - 1.0).abs() < 1e-12);
8957        assert!((vals[1] - 1.0).abs() < 1e-12);
8958
8959        // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
8960        // λ contributes nothing because the constraint is linear, and
8961        // x0^2 + x1^2 is separable so there's no (1,0) entry in the
8962        // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
8963        assert_eq!(info.nnz_h_lag, 2);
8964        let mut hirow = [0_i32; 2];
8965        let mut hjcol = [0_i32; 2];
8966        assert!(t.eval_h(
8967            None,
8968            true,
8969            1.0,
8970            None,
8971            true,
8972            SparsityRequest::Structure {
8973                irow: &mut hirow,
8974                jcol: &mut hjcol
8975            }
8976        ));
8977        assert_eq!(hirow, [0, 1]);
8978        assert_eq!(hjcol, [0, 1]);
8979        let mut hvals = [0.0_f64; 2];
8980        assert!(t.eval_h(
8981            Some(&[0.3, 0.4]),
8982            true,
8983            1.0,
8984            Some(&[0.5]),
8985            true,
8986            SparsityRequest::Values { values: &mut hvals }
8987        ));
8988        assert!((hvals[0] - 2.0).abs() < 1e-12);
8989        assert!((hvals[1] - 2.0).abs() < 1e-12);
8990    }
8991
8992    /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
8993    /// encoded as common-subexpression `V2`. Header line 10 declares
8994    /// one obj-only CSE; expression tree references `v2` twice.
8995    const CSE_OBJ: &str = "g3 0 1 0
89962 0 1 0 0
89970 1
89980 0
89990 2 0
90000 0 0 1
90010 0 0 0 0
90020 0
90030 0
90040 1 0 0 0
9005V2 0 0
9006o0
9007v0
9008v1
9009O0 0
9010o0
9011o5
9012v2
9013n2
9014v2
9015b
90163
90173
9018";
9019
9020    #[test]
9021    fn parses_v_segment_cse() {
9022        let p = parse_nl_text(CSE_OBJ).expect("parse");
9023        assert_eq!(p.n, 2);
9024        // f(1,2) = 9 + 3 = 12
9025        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
9026        assert!((f - 12.0).abs() < 1e-12, "got {f}");
9027        // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
9028        let mut g = [0.0_f64; 2];
9029        grad_expr(&p.obj_expr(), &[1.0, 2.0], 1.0, &mut g);
9030        assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
9031        assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
9032        // collect_vars reaches into the CSE body and finds {0, 1}.
9033        let mut vs = BTreeSet::new();
9034        p.obj_nonlinear.collect_vars(&mut vs);
9035        assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
9036    }
9037
9038    /// `min (x0 - 1)^2` with three suffix segments attached: an
9039    /// integer constraint-suffix (target=1, kind=1), an integer var-
9040    /// suffix (target=0, kind=0), and a real var-suffix (target=0,
9041    /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
9042    /// `<idx> <value>` lines.
9043    const WITH_SUFFIXES: &str = "g3 0 1 0
90441 0 1 0 0
90450 1
90460 0
90470 1 0
90480 0 0 1
90490 0 0 0 0
90500 0
90510 0
90520 0 0 0 0
9053O0 0
9054o5
9055o1
9056v0
9057n1
9058n2
9059b
90603
9061S0 1 sens_state_1
90620 7
9063S4 1 sens_state_value_1
90640 4.5
9065";
9066
9067    #[test]
9068    fn parses_var_int_and_var_real_suffixes() {
9069        let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
9070        // Integer var-suffix: dense length 1, slot 0 = 7.
9071        let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
9072        assert_eq!(v.as_slice(), &[7]);
9073        // Real var-suffix: dense length 1, slot 0 = 4.5.
9074        let r = p
9075            .suffixes
9076            .var_real
9077            .get("sens_state_value_1")
9078            .expect("var_real");
9079        assert_eq!(r.len(), 1);
9080        assert!((r[0] - 4.5).abs() < 1e-12);
9081        // Other suffix slots stay empty.
9082        assert!(p.suffixes.con_int.is_empty());
9083        assert!(p.suffixes.con_real.is_empty());
9084    }
9085
9086    /// Two-variable + two-constraint problem with a constraint-level
9087    /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
9088    const WITH_CON_SUFFIX: &str = "g3 0 1 0
90892 2 1 0 0
90900 0
90910 0
90920 2 0
90930 0 0 1
90940 0 0 0 0
90954 0
90960 0
90970 0 0 0 0 0
9098C0
9099n0
9100C1
9101n0
9102O0 0
9103n0
9104r
91054 0.0
91064 0.0
9107b
91083
91093
9110k1
91110
9112J0 2
91130 1
91141 1
9115J1 2
91160 1
91171 -1
9118S1 2 sens_init_constr
91190 1
91201 2
9121";
9122
9123    #[test]
9124    fn parses_con_int_suffix() {
9125        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9126        let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
9127        // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
9128        assert_eq!(s.as_slice(), &[1, 2]);
9129    }
9130
9131    // ---- gh#785: a truncated file is rejected, not silently defaulted ----
9132    //
9133    // `WITH_CON_SUFFIX` is a complete, well-formed file, so cutting it at a
9134    // segment boundary is exactly the failure the issue reports: an
9135    // interrupted write. Each cut loses a different first segment and each
9136    // is caught by a different check, so all three are asserted — and the
9137    // untruncated text parsing cleanly (`parses_con_int_suffix` above) is
9138    // what keeps these from passing against a parser that rejects
9139    // everything.
9140
9141    /// Everything from `at` (a segment header line) onward is gone.
9142    fn truncate_before(txt: &str, at: &str) -> String {
9143        let cut = txt
9144            .find(at)
9145            .unwrap_or_else(|| panic!("fixture has no {at:?} segment"));
9146        txt[..cut].to_string()
9147    }
9148
9149    #[test]
9150    fn truncation_before_the_row_bounds_is_a_parse_error() {
9151        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nr\n"))
9152            .expect_err("truncated file must not parse");
9153        assert!(
9154            err.contains("`r` (constraint-bounds) segment"),
9155            "error should name the missing segment: {err}"
9156        );
9157    }
9158
9159    #[test]
9160    fn truncation_before_the_variable_bounds_is_a_parse_error() {
9161        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nb\n"))
9162            .expect_err("truncated file must not parse");
9163        assert!(
9164            err.contains("`b` (variable-bounds) segment"),
9165            "error should name the missing segment: {err}"
9166        );
9167    }
9168
9169    /// The cut that leaves every bound in place and takes only the
9170    /// coefficients. Neither presence check can see it; the declared-vs-
9171    /// parsed nonzero count is the whole of the evidence.
9172    #[test]
9173    fn truncation_before_the_jacobian_is_a_parse_error() {
9174        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nk1\n"))
9175            .expect_err("truncated file must not parse");
9176        assert!(
9177            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 0"),
9178            "error should report the mismatch: {err}"
9179        );
9180    }
9181
9182    /// The mismatch is an equality, not a floor: a file supplying *more*
9183    /// entries than it declares is as corrupt as one supplying fewer, and a
9184    /// `>=` check would wave it through.
9185    #[test]
9186    fn more_jacobian_entries_than_declared_is_also_a_parse_error() {
9187        let extra = WITH_CON_SUFFIX.replace("J1 2\n0 1\n1 -1\n", "J1 2\n0 1\n1 -1\nJ0 1\n0 5\n");
9188        let err = parse_nl_text(&extra).expect_err("over-full file must not parse");
9189        assert!(
9190            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 5"),
9191            "error should report the mismatch: {err}"
9192        );
9193    }
9194
9195    /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
9196    /// (n = m = 2) and hand back everything the engine would see.
9197    fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
9198        let mut obj = 1.0;
9199        let mut use_x = false;
9200        let mut x = vec![0.0; 2];
9201        let mut use_g = false;
9202        let mut g = vec![0.0; 2];
9203        let ok = tnlp.get_scaling_parameters(ScalingRequest {
9204            obj_scaling: &mut obj,
9205            use_x_scaling: &mut use_x,
9206            x_scaling: &mut x,
9207            use_g_scaling: &mut use_g,
9208            g_scaling: &mut g,
9209        });
9210        (ok, obj, use_x, x, use_g, g)
9211    }
9212
9213    /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
9214    /// the objective (`S6`) and one constraint (`S5`) reaches the
9215    /// engine's `user-scaling` pathway. The untagged second row is
9216    /// unscaled — its AMPL suffix default is 0, which is not a usable
9217    /// scale factor and reads as "not tagged".
9218    #[test]
9219    fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
9220        let nl = WITH_CON_SUFFIX.to_string()
9221            + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
9222        let p = parse_nl_text(&nl).expect("parse");
9223        let mut tnlp = NlTnlp::new(p);
9224        let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
9225        assert!(ok, "a tagged model must supply scaling");
9226        assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
9227        assert!(use_g);
9228        assert_eq!(g, vec![10.0, 1.0]);
9229        assert!(!use_x, "no variable suffix was declared");
9230    }
9231
9232    /// Variable-level `scaling_factor` entries are passed through, not
9233    /// dropped on the floor: `OrigIpoptNlp` does not model them and
9234    /// refuses the solve, which is the whole point of gh#483.
9235    #[test]
9236    fn scaling_factor_suffix_forwards_variable_factors() {
9237        let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
9238        let p = parse_nl_text(&nl).expect("parse");
9239        let mut tnlp = NlTnlp::new(p);
9240        let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
9241        assert!(ok);
9242        assert!(use_x, "variable factors must reach the engine");
9243        assert_eq!(x, vec![1.0, 3.0]);
9244    }
9245
9246    /// gh #703: with curvature-based scaling switched on, the computed
9247    /// factors are the base and a `scaling_factor` suffix the model
9248    /// actually carries wins **component by component** — an explicit
9249    /// factor from the modeller beats one inferred from the coefficients,
9250    /// and the components they did not tag keep the inferred one rather
9251    /// than snapping back to 1.
9252    #[test]
9253    fn a_user_suffix_overrides_the_computed_factor_component_wise() {
9254        let baseline = {
9255            let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9256            let mut t = NlTnlp::new(p);
9257            assert!(t.enable_curvature_scaling(), "an LP is degree ≤ 2");
9258            scaling_of(&mut t).5
9259        };
9260        assert!(
9261            baseline.iter().all(|v| *v > 0.0),
9262            "curvature scaling should produce usable row factors, got {baseline:?}"
9263        );
9264
9265        // Tag row 0 only. AMPL's untagged default is 0, which must read as
9266        // "not tagged" and leave row 1 on the computed factor.
9267        let nl = WITH_CON_SUFFIX.to_string() + "S5 1 scaling_factor\n0 7.0\n";
9268        let p = parse_nl_text(&nl).expect("parse");
9269        let mut t = NlTnlp::new(p);
9270        assert!(t.enable_curvature_scaling());
9271        let (ok, _obj, use_x, _x, use_g, g) = scaling_of(&mut t);
9272        assert!(ok);
9273        assert!(use_g && use_x);
9274        assert_eq!(g[0], 7.0, "the tagged row takes the user's factor");
9275        assert_eq!(
9276            g[1], baseline[1],
9277            "the untagged row keeps the computed one, not 1.0"
9278        );
9279    }
9280
9281    /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
9282    /// same answer the default `TNLP` impl gives, so `user-scaling`
9283    /// falls back to no scaling instead of a bogus all-zero vector.
9284    #[test]
9285    fn no_scaling_factor_suffix_declines() {
9286        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9287        let mut tnlp = NlTnlp::new(p);
9288        let (ok, ..) = scaling_of(&mut tnlp);
9289        assert!(!ok);
9290    }
9291
9292    #[test]
9293    fn rejects_suffix_with_out_of_range_index() {
9294        let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
9295        let err = parse_nl_text(&bad).expect_err("must reject");
9296        assert!(
9297            err.contains("out of range"),
9298            "expected out-of-range error, got: {err}"
9299        );
9300    }
9301
9302    #[test]
9303    fn tnlp_round_trip_solves() {
9304        let p = parse_nl_text(SIMPLE).expect("parse");
9305        let mut tnlp = NlTnlp::new(p);
9306        let info = tnlp.get_nlp_info().unwrap();
9307        assert_eq!(info.n, 2);
9308        assert_eq!(info.m, 0);
9309        let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
9310        assert!((f0 - 5.0).abs() < 1e-12);
9311        let mut g = [0.0_f64; 2];
9312        tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
9313        // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
9314        assert!((g[0] - (-2.0)).abs() < 1e-12);
9315        assert!((g[1] - (-4.0)).abs() < 1e-12);
9316    }
9317
9318    // ---- Sibling `.col` / `.row` name-file capture --------------------
9319    //
9320    // Names let diagnostics name the offending equation instead of "row 3"
9321    // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
9322    // the read path and the documented fallback-to-empty behavior.
9323
9324    use pounce_nlp::expression_provider::ExpressionProvider;
9325    use std::sync::atomic::{AtomicUsize, Ordering};
9326
9327    /// Unique scratch dir for one test (no `tempfile` dev-dep available).
9328    fn scratch_dir(tag: &str) -> std::path::PathBuf {
9329        static N: AtomicUsize = AtomicUsize::new(0);
9330        let seq = N.fetch_add(1, Ordering::Relaxed);
9331        let dir = std::env::temp_dir().join(format!(
9332            "pounce_nlnames_{}_{}_{}",
9333            std::process::id(),
9334            tag,
9335            seq
9336        ));
9337        std::fs::create_dir_all(&dir).expect("create scratch dir");
9338        dir
9339    }
9340
9341    #[test]
9342    fn read_name_file_reads_in_order() {
9343        let dir = scratch_dir("col_order");
9344        let p = dir.join("m.col");
9345        std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
9346        assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
9347    }
9348
9349    #[test]
9350    fn read_name_file_truncates_extra_lines() {
9351        // `.row` conventionally appends the objective name after the m
9352        // constraint names; `.take(expected)` must drop it so names stay
9353        // 1:1 with `g`.
9354        let dir = scratch_dir("row_obj");
9355        let p = dir.join("m.row");
9356        std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
9357        assert_eq!(
9358            read_name_file(&p, 2),
9359            vec!["mass_balance", "energy_balance"]
9360        );
9361    }
9362
9363    #[test]
9364    fn read_name_file_empty_on_short_or_missing() {
9365        let dir = scratch_dir("short");
9366        let short = dir.join("m.col");
9367        std::fs::write(&short, "only_one\n").unwrap();
9368        // Fewer lines than expected ⇒ empty (never a partial mapping).
9369        assert!(read_name_file(&short, 3).is_empty());
9370        // Missing file ⇒ empty, no error.
9371        assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
9372    }
9373
9374    #[test]
9375    fn read_nl_file_captures_sibling_names() {
9376        // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
9377        // names ride through onto the TNLP's ExpressionProvider.
9378        let dir = scratch_dir("sibling");
9379        let nl = dir.join("m.nl");
9380        std::fs::write(&nl, SIMPLE).unwrap();
9381        std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
9382
9383        let prob = read_nl_file(&nl).expect("parse + name capture");
9384        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9385        assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
9386
9387        let tnlp = NlTnlp::new(prob);
9388        assert_eq!(tnlp.variable_name(0), Some("alpha"));
9389        assert_eq!(tnlp.variable_name(1), Some("beta"));
9390        assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
9391    }
9392
9393    #[test]
9394    fn read_nl_file_without_names_yields_empty() {
9395        let dir = scratch_dir("noname");
9396        let nl = dir.join("m.nl");
9397        std::fs::write(&nl, SIMPLE).unwrap();
9398        let prob = read_nl_file(&nl).expect("parse");
9399        assert!(prob.var_names.is_empty());
9400        assert!(prob.con_names.is_empty());
9401        let tnlp = NlTnlp::new(prob);
9402        assert_eq!(tnlp.variable_name(0), None);
9403    }
9404
9405    #[test]
9406    fn read_nl_file_resolves_extensionless_ampl_stub() {
9407        // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
9408        // the `.nl` extension; the solver must read `mystub.nl`. Code
9409        // review 2026-06 item M15.
9410        let dir = scratch_dir("stub");
9411        std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
9412        // Pass the extensionless stub — the file `mystub` does not exist.
9413        let stub = dir.join("mystub");
9414        assert!(!stub.exists(), "stub must be extensionless / absent");
9415        let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
9416        assert_eq!(prob.n, 2);
9417        assert_eq!(prob.m, 0);
9418
9419        // Sibling name files are still found off the resolved stem.
9420        std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
9421        let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
9422        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9423    }
9424
9425    #[test]
9426    fn read_nl_file_prefers_exact_path_over_nl_sibling() {
9427        // An existing path is read verbatim — the `.nl` fallback only
9428        // kicks in when the literal path is missing, so a caller passing a
9429        // real file is never silently redirected to a `<file>.nl` sibling.
9430        let dir = scratch_dir("exact");
9431        // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
9432        std::fs::write(dir.join("data"), SIMPLE).unwrap();
9433        std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
9434        let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
9435        assert_eq!(prob.n, 2);
9436    }
9437
9438    #[test]
9439    fn append_extension_appends_rather_than_replaces() {
9440        use std::path::Path;
9441        assert_eq!(
9442            append_extension(Path::new("mystub"), "nl"),
9443            Path::new("mystub.nl")
9444        );
9445        // A stub that itself contains a dot keeps its stem (AMPL names it
9446        // `my.model.nl`, not `my.nl`).
9447        assert_eq!(
9448            append_extension(Path::new("my.model"), "nl"),
9449            Path::new("my.model.nl")
9450        );
9451    }
9452
9453    // ---- equation rendering (`print equation`) ----
9454
9455    fn names(v: &[&str]) -> Vec<String> {
9456        v.iter().map(|s| s.to_string()).collect()
9457    }
9458
9459    #[test]
9460    fn render_uses_variable_names_when_present() {
9461        let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9462        assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
9463        // Falls back to x[i] when names are absent.
9464        assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
9465    }
9466
9467    #[test]
9468    fn render_parenthesizes_by_precedence() {
9469        // (x0 + x1) * x2  must keep the parens around the sum.
9470        let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9471        let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
9472        assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
9473
9474        // x0 + x1 * x2  needs no parens (mul binds tighter).
9475        let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9476        let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
9477        assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
9478    }
9479
9480    #[test]
9481    fn render_subtraction_right_assoc_parens() {
9482        // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
9483        let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9484        let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
9485        assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
9486    }
9487
9488    #[test]
9489    fn render_functions_and_pow() {
9490        let sq = Expr::Binary(
9491            BinOp::Pow,
9492            Box::new(Expr::Var(0)),
9493            Box::new(Expr::Const(2.0)),
9494        );
9495        let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
9496        assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
9497    }
9498
9499    #[test]
9500    fn render_linear_signs_are_tidy() {
9501        // 1*a - 2*b + c  (coef +1 omits the multiplier).
9502        let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
9503        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
9504    }
9505
9506    #[test]
9507    fn render_linear_skips_zero_coefficients() {
9508        // A 0 coefficient (a variable present only in the nonlinear part)
9509        // is dropped, not rendered as `0*x`.
9510        let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
9511        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
9512        // Leading term zero ⇒ the first emitted term still has no ` + `.
9513        let lin = vec![(0usize, 0.0), (1, 2.0)];
9514        assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
9515    }
9516
9517    #[test]
9518    fn render_sum_folds_negative_terms() {
9519        // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
9520        let sq = |i| {
9521            Expr::Binary(
9522                BinOp::Pow,
9523                Box::new(Expr::Var(i)),
9524                Box::new(Expr::Const(2.0)),
9525            )
9526        };
9527        let neg = |i| {
9528            Expr::Binary(
9529                BinOp::Mul,
9530                Box::new(Expr::Const(-1.0)),
9531                Box::new(Expr::Var(i)),
9532            )
9533        };
9534        let e = Expr::Sum(vec![
9535            sq(0),
9536            neg(1),
9537            Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
9538        ]);
9539        assert_eq!(
9540            render_expr(&e, &names(&["a", "b", "c"]), &[]),
9541            "a^2 - 1*b - c"
9542        );
9543    }
9544
9545    #[test]
9546    fn render_constraint_equation_forms() {
9547        // Build a 2-constraint problem by hand: an equality and a range.
9548        let mut prob = parse_nl_text(SIMPLE).unwrap();
9549        // Overwrite to a known small shape: 1 var, 2 cons.
9550        prob.n = 2;
9551        prob.m = 2;
9552        prob.var_names = names(&["mass_in", "mass_out"]);
9553        prob.con_names = names(&["balance", "window"]);
9554        prob.con_linear = vec![
9555            vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
9556            vec![(0, 1.0)],            // mass_in
9557        ];
9558        prob.con_nonlinear = vec![
9559            NlBody::Tree(Expr::Const(0.0)),
9560            NlBody::Tree(Expr::Const(0.0)),
9561        ];
9562        prob.g_l = vec![0.0, 0.0];
9563        prob.g_u = vec![0.0, 500.0];
9564
9565        assert_eq!(
9566            render_constraint_equation(&prob, 0),
9567            "mass_in - mass_out = 0"
9568        );
9569        assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
9570
9571        let all = render_all_constraint_equations(&prob);
9572        assert_eq!(all.len(), 2);
9573        assert_eq!(all[1], "0 <= mass_in <= 500");
9574    }
9575
9576    #[test]
9577    fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
9578        let mut prob = parse_nl_text(SIMPLE).unwrap();
9579        prob.n = 3;
9580        prob.m = 2;
9581        // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
9582        // Row 1: linear in x2 only → support {2}.
9583        prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
9584        prob.con_nonlinear = vec![
9585            NlBody::Tree(Expr::Binary(
9586                BinOp::Mul,
9587                Box::new(Expr::Var(0)),
9588                Box::new(Expr::Var(2)),
9589            )),
9590            NlBody::Tree(Expr::Const(0.0)),
9591        ];
9592        prob.g_l = vec![0.0, 0.0];
9593        prob.g_u = vec![0.0, 0.0];
9594
9595        let (irow, jcol) = constraint_jacobian_sparsity(&prob);
9596        // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
9597        assert_eq!(irow, vec![0, 0, 0, 1]);
9598        assert_eq!(jcol, vec![0, 1, 2, 2]);
9599    }
9600
9601    #[test]
9602    fn funcall_string_arg_with_hash_is_not_truncated() {
9603        // Code review L31: an AMPL string argument is a Hollerith literal
9604        // `h<len>:<chars>` whose content is exactly <len> bytes and may
9605        // legitimately contain '#' (e.g. a parameters-directory path). The
9606        // old parser ran strip_comment() over the line first, truncating
9607        // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
9608        let mut p = Parser::new("h3:a#b\n", false);
9609        match p.parse_funcall_arg().expect("parse hollerith arg") {
9610            FuncallArg::Str(s) => assert_eq!(s, "a#b"),
9611            other => panic!("expected Str, got {other:?}"),
9612        }
9613    }
9614
9615    #[test]
9616    fn funcall_string_arg_honors_declared_length() {
9617        // The declared `<len>` is authoritative: exactly that many bytes
9618        // after the ':' form the string; trailing content (here a real
9619        // ` # comment`) is not part of it.
9620        let mut p = Parser::new("h3:abc # trailing comment\n", false);
9621        match p.parse_funcall_arg().expect("parse hollerith arg") {
9622            FuncallArg::Str(s) => assert_eq!(s, "abc"),
9623            other => panic!("expected Str, got {other:?}"),
9624        }
9625    }
9626
9627    // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
9628    //
9629    // AMPL emits these in place of the general `o5` (OPPOW) when one operand
9630    // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
9631    // negative-base-safe constant-power lowering applies. The eval points below
9632    // are chosen to pin down BOTH the arity and the operand order: a swapped
9633    // `base`/`exp` (or treating `o82` as a different unary op) gives a
9634    // different number at these points, so each assertion is discriminating.
9635
9636    /// Parse a single expression `expr_src` with `n` variables in scope,
9637    /// driving the real `parse_opcode` path through `parse_expr`.
9638    fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
9639        let mut p = Parser::new(expr_src, false);
9640        p.n = n;
9641        p.parse_expr().expect("parse expression")
9642    }
9643
9644    #[test]
9645    fn opcode_o82_square_is_unary_pow_of_two() {
9646        // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
9647        let e = parse_one_expr(1, "o82\nv0\n");
9648        match &e {
9649            Expr::Binary(BinOp::Pow, base, exp) => {
9650                assert!(matches!(**base, Expr::Var(0)));
9651                match **exp {
9652                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
9653                    ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
9654                }
9655            }
9656            other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
9657        }
9658        // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
9659        // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
9660        assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
9661        assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
9662        // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
9663        let mut g = [0.0_f64; 1];
9664        grad_expr(&e, &[3.0], 1.0, &mut g);
9665        assert!((g[0] - 6.0).abs() < 1e-9, "grad at 3 = {}", g[0]);
9666        g[0] = 0.0;
9667        grad_expr(&e, &[-3.0], 1.0, &mut g);
9668        assert!((g[0] + 6.0).abs() < 1e-9, "grad at -3 = {}", g[0]);
9669    }
9670
9671    #[test]
9672    fn opcode_o81_const_exponent_is_base_pow_const() {
9673        // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
9674        let e = parse_one_expr(1, "o81\nv0\nn3\n");
9675        match &e {
9676            Expr::Binary(BinOp::Pow, base, exp) => {
9677                assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
9678                match **exp {
9679                    Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
9680                    ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
9681                }
9682            }
9683            other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
9684        }
9685        // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
9686        assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
9687        // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
9688        // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
9689        assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
9690        // gradient d/dx x^3 = 3x^2 = 12 at x=2.
9691        let mut g = [0.0_f64; 1];
9692        grad_expr(&e, &[2.0], 1.0, &mut g);
9693        assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
9694    }
9695
9696    #[test]
9697    fn opcode_o83_const_base_is_const_pow_exp() {
9698        // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
9699        let e = parse_one_expr(1, "o83\nn2\nv0\n");
9700        match &e {
9701            Expr::Binary(BinOp::Pow, base, exp) => {
9702                match **base {
9703                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
9704                    ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
9705                }
9706                assert!(
9707                    matches!(**exp, Expr::Var(0)),
9708                    "exponent must be the variable"
9709                );
9710            }
9711            other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
9712        }
9713        // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
9714        assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
9715        assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
9716        // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
9717        let mut g = [0.0_f64; 1];
9718        grad_expr(&e, &[3.0], 1.0, &mut g);
9719        assert!(
9720            (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
9721            "grad at 3 = {} (want {})",
9722            g[0],
9723            8.0 * 2.0_f64.ln()
9724        );
9725    }
9726
9727    #[test]
9728    fn power_specializations_agree_with_general_o5() {
9729        // Where both are defined, o81/o82/o83 must be numerically identical to
9730        // the general `o5` pow on the same operands — they are only routing
9731        // hints, not different math.
9732        let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
9733        let o82 = parse_one_expr(1, "o82\nv0\n");
9734        let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
9735        let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
9736        let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
9737        let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
9738        for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
9739            assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
9740            assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
9741            // 2^x is real for all x; compare across the same points.
9742            assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
9743        }
9744    }
9745
9746    #[test]
9747    fn power_opcodes_round_trip_through_parse_nl_text() {
9748        // End-to-end through the public entry point: `min x0^2 + x1^2` written
9749        // with o82 (square) parses and evaluates like its o5 twin. Reuses the
9750        // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
9751        let nl = SIMPLE.replace(
9752            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9753            "o0\no82\nv0\no82\nv1\n",
9754        );
9755        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9756        let p = parse_nl_text(&nl).expect("parse o82 objective");
9757        // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
9758        assert!((eval_expr(&p.obj_expr(), &[3.0, 4.0]) - 25.0).abs() < 1e-12);
9759        assert!((eval_expr(&p.obj_expr(), &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
9760    }
9761
9762    #[test]
9763    fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
9764        // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
9765        // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
9766        // (the negative-base-safe path) rather than a generic `powf`. The check
9767        // at a NEGATIVE base is the one that would break if o81 wrongly routed
9768        // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
9769        let nl = SIMPLE.replace(
9770            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9771            "o0\no81\nv0\nn3\no81\nv1\nn3\n",
9772        );
9773        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9774        let p = parse_nl_text(&nl).expect("parse o81 objective");
9775        let mut tnlp = NlTnlp::new(p);
9776        tnlp.get_nlp_info().unwrap();
9777        // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
9778        let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
9779        assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
9780        // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
9781        let mut g = [0.0_f64; 2];
9782        assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
9783        assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
9784        assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
9785    }
9786
9787    // ---- Shared-CSE constraint tape (issue #476) ----------------------
9788
9789    /// Three constraints over one `V` segment (a `.nl` *defined variable*),
9790    /// `V3 = 2*x0 + 3*x1`, referenced by all three:
9791    ///   C0: V3^2        C1: V3^3 + x2        C2: V3*x2
9792    /// `{BODY2}` is a substitution point so a variant can drop an opcode the
9793    /// hybrid path rejects into C2.
9794    const SHARED_CSE: &str = "g3 1 1 0
9795 3 3 1 0 0
9796 3 0
9797 0 0
9798 3 0 0
9799 0 0 0 1
9800 0 0 0 0 0
9801 8 3
9802 0 0
9803 0 1 0 0 0
9804V3 2 0
98050 2.0
98061 3.0
9807n0
9808C0
9809o5
9810v3
9811n2
9812C1
9813o0
9814o5
9815v3
9816n3
9817v2
9818C2
9819{BODY2}
9820O0 0
9821n0
9822r
98232 0
98242 0
98252 0
9826b
98273
98283
98293
9830k2
98313
98326
9833J0 2
98340 0
98351 0
9836J1 3
98370 0
98381 0
98392 0
9840J2 3
98410 0
98421 0
98432 0
9844G0 3
98450 1.0
98461 1.0
98472 1.0
9848";
9849
9850    fn shared_cse_nl(body2: &str) -> String {
9851        SHARED_CSE.replace("{BODY2}", body2)
9852    }
9853
9854    /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
9855    /// per-summand tapes return — it is a different traversal of the same
9856    /// DAG, not a different derivative. Forced on here regardless of
9857    /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
9858    /// the size heuristic that decides when to use it.
9859    #[test]
9860    fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
9861        let nl = shared_cse_nl("o2\nv3\nv2");
9862        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9863
9864        let mut hybrid = NlTnlp::new(p.clone());
9865        let info = hybrid.get_nlp_info().unwrap();
9866        let nnz = info.nnz_jac_g as usize;
9867        hybrid
9868            .con_hybrid
9869            .as_mut()
9870            .expect("CSE shared by 3 constraints must build the hybrid tape")
9871            .use_for_jac = true;
9872
9873        let mut flat = NlTnlp::new(p);
9874        flat.get_nlp_info().unwrap();
9875        flat.con_hybrid = None;
9876
9877        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9878            let mut jh = vec![0.0_f64; nnz];
9879            let mut jf = vec![0.0_f64; nnz];
9880            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9881            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9882            assert_eq!(
9883                jh, jf,
9884                "hybrid Jacobian differs from the flat tape at {x:?}"
9885            );
9886
9887            // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
9888            let s = 2.0 * x[0] + 3.0 * x[1];
9889            let want = [
9890                4.0 * s,
9891                6.0 * s,
9892                6.0 * s * s,
9893                9.0 * s * s,
9894                1.0,
9895                2.0 * x[2],
9896                3.0 * x[2],
9897                s,
9898            ];
9899            assert_eq!(nnz, want.len());
9900            for k in 0..nnz {
9901                assert!(
9902                    (jh[k] - want[k]).abs() < 1e-9,
9903                    "entry {k} at {x:?}: got {}, want {}",
9904                    jh[k],
9905                    want[k]
9906                );
9907            }
9908        }
9909    }
9910
9911    /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
9912    /// CSE `S = body(x0, x1)` (the caller passes the body's expression
9913    /// text, which must reference exactly `v0` and `v1`). A deep shared
9914    /// body over few local ops per row is the regime the op-ratio gates
9915    /// are meant to catch: no repository fixture has a CSE shared across
9916    /// constraints at all, so without this the gate-on paths have no
9917    /// natural coverage.
9918    fn shared_body_chain_nl(m: usize, body: &str) -> String {
9919        let n = m + 2;
9920        let nzc = 3 * m;
9921        let mut s = String::new();
9922        s.push_str("g3 1 1 0\n");
9923        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
9924        s.push_str(&format!(" {m} 0\n 0 0\n"));
9925        s.push_str(&format!(" {n} 0 0\n"));
9926        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
9927        s.push_str(&format!(" {nzc} {n}\n"));
9928        s.push_str(" 0 0\n 0 1 0 0 0\n");
9929        // The shared body.
9930        s.push_str(&format!("V{n} 0 0\n"));
9931        s.push_str(body);
9932        // Rows: S * x_{i+2}.
9933        for i in 0..m {
9934            s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
9935        }
9936        s.push_str("O0 0\nn0\n");
9937        s.push_str(&format!("x{n}\n"));
9938        for j in 0..n {
9939            s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
9940        }
9941        s.push_str("r\n");
9942        for _ in 0..m {
9943            s.push_str("2 0\n");
9944        }
9945        s.push_str("b\n");
9946        for _ in 0..n {
9947            s.push_str("3\n");
9948        }
9949        // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
9950        s.push_str(&format!("k{}\n", n - 1));
9951        let mut acc = 0;
9952        for j in 0..n - 1 {
9953            acc += if j < 2 { m } else { 1 };
9954            s.push_str(&format!("{acc}\n"));
9955        }
9956        for i in 0..m {
9957            s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
9958        }
9959        s.push_str(&format!("G0 {n}\n"));
9960        for j in 0..n {
9961            s.push_str(&format!("{j} 0.0\n"));
9962        }
9963        s
9964    }
9965
9966    /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
9967    /// The forward value saturates to `inf` past depth ≈ 5, which the
9968    /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
9969    /// bounded variant below instead, whose second-order terms would
9970    /// otherwise mix `inf`s of both signs into `NaN`.
9971    fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
9972        let mut body = String::new();
9973        for _ in 0..depth {
9974            body.push_str("o44\n");
9975        }
9976        body.push_str("o2\nn0.01\no0\nv0\nv1\n");
9977        shared_body_chain_nl(m, &body)
9978    }
9979
9980    /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
9981    /// (x0 + x1))` — mathematically the identity chain, so the value
9982    /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
9983    /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
9984    /// through both second-order sweeps. `2 * pairs` body ops drive the
9985    /// flat/shared op ratio as high as the Hessian gate tests need.
9986    fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
9987        let mut body = String::new();
9988        for _ in 0..pairs {
9989            body.push_str("o43\no44\n");
9990        }
9991        body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
9992        shared_body_chain_nl(m, &body)
9993    }
9994
9995    /// With a deep shared body the gate turns itself on, and the path it
9996    /// turns on must still agree with the flat tapes exactly.
9997    #[test]
9998    fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
9999        let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
10000
10001        let mut hybrid = NlTnlp::new(p.clone());
10002        let info = hybrid.get_nlp_info().unwrap();
10003        let nnz = info.nnz_jac_g as usize;
10004        assert!(
10005            hybrid
10006                .con_hybrid
10007                .as_ref()
10008                .expect("shared CSE must build the hybrid tape")
10009                .use_for_jac,
10010            "a 40-deep body shared by 16 rows is well past the op-ratio gate"
10011        );
10012
10013        let mut flat = NlTnlp::new(p);
10014        flat.get_nlp_info().unwrap();
10015        flat.con_hybrid = None;
10016
10017        for scale in [1.0_f64, -0.7, 2.5] {
10018            let x: Vec<f64> = (0..info.n as usize)
10019                .map(|j| scale * (0.2 + 0.03 * j as f64))
10020                .collect();
10021            let mut jh = vec![0.0_f64; nnz];
10022            let mut jf = vec![0.0_f64; nnz];
10023            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
10024            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
10025            assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
10026            assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
10027        }
10028    }
10029
10030    /// The Jacobian gate is off for a model whose shared bodies are small,
10031    /// because there the hybrid traversal's per-op overhead outweighs the
10032    /// halved forward sweep. `eval_g` still takes the hybrid path — that
10033    /// one is a win at any ratio.
10034    #[test]
10035    fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
10036        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10037        let mut t = NlTnlp::new(p);
10038        t.get_nlp_info().unwrap();
10039        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10040        assert!(
10041            !h.use_for_jac,
10042            "a 3-row model with a 2-term CSE is far below the op-ratio gate"
10043        );
10044    }
10045
10046    /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
10047    /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
10048    /// adjoints and running one prelude sweep reassociates floating-point
10049    /// products, so the two paths agree only to rounding on general inputs —
10050    /// but here every operation in both traversals is exact (dyadic values,
10051    /// small-integer coefficients, polynomial ops), so the results must be
10052    /// bit-identical, pinning the arithmetic itself and not just its
10053    /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
10054    /// path is covered independently of the size heuristic.
10055    #[test]
10056    fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
10057        let nl = shared_cse_nl("o2\nv3\nv2");
10058        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10059
10060        let mut hybrid = NlTnlp::new(p.clone());
10061        let info = hybrid.get_nlp_info().unwrap();
10062        let nnz = info.nnz_h_lag as usize;
10063        hybrid
10064            .con_hybrid
10065            .as_mut()
10066            .expect("CSE shared by 3 constraints must build the hybrid tape")
10067            .use_for_hess = true;
10068
10069        let mut flat = NlTnlp::new(p);
10070        flat.get_nlp_info().unwrap();
10071        flat.con_hybrid = None;
10072
10073        let pairs: Vec<(usize, usize)> = hybrid
10074            .h_irow
10075            .iter()
10076            .zip(&hybrid.h_jcol)
10077            .map(|(&i, &j)| (i as usize, j as usize))
10078            .collect();
10079
10080        // Two multiplier sets: one all-live, one with a dead row so the
10081        // λ == 0 skip is exercised on the hybrid path too.
10082        for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
10083            for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10084                let mut hh = vec![0.0_f64; nnz];
10085                let mut hf = vec![0.0_f64; nnz];
10086                assert!(hybrid.eval_h(
10087                    Some(&x),
10088                    true,
10089                    1.0,
10090                    Some(&lam),
10091                    true,
10092                    SparsityRequest::Values { values: &mut hh }
10093                ));
10094                assert!(flat.eval_h(
10095                    Some(&x),
10096                    true,
10097                    1.0,
10098                    Some(&lam),
10099                    true,
10100                    SparsityRequest::Values { values: &mut hf }
10101                ));
10102                assert_eq!(
10103                    hh, hf,
10104                    "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
10105                );
10106
10107                // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
10108                // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
10109                // objective is constant, so the Lagrangian Hessian is
10110                //   (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
10111                let s = 2.0 * x[0] + 3.0 * x[1];
10112                let q = 2.0 * lam[0] + 6.0 * s * lam[1];
10113                let dv = [2.0, 3.0, 0.0];
10114                for (k, &(i, j)) in pairs.iter().enumerate() {
10115                    let mut want = q * dv[i] * dv[j];
10116                    if i == 2 {
10117                        want += lam[2] * dv[j];
10118                    }
10119                    if j == 2 {
10120                        want += lam[2] * dv[i];
10121                    }
10122                    assert!(
10123                        (hh[k] - want).abs() < 1e-9,
10124                        "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
10125                        hh[k]
10126                    );
10127                }
10128            }
10129        }
10130    }
10131
10132    /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
10133    /// turns the Hessian gate on by itself, and the path it turns on must
10134    /// agree with the flat tapes. Not bitwise here: the shared prelude
10135    /// reverse sweep runs once over the `λ_k`-folded adjoints of all
10136    /// summands where the flat path runs per summand, and that
10137    /// reassociation moves transcendental results by rounding — so the bar
10138    /// is a relative few-ULP band, with the exact-arithmetic case pinned
10139    /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
10140    #[test]
10141    fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
10142        let m = 16;
10143        let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
10144
10145        let mut hybrid = NlTnlp::new(p.clone());
10146        let info = hybrid.get_nlp_info().unwrap();
10147        let nnz = info.nnz_h_lag as usize;
10148        assert!(
10149            hybrid
10150                .con_hybrid
10151                .as_ref()
10152                .expect("shared CSE must build the hybrid tape")
10153                .use_for_hess,
10154            "a 40-op body shared by 16 rows is well past the op-ratio gate"
10155        );
10156
10157        let mut flat = NlTnlp::new(p);
10158        flat.get_nlp_info().unwrap();
10159        flat.con_hybrid = None;
10160
10161        let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
10162        for scale in [1.0_f64, -0.7, 2.5] {
10163            let x: Vec<f64> = (0..info.n as usize)
10164                .map(|j| scale * (0.2 + 0.03 * j as f64))
10165                .collect();
10166            // Run the hybrid Jacobian first, the order a real solve
10167            // iteration uses. Its `gradient_summand` sweeps leave the
10168            // *Jacobian's* prelude adjoint arena dirty; the Hessian's
10169            // accumulators must be its own buffers with the all-zero-
10170            // between-colors invariant, or this seeds `eval_h` with a
10171            // stale row gradient (caught here).
10172            let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
10173            assert!(hybrid.eval_jac_g(
10174                Some(&x),
10175                true,
10176                SparsityRequest::Values { values: &mut jac }
10177            ));
10178            let mut hh = vec![0.0_f64; nnz];
10179            let mut hf = vec![0.0_f64; nnz];
10180            assert!(hybrid.eval_h(
10181                Some(&x),
10182                true,
10183                1.0,
10184                Some(&lam),
10185                true,
10186                SparsityRequest::Values { values: &mut hh }
10187            ));
10188            assert!(flat.eval_h(
10189                Some(&x),
10190                true,
10191                1.0,
10192                Some(&lam),
10193                true,
10194                SparsityRequest::Values { values: &mut hf }
10195            ));
10196            for k in 0..nnz {
10197                assert!(
10198                    hh[k].is_finite() && hf[k].is_finite(),
10199                    "non-finite Hessian entry {k} defeats the comparison"
10200                );
10201                let tol = 1e-12 * hf[k].abs().max(1.0);
10202                assert!(
10203                    (hh[k] - hf[k]).abs() <= tol,
10204                    "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
10205                    hh[k],
10206                    hf[k]
10207                );
10208            }
10209            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10210        }
10211    }
10212
10213    /// `.nl` text for two independent shared-CSE blocks of different widths:
10214    /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
10215    /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
10216    /// of the form `body * x_r`.
10217    ///
10218    /// The width difference is the point. A body summing `w` variables gives
10219    /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
10220    /// conflict and the coloring must spend `w` colors on them; the narrower
10221    /// block reuses the low colors. The surplus colors therefore belong to
10222    /// block A alone, and a per-color prelude walk over *both* bodies would
10223    /// be doing work for a body that color cannot reach.
10224    fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
10225        let nvars = wide + narrow;
10226        let m = 2 * rows;
10227        let n = nvars + m;
10228        // Block A rows touch `wide` body vars + 1 row var; block B rows
10229        // touch `narrow` + 1.
10230        let nzc = rows * (wide + 1) + rows * (narrow + 1);
10231        let mut s = String::new();
10232        s.push_str("g3 1 1 0\n");
10233        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
10234        s.push_str(&format!(" {m} 0\n 0 0\n"));
10235        s.push_str(&format!(" {n} 0 0\n"));
10236        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
10237        s.push_str(&format!(" {nzc} {n}\n"));
10238        s.push_str(" 0 0\n 0 2 0 0 0\n");
10239        // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
10240        // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
10241        for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
10242            s.push_str(&format!("V{} 0 0\n", n + b));
10243            s.push_str("o41\n");
10244            for _ in 0..count - 1 {
10245                s.push_str("o0\n");
10246            }
10247            for j in 0..*count {
10248                s.push_str(&format!("v{}\n", base + j));
10249            }
10250        }
10251        // Rows: body_b * x_{rowvar}.
10252        for i in 0..m {
10253            let b = i / rows;
10254            s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
10255        }
10256        s.push_str("O0 0\nn0\n");
10257        s.push_str(&format!("x{n}\n"));
10258        for j in 0..n {
10259            s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
10260        }
10261        s.push_str("r\n");
10262        for _ in 0..m {
10263            s.push_str("2 0\n");
10264        }
10265        s.push_str("b\n");
10266        for _ in 0..n {
10267            s.push_str("3\n");
10268        }
10269        // Cumulative Jacobian column counts for the first n-1 columns.
10270        s.push_str(&format!("k{}\n", n - 1));
10271        let mut acc = 0;
10272        for j in 0..n - 1 {
10273            acc += if j < nvars { rows } else { 1 };
10274            s.push_str(&format!("{acc}\n"));
10275        }
10276        for i in 0..m {
10277            let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
10278            s.push_str(&format!("J{i} {}\n", count + 1));
10279            let mut cols: Vec<usize> = (base..base + count).collect();
10280            cols.push(nvars + i);
10281            cols.sort_unstable();
10282            for c in cols {
10283                s.push_str(&format!("{c} 0.0\n"));
10284            }
10285        }
10286        s.push_str(&format!("G0 {n}\n"));
10287        for j in 0..n {
10288            s.push_str(&format!("{j} 0.0\n"));
10289        }
10290        s
10291    }
10292
10293    /// Both prelude sweeps run once per color, so iterating the whole prelude
10294    /// each time would cost `n_colors × |prelude|` where the op-ratio gate
10295    /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
10296    /// `eval_h` instead walks the union of that color's summands'
10297    /// `prelude_reach`.
10298    ///
10299    /// What this can and cannot pin is worth being exact about, because the
10300    /// change is pure performance: walking the whole prelude per color
10301    /// computes the *same* Hessian, so no assertion on output values can
10302    /// detect it, and a timing assertion would be flaky. So this asserts the
10303    /// two things that are checkable — that the table the sweeps iterate is
10304    /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
10305    /// where colors genuinely reach different bodies, and that each reach list
10306    /// is ascending and operand-closed, the invariants that make the narrowed
10307    /// walk safe — plus agreement with the flat tapes under narrowing.
10308    #[test]
10309    fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
10310        let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
10311        let mut hybrid = NlTnlp::new(p.clone());
10312        let info = hybrid.get_nlp_info().unwrap();
10313        let nnz = info.nnz_h_lag as usize;
10314        let m = info.m as usize;
10315
10316        {
10317            let h = hybrid
10318                .con_hybrid
10319                .as_mut()
10320                .expect("two shared CSE bodies must build the hybrid tape");
10321            h.use_for_hess = true;
10322
10323            let np = h.tape.n_prelude_ops();
10324            let n_colors = h.hess_color_reach_off.len() - 1;
10325            let total: usize = h.hess_color_reach.len();
10326            assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
10327            assert!(
10328                total < n_colors * np,
10329                "per-color reach must be strictly smaller than walking the whole \
10330                 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
10331                n_colors * np
10332            );
10333            // Every reach list must be ascending and operand-closed, which is
10334            // what makes the narrowed walk safe.
10335            for c in 0..n_colors {
10336                let r =
10337                    &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
10338                assert!(
10339                    r.windows(2).all(|w| w[0] < w[1]),
10340                    "color {c} reach is not strictly ascending"
10341                );
10342                let member: std::collections::HashSet<u32> = r.iter().copied().collect();
10343                for &i in r {
10344                    let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
10345                    for opnd in [a, b].into_iter().flatten() {
10346                        assert!(
10347                            member.contains(&(opnd as u32)),
10348                            "color {c}: slot {i}'s operand {opnd} is missing from its reach"
10349                        );
10350                    }
10351                }
10352            }
10353        }
10354
10355        let mut flat = NlTnlp::new(p);
10356        flat.get_nlp_info().unwrap();
10357        flat.con_hybrid = None;
10358
10359        let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
10360        for scale in [1.0_f64, -0.6] {
10361            let x: Vec<f64> = (0..info.n as usize)
10362                .map(|j| scale * (0.15 + 0.02 * j as f64))
10363                .collect();
10364            let mut hh = vec![0.0_f64; nnz];
10365            let mut hf = vec![0.0_f64; nnz];
10366            assert!(hybrid.eval_h(
10367                Some(&x),
10368                true,
10369                1.0,
10370                Some(&lam),
10371                true,
10372                SparsityRequest::Values { values: &mut hh }
10373            ));
10374            assert!(flat.eval_h(
10375                Some(&x),
10376                true,
10377                1.0,
10378                Some(&lam),
10379                true,
10380                SparsityRequest::Values { values: &mut hf }
10381            ));
10382            for k in 0..nnz {
10383                let tol = 1e-12 * hf[k].abs().max(1.0);
10384                assert!(
10385                    (hh[k] - hf[k]).abs() <= tol,
10386                    "narrowed-reach Hessian entry {k} at scale {scale}: \
10387                     hybrid {} vs flat {}",
10388                    hh[k],
10389                    hf[k]
10390                );
10391            }
10392            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10393        }
10394    }
10395
10396    /// The Hessian gate is off for a model whose shared bodies are small —
10397    /// below the ratio where the shared prelude sweeps pay for the hybrid
10398    /// traversal's per-op overhead — leaving `eval_h` on the flat path
10399    /// (which the gate keeps bit-identical for such models by definition).
10400    #[test]
10401    fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
10402        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10403        let mut t = NlTnlp::new(p);
10404        t.get_nlp_info().unwrap();
10405        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10406        assert!(
10407            !h.use_for_hess,
10408            "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
10409        );
10410    }
10411
10412    /// A CSE referenced from several constraints is evaluated once per
10413    /// `eval_g` via the shared prelude instead of once per reference. The
10414    /// values must be bit-identical to the flat per-summand `Tape` path,
10415    /// which is what makes the optimization safe to apply unconditionally.
10416    #[test]
10417    fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
10418        let nl = shared_cse_nl("o2\nv3\nv2");
10419        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10420        let mut hybrid = NlTnlp::new(p.clone());
10421        hybrid.get_nlp_info().unwrap();
10422        let h = hybrid
10423            .con_hybrid
10424            .as_ref()
10425            .expect("CSE shared by 3 constraints must take the hybrid path");
10426        assert!(
10427            h.tape.n_prelude_ops() > 0,
10428            "shared CSE body must land in the prelude"
10429        );
10430
10431        // Same model with the hybrid path switched off: the reference.
10432        let mut flat = NlTnlp::new(p);
10433        flat.get_nlp_info().unwrap();
10434        flat.con_hybrid = None;
10435
10436        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10437            let mut gh = [0.0_f64; 3];
10438            let mut gf = [0.0_f64; 3];
10439            assert!(hybrid.eval_g(&x, true, &mut gh));
10440            assert!(flat.eval_g(&x, true, &mut gf));
10441            // V3 = 2 x0 + 3 x1.
10442            let s = 2.0 * x[0] + 3.0 * x[1];
10443            let want = [s * s, s * s * s + x[2], s * x[2]];
10444            for i in 0..3 {
10445                assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
10446                assert!(
10447                    (gh[i] - want[i]).abs() < 1e-9,
10448                    "row {i}: got {}, want {}",
10449                    gh[i],
10450                    want[i]
10451                );
10452            }
10453        }
10454    }
10455
10456    /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
10457    /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
10458    /// take that path after `hybrid_supported` clears the model. Here a
10459    /// min-list in one constraint has to disable it for the whole block —
10460    /// falling back, not panicking.
10461    #[test]
10462    fn unsupported_opcode_falls_back_to_the_flat_tape() {
10463        let nl = shared_cse_nl("o11\n2\nv3\nv2");
10464        let p = parse_nl_text(&nl).expect("parse min-list model");
10465        let mut tnlp = NlTnlp::new(p);
10466        tnlp.get_nlp_info().unwrap();
10467        assert!(
10468            tnlp.con_hybrid.is_none(),
10469            "a min-list anywhere in the constraint block must disable the hybrid path"
10470        );
10471        let mut g = [0.0_f64; 3];
10472        assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
10473        let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
10474        assert!((g[0] - s * s).abs() < 1e-9);
10475        assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
10476    }
10477
10478    // ---- In-memory construction + HVP (issue #469) --------------------
10479
10480    fn v(i: usize) -> Expr {
10481        Expr::Var(i)
10482    }
10483
10484    fn c(x: Number) -> Expr {
10485        Expr::Const(x)
10486    }
10487
10488    fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
10489        Expr::Binary(op, Box::new(a), Box::new(b))
10490    }
10491
10492    fn un(op: UnaryOp, a: Expr) -> Expr {
10493        Expr::Unary(op, Box::new(a))
10494    }
10495
10496    /// `NlProblemParts` for an `n`-variable, unbounded model.
10497    fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
10498        let m = constraints.len();
10499        NlProblemParts {
10500            minimize: true,
10501            objective,
10502            obj_constant: 0.0,
10503            constraints,
10504            x_l: vec![-1e19; n],
10505            x_u: vec![1e19; n],
10506            x0: vec![0.0; n],
10507            g_l: vec![-1e19; m],
10508            g_u: vec![1e19; m],
10509            var_names: Vec::new(),
10510            con_names: Vec::new(),
10511        }
10512    }
10513
10514    /// A model built from expressions evaluates exactly like a parsed one:
10515    /// objective, gradient, constraints, and Jacobian all come from the
10516    /// same tape, with no `.nl` text in the loop.
10517    #[test]
10518    fn from_expressions_builds_evaluable_problem() {
10519        // min (1-x0)^2 + 100*(x1 - x0^2)^2   s.t.  x0^2 + x1^2 <= 2
10520        let rosen = bin(
10521            BinOp::Add,
10522            bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
10523            bin(
10524                BinOp::Mul,
10525                c(100.0),
10526                bin(
10527                    BinOp::Pow,
10528                    bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
10529                    c(2.0),
10530                ),
10531            ),
10532        );
10533        let circle = bin(
10534            BinOp::Add,
10535            bin(BinOp::Pow, v(0), c(2.0)),
10536            bin(BinOp::Pow, v(1), c(2.0)),
10537        );
10538
10539        let mut p = parts(2, rosen, vec![circle]);
10540        p.g_l = vec![0.0];
10541        p.g_u = vec![2.0];
10542        p.x0 = vec![-1.2, 1.0];
10543        p.var_names = names(&["x", "y"]);
10544        p.con_names = names(&["circle"]);
10545
10546        let prob = NlProblem::from_expressions(p).expect("build");
10547        assert_eq!((prob.n, prob.m), (2, 1));
10548        assert_eq!(prob.var_names, names(&["x", "y"]));
10549
10550        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10551        t.get_nlp_info().unwrap();
10552
10553        // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
10554        let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
10555        assert!((f - 24.2).abs() < 1e-10, "f = {f}");
10556
10557        // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
10558        //    = (4.4 + 480*(-0.44)... ) — computed below rather than
10559        //      transcribed, so the check is the formula, not an editor.
10560        let (x0, x1) = (-1.2, 1.0);
10561        let want = [
10562            -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
10563            200.0 * (x1 - x0 * x0),
10564        ];
10565        let mut g = [0.0_f64; 2];
10566        assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
10567        for j in 0..2 {
10568            assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
10569        }
10570
10571        // g(x) = x0^2 + x1^2 = 2.44
10572        let mut gv = [0.0_f64; 1];
10573        assert!(t.eval_g(&[x0, x1], true, &mut gv));
10574        assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
10575    }
10576
10577    /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
10578    /// this path. None of the three survives a `.nl` round trip in a
10579    /// typical frontend — `atan2` has no two-argument funcall path,
10580    /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
10581    /// at all — which is the reason the in-memory door exists.
10582    #[test]
10583    fn from_expressions_carries_ops_nl_cannot_express() {
10584        let obj = Expr::Sum(vec![
10585            bin(BinOp::Atan2, v(0), v(1)),
10586            Expr::MinList(vec![v(0), v(1)]),
10587            Expr::MaxList(vec![v(0), v(1)]),
10588            un(UnaryOp::Erf, v(0)),
10589        ]);
10590        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10591        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10592        t.get_nlp_info().unwrap();
10593
10594        let x: [Number; 2] = [0.8, 1.5];
10595        // atan2 + min + max + erf; min+max == x0+x1 for any pair.
10596        let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
10597        let f = t.eval_f(&x, true).unwrap();
10598        assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
10599    }
10600
10601    /// A `Var` index past `n` would be an out-of-bounds read in the
10602    /// tape's forward sweep. It has to be caught at construction, while
10603    /// it is still a diagnosable user error.
10604    #[test]
10605    fn from_expressions_rejects_out_of_range_var() {
10606        let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
10607            .expect_err("Var(5) with n = 2 must be rejected");
10608        assert!(err.contains("Var(5)"), "{err}");
10609
10610        let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
10611            .expect_err("constraint Var(2) with n = 2 must be rejected");
10612        assert!(err.contains("constraint 0"), "{err}");
10613
10614        // Length mismatches are errors too, not panics.
10615        let mut p = parts(2, c(0.0), Vec::new());
10616        p.x0 = vec![0.0; 3];
10617        let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
10618        assert!(err.contains("x0"), "{err}");
10619    }
10620
10621    /// An out-of-range `Var` must be caught wherever it hides, not just at
10622    /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
10623    /// branch, and a funcall argument all reach the same forward sweep.
10624    #[test]
10625    fn from_expressions_finds_out_of_range_vars_in_every_position() {
10626        let inner_cse = Arc::new(v(7));
10627        let cases: Vec<(&str, Expr)> = vec![
10628            ("bare", v(7)),
10629            ("cse", Expr::Cse(Arc::new(v(7)))),
10630            ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
10631            (
10632                "cond branch",
10633                Expr::Cond {
10634                    cond: Box::new(c(1.0)),
10635                    then_: Box::new(v(7)),
10636                    else_: Box::new(c(0.0)),
10637                },
10638            ),
10639            ("min list", Expr::MinList(vec![c(0.0), v(7)])),
10640            (
10641                "sum",
10642                Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
10643            ),
10644        ];
10645        for (label, e) in cases {
10646            let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
10647                .err()
10648                .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
10649            assert!(err.contains("Var(7)"), "{label}: {err}");
10650        }
10651    }
10652
10653    /// A balanced share-DAG: each level is one `Cse` whose body
10654    /// references the level below twice. Depth `d` is `d` distinct nodes
10655    /// but `2^d` paths, so any walk that re-enters a shared body per
10656    /// occurrence is Θ(2^d).
10657    fn share_dag(depth: usize) -> Expr {
10658        let mut e = v(0);
10659        for _ in 0..depth {
10660            let shared = Arc::new(e);
10661            e = bin(
10662                BinOp::Add,
10663                Expr::Cse(Arc::clone(&shared)),
10664                Expr::Cse(shared),
10665            );
10666        }
10667        e
10668    }
10669
10670    /// The walks over a shared DAG must be memoized, not exponential.
10671    ///
10672    /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
10673    /// does not "fail" so much as never finish, which is exactly the
10674    /// signal. `from_expressions` is the door that makes such a DAG
10675    /// trivially constructible, but the blowup was reachable through
10676    /// `collect_vars` (which presolve calls on every solve, via
10677    /// `get_variables_linearity`) and `collect_funcall_ids` (which
10678    /// `NlTnlp::try_new` runs over every row).
10679    #[test]
10680    fn shared_dag_walks_are_memoized_not_exponential() {
10681        const DEPTH: usize = 30;
10682        let e = share_dag(DEPTH);
10683
10684        let mut vars = BTreeSet::new();
10685        collect_vars(&e, &mut vars);
10686        assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
10687
10688        let mut ids = BTreeSet::new();
10689        super::super::nl_external::collect_funcall_ids(&e, &mut ids);
10690        assert!(ids.is_empty());
10691
10692        // The whole build path, end to end: validation, tape construction,
10693        // and the linearity metadata presolve consumes.
10694        let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
10695        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10696        t.get_nlp_info().unwrap();
10697        let mut lin = vec![Linearity::Linear; 1];
10698        assert!(t.get_variables_linearity(&mut lin));
10699    }
10700
10701    /// `from_expressions` cannot carry AMPL imported functions — there is
10702    /// nowhere to put the `F`-segment declarations that bind a funcall id
10703    /// to a library — so a `Funcall` must be refused up front. Accepting it
10704    /// produces "AMPLFUNC is not set", which the user cannot act on:
10705    /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
10706    #[test]
10707    fn from_expressions_rejects_imported_function_calls() {
10708        let call = Expr::Funcall {
10709            id: 0,
10710            args: vec![FuncallArg::Real(v(0))],
10711        };
10712        let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
10713            .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
10714        assert!(err.contains("imported function"), "{err}");
10715        assert!(
10716            err.contains("read_nl") || err.contains("parse_nl_text"),
10717            "the error must point at the paths that do support externals: {err}"
10718        );
10719
10720        // Also when buried in a constraint, behind a Cse.
10721        let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
10722        let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
10723            .expect_err("a buried Funcall must be rejected too");
10724        assert!(err.contains("constraint 0"), "{err}");
10725    }
10726
10727    /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
10728    /// same tapes, same weights, one seed instead of a color sweep. The
10729    /// objective and both constraints are chosen with cross terms so the
10730    /// off-diagonal blocks actually carry signal.
10731    #[test]
10732    fn hessian_vector_product_matches_dense_hessian() {
10733        let obj = Expr::Sum(vec![
10734            bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
10735            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10736            un(UnaryOp::Erf, v(2)),
10737        ]);
10738        let cons = vec![
10739            bin(
10740                BinOp::Add,
10741                bin(BinOp::Pow, v(0), c(2.0)),
10742                un(UnaryOp::Sin, v(2)),
10743            ),
10744            bin(BinOp::Mul, v(1), v(2)),
10745        ];
10746        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10747        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10748        let info = t.get_nlp_info().unwrap();
10749
10750        let x = [0.3, -0.7, 1.1];
10751        let lam = [0.5, -1.25];
10752        let obj_factor = 2.0;
10753
10754        // Dense Hessian from the sparse lower triangle.
10755        let nnz = info.nnz_h_lag as usize;
10756        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10757        assert!(t.eval_h(
10758            None,
10759            false,
10760            1.0,
10761            None,
10762            false,
10763            SparsityRequest::Structure {
10764                irow: &mut irow,
10765                jcol: &mut jcol
10766            }
10767        ));
10768        let mut hvals = vec![0.0_f64; nnz];
10769        assert!(t.eval_h(
10770            Some(&x),
10771            true,
10772            obj_factor,
10773            Some(&lam),
10774            true,
10775            SparsityRequest::Values { values: &mut hvals }
10776        ));
10777        let mut dense = [[0.0_f64; 3]; 3];
10778        for k in 0..nnz {
10779            let (i, j) = (irow[k] as usize, jcol[k] as usize);
10780            dense[i][j] += hvals[k];
10781            if i != j {
10782                dense[j][i] += hvals[k];
10783            }
10784        }
10785
10786        // Each unit seed recovers a column; a mixed seed catches an HVP
10787        // that only happens to be right on the basis vectors.
10788        let seeds: [[Number; 3]; 4] = [
10789            [1.0, 0.0, 0.0],
10790            [0.0, 1.0, 0.0],
10791            [0.0, 0.0, 1.0],
10792            [0.4, -1.3, 2.0],
10793        ];
10794        let mut out = vec![0.0; 3];
10795        for s in &seeds {
10796            t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
10797                .expect("hvp");
10798            for i in 0..3 {
10799                let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
10800                assert!(
10801                    (out[i] - want).abs() < 1e-9,
10802                    "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
10803                    out[i]
10804                );
10805            }
10806        }
10807    }
10808
10809    /// `lam = None` is the objective block alone, and `out` is
10810    /// overwritten (not accumulated) so a reused buffer is safe.
10811    #[test]
10812    fn hessian_vector_product_defaults_and_validation() {
10813        // f = x0^2 + 3 x0 x1  ->  ∇²f = [[2, 3], [3, 0]]
10814        let obj = bin(
10815            BinOp::Add,
10816            bin(BinOp::Pow, v(0), c(2.0)),
10817            bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
10818        );
10819        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10820        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10821        t.get_nlp_info().unwrap();
10822
10823        let mut out = vec![7.0, -7.0]; // dirty buffer
10824        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
10825            .expect("hvp");
10826        assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
10827        assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
10828
10829        // obj_factor scales linearly.
10830        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
10831            .expect("hvp");
10832        assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
10833
10834        // Length mismatches are errors, not panics or silent truncation.
10835        let mut short = vec![0.0; 1];
10836        assert!(
10837            t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
10838                .is_err()
10839        );
10840        assert!(
10841            t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
10842                .is_err()
10843        );
10844        assert!(
10845            t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
10846                .is_err()
10847        );
10848    }
10849
10850    /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
10851    /// Hessian — the sparse shape an IPM actually meets. The block HVP has
10852    /// to reproduce it column for column, including the structural zeros:
10853    /// a bug that leaked coupling between non-adjacent variables would
10854    /// show up here and nowhere in a small dense test.
10855    #[test]
10856    fn hessian_vector_products_on_a_sparse_hessian() {
10857        const N: usize = 8;
10858        let mut terms = Vec::new();
10859        for i in 0..N - 1 {
10860            terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
10861        }
10862        for i in 0..N {
10863            terms.push(un(UnaryOp::Exp, v(i)));
10864        }
10865        let prob =
10866            NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
10867        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10868        let info = t.get_nlp_info().unwrap();
10869
10870        // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
10871        assert_eq!(
10872            info.nnz_h_lag as usize,
10873            2 * N - 1,
10874            "chain objective should give a tridiagonal Hessian, not a dense one"
10875        );
10876
10877        let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
10878
10879        // Densify the sparse triangle for the reference.
10880        let nnz = info.nnz_h_lag as usize;
10881        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10882        assert!(t.eval_h(
10883            None,
10884            false,
10885            1.0,
10886            None,
10887            false,
10888            SparsityRequest::Structure {
10889                irow: &mut irow,
10890                jcol: &mut jcol
10891            }
10892        ));
10893        let mut hvals = vec![0.0; nnz];
10894        assert!(t.eval_h(
10895            Some(&x),
10896            true,
10897            1.0,
10898            None,
10899            true,
10900            SparsityRequest::Values { values: &mut hvals }
10901        ));
10902        let mut dense = vec![vec![0.0; N]; N];
10903        for k in 0..nnz {
10904            let (i, j) = (irow[k] as usize, jcol[k] as usize);
10905            dense[i][j] += hvals[k];
10906            if i != j {
10907                dense[j][i] += hvals[k];
10908            }
10909        }
10910
10911        // One block of N unit seeds recovers the whole matrix — the
10912        // "densify via HVPs" path, in one call.
10913        let mut seeds = vec![0.0; N * N];
10914        for cc in 0..N {
10915            seeds[cc * N + cc] = 1.0;
10916        }
10917        let mut out = vec![0.0; N * N];
10918        t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
10919            .expect("block hvp");
10920        for cc in 0..N {
10921            for i in 0..N {
10922                assert!(
10923                    (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
10924                    "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
10925                    out[cc * N + i],
10926                    dense[i][cc]
10927                );
10928            }
10929        }
10930    }
10931
10932    /// The block form must agree with `k` separate single-vector calls
10933    /// (it shares one forward sweep across directions, so a bug there
10934    /// would show as a per-direction discrepancy), and must skip an
10935    /// all-zero direction without disturbing its neighbours.
10936    #[test]
10937    fn hessian_vector_products_match_repeated_single_calls() {
10938        let obj = Expr::Sum(vec![
10939            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10940            bin(BinOp::Pow, v(2), c(4.0)),
10941            bin(BinOp::Mul, v(0), v(2)),
10942        ]);
10943        let cons = vec![bin(BinOp::Mul, v(1), v(2))];
10944        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10945        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10946        t.get_nlp_info().unwrap();
10947
10948        let x = [0.4, -0.6, 1.3];
10949        let lam = [0.75];
10950        let cols: [[Number; 3]; 4] = [
10951            [1.0, 2.0, -3.0],
10952            [0.0, 0.0, 0.0], // the skipped direction
10953            [0.5, 0.0, 0.0],
10954            [-1.0, 1.0, 1.0],
10955        ];
10956
10957        let mut block = vec![0.0; 3 * cols.len()];
10958        let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
10959        t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
10960            .expect("block hvp");
10961
10962        for (c, col) in cols.iter().enumerate() {
10963            let mut single = vec![0.0; 3];
10964            t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
10965                .expect("single hvp");
10966            for i in 0..3 {
10967                assert!(
10968                    (block[c * 3 + i] - single[i]).abs() < 1e-12,
10969                    "direction {c} row {i}: block={:.12e} single={:.12e}",
10970                    block[c * 3 + i],
10971                    single[i]
10972                );
10973            }
10974        }
10975        // The zero direction really is zero, not stale scratch.
10976        assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
10977    }
10978
10979    /// `k = 0` is a legal empty block, and the length checks scale with
10980    /// `k` rather than assuming a single direction.
10981    #[test]
10982    fn hessian_vector_products_validate_block_shape() {
10983        let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
10984            .expect("build");
10985        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10986        t.get_nlp_info().unwrap();
10987
10988        let mut empty: Vec<Number> = Vec::new();
10989        assert!(
10990            t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
10991                .is_ok()
10992        );
10993
10994        // v sized for one direction while k says two.
10995        let mut out = vec![0.0; 4];
10996        assert!(
10997            t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
10998                .is_err()
10999        );
11000        // out sized for one direction while k says two.
11001        let mut short = vec![0.0; 2];
11002        assert!(
11003            t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
11004                .is_err()
11005        );
11006    }
11007
11008    /// A `maximize` model's objective is negated by the evaluator, and
11009    /// the HVP has to agree with `eval_h` about that — otherwise a
11010    /// Hessian-free step would climb where the sparse path descends.
11011    #[test]
11012    fn hessian_vector_product_respects_maximize_sign() {
11013        let obj = bin(BinOp::Pow, v(0), c(2.0));
11014        let mut p = parts(1, obj, Vec::new());
11015        p.minimize = false;
11016        let prob = NlProblem::from_expressions(p).expect("build");
11017        let mut t = NlTnlp::try_new(prob).expect("tnlp");
11018        t.get_nlp_info().unwrap();
11019
11020        // max x0^2 is minimized as -x0^2, so ∇² = -2.
11021        let mut out = vec![0.0; 1];
11022        t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
11023            .expect("hvp");
11024        assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
11025    }
11026
11027    // ---- gh#1042 R3: `|e|^p · |e|^q` merged at parse time ----------------
11028
11029    /// An unconstrained `.nl` model over `n` variables whose objective is
11030    /// the given expression-token body (one token per line).
11031    fn abs_pow_nl(n: usize, body: &[&str]) -> String {
11032        let mut s = format!(
11033            "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"
11034        );
11035        for t in body {
11036            s.push_str(t);
11037            s.push('\n');
11038        }
11039        s.push_str("b\n");
11040        for _ in 0..n {
11041            s.push_str("3\n");
11042        }
11043        s
11044    }
11045
11046    /// `(3·|x|^2)·|x|^0.85` as Vanderbei's `dallass` writes its head-loss
11047    /// terms. Read as written, the tape's product rule forms `0·∞` at
11048    /// `x = 0` and the Hessian is NaN there (measured; the gradient reads
11049    /// `0`), which fails the
11050    /// KKT factorization and forces a restoration entry (11 of `dallass`'s
11051    /// 12 on 0.13.0). Merged into `3·|x|^2.85` every value is unchanged and
11052    /// the derivatives at the kink are the finite limits, `0` and `0`.
11053    #[test]
11054    fn abs_pow_product_is_merged_and_its_derivatives_are_finite_at_the_kink() {
11055        let txt = abs_pow_nl(
11056            1,
11057            &[
11058                "o2", "o2", "n3", "o5", "o15", "v0", "n2", "o5", "o15", "v0", "n0.85",
11059            ],
11060        );
11061        let p = parse_nl_text(&txt).expect("parse");
11062        // The tree is `Mul(3, Pow(Abs(x0), 2.85))`.
11063        match &*p.obj_expr() {
11064            Expr::Binary(BinOp::Mul, k, f) => {
11065                assert!(matches!(**k, Expr::Const(c) if c == 3.0), "{k:?}");
11066                assert!(
11067                    matches!(abs_pow_parts(f), Some((Expr::Var(0), q)) if q == 2.85),
11068                    "{f:?}"
11069                );
11070            }
11071            other => panic!("not merged: {other:?}"),
11072        }
11073
11074        let mut t = NlTnlp::new(p);
11075        let info = t.get_nlp_info().unwrap();
11076        assert_eq!(info.nnz_h_lag, 1);
11077        let f = |t: &mut NlTnlp, x: f64| t.eval_f(&[x], true).unwrap();
11078        let g = |t: &mut NlTnlp, x: f64| {
11079            let mut g = [0.0];
11080            assert!(t.eval_grad_f(&[x], true, &mut g));
11081            g[0]
11082        };
11083        let h = |t: &mut NlTnlp, x: f64| {
11084            let mut v = [0.0];
11085            assert!(t.eval_h(
11086                Some(&[x]),
11087                true,
11088                1.0,
11089                None,
11090                true,
11091                SparsityRequest::Values { values: &mut v }
11092            ));
11093            v[0]
11094        };
11095        let (pw, k) = (2.85_f64, 3.0_f64);
11096        for &x in &[-2.0, -0.7, -1e-3, 0.0, 1e-3, 0.7, 2.0, 1e3] {
11097            let a = f64::abs(x);
11098            // The value is the product's: `k·|x|^2·|x|^0.85`.
11099            let want_f = k * a.powi(2) * a.powf(0.85);
11100            let want_g = k * pw * a.powf(pw - 1.0) * x.signum() * f64::from(u8::from(x != 0.0));
11101            let want_h = k * pw * (pw - 1.0) * a.powf(pw - 2.0);
11102            let (gf, gg, gh) = (f(&mut t, x), g(&mut t, x), h(&mut t, x));
11103            assert!(
11104                gf.is_finite() && gg.is_finite() && gh.is_finite(),
11105                "x={x}: {gf} {gg} {gh}"
11106            );
11107            let tol = |w: f64| 1e-13 * w.abs().max(1e-300);
11108            assert!(
11109                (gf - want_f).abs() <= tol(want_f),
11110                "f({x}) = {gf}, want {want_f}"
11111            );
11112            assert!(
11113                (gg - want_g).abs() <= tol(want_g),
11114                "g({x}) = {gg}, want {want_g}"
11115            );
11116            assert!(
11117                (gh - want_h).abs() <= tol(want_h),
11118                "h({x}) = {gh}, want {want_h}"
11119            );
11120        }
11121        // At the kink: exactly zero, not NaN.
11122        assert_eq!(
11123            (f(&mut t, 0.0), g(&mut t, 0.0), h(&mut t, 0.0)),
11124            (0.0, 0.0, 0.0)
11125        );
11126        // The Hessian matches a central difference of the gradient on both
11127        // sides of the kink.
11128        for &x in &[-1e-3, 1e-3] {
11129            let e = 1e-8;
11130            let fd = (g(&mut t, x + e) - g(&mut t, x - e)) / (2.0 * e);
11131            let hx = h(&mut t, x);
11132            assert!(
11133                (fd - hx).abs() <= 1e-6 * hx.abs(),
11134                "x={x}: fd {fd} vs h {hx}"
11135            );
11136        }
11137    }
11138
11139    /// Every near miss keeps the tree it always had: `Mul` of the two
11140    /// operands exactly as read. Different operands, a non-positive power, a
11141    /// bare (signed) base and a variable exponent are each a case where the
11142    /// merge would change a value, not only a derivative, or is not known to
11143    /// be the same function.
11144    #[test]
11145    fn abs_pow_near_misses_build_the_unmerged_tree() {
11146        let cases: [(&[&str], &[&str]); 5] = [
11147            // different operands
11148            (&["o5", "o15", "v0", "n2"], &["o5", "o15", "v1", "n0.85"]),
11149            // a negative power: `|0|^-1·|0|^2` is NaN, `|0|^1` is not
11150            (&["o5", "o15", "v0", "n-1"], &["o5", "o15", "v0", "n2"]),
11151            // a zero power
11152            (&["o5", "o15", "v0", "n0"], &["o5", "o15", "v0", "n2"]),
11153            // no abs: `x^0.5·x^0.5` is NaN for x < 0, `x^1` is not
11154            (&["o5", "v0", "n0.5"], &["o5", "v0", "n0.5"]),
11155            // a variable exponent
11156            (&["o5", "o15", "v0", "v1"], &["o5", "o15", "v0", "n2"]),
11157        ];
11158        for (a, b) in cases {
11159            let mut body = vec!["o2"];
11160            body.extend_from_slice(a);
11161            body.extend_from_slice(b);
11162            let p = parse_nl_text(&abs_pow_nl(2, &body)).expect("parse");
11163            let frag = |toks: &[&str]| {
11164                let txt: String = toks.iter().map(|t| format!("{t}\n")).collect();
11165                parse_body_fragment(&txt, 2, &[]).expect("fragment")
11166            };
11167            let want = Expr::Binary(BinOp::Mul, Box::new(frag(a)), Box::new(frag(b)));
11168            assert_eq!(
11169                format!("{:?}", p.obj_expr()),
11170                format!("{want:?}"),
11171                "near miss {body:?} was rewritten"
11172            );
11173        }
11174    }
11175
11176    /// The merge reaches into one `Mul` level on either side and keeps the
11177    /// remaining factors, so `|x|^2·(|x|^0.85·y)` and `(|x|^0.5·y)·|x|^1.5`
11178    /// become `|x|^2.85·y` and `|x|^2·y` — the latter lowered to a mul chain
11179    /// by the tape — with values unchanged.
11180    #[test]
11181    fn abs_pow_merge_keeps_the_other_factors() {
11182        for (body, pw) in [
11183            (
11184                &[
11185                    "o2", "o5", "o15", "v0", "n2", "o2", "o5", "o15", "v0", "n0.85", "v1",
11186                ][..],
11187                2.85,
11188            ),
11189            (
11190                &[
11191                    "o2", "o2", "o5", "o15", "v0", "n0.5", "v1", "o5", "o15", "v0", "n1.5",
11192                ][..],
11193                2.0,
11194            ),
11195        ] {
11196            let p = parse_nl_text(&abs_pow_nl(2, body)).expect("parse");
11197            match &*p.obj_expr() {
11198                Expr::Binary(BinOp::Mul, l, r) => {
11199                    assert!(
11200                        matches!(abs_pow_parts(l), Some((Expr::Var(0), q)) if q == pw),
11201                        "{l:?}"
11202                    );
11203                    assert!(matches!(**r, Expr::Var(1)), "{r:?}");
11204                }
11205                other => panic!("not merged: {other:?}"),
11206            }
11207            let mut t = NlTnlp::new(p);
11208            for &(x, y) in &[(-1.3, 0.4), (0.0, 2.0), (2.5, -1.0)] {
11209                let want = f64::abs(x).powf(pw) * y;
11210                let got = t.eval_f(&[x, y], true).unwrap();
11211                assert!(
11212                    (got - want).abs() <= 1e-13 * want.abs(),
11213                    "{body:?} at ({x},{y}): {got} vs {want}"
11214                );
11215            }
11216        }
11217    }
11218}